release: 2026-08-01
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env node
|
||||
// Force a run's next step to fire immediately instead of waiting for the
|
||||
// normal delay/recheck schedule. Useful when testing a workflow end-to-end
|
||||
// on a single lead, without waiting hours for the real schedule.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/force-next-step.js --run <run_id> --target <target_id> [--apply] [--skip-schedule]
|
||||
// node scripts/force-next-step.js --run <run_id> --all-targets [--apply] [--skip-schedule] (whole run — careful, real sends)
|
||||
// node scripts/force-next-step.js --list (list active runs)
|
||||
//
|
||||
// Without --apply this only PRINTS what would change (dry run). Pass --apply
|
||||
// to actually update the DB. DB path follows the same LINKI_DB_PATH env var
|
||||
// (or ./linki.db) that the app itself uses.
|
||||
//
|
||||
// --target is required unless you pass --all-targets: forcing an entire real
|
||||
// campaign to fire at once means every eligible lead gets messaged/connected
|
||||
// right now instead of on its normal schedule. Scope to one lead for testing.
|
||||
//
|
||||
// --skip-schedule: setting next_step_at alone isn't enough — the runner
|
||||
// re-checks the account's working-hours window (active_hours_start/end,
|
||||
// timezone, working_days) on every tick and reschedules again if outside it
|
||||
// ("Outside working schedule"). This flag additionally widens the run's
|
||||
// LinkedIn account (and email account, if any) to a 24/7 window so the
|
||||
// forced step actually fires on the next tick instead of bouncing. Only
|
||||
// takes effect together with --apply. Does NOT auto-restore the original
|
||||
// hours — note the printed "was" values and set them back in Settings when
|
||||
// you're done testing.
|
||||
|
||||
const path = require("path");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
const DB_PATH = process.env.LINKI_DB_PATH || path.join(process.cwd(), "linki.db");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { apply: false, list: false, allTargets: false, skipSchedule: false, run: null, target: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--apply") args.apply = true;
|
||||
else if (a === "--list") args.list = true;
|
||||
else if (a === "--all-targets") args.allTargets = true;
|
||||
else if (a === "--skip-schedule") args.skipSchedule = true;
|
||||
else if (a === "--run") args.run = argv[++i];
|
||||
else if (a === "--target") args.target = argv[++i];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function widenSchedule(db, table, id) {
|
||||
const before = db.prepare(`SELECT active_hours_start, active_hours_end, working_days FROM ${table} WHERE id = ?`).get(id);
|
||||
if (!before) return null;
|
||||
db.prepare(`UPDATE ${table} SET active_hours_start = 0, active_hours_end = 24, working_days = '1,2,3,4,5,6,7' WHERE id = ?`).run(id);
|
||||
return before;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
if (args.list) {
|
||||
const runs = db
|
||||
.prepare(
|
||||
`SELECT r.id, r.status, w.name as workflow_name, r.created_at
|
||||
FROM runs r JOIN workflows w ON w.id = r.workflow_id
|
||||
WHERE r.status IN ('running', 'paused')
|
||||
ORDER BY r.created_at DESC`
|
||||
)
|
||||
.all();
|
||||
console.log(JSON.stringify(runs, null, 2));
|
||||
db.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.run) {
|
||||
console.error("Usage: node scripts/force-next-step.js --run <run_id> --target <target_id> [--apply]");
|
||||
console.error(" node scripts/force-next-step.js --run <run_id> --all-targets [--apply]");
|
||||
console.error(" node scripts/force-next-step.js --list");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!args.target && !args.allTargets) {
|
||||
console.error("Refusing to run: pass --target <target_id> to scope this to one lead (recommended for testing),");
|
||||
console.error("or --all-targets to force the ENTIRE run (every eligible lead fires immediately — real sends).");
|
||||
db.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const where = ["rp.run_id = ?"];
|
||||
const params = [args.run];
|
||||
if (args.target) {
|
||||
where.push("rp.target_id = ?");
|
||||
params.push(args.target);
|
||||
}
|
||||
|
||||
const tracks = db
|
||||
.prepare(
|
||||
`SELECT rt.id, rt.track, rt.state, rt.current_step, rt.next_step_at,
|
||||
rp.target_id, t.full_name
|
||||
FROM run_profile_tracks rt
|
||||
JOIN run_profiles rp ON rp.id = rt.run_profile_id
|
||||
JOIN targets t ON t.id = rp.target_id
|
||||
WHERE ${where.join(" AND ")} AND rt.state != 'completed'`
|
||||
)
|
||||
.all(...params);
|
||||
|
||||
if (tracks.length === 0) {
|
||||
console.log("No eligible tracks found for that run/target.");
|
||||
db.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${args.apply ? "Updating" : "[DRY RUN] Would update"} ${tracks.length} track(s):`);
|
||||
for (const t of tracks) {
|
||||
console.log(` - ${t.full_name} (${t.track}, step ${t.current_step}, state=${t.state}) next_step_at: ${t.next_step_at} -> now`);
|
||||
}
|
||||
|
||||
if (args.skipSchedule) {
|
||||
const run = db.prepare("SELECT account_id, email_account_id FROM runs WHERE id = ?").get(args.run);
|
||||
if (!run) {
|
||||
console.error(`No run found with id ${args.run}`);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
}
|
||||
if (!args.apply) {
|
||||
console.log("\n[DRY RUN] --skip-schedule would widen this run's account(s) to a 24/7 window.");
|
||||
} else {
|
||||
const accountBefore = widenSchedule(db, "accounts", run.account_id);
|
||||
if (accountBefore) {
|
||||
console.log(`Widened LinkedIn account ${run.account_id} to 24/7 (was: ${accountBefore.active_hours_start}-${accountBefore.active_hours_end}, days ${accountBefore.working_days}). Restore this in Settings when done testing.`);
|
||||
}
|
||||
if (run.email_account_id) {
|
||||
const emailBefore = widenSchedule(db, "email_accounts", run.email_account_id);
|
||||
if (emailBefore) {
|
||||
console.log(`Widened email account ${run.email_account_id} to 24/7 (was: ${emailBefore.active_hours_start}-${emailBefore.active_hours_end}, days ${emailBefore.working_days}). Restore this in Settings when done testing.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.apply) {
|
||||
const update = db.prepare("UPDATE run_profile_tracks SET next_step_at = datetime('now') WHERE id = ?");
|
||||
const tx = db.transaction((rows) => {
|
||||
for (const t of rows) update.run(t.id);
|
||||
});
|
||||
tx(tracks);
|
||||
console.log("Done. The runner will pick these up on its next loop tick (a few seconds).");
|
||||
} else {
|
||||
console.log("\nDry run only — pass --apply to actually update the DB.");
|
||||
}
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* scripts/mirror-ee.js — runs at predev/prebuild.
|
||||
*
|
||||
* Next.js Pages Router only routes files physically inside pages/. Premium pages/APIs
|
||||
* live in ee/pages/** so they can be stripped from the public build, but they must be
|
||||
* COPIED into pages/** for Next to see them during dev/build in the private repo.
|
||||
*
|
||||
* This script copies ee/pages/** → pages/** and records every file it wrote in
|
||||
* pages/.ee-mirror-manifest.json. It also writes a generated pages/.gitignore listing
|
||||
* every mirrored path — git reads nested .gitignore files natively, so the copies are
|
||||
* never tracked in either repo and pages/ stays clean in git. Source of truth is ee/pages.
|
||||
*
|
||||
* When ee/ is absent (public build), this is a no-op. See docs/OPEN_CORE.md.
|
||||
*
|
||||
* Everything else in ee/ (lib code) is imported in place via @/ee — NOT mirrored.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const EE_PAGES = path.join(ROOT, "ee", "pages");
|
||||
const PAGES = path.join(ROOT, "pages");
|
||||
const MANIFEST = path.join(PAGES, ".ee-mirror-manifest.json");
|
||||
const NESTED_IGNORE = path.join(PAGES, ".gitignore");
|
||||
|
||||
function cleanPrevious() {
|
||||
// Remove files written by a previous mirror run so renames/deletions in ee/pages
|
||||
// don't leave stale routes behind.
|
||||
if (!fs.existsSync(MANIFEST)) return;
|
||||
try {
|
||||
const prev = JSON.parse(fs.readFileSync(MANIFEST, "utf8"));
|
||||
for (const rel of prev.files ?? []) {
|
||||
const p = path.join(PAGES, rel);
|
||||
if (fs.existsSync(p)) fs.rmSync(p);
|
||||
}
|
||||
fs.rmSync(MANIFEST);
|
||||
if (fs.existsSync(NESTED_IGNORE)) fs.rmSync(NESTED_IGNORE);
|
||||
} catch {
|
||||
/* ignore a corrupt manifest — fresh mirror below overwrites cleanly */
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir, base = dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...walk(abs, base));
|
||||
else out.push(path.relative(base, abs));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function main() {
|
||||
cleanPrevious();
|
||||
|
||||
if (!fs.existsSync(EE_PAGES)) {
|
||||
console.log("[mirror-ee] no ee/pages (public build or none yet) — nothing to mirror");
|
||||
return;
|
||||
}
|
||||
|
||||
const files = walk(EE_PAGES);
|
||||
for (const rel of files) {
|
||||
const src = path.join(EE_PAGES, rel);
|
||||
const dst = path.join(PAGES, rel);
|
||||
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
||||
fs.copyFileSync(src, dst);
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
MANIFEST,
|
||||
JSON.stringify({ generatedAt: new Date().toISOString(), files }, null, 2)
|
||||
);
|
||||
|
||||
// Nested .gitignore so the mirrored copies are never tracked. Git reads this natively.
|
||||
// Paths are POSIX-style and relative to pages/. The manifest + this file ignore themselves.
|
||||
// IMPORTANT: gitignore treats [ ] * ? as glob metacharacters, so Next's dynamic-route
|
||||
// segments like [replyId] must be backslash-escaped or the pattern won't match the literal
|
||||
// directory (leaving the mirrored copy tracked). Escape them.
|
||||
const esc = (p) => p.replace(/([[\]*?])/g, "\\$1");
|
||||
const ignoreBody = [
|
||||
"# GENERATED by scripts/mirror-ee.js — do not edit. Lists premium pages mirrored",
|
||||
"# from ee/pages/. Source of truth is ee/pages/**. See docs/OPEN_CORE.md.",
|
||||
".gitignore",
|
||||
".ee-mirror-manifest.json",
|
||||
...files.map((f) => "/" + esc(f.split(path.sep).join("/"))),
|
||||
"",
|
||||
].join("\n");
|
||||
fs.writeFileSync(NESTED_IGNORE, ignoreBody);
|
||||
|
||||
console.log(`[mirror-ee] mirrored ${files.length} premium page(s) into pages/`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* scripts/strip-mirrored-pages.js <targetDir>
|
||||
*
|
||||
* Removes the premium pages that scripts/mirror-ee.js copied into pages/**, using the
|
||||
* manifest it wrote. Also removes the generated pages/.gitignore and prunes any now-empty
|
||||
* directories left behind (e.g. pages/api/oauth/ after all its routes are stripped).
|
||||
*
|
||||
* Used by .publish/publish.sh and .publish/dry-run.sh so the public tree contains only
|
||||
* genuinely open-core routes. Idempotent; no-op if there's no manifest. See docs/OPEN_CORE.md.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const root = process.argv[2] || process.cwd();
|
||||
const pagesDir = path.join(root, "pages");
|
||||
const manifestPath = path.join(pagesDir, ".ee-mirror-manifest.json");
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
console.log(" (no mirror manifest — nothing to strip)");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { files } = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
|
||||
// Remove mirrored files, collecting their parent dirs for empty-pruning.
|
||||
const touchedDirs = new Set();
|
||||
for (const rel of files) {
|
||||
const p = path.join(pagesDir, rel);
|
||||
if (fs.existsSync(p)) fs.rmSync(p);
|
||||
touchedDirs.add(path.dirname(p));
|
||||
}
|
||||
|
||||
fs.rmSync(manifestPath);
|
||||
const gi = path.join(pagesDir, ".gitignore");
|
||||
if (fs.existsSync(gi)) fs.rmSync(gi);
|
||||
|
||||
// Prune empty dirs bottom-up (deepest first), but never remove pages/ itself.
|
||||
const dirsDeepestFirst = [...touchedDirs].sort((a, b) => b.length - a.length);
|
||||
for (const dir of dirsDeepestFirst) {
|
||||
let d = dir;
|
||||
while (d.startsWith(pagesDir) && d !== pagesDir) {
|
||||
if (fs.existsSync(d) && fs.readdirSync(d).length === 0) {
|
||||
fs.rmdirSync(d);
|
||||
d = path.dirname(d);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` stripped ${files.length} mirrored premium page(s)`);
|
||||
Reference in New Issue
Block a user