CLI runs on Node 20+: npx storebound (bunx keeps working)

- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and
  "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot,
  config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it
- tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded
  (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci
- package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20
- bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help,
  init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/
- install writes npx MCP commands when run with Node; bin/storebound falls back to Node
- docs: npx storebound init (Node 20+) or bunx storebound init
This commit is contained in:
CRTkafa 2026-10-13 04:03:09 +08:00
parent 614c32bcba
commit 36a8bf9625
73 changed files with 1195 additions and 251 deletions

View file

@ -39,6 +39,7 @@ import { openAcross, withAccountIds } from './accounts';
import { appleAppId, withAppleAppId } from './app-id';
import { bareUrl } from './debug';
import { type ConsoleBrowser, GOOGLE_REJECTED, googleRejected, openConsoleBrowser, parseUseChromeSession } from './session';
import * as rt from '../lib/rt';
/* eslint-disable @typescript-eslint/no-explicit-any */
export type Page = any;
@ -686,7 +687,7 @@ export async function poll<T>(ms: number, fn: () => Promise<T | null>, stop?: St
try { const r = await fn(); if (r !== null) return r; } catch { /* navigating */ }
if (Date.now() >= end) return null;
if (page && WAITS.quietMs > 0 && await quietFor(page) >= WAITS.quietMs) return null;
await Bun.sleep(WAITS.pollMs);
await rt.sleep(WAITS.pollMs);
}
}
@ -989,7 +990,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
// the type's last step was saved: what comes next is the App Privacy page again
const closing = !!t && def.sections.every((x, i) => x.type !== t || done.has(i));
progress(t && !closing ? `the "${t}" dialog step after "${name}"` : `the page after "${t ? `${t}: ` : ''}${name}"`);
if (o.debugEveryStep) { const p = active(); if (p) void Bun.sleep(tick).then(() => dump(p, t && !/^Set Up /.test(name) ? `${t} after ${name}` : `after ${name}`)).catch(() => { /* gone */ }); }
if (o.debugEveryStep) { const p = active(); if (p) void rt.sleep(tick).then(() => dump(p, t && !/^Set Up /.test(name) ? `${t} after ${name}` : `after ${name}`)).catch(() => { /* gone */ }); }
};
const meta = formMeta(form, config), res = consoleResult(form, config);
const active = (): Page | undefined => b.pages().at(-1);
@ -1056,7 +1057,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
}
} else if (p && reached(p)) { if (++ok >= 2) { if (told) say('signed in'); lastWhy = ''; return true; } }
else ok = 0;
await Bun.sleep(tick);
await rt.sleep(tick);
}
return false;
}
@ -1297,13 +1298,13 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
while (!b.isClosed() && !finished()) {
const p = active();
if (!await watch(p)) break;
if (!p) { await Bun.sleep(tick); continue; }
if (!p) { await rt.sleep(tick); continue; }
const w = await signInReason(p).catch(() => ({ rule: 'the page could not be read', url: bareUrl(p.url()) }));
if (w) {
sayWhy(w);
if (!signedOut && plan.length) signInHint();
signedOut = true;
await Bun.sleep(tick); continue;
await rt.sleep(tick); continue;
}
// sign-in finished while watching: open the form again from step 1 (nothing filled yet)
if (signedOut) {
@ -1328,13 +1329,13 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
}
}
let s: FillStep;
try { s = await fillPage(p, def, pageAnswers, done, false, currentType); } catch { await Bun.sleep(tick); continue; } // navigating
try { s = await fillPage(p, def, pageAnswers, done, false, currentType); } catch { await rt.sleep(tick); continue; } // navigating
if (s.section !== null) {
// a page still loading can miss a field for a moment: only a miss that lasts (3 rounds, at least WAITS.missMs) is a UI change
if (s.fallback && (++misses < 3 || Date.now() - firstMiss < WAITS.missMs)) { if (misses === 1) firstMiss = Date.now(); await Bun.sleep(tick); continue; }
if (s.fallback && (++misses < 3 || Date.now() - firstMiss < WAITS.missMs)) { if (misses === 1) firstMiss = Date.now(); await rt.sleep(tick); continue; }
misses = 0;
// the miss lasts: fill every field that was found, leave the missing ones to the owner (only all-missing is a UI change)
if (s.fallback) { try { s = await fillPage(p, def, pageAnswers, done, true, currentType); } catch { await Bun.sleep(tick); continue; } }
if (s.fallback) { try { s = await fillPage(p, def, pageAnswers, done, true, currentType); } catch { await rt.sleep(tick); continue; } }
if (!s.fallback && s.missing.length) warn(TAG, `${s.title}: could not find ${s.missing.map(id => labels.get(id) ?? id).join(', ')} on the page (the console UI may have changed): filling ${s.missing.length === 1 ? 'it' : 'them'} is left to ${agentPart(form)}`);
if (s.fallback) {
fallback = true;
@ -1367,7 +1368,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
continue; // more of the form may be on this page
}
if (waitFor) {
if (!await answered(waitFor.pending)) { await Bun.sleep(tick); continue; }
if (!await answered(waitFor.pending)) { await rt.sleep(tick); continue; }
say(`"${waitFor.title}" answered: storebound goes on`);
progress(`the next part after "${waitFor.title}"`);
waitFor = null;
@ -1376,7 +1377,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
pendingAdvance = false;
const moved = await advance(p).catch(() => false);
if (moved === 'draft') pendingAdvance = true; // Next follows the saved draft
if (moved) { await Bun.sleep(tick); continue; }
if (moved) { await rt.sleep(tick); continue; }
}
// an App Privacy dialog step with nothing to answer (the "Let's define two important terms" page between linked and
// tracking): no inputs, only Back / Cancel / Next -> Next is navigation
@ -1386,10 +1387,10 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
if (info && (infoRounds += 1) >= 2) {
infoRounds = 0;
const next = await controlWhere(info, FLOW_NAMES.next, () => true, dataTypes);
if (next && await flowClick(next, form, dataTypes, say, clicked)) { say('an information-only step of the dialog: storebound pressed Next'); progress('the next step of the dialog'); await Bun.sleep(tick); continue; }
if (next && await flowClick(next, form, dataTypes, say, clicked)) { say('an information-only step of the dialog: storebound pressed Next'); progress('the next step of the dialog'); await rt.sleep(tick); continue; }
}
if (privacy) await privacyStep(p).catch(() => { /* navigating */ });
await Bun.sleep(tick);
await rt.sleep(tick);
}
if (timedOut) {
say(`nothing storebound knows happened for ${mins(idleMs)}: it stops here and closes the window${o.debugDir && dumps ? ` (debug: ${o.debugDir})` : ''}. Answers saved as drafts stay; run it again when the page is fixed.`);
@ -1403,7 +1404,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
// the owner reviews and presses the final button, then closes the window: a reminder every 10 heartbeats, at most finalMs
const end = Date.now() + finalMs;
while (!b.isClosed() && Date.now() < end) {
await Promise.race([b.closed, Bun.sleep(Math.min(10 * heartbeatMs, Math.max(0, end - Date.now())))]);
await Promise.race([b.closed, rt.sleep(Math.min(10 * heartbeatMs, Math.max(0, end - Date.now())))]);
if (!b.isClosed() && Date.now() < end) say('still open: storebound waits for you to review, press the final button and close the window');
}
if (!b.isClosed()) { say(`the window has been open for ${mins(finalMs)} after the fill: storebound closes it now`); await b.close(); }