demobites 1.26.0 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/launcher/index.mjs +34 -4
- package/package.json +1 -1
- package/scripts/record.mjs +59 -4
- package/scripts/trim.mjs +45 -0
- package/skill/scripts/look.mjs +1 -1
- package/skill/scripts/signin.mjs +14 -1
- package/skill/scripts/signout.mjs +1 -1
package/launcher/index.mjs
CHANGED
|
@@ -509,11 +509,41 @@ if (arg === "listen") {
|
|
|
509
509
|
})();
|
|
510
510
|
writeJson(LOCK, { pid: process.pid, since: new Date().toISOString(), cwd: process.cwd(), service: SERVICE, repo });
|
|
511
511
|
const noteState = (extra) => writeJson(STATE, { at: new Date().toISOString(), machine_id: listen.machine_id, name, base, workspace: cfg.workspace?.name ?? null, ...extra });
|
|
512
|
+
// PERSONAS (1.27.0, founder 2026-10-17: "the same domain, but three different users sign in, so three tabs"):
|
|
513
|
+
// a tab's jobs run in the tab's own Chrome profile, seeded once from the machine's profile (so what was
|
|
514
|
+
// signed in stays), with its own ledger. The server names the profile on the job ("t12"); none = the machine's.
|
|
515
|
+
const SKIP_IN_PROFILE = /^(Singleton|lockfile$|.*Cache$|Crashpad$|BrowserMetrics|Safe Browsing|component_crx_cache|extensions_crx_cache)/i;
|
|
516
|
+
const rec = () => path.join(process.cwd(), ".recorder");
|
|
517
|
+
const profileFor = (job) => {
|
|
518
|
+
const key = typeof job?.profile === "string" && /^[a-z0-9_-]{1,40}$/i.test(job.profile) ? job.profile : null;
|
|
519
|
+
if (!key) return {};
|
|
520
|
+
const dir = path.join(rec(), "profiles", key);
|
|
521
|
+
const ledger = path.join(rec(), "sessions", `${key}.json`);
|
|
522
|
+
if (!fs.existsSync(dir)) {
|
|
523
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
524
|
+
const base = path.join(rec(), "profile");
|
|
525
|
+
if (fs.existsSync(base)) { try { fs.cpSync(base, dir, { recursive: true, force: true, filter: (src) => !SKIP_IN_PROFILE.test(path.basename(src)) }); } catch (e) { console.log(` ! profile seed: ${e.message}`); } }
|
|
526
|
+
fs.mkdirSync(path.dirname(ledger), { recursive: true });
|
|
527
|
+
if (!fs.existsSync(ledger)) { try { fs.copyFileSync(path.join(rec(), "signins.json"), ledger); } catch { fs.writeFileSync(ledger, JSON.stringify({ origins: {} }, null, 2) + "\n"); } }
|
|
528
|
+
console.log(` · a profile of its own for this tab (${key}), seeded from the machine's`);
|
|
529
|
+
}
|
|
530
|
+
return { RECORDER_PROFILE: dir, RECORDER_LEDGER: ledger };
|
|
531
|
+
};
|
|
512
532
|
const readiness = () => {
|
|
513
533
|
let signins = [], whos = {};
|
|
514
534
|
try { const l = JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "signins.json"), "utf8")); signins = Object.keys(l.origins || {}); whos = l.who && typeof l.who === "object" ? l.who : {}; } catch { /* none */ }
|
|
535
|
+
// Every session on this machine, by profile: the machine's own, then each tab's.
|
|
536
|
+
const sessions = [];
|
|
537
|
+
for (const o of signins) sessions.push({ profile: "default", origin: o, who: whos[o] ?? null });
|
|
538
|
+
try {
|
|
539
|
+
const dir = path.join(process.cwd(), ".recorder", "sessions");
|
|
540
|
+
for (const f of fs.existsSync(dir) ? fs.readdirSync(dir) : []) {
|
|
541
|
+
if (!f.endsWith(".json")) continue;
|
|
542
|
+
try { const l = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); const key = f.slice(0, -5); for (const o of Object.keys(l.origins || {})) sessions.push({ profile: key, origin: o, who: l.who?.[o] ?? null }); } catch { /* skip */ }
|
|
543
|
+
}
|
|
544
|
+
} catch { /* none */ }
|
|
515
545
|
const repos = Object.entries(readJson(reposFile(process.cwd())) ?? {}).filter(([, v]) => v && typeof v.folder === "string" && fs.existsSync(v.folder)).map(([site, v]) => ({ site, folder: v.folder, repo: v.repo ?? null }));
|
|
516
|
-
return { ...agentInfo, chrome: haveChrome, recorder: pkg.version, signins, whos, cwd: process.cwd(), platform: process.platform, repo, repos };
|
|
546
|
+
return { ...agentInfo, chrome: haveChrome, recorder: pkg.version, signins, whos, sessions, cwd: process.cwd(), platform: process.platform, repo, repos };
|
|
517
547
|
};
|
|
518
548
|
// The code bound to a job's site (1.22.0): the planner reads there.
|
|
519
549
|
const codeFor = (url) => { try { const site = siteOf(new URL(url).hostname); const b = (readJson(reposFile(process.cwd())) ?? {})[site]; return b && fs.existsSync(b.folder) ? { site, ...b } : null; } catch { return null; } };
|
|
@@ -624,7 +654,7 @@ if (arg === "listen") {
|
|
|
624
654
|
// Alive while the person signs in (a window can wait minutes): the heartbeat, or the rail calls this machine offline.
|
|
625
655
|
const heartbeat = setInterval(() => { void api({ action: "checkin", machine_id: listen.machine_id, name, readiness: readiness(), busy: true }); }, 5000);
|
|
626
656
|
const { spawn } = await import("node:child_process");
|
|
627
|
-
const child = spawn(process.execPath, [script, job.url], { cwd: process.cwd(), env: { ...process.env, RECORDER_JOB: job.id, RECORDER_BASE: base, RECORDER_LISTEN: "1" }, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
657
|
+
const child = spawn(process.execPath, [script, job.url, ...(job.fresh && !isLook && !isOut ? ["--fresh"] : [])], { cwd: process.cwd(), env: { ...process.env, RECORDER_JOB: job.id, RECORDER_BASE: base, RECORDER_LISTEN: "1", ...profileFor(job) }, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
628
658
|
track(child);
|
|
629
659
|
let verdict = null;
|
|
630
660
|
let buf = "";
|
|
@@ -664,7 +694,7 @@ if (arg === "listen") {
|
|
|
664
694
|
await event(job.id, "started", { machine: name });
|
|
665
695
|
await event(job.id, "ready", { disconnected: true, machine: name });
|
|
666
696
|
const rec = path.join(process.cwd(), ".recorder");
|
|
667
|
-
for (const f of ["profile", "signins.json", "config.json", "listen.json"]) fs.rmSync(path.join(rec, f), { recursive: true, force: true });
|
|
697
|
+
for (const f of ["profile", "profiles", "sessions", "signins.json", "config.json", "listen.json"]) fs.rmSync(path.join(rec, f), { recursive: true, force: true });
|
|
668
698
|
noteState({ busy: false, job: null, last_error: null, disconnected: true });
|
|
669
699
|
console.log(` ○ Disconnected. The sign-ins and the key are gone from this machine; the login service is removed.`);
|
|
670
700
|
if (SERVICE) removeService(); // bootout ends this process; the handlers below clear the lock
|
|
@@ -705,7 +735,7 @@ if (arg === "listen") {
|
|
|
705
735
|
// during a take and the rail called the machine offline mid-recording (founder 2026-10-15 17:39).
|
|
706
736
|
const bound = job.url ? codeFor(job.url) : null;
|
|
707
737
|
if (bound) console.log(` Code: ${bound.repo ?? bound.site} (${bound.folder.replace(os.homedir(), "~")})`);
|
|
708
|
-
const child = (await import("node:child_process")).spawn(process.execPath, [path.join(dest, "scripts", "plan.mjs"), takeDir], { cwd: process.cwd(), stdio: "inherit", env: { ...process.env, RECORDER_JOB: job.id, RECORDER_BASE: base, RECORDER_LISTEN: "1", ...(bound ? { DEMOBITES_CODE_DIR: bound.folder } : {}) }, detached: process.platform !== "win32" });
|
|
738
|
+
const child = (await import("node:child_process")).spawn(process.execPath, [path.join(dest, "scripts", "plan.mjs"), takeDir], { cwd: process.cwd(), stdio: "inherit", env: { ...process.env, RECORDER_JOB: job.id, RECORDER_BASE: base, RECORDER_LISTEN: "1", ...(bound ? { DEMOBITES_CODE_DIR: bound.folder } : {}), ...profileFor(job) }, detached: process.platform !== "win32" });
|
|
709
739
|
track(child);
|
|
710
740
|
const code = await new Promise((resolve) => child.on("close", resolve));
|
|
711
741
|
const st = await api({ action: "poll", job_id: job.id });
|
package/package.json
CHANGED
package/scripts/record.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import { chromium } from "playwright";
|
|
13
13
|
import fs from "node:fs";
|
|
14
14
|
import path from "node:path";
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { ffmpeg as FFMPEG } from "./media-tools.mjs";
|
|
15
17
|
// LISTENING (2026-10-14): the app's mailbox, when `npx demobite listen` runs
|
|
16
18
|
// this take; a no-op everywhere else (the terminal flow, the cloud sandbox
|
|
17
19
|
// copy that may not ship listen-event.mjs).
|
|
@@ -385,14 +387,64 @@ const postFrame = async () => {
|
|
|
385
387
|
const within = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`timeout ${ms} ms`)), ms))]);
|
|
386
388
|
try {
|
|
387
389
|
frameCdp ??= await within(ctx.newCDPSession(page), 4000);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
390
|
+
// THE CAMERA'S VIEWPORT IS NEVER TOUCHED (founder's take of 2026-10-18, bite 1234: grey quarter-size frames at
|
|
391
|
+
// 0.6 s, 8.3 s, 11.1 s, 16.1 s). A captureScreenshot with a clip at scale 0.25 made Chromium emulate that scale
|
|
392
|
+
// for the capture, and the video screencast recorded those frames. So: capture at scale 1, no clip, and shrink
|
|
393
|
+
// the JPEG OUTSIDE the page with ffmpeg. No ffmpeg → no thumbnail, never a glitch.
|
|
394
|
+
const { data } = await within(frameCdp.send("Page.captureScreenshot", { format: "jpeg", quality: 60 }), 4000);
|
|
395
|
+
const small = await within(shrinkJpeg(Buffer.from(data, "base64"), 480), 4000);
|
|
396
|
+
if (small) await within(postEvent("frame", { ...frameStep, jpeg: small.toString("base64") }), 8000);
|
|
391
397
|
} catch (e) {
|
|
392
398
|
if (!frameWarned) { frameWarned = true; console.error(`frame: dropped (${String(e?.message ?? e).split("\n")[0]})`); }
|
|
393
399
|
} finally { framing = false; }
|
|
394
400
|
};
|
|
395
401
|
const framer = setInterval(() => void postFrame(), FRAME_EVERY_MS);
|
|
402
|
+
/** A JPEG scaled to `width` by ffmpeg, in memory; null when ffmpeg is not there. */
|
|
403
|
+
let ffmpegPath;
|
|
404
|
+
function shrinkJpeg(buf, width) {
|
|
405
|
+
if (ffmpegPath === undefined) { try { ffmpegPath = FFMPEG() || null; } catch { ffmpegPath = null; } }
|
|
406
|
+
if (!ffmpegPath) return Promise.resolve(null);
|
|
407
|
+
return new Promise((resolve) => {
|
|
408
|
+
const ff = spawn(ffmpegPath, ["-hide_banner", "-loglevel", "error", "-f", "image2pipe", "-i", "pipe:0", "-vf", `scale=${width}:-2`, "-q:v", "7", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1"], { stdio: ["pipe", "pipe", "ignore"] });
|
|
409
|
+
const out = [];
|
|
410
|
+
ff.stdout.on("data", (d) => out.push(d));
|
|
411
|
+
ff.on("error", () => resolve(null));
|
|
412
|
+
ff.on("close", (code) => resolve(code === 0 && out.length ? Buffer.concat(out) : null));
|
|
413
|
+
ff.stdin.on("error", () => {});
|
|
414
|
+
ff.stdin.end(buf);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* THE PICTURE IS STILL (founder 2026-10-18: "wait till the page loads before you're actually recording"; bite 1234
|
|
420
|
+
* opened on skeleton cards). Paint is not load: text and boxes exist while thumbnails and skeletons still shimmer.
|
|
421
|
+
* This waits until no skeleton / busy / shimmer element shows AND two stills 350 ms apart are identical, up to
|
|
422
|
+
* `maxMs`. Stills are full-frame captures at scale 1: they never touch the camera's viewport.
|
|
423
|
+
*/
|
|
424
|
+
async function settledPicture(maxMs, label) {
|
|
425
|
+
const deadline = Date.now() + maxMs;
|
|
426
|
+
let prev = null;
|
|
427
|
+
while (Date.now() < deadline) {
|
|
428
|
+
const busy = await page.evaluate(() => {
|
|
429
|
+
let n = 0;
|
|
430
|
+
for (const el of document.querySelectorAll('.animate-pulse, [class*="skeleton" i], [class*="shimmer" i], [aria-busy="true"], [data-loading="true"]')) {
|
|
431
|
+
const r = el.getBoundingClientRect();
|
|
432
|
+
if (r.width > 8 && r.height > 8) n++;
|
|
433
|
+
}
|
|
434
|
+
return n;
|
|
435
|
+
}).catch(() => 0);
|
|
436
|
+
let shot = null;
|
|
437
|
+
try {
|
|
438
|
+
frameCdp ??= await ctx.newCDPSession(page);
|
|
439
|
+
shot = (await frameCdp.send("Page.captureScreenshot", { format: "jpeg", quality: 25 })).data;
|
|
440
|
+
} catch { frameCdp = null; }
|
|
441
|
+
if (!busy && shot && prev && shot === prev) return true;
|
|
442
|
+
prev = shot;
|
|
443
|
+
await page.waitForTimeout(350);
|
|
444
|
+
}
|
|
445
|
+
console.log(`STILL GATE (${label}): the picture kept changing for ${Math.round(maxMs / 1000)} s — going on anyway.`);
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
396
448
|
|
|
397
449
|
/** Ask the element what cursor it shows. Takes the locator visibleBox already
|
|
398
450
|
* resolved — re-scanning cost ~1s of dead video per step, which inflated a
|
|
@@ -836,6 +888,7 @@ try {
|
|
|
836
888
|
if (painted) break;
|
|
837
889
|
await page.waitForTimeout(200);
|
|
838
890
|
}
|
|
891
|
+
await settledPicture(6000, "navigation");
|
|
839
892
|
await page.waitForTimeout(350);
|
|
840
893
|
rec.nav = { from: navIssuedAt, to: t() };
|
|
841
894
|
}
|
|
@@ -871,6 +924,7 @@ try {
|
|
|
871
924
|
if (!painted) {
|
|
872
925
|
console.log("PAINT GATE: page never showed real content within 15s — the take would open on a blank frame. Judge the footage carefully.");
|
|
873
926
|
}
|
|
927
|
+
await settledPicture(8000, "first frame");
|
|
874
928
|
const nextStep = STORYBOARD.steps[STORYBOARD.steps.indexOf(step) + 1];
|
|
875
929
|
if (nextStep?.action === "expect" && nextStep.selector) {
|
|
876
930
|
const sel = nextStep.text
|
|
@@ -1073,7 +1127,7 @@ try {
|
|
|
1073
1127
|
// the dialog opened in the middle of the screen. A step framed wide
|
|
1074
1128
|
// already frames its result; a click that changed the URL leaves the
|
|
1075
1129
|
// landing to the settle that follows (framed wide there).
|
|
1076
|
-
if (page.url() !== urlBeforeClick) landingPending = true;
|
|
1130
|
+
if (page.url() !== urlBeforeClick) { landingPending = true; await settledPicture(5000, "landing"); }
|
|
1077
1131
|
if (step.reveals !== false && !stepWide) {
|
|
1078
1132
|
const opened = await revealedBox(step);
|
|
1079
1133
|
if (opened) {
|
|
@@ -1169,6 +1223,7 @@ try {
|
|
|
1169
1223
|
await page.waitForLoadState("domcontentloaded", { timeout: 30000 }).catch(() => {});
|
|
1170
1224
|
if (page.url() !== urlBefore) {
|
|
1171
1225
|
await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => {});
|
|
1226
|
+
await settledPicture(5000, "landing");
|
|
1172
1227
|
await ensureCursor();
|
|
1173
1228
|
await applyHideCss();
|
|
1174
1229
|
await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [x, y]);
|
package/scripts/trim.mjs
CHANGED
|
@@ -150,6 +150,51 @@ execFileSync(FFMPEG(), [
|
|
|
150
150
|
clean,
|
|
151
151
|
], { stdio: "inherit" });
|
|
152
152
|
|
|
153
|
+
// GLITCH GUARD (founder 2026-10-18, bite 1234: "extremely bad glitches"): a frame the camera should never have
|
|
154
|
+
// produced — the page painted small over a grey canvas (a viewport emulation caught by the screencast) — must never
|
|
155
|
+
// reach a demo, whatever caused it. Each such frame is replaced by the last good one (freezeframes), the count is
|
|
156
|
+
// logged and kept in the manifest. Detection: the bottom-right quadrant is flat mid-grey (Y≈128, almost no spread);
|
|
157
|
+
// real UI there is white, dark or busy. Needs signalstats + freezeframes in the ffmpeg build; without them it skips.
|
|
158
|
+
try {
|
|
159
|
+
const filters = execFileSync(FFMPEG(), ["-hide_banner", "-filters"], { stdio: ["ignore", "pipe", "ignore"] }).toString();
|
|
160
|
+
if (/\bfreezeframes\b/.test(filters) && /\bsignalstats\b/.test(filters)) {
|
|
161
|
+
const stats = spawnSync(FFMPEG(), ["-hide_banner", "-loglevel", "error", "-i", clean, "-vf", "crop=iw/2:ih/2:iw/2:ih/2,signalstats,metadata=print:file=-", "-f", "null", "-"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
162
|
+
const bad = [];
|
|
163
|
+
let frame = -1, yavg = null, ymin = null, ymax = null;
|
|
164
|
+
const flush = () => { if (frame >= 0 && yavg !== null && yavg >= 118 && yavg <= 138 && ymin !== null && ymax !== null && ymax - ymin <= 24) bad.push(frame); };
|
|
165
|
+
for (const line of (stats.stdout || "").split("\n")) {
|
|
166
|
+
const f = line.match(/^frame:(\d+)/);
|
|
167
|
+
if (f) { flush(); frame = Number(f[1]); yavg = ymin = ymax = null; continue; }
|
|
168
|
+
const m = line.match(/lavfi\.signalstats\.(YAVG|YMIN|YMAX)=([\d.]+)/);
|
|
169
|
+
if (!m) continue;
|
|
170
|
+
if (m[1] === "YAVG") yavg = Number(m[2]); else if (m[1] === "YMIN") ymin = Number(m[2]); else ymax = Number(m[2]);
|
|
171
|
+
}
|
|
172
|
+
flush();
|
|
173
|
+
const total = frame + 1;
|
|
174
|
+
if (bad.length && bad.length < total * 0.5) {
|
|
175
|
+
const runs = [];
|
|
176
|
+
for (const n of bad) { const r = runs[runs.length - 1]; if (r && n === r.last + 1) r.last = n; else runs.push({ first: n, last: n }); }
|
|
177
|
+
// Replace each run with the frame before it; a run at the very start takes the first good frame after it.
|
|
178
|
+
const chain = runs.map((r) => ({ ...r, replace: r.first > 0 ? r.first - 1 : Math.min(total - 1, r.last + 1) })).filter((r) => r.replace >= 0 && r.replace < total);
|
|
179
|
+
const labels = chain.map((_, i) => `[r${i}]`).join("");
|
|
180
|
+
let graph = `[0:v]split=${chain.length + 1}[a0]${labels};`;
|
|
181
|
+
chain.forEach((r, i) => { graph += `[a${i}][r${i}]freezeframes=first=${r.first}:last=${r.last}:replace=${r.replace}[a${i + 1}];`; });
|
|
182
|
+
graph += `[a${chain.length}]format=yuv420p[out]`;
|
|
183
|
+
const fixed = path.join(dir, "clean-fixed.mp4");
|
|
184
|
+
void postEvent("finishing", { text: `Replacing ${bad.length} glitch frame${bad.length === 1 ? "" : "s"}…` });
|
|
185
|
+
execFileSync(FFMPEG(), ["-y", "-loglevel", "error", "-i", clean, "-filter_complex", graph, "-map", "[out]", "-c:v", "libx264", "-preset", "medium", "-crf", "19", "-movflags", "+faststart", fixed], { stdio: "inherit" });
|
|
186
|
+
fs.renameSync(fixed, clean);
|
|
187
|
+
man.glitch = { frames: bad.length, windows: chain.map((r) => ({ first: r.first, last: r.last })) };
|
|
188
|
+
fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
|
|
189
|
+
console.log(`glitch guard: ${bad.length} frame${bad.length === 1 ? "" : "s"} in ${chain.length} window${chain.length === 1 ? "" : "s"} replaced by the last good frame (${chain.map((r) => `${(r.first / 30).toFixed(2)}–${((r.last + 1) / 30).toFixed(2)}s`).join(", ")})`);
|
|
190
|
+
} else if (bad.length) {
|
|
191
|
+
console.log(`glitch guard: ${bad.length} of ${total} frames look padded — too many to be a glitch, left as filmed`);
|
|
192
|
+
} else console.log(`glitch guard: clean (${total} frames)`);
|
|
193
|
+
} else console.log("glitch guard: skipped (ffmpeg build without signalstats/freezeframes)");
|
|
194
|
+
} catch (e) {
|
|
195
|
+
console.error("glitch guard: skipped (" + String(e?.message ?? e).split("\n")[0] + ")");
|
|
196
|
+
}
|
|
197
|
+
|
|
153
198
|
const dur = execFileSync(FFPROBE(), [
|
|
154
199
|
"-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", clean,
|
|
155
200
|
]).toString().trim();
|
package/skill/scripts/look.mjs
CHANGED
|
@@ -20,7 +20,7 @@ const host = target.host;
|
|
|
20
20
|
say(`Knocking on ${host}…`);
|
|
21
21
|
|
|
22
22
|
let ledger = {};
|
|
23
|
-
try { ledger = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), ".recorder", "signins.json"), "utf8")).origins ?? {}; } catch { /* none yet */ }
|
|
23
|
+
try { ledger = JSON.parse(fs.readFileSync(process.env.RECORDER_LEDGER ? path.resolve(process.env.RECORDER_LEDGER) : path.resolve(process.cwd(), ".recorder", "signins.json"), "utf8")).origins ?? {}; } catch { /* none yet */ }
|
|
24
24
|
|
|
25
25
|
const { chromium } = await import("playwright");
|
|
26
26
|
const profileDir = process.env.RECORDER_PROFILE || path.resolve(process.cwd(), ".recorder", "profile");
|
package/skill/scripts/signin.mjs
CHANGED
|
@@ -29,7 +29,9 @@ import path from "node:path";
|
|
|
29
29
|
import { postEvent } from "./listen-event.mjs";
|
|
30
30
|
|
|
31
31
|
const argv = process.argv.slice(2);
|
|
32
|
-
|
|
32
|
+
// PERSONAS (1.27.0, founder 2026-10-17: "three different users sign in, three tabs"): a tab has its own profile
|
|
33
|
+
// and its own ledger (RECORDER_PROFILE / RECORDER_LEDGER from the listener); the defaults are the machine's.
|
|
34
|
+
const ledgerPath = process.env.RECORDER_LEDGER ? path.resolve(process.env.RECORDER_LEDGER) : path.resolve(process.cwd(), ".recorder", "signins.json");
|
|
33
35
|
const readLedger = () => { try { return JSON.parse(fs.readFileSync(ledgerPath, "utf8")); } catch { return { origins: {} }; } };
|
|
34
36
|
if (argv[0] === "--check") {
|
|
35
37
|
let origin; try { origin = new URL(argv[1]).origin; } catch { console.error("Usage: node scripts/signin.mjs --check <url>"); process.exit(2); }
|
|
@@ -41,6 +43,8 @@ const { chromium } = await import("playwright"); // after --check, which needs n
|
|
|
41
43
|
const url = argv.find((a) => !a.startsWith("--"));
|
|
42
44
|
if (!url) { console.error("Usage: node scripts/signin.mjs <login url> [--timeout <ms>] [--probe]"); process.exit(2); }
|
|
43
45
|
const probe = argv.includes("--probe");
|
|
46
|
+
// --fresh: sign in as a different user — the site's cookies in THIS profile go first, so the wall shows.
|
|
47
|
+
const fresh = argv.includes("--fresh");
|
|
44
48
|
const timeoutMs = Number(argv[argv.indexOf("--timeout") + 1]) || 10 * 60 * 1000;
|
|
45
49
|
const profileDir = process.env.RECORDER_PROFILE || path.resolve(process.cwd(), ".recorder", "profile");
|
|
46
50
|
|
|
@@ -60,6 +64,15 @@ try {
|
|
|
60
64
|
ctx = await chromium.launchPersistentContext(profileDir, launchOpts);
|
|
61
65
|
}
|
|
62
66
|
const page = ctx.pages()[0] ?? (await ctx.newPage());
|
|
67
|
+
if (fresh) {
|
|
68
|
+
try {
|
|
69
|
+
const host = new URL(url).hostname;
|
|
70
|
+
const site = host.split(".").slice(-2).join(".");
|
|
71
|
+
await ctx.clearCookies({ domain: new RegExp(`(^|\\.)${site.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") });
|
|
72
|
+
try { const l = readLedger(); for (const o of Object.keys(l.origins ?? {})) { try { if (new URL(o).hostname.endsWith(site)) { delete l.origins[o]; if (l.who) delete l.who[o]; } } catch { /* skip */ } } fs.writeFileSync(ledgerPath, JSON.stringify(l, null, 2) + "\n"); } catch { /* fine */ }
|
|
73
|
+
console.log(`· Forgot ${site} in this tab's profile; sign in as the other user.`);
|
|
74
|
+
} catch { /* the wall may still show */ }
|
|
75
|
+
}
|
|
63
76
|
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
64
77
|
const startPath = new URL(page.url()).pathname;
|
|
65
78
|
|
|
@@ -32,7 +32,7 @@ export const siteOf = (hostname) => {
|
|
|
32
32
|
const site = siteOf(target.hostname);
|
|
33
33
|
const ofSite = (hostname) => { const h = String(hostname).replace(/^\./, "").toLowerCase(); return h === site || h.endsWith("." + site); };
|
|
34
34
|
|
|
35
|
-
const ledgerPath = path.resolve(process.cwd(), ".recorder", "signins.json");
|
|
35
|
+
const ledgerPath = process.env.RECORDER_LEDGER ? path.resolve(process.env.RECORDER_LEDGER) : path.resolve(process.cwd(), ".recorder", "signins.json");
|
|
36
36
|
let ledger = { origins: {} };
|
|
37
37
|
try { ledger = JSON.parse(fs.readFileSync(ledgerPath, "utf8")); } catch { /* none */ }
|
|
38
38
|
const origins = Object.keys(ledger.origins ?? {}).filter((o) => { try { return ofSite(new URL(o).hostname); } catch { return false; } });
|