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

@ -26,6 +26,7 @@ import type { ShipConfig } from '../lib/config';
import type { FormId } from '../lib/console-answers';
import { hasToken, type Name, NAV_BASES, type NavBases, type NavStep, navPlan, playAppIdOf, playDeveloperId, switchAllowed } from '../lib/console-forms';
import { ACTION_MS, accName, findAny, type Locator, navClick, navigate, WAITS, type NavResult, type Page, poll, realRole, safeClick, signInPage, SignInSeen, type Stop, vis } from './fill';
import * as rt from '../lib/rt';
// ---- the cache ----
@ -235,7 +236,7 @@ export async function switchTeam(page: Page, name: string, listed: string[], ms:
if (!switchAllowed(real, role, listed)) { await closeMenu(page); return false; }
await it.loc.click({ timeout: ACTION_MS });
await page.waitForLoadState('domcontentloaded', { timeout: ms }).catch(() => { /* same page, or slow: read what is there */ });
await Bun.sleep(WAITS.settleMs); // the switched team's page settles
await rt.sleep(WAITS.settleMs); // the switched team's page settles
return true;
}

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(); }

View file

@ -43,6 +43,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync
import { homedir, tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { chromiumFor, installedBrowsers, type LaunchDeps, persistentExecutable, profileHolder, sandboxArgs } from '../shots/browser';
import * as rt from '../lib/rt';
/* eslint-disable @typescript-eslint/no-explicit-any */
@ -135,15 +136,15 @@ export function cookieApplies(hostKey: string, hosts: string[] = CONSOLE_HOSTS):
/** Deletes every cookie of the copied DB that the consoles never get. Returns counts only (never values or names). */
export async function filterCookies(dbPath: string, hosts: string[] = CONSOLE_HOSTS): Promise<{ kept: number; removed: number }> {
const { Database } = await import('bun:sqlite');
const db = new Database(dbPath);
// bun:sqlite under Bun, node:sqlite on Node 22.5+; without SQLite (Node 20) this throws and the caller says so
const db = await rt.openSqlite(dbPath);
try {
const keys = (db.query('SELECT DISTINCT host_key AS h FROM cookies').all() as { h: string }[]).map(r => r.h);
const keys = db.all<{ h: string }>('SELECT DISTINCT host_key AS h FROM cookies').map(r => r.h);
const drop = keys.filter(k => !cookieApplies(k, hosts));
const count = (db.query('SELECT COUNT(*) AS n FROM cookies').get() as { n: number }).n;
const del = db.prepare('DELETE FROM cookies WHERE host_key = ?');
db.transaction(() => { for (const k of drop) del.run(k); })();
const kept = (db.query('SELECT COUNT(*) AS n FROM cookies').get() as { n: number }).n;
const count = Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM cookies')!.n);
db.exec('BEGIN');
try { for (const k of drop) db.run('DELETE FROM cookies WHERE host_key = ?', k); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; }
const kept = Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM cookies')!.n);
db.exec('VACUUM');
return { kept, removed: count - kept };
} finally { db.close(); }
@ -163,7 +164,7 @@ export async function copySession(src: string, opts: { profile?: string; hosts?:
const dir = mkdtempSync(join(opts.tmpRoot ?? tmpdir(), 'storebound-chrome-'));
const cleanup = () => {
// Chrome may still write a file while it exits: retry, and only stop once the folder is gone
for (let i = 0; i < 5 && existsSync(dir); i++) { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } catch { Bun.sleepSync(100); } }
for (let i = 0; i < 5 && existsSync(dir); i++) { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } catch { rt.sleepSync(100); } }
};
try {
for (const [s, d] of files) { mkdirSync(dirname(join(dir, d)), { recursive: true }); copyFileSync(join(src, s), join(dir, d)); }
@ -234,7 +235,7 @@ export interface OpenOpts {
/** Runs a graceful close, at most `ms`; errors (already closed) are ignored. */
async function gracefully(fn: () => Promise<unknown>, ms = 10_000): Promise<void> {
await Promise.race([Promise.resolve().then(fn).catch(() => { /* already closed */ }), Bun.sleep(ms)]);
await Promise.race([Promise.resolve().then(fn).catch(() => { /* already closed */ }), rt.sleep(ms)]);
}
/**
@ -284,14 +285,14 @@ async function startOwnChrome(root: string, mode: 'copy' | 'profile', exe: { lab
const portFile = join(userDataDir, 'DevToolsActivePort');
rmSync(portFile, { force: true });
const args = consoleChromeArgs(userDataDir, { headless: o.headless ?? false });
const proc = Bun.spawn([exe.path, ...args], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' });
const proc = rt.spawn([exe.path, ...args], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' });
let exited = false;
const gone = proc.exited.then(() => { exited = true; });
const end = Date.now() + (o.launchTimeoutMs ?? 30_000);
let endpoint: string | null = null;
while (!endpoint && !exited && Date.now() < end) { endpoint = devToolsEndpoint(userDataDir); if (!endpoint) await Bun.sleep(100); }
while (!endpoint && !exited && Date.now() < end) { endpoint = devToolsEndpoint(userDataDir); if (!endpoint) await rt.sleep(100); }
if (!endpoint) {
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, Bun.sleep(5000)]); }
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, rt.sleep(5000)]); }
throw new Error(`${exe.label} did not start${exited ? ` (exit code ${proc.exitCode})` : ' in time'}`);
}
let browser: any;
@ -299,18 +300,18 @@ async function startOwnChrome(root: string, mode: 'copy' | 'profile', exe: { lab
const pw = o.launchDeps?.pw ?? await chromiumFor(root);
browser = await pw.connectOverCDP(endpoint, { timeout: Math.max(5000, end - Date.now()) });
} catch (e) {
proc.kill('SIGTERM'); await Promise.race([gone, Bun.sleep(5000)]);
proc.kill('SIGTERM'); await Promise.race([gone, rt.sleep(5000)]);
throw new Error(`${exe.label}: could not attach (${(e as Error).message.split('\n')[0]})`, { cause: e });
}
const context = browser.contexts()[0] ?? await browser.newContext();
const close = async () => {
if (exited) return;
await gracefully(async () => { const s = await browser.newBrowserCDPSession(); await s.send('Browser.close'); });
await Promise.race([gone, Bun.sleep(10_000)]);
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, Bun.sleep(5000)]); }
await Promise.race([gone, rt.sleep(10_000)]);
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, rt.sleep(5000)]); }
};
let closed = false;
const done = gone.then(async () => { closed = true; cleanup(); await Bun.sleep(300); cleanup(); });
const done = gone.then(async () => { closed = true; cleanup(); await rt.sleep(300); cleanup(); });
const page = await ownTab(context);
const watch = (p: any) => p.on('close', () => { if (!exited && context.pages().length === 0) void close(); });
for (const p of context.pages()) watch(p);

View file

@ -12,9 +12,10 @@ import type { Ctx } from '../lib/ctx';
import { isPlanForm, PLAN_FORM_IDS } from '../lib/console-answers';
import { verifyAgeRatingApi, verifyAppRecordApi, type VerifyResult, verifyTable, verifyText } from '../lib/console-verify';
import { printJson } from '../lib/log';
import * as rt from '../lib/rt';
async function readText(src: string): Promise<string> {
if (src === '-' || src === 'true') return Bun.stdin.text();
if (src === '-' || src === 'true') return rt.stdinText();
return readFileSync(src, 'utf8');
}