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:
parent
614c32bcba
commit
36a8bf9625
73 changed files with 1195 additions and 251 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue