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
43
tools/build-node.ts
Normal file
43
tools/build-node.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env bun
|
||||
// Builds the npm package's CLI: one Node ESM bundle, dist/storebound.mjs (package.json "bin"), so `npx storebound`
|
||||
// runs on Node 20+ and `bunx storebound` keeps working. Zero runtime dependencies: playwright stays an optional peer
|
||||
// resolved from the project at run time (playwright-core only in the compiled sidecar), so it is left out.
|
||||
// bun tools/build-node.ts write dist/storebound.mjs (npm pack / publish run it: prepack)
|
||||
// bun tools/build-node.ts --out <f> write elsewhere (tests)
|
||||
// The bundle is checked for this machine's home and checkout paths (like the sidecar): nothing private may ship.
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { privateStrings, ROOT } from './build-sidecars';
|
||||
|
||||
/** Packages the CLI only imports at run time from the project (or never under Node). */
|
||||
export const NODE_EXTERNALS = ['playwright', 'playwright-core', 'electron', 'chromium-bidi'];
|
||||
export const NODE_BUNDLE = join(ROOT, 'dist', 'storebound.mjs');
|
||||
|
||||
export function buildNode(out = NODE_BUNDLE): { path: string; bytes: number } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-node-'));
|
||||
try {
|
||||
const tmp = join(dir, 'storebound.mjs');
|
||||
const p = Bun.spawnSync(['bun', 'build', 'cli/index.ts', '--target=node', '--format=esm', ...NODE_EXTERNALS.flatMap(e => ['--external', e]), '--outfile', tmp],
|
||||
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
|
||||
if (p.exitCode !== 0) throw new Error(`bun build failed\n${p.stderr.toString()}${p.stdout.toString()}`);
|
||||
// the entry's own `#!/usr/bin/env bun` and Bun's `// @bun` marker go; node runs this file
|
||||
const body = readFileSync(tmp, 'utf8').replace(/^#!.*\n/, '').replace(/^\/\/ @bun[^\n]*\n/, '');
|
||||
const js = `#!/usr/bin/env node\n${body}`;
|
||||
const bunGlobal = /\bBun\.[a-zA-Z]/.exec(js);
|
||||
if (bunGlobal) throw new Error(`the Node bundle uses the Bun global (${bunGlobal[0]}...): go through cli/lib/rt.ts`);
|
||||
const leaked = privateStrings().filter(s => js.includes(s));
|
||||
if (leaked.length) throw new Error(`the Node bundle contains private paths (${leaked.length}); refusing to write it`);
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
writeFileSync(out, js);
|
||||
chmodSync(out, 0o755);
|
||||
return { path: out, bytes: statSync(out).size };
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const i = process.argv.indexOf('--out');
|
||||
const t = Date.now();
|
||||
const r = buildNode(i >= 0 ? resolve(process.argv[i + 1]!) : NODE_BUNDLE);
|
||||
console.log(`node bundle -> ${relative(process.cwd(), r.path) || r.path} (${(r.bytes / 1e6).toFixed(2)} MB, ${((Date.now() - t) / 1000).toFixed(1)}s)`);
|
||||
}
|
||||
13
tools/ci.ts
13
tools/ci.ts
|
|
@ -33,6 +33,14 @@ const add = (t: Task) => tasks.push(t);
|
|||
const tsc = (name: string, project: string) => add({ name, cmd: ['bunx', 'tsc', '--noEmit', '--incremental', '-p', project,
|
||||
'--tsBuildInfoFile', join(cache, `${name.replace(/\W+/g, '-')}.tsbuildinfo`)] });
|
||||
const junitDir = mkdtempSync(join(tmpdir(), 'sb-ci-'));
|
||||
const node = Bun.which('node');
|
||||
|
||||
/** The npm bundle (dist/storebound.mjs, Node 20+) and its smoke test with the real `node` (tools/node-smoke.ts). */
|
||||
function addNode() {
|
||||
add({ name: 'node bundle', cmd: ['bun', 'tools/build-node.ts'] });
|
||||
if (node) add({ name: 'node smoke', cmd: ['bun', 'tools/node-smoke.ts', '--node', node], after: ['node bundle'] });
|
||||
else console.log('ci: no `node` on PATH: the Node smoke test of the npm bundle is skipped');
|
||||
}
|
||||
|
||||
function addTests(files: string[]) {
|
||||
planShards(files).forEach((shard, i, all) => {
|
||||
|
|
@ -53,7 +61,9 @@ if (quick) {
|
|||
console.log(`ci:quick: ${changed === null ? 'no origin/main to diff against: every test' : `${changed.length} changed file(s), ${related.length} related test file(s)`}`);
|
||||
if (far.length) console.log(`ci:quick: left out ${far.length} browser suite(s) reached only through shared modules (bun run ci runs them): ${far.join(' ')}`);
|
||||
add({ name: 'adapters', cmd: ['bun', 'tools/gen-adapters.ts', '--check'] });
|
||||
add({ name: 'embedded', cmd: ['bun', 'tools/gen-embedded.ts', '--check'] });
|
||||
tsc('typecheck', '.');
|
||||
if (touches(/^(cli\/|templates\/|tools\/(build-node|node-smoke|gen-embedded)\.ts$|package\.json$)/)) addNode();
|
||||
if (touches(/^apps\/desktop\//)) tsc('desktop typecheck', 'apps/desktop');
|
||||
if (related.length) addTests(related);
|
||||
add({ name: 'scrub', cmd: ['bun', 'tools/scrub.ts'] });
|
||||
|
|
@ -63,6 +73,9 @@ if (quick) {
|
|||
if (!cargo) console.log('ci: cargo not found (or STOREBOUND_CI_RUST=0): desktop Rust tests and clippy skipped');
|
||||
else if (!rust) console.log('ci: apps/desktop/src-tauri and the sidecar build unchanged against origin/main: desktop Rust tests and clippy skipped (CI=full runs them)');
|
||||
add({ name: 'adapters', cmd: ['bun', 'tools/gen-adapters.ts', '--check'] });
|
||||
// templates/, cli/shots/*.html and Play's form export, embedded in cli/lib/embedded.ts (every runtime and build)
|
||||
add({ name: 'embedded', cmd: ['bun', 'tools/gen-embedded.ts', '--check'] });
|
||||
addNode();
|
||||
// the App Store Connect / Google Play operation tables, the wrapped-by scan and docs/api-coverage.md match specs/ and cli/
|
||||
add({ name: 'api tables', cmd: ['bun', 'tools/gen-api.ts', '--check'] });
|
||||
// one desktop version: package.json desktopVersion = apps/desktop/package.json = tauri.conf.json = Cargo.toml / .lock
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export function generate(root: string): Map<string, string> {
|
|||
const cli = [
|
||||
'## Tools',
|
||||
'',
|
||||
'- CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`.',
|
||||
'- CLI: `storebound <command>` (`storebound help`; outside the plugin: `npx storebound` on Node 20+ or `bunx storebound` on Bun); config `storebound.config.ts`, credentials in env or `.storebound/`.',
|
||||
'- MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed.',
|
||||
'- Readiness: `storebound scan` (offline rejection risks in the project files), `storebound check` (offline), `storebound validate` (read-only, both stores).',
|
||||
'- Every endpoint: `storebound api apple|play <operationId>` (skill store-api). GETs run; writes are dry unless the owner adds `--yes` in a terminal; MCP has the read-only `api_get`.',
|
||||
|
|
|
|||
60
tools/gen-embedded.ts
Normal file
60
tools/gen-embedded.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
#!/usr/bin/env bun
|
||||
// Writes cli/lib/embedded.ts: the text files the CLI ships inside its code, so they work on every runtime and in every
|
||||
// build without a files folder next to it: the Node bundle (dist/storebound.mjs), the compiled desktop sidecar
|
||||
// (`bun build --compile`) and the sources. Bun's `import ... with { type: 'text' }` did this before, but Node cannot
|
||||
// run that. Embedded: every file under templates/ (storebound init, web init), the shots pages (cli/shots/*.html,
|
||||
// served by shots render / video) and Play's Data safety form export (cli/data/play-data-safety-form.csv).
|
||||
// bun tools/gen-embedded.ts write cli/lib/embedded.ts
|
||||
// bun tools/gen-embedded.ts --check exit 1 when the file is stale (bun run ci)
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
export const OUT = join(ROOT, 'cli/lib/embedded.ts');
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap(e => {
|
||||
if (e.name === '.DS_Store') return [];
|
||||
const p = join(dir, e.name);
|
||||
return e.isDirectory() ? walk(p) : [p];
|
||||
}).sort();
|
||||
}
|
||||
|
||||
const rel = (base: string, p: string) => relative(base, p).split('\\').join('/');
|
||||
const table = (entries: [string, string][]): string => entries.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`).join('\n');
|
||||
|
||||
export function generate(root = ROOT): string {
|
||||
const templates = walk(join(root, 'templates')).map(p => [rel(join(root, 'templates'), p), readFileSync(p, 'utf8')] as [string, string]);
|
||||
const shots = readdirSync(join(root, 'cli/shots')).filter(f => f.endsWith('.html')).sort().map(f => [f, readFileSync(join(root, 'cli/shots', f), 'utf8')] as [string, string]);
|
||||
const form = readFileSync(join(root, 'cli/data/play-data-safety-form.csv'), 'utf8');
|
||||
return [
|
||||
'// GENERATED by tools/gen-embedded.ts from templates/, cli/shots/*.html and cli/data/play-data-safety-form.csv.',
|
||||
'// Do not edit: change those files and run `bun tools/gen-embedded.ts` (bun run ci checks it is fresh).',
|
||||
'/* eslint-disable */',
|
||||
'',
|
||||
'/** Every file under templates/, by its path there (forward slashes). */',
|
||||
'export const TEMPLATE_TEXT: Record<string, string> = {',
|
||||
table(templates),
|
||||
'};',
|
||||
'',
|
||||
'/** The pages shots render / video load (cli/shots/*.html), by file name. */',
|
||||
'export const SHOTS_PAGES: Record<string, string> = {',
|
||||
table(shots),
|
||||
'};',
|
||||
'',
|
||||
"/** Play Console's Data safety form export (cli/data/play-data-safety-form.csv). */",
|
||||
`export const PLAY_DATA_SAFETY_FORM = ${JSON.stringify(form)};`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const want = generate();
|
||||
let have = '';
|
||||
try { have = readFileSync(OUT, 'utf8'); } catch { /* missing */ }
|
||||
if (process.argv.includes('--check')) {
|
||||
if (have !== want) { console.error(`stale: ${rel(ROOT, OUT)} (run bun tools/gen-embedded.ts)`); process.exit(1); }
|
||||
console.log(`${rel(ROOT, OUT)}: fresh`);
|
||||
} else if (have !== want) { writeFileSync(OUT, want); console.log(`wrote ${rel(ROOT, OUT)}`); }
|
||||
else console.log(`${rel(ROOT, OUT)}: unchanged`);
|
||||
}
|
||||
|
|
@ -49,7 +49,8 @@ function scan(entry: string, errors: string[]): Set<string> {
|
|||
if (seen.has(f)) continue;
|
||||
seen.add(f);
|
||||
const rel = relative(CLI, f);
|
||||
if (rel === 'index.ts' || rel === 'commands.ts') continue;
|
||||
// lib/embedded.ts holds file texts (templates, shots pages: their own scripts read URL params), not flag reads
|
||||
if (rel === 'index.ts' || rel === 'commands.ts' || rel === join('lib', 'embedded.ts')) continue;
|
||||
const src = readFileSync(f, 'utf8');
|
||||
for (const m of src.matchAll(READ)) if (!NOT_ARGS.test(m[1]!)) flags.add(m[3]!);
|
||||
for (const fl of DYNAMIC[rel] ?? []) flags.add(fl);
|
||||
|
|
|
|||
124
tools/node-smoke.ts
Normal file
124
tools/node-smoke.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env bun
|
||||
// Smoke test of the npm bundle (dist/storebound.mjs) with the real `node` binary: the CLI must run on Node 20+.
|
||||
// bun tools/node-smoke.ts [--bundle dist/storebound.mjs] [--node <path>]
|
||||
// Offline only: --version, help, check on examples/demo-game (same exit code and problems as under Bun), scan on the
|
||||
// teaser fixture, release plan --help, init + check in an empty folder (the TypeScript config the template writes),
|
||||
// and an MCP initialize handshake over stdio with tools/list and one tools/call (a child run through the bundle).
|
||||
// Every step has a timeout; on a failure the step's output is printed.
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { ROOT } from './build-sidecars';
|
||||
import { NODE_BUNDLE } from './build-node';
|
||||
|
||||
const arg = (n: string) => { const i = process.argv.indexOf(n); return i >= 0 ? process.argv[i + 1] : undefined; };
|
||||
const bundle = resolve(arg('--bundle') ?? NODE_BUNDLE);
|
||||
const node = arg('--node') ?? Bun.which('node');
|
||||
if (!node) { console.error('node-smoke: no `node` on PATH'); process.exit(1); }
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { version: string; engines?: { node?: string } };
|
||||
const env: Record<string, string | undefined> = { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', STOREBOUND_NO_UPDATE_CHECK: '1' };
|
||||
|
||||
interface Out { code: number | null; out: string; err: string }
|
||||
function run(cmd: string[], cwd = ROOT, stdin?: string, timeout = 60_000): Out {
|
||||
const r = Bun.spawnSync(cmd, { cwd, env, stdout: 'pipe', stderr: 'pipe', timeout, ...(stdin !== undefined ? { stdin: new TextEncoder().encode(stdin) } : {}) });
|
||||
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
|
||||
}
|
||||
const sb = (args: string[], cwd = ROOT, stdin?: string) => run([node, bundle, ...args], cwd, stdin);
|
||||
|
||||
let failed = 0;
|
||||
function step(name: string, f: () => string | void): void {
|
||||
const t = Date.now();
|
||||
try {
|
||||
const note = f();
|
||||
console.log(`ok ${name}${note ? `: ${note}` : ''} (${Date.now() - t} ms)`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(`FAIL ${name}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
function expectOk(r: Out, what: string, code = 0): void {
|
||||
if (r.code !== code) throw new Error(`${what}: exit ${r.code} (want ${code})\n--- stdout\n${r.out.slice(-2000)}\n--- stderr\n${r.err.slice(-2000)}`);
|
||||
}
|
||||
/** Output without timestamps and absolute paths, to compare a Node and a Bun run. */
|
||||
const norm = (s: string) => s.replace(/^\d\d:\d\d:\d\d /gm, '').replace(/\/\S*\/storebound\.config\.ts/g, '<config>').trim();
|
||||
|
||||
const nodeVersion = run([node, '--version']).out.trim();
|
||||
console.log(`node-smoke: ${bundle.replace(ROOT + '/', '')} with node ${nodeVersion} (package engines: node ${pkg.engines?.node ?? '?'})`);
|
||||
|
||||
step('--version', () => {
|
||||
const r = sb(['--version']);
|
||||
expectOk(r, '--version');
|
||||
if (r.out.trim() !== pkg.version) throw new Error(`printed ${JSON.stringify(r.out.trim())}, package.json says ${pkg.version}`);
|
||||
return r.out.trim();
|
||||
});
|
||||
|
||||
step('help', () => {
|
||||
const r = sb(['help']);
|
||||
expectOk(r, 'help');
|
||||
if (!r.out.includes('usage: storebound <command>')) throw new Error('no usage line');
|
||||
return `${r.out.split('\n').length} lines`;
|
||||
});
|
||||
|
||||
step('check (examples/demo-game, same result as Bun)', () => {
|
||||
const cwd = join(ROOT, 'examples/demo-game');
|
||||
const n = sb(['check'], cwd);
|
||||
const b = run(['bun', join(ROOT, 'cli/index.ts'), 'check'], cwd);
|
||||
if (n.code !== b.code) throw new Error(`node exit ${n.code}, bun exit ${b.code}\n${n.out}\n${n.err}`);
|
||||
if (norm(n.out + n.err) !== norm(b.out + b.err)) throw new Error(`output differs from Bun's\n--- node\n${norm(n.out + n.err)}\n--- bun\n${norm(b.out + b.err)}`);
|
||||
return `exit ${n.code}, ${(n.out + n.err).match(/error: .*/)?.[0] ?? 'no problems'}`;
|
||||
});
|
||||
|
||||
step('scan (tests/fixtures/scan/teaser)', () => {
|
||||
const cwd = join(ROOT, 'tests/fixtures/scan/teaser');
|
||||
const r = sb(['scan', '--json'], cwd);
|
||||
const b = run(['bun', join(ROOT, 'cli/index.ts'), 'scan', '--json'], cwd);
|
||||
if (r.code !== b.code) throw new Error(`node exit ${r.code}, bun exit ${b.code}\n${r.err}`);
|
||||
const j = JSON.parse(r.out) as { findings?: unknown[] };
|
||||
if (JSON.stringify(j) !== JSON.stringify(JSON.parse(b.out))) throw new Error('scan --json differs from Bun\'s');
|
||||
return `exit ${r.code}, ${Array.isArray(j.findings) ? `${j.findings.length} findings` : 'json ok'}`;
|
||||
});
|
||||
|
||||
step('release plan --help', () => {
|
||||
const r = sb(['release', 'plan', '--help']);
|
||||
expectOk(r, 'release plan --help');
|
||||
if (!/storebound release/.test(r.out + r.err)) throw new Error('no usage');
|
||||
});
|
||||
|
||||
step('init + inspect in an empty folder (TypeScript config)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-node-init-'));
|
||||
try {
|
||||
const i = sb(['init'], dir);
|
||||
expectOk(i, 'init');
|
||||
if (!readFileSync(join(dir, 'storebound.config.ts'), 'utf8').includes('ShipConfig')) throw new Error('no storebound.config.ts written');
|
||||
const c = sb(['inspect', '--json'], dir);
|
||||
expectOk(c, 'inspect --json after init');
|
||||
return `config loads: ${(JSON.parse(c.out) as { name?: string }).name}`;
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
step('mcp: initialize, tools/list, tools/call over stdio', () => {
|
||||
const lines = [
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'node-smoke', version: '1' } } },
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'check', arguments: {} } },
|
||||
].map(m => JSON.stringify(m)).join('\n') + '\n';
|
||||
const prev = env.STOREBOUND_PROJECT;
|
||||
env.STOREBOUND_PROJECT = join(ROOT, 'examples/demo-game');
|
||||
let r: Out;
|
||||
try { r = sb(['mcp'], ROOT, lines); } finally { if (prev === undefined) delete env.STOREBOUND_PROJECT; else env.STOREBOUND_PROJECT = prev; }
|
||||
expectOk(r, 'mcp');
|
||||
const res = new Map(r.out.trim().split('\n').filter(Boolean).map(l => JSON.parse(l) as { id: number; result?: Record<string, unknown>; error?: unknown }).map(m => [m.id, m]));
|
||||
const init = res.get(1)?.result as { protocolVersion?: string; serverInfo?: { name: string; version: string } } | undefined;
|
||||
if (init?.serverInfo?.name !== 'storebound' || init.serverInfo.version !== pkg.version) throw new Error(`initialize: ${JSON.stringify(res.get(1))}`);
|
||||
const tools = (res.get(2)?.result as { tools?: unknown[] } | undefined)?.tools ?? [];
|
||||
if (tools.length < 10) throw new Error(`tools/list: ${tools.length} tools`);
|
||||
const call = res.get(3)?.result as { isError?: boolean; content?: { text: string }[] } | undefined;
|
||||
// demo-game's check reports 2 problems (exit 1): what counts is that the child ran through the bundle and loaded the config
|
||||
const text = call?.content?.[0]?.text ?? '';
|
||||
if (!/config ok/.test(text) || !/\[exit [01]\]/.test(text)) throw new Error(`tools/call check: ${JSON.stringify(res.get(3)).slice(0, 1500)}`);
|
||||
return `protocol ${init.protocolVersion}, ${tools.length} tools, check ran as a child of the bundle (${text.match(/\[exit \d+\]/)![0]})`;
|
||||
});
|
||||
|
||||
if (failed) { console.error(`node-smoke: ${failed} step(s) failed`); process.exit(1); }
|
||||
console.log('node-smoke: all steps passed');
|
||||
Loading…
Add table
Add a link
Reference in a new issue