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

@ -71,7 +71,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
## 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`.

View file

@ -73,6 +73,10 @@ jobs:
# (cli/shots/browser.ts sandboxArgs), Playwright's own launches already pass it
- name: Headless Chromium (browser tests, desktop boot check)
run: bunx playwright install --with-deps chromium
# the npm bundle runs on Node 20+: tools/ci.ts smoke-tests dist/storebound.mjs with the oldest supported Node
- uses: actions/setup-node@v4
with:
node-version: '20'
# the runner sets CI=true in the job and it wins over the workflow env above: set CI=full for the command
# itself, or tools/ci.ts skips the desktop Rust tests and clippy when the push did not touch src-tauri
- name: bun run ci

View file

@ -71,7 +71,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
## 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`.

3
.gitignore vendored
View file

@ -18,3 +18,6 @@ apps/desktop/.screens/
# scan fixtures: a monorepo whose Capacitor plugins live in apps/native/node_modules
!tests/fixtures/scan/monorepo/apps/native/node_modules/
# npm bundle (bun tools/build-node.ts; npm pack / publish build it)
/dist/

View file

@ -72,7 +72,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
## 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`.

View file

@ -2,6 +2,19 @@
## Unreleased
- The CLI runs on Node 20+ (owner decision, 2026-10-13): `npx storebound init` works, `bunx storebound` keeps working.
The npm package's `bin` is one Node ESM bundle, `dist/storebound.mjs` (built by `prepack` with
`bun tools/build-node.ts`, zero runtime dependencies, playwright still an optional peer); `engines` names Node 20.
Everything the CLI needs from its runtime goes through `cli/lib/rt.ts` (spawn / spawnSync with Bun's defaults and
exit codes, which with PATHEXT, sleep, a small HTTP server for shots render / video, glob, hashing, gunzip, stdin,
SQLite, the package folder); templates, the shots pages and Play's Data safety form are embedded by
`tools/gen-embedded.ts` instead of Bun text imports, so the bundle and the compiled desktop sidecar carry them.
`storebound.config.ts` loads on Node too (Node's own type stripping, or a fallback on Node 20). `storebound install`
run with Node writes `npx` MCP commands. `bun run ci` builds the bundle and smoke-tests it with the real `node`
(`--version`, `help`, `check` same as Bun, `scan`, `release plan --help`, `init`, an MCP handshake and tool call);
`tests/node-compat.test.ts` fails on any direct Bun API use in `cli/`. `bin/storebound` falls back to Node when Bun
is not installed. Development, tests and CI stay on Bun.
- `console verify apple-app-privacy` checked against real page text (2026-10-12): the summary sections ("Data Used to
Track You" and the others) list category names only and are skipped; a type's lines may come joined by " | "; Apple's
comma style ("X, and Y") and the straight apostrophe parse. The tracking radio label of a type that already ends in

View file

@ -33,6 +33,12 @@ Try the screenshot and video pipeline on the demo game without any store account
- **Bun + TypeScript strict, zero runtime dependencies.** `fetch` against the official store APIs, `node:` built-ins,
external tools (`ffmpeg`, `xcrun`, `adb`) only through argv builders. Playwright is resolved from the user's project.
- **Runs on Node 20+ and Bun.** Development, tests and CI use Bun; users run the npm bundle with Node (`npx`) or Bun
(`bunx`). The CLI never touches the `Bun` global, `bun:` modules, `import.meta.dir` or text imports: everything goes
through `cli/lib/rt.ts` (spawn, which, sleep, serve, glob, hashing, stdin, the package folder, config imports), and
text files the CLI ships are embedded by `bun tools/gen-embedded.ts` (templates/, `cli/shots/*.html`, Play's form
export: run it after changing one). `tests/node-compat.test.ts` fails on a direct Bun use; `bun run ci` builds the
bundle (`tools/build-node.ts`) and runs it with the real `node` (`tools/node-smoke.ts`).
- **Pure where possible.** Rules, argv builders, plans and parsers are pure functions with tests in `tests/`; the IO
stays in the command's `run`. Tests never need a store account, a simulator or a network.
- **Every write command takes `--dry`** and is idempotent. New MCP write tools default to `dry_run: true`. Nothing
@ -65,6 +71,13 @@ Try the screenshot and video pipeline on the demo game without any store account
`cli/lib/dry.ts`), not only a count; `tests/pitfalls.test.ts` enforces it.
- Plain English in docs and messages, short sentences, no marketing words.
## Releasing the npm package
`npm pack` and `npm publish` run `prepack`, which builds `dist/storebound.mjs` (the `bin`, `#!/usr/bin/env node`,
gitignored) with `bun tools/build-node.ts`. Before publishing: `bun run ci` green (it smoke-tests the bundle with
Node), `npm pack --dry-run` lists `dist/storebound.mjs`, the privacy scan on the tree and on the bundle, and a run of
the packed tarball in an empty folder (`npx --yes ./storebound-<version>.tgz --version`, then `init`).
## Pull requests
One topic per PR, `bun run ci` green, a line in `CHANGELOG.md` under an "Unreleased" heading, and for anything

View file

@ -70,7 +70,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
## 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`.

View file

@ -10,12 +10,12 @@ in-app products and prices, the compliance forms, even the consoles that have no
[![npm](https://img.shields.io/npm/v/storebound?label=npm&color=cb3837)](https://www.npmjs.com/package/storebound)
[![license: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
[![version 0.6.0](https://img.shields.io/badge/version-0.6.0-informational)](CHANGELOG.md)
[![runtime: Bun](https://img.shields.io/badge/runtime-Bun%201.1%2B-f9f1e1)](https://bun.sh)
[![runtime: Node 20+ or Bun 1.1+](https://img.shields.io/badge/runtime-Node%2020%2B%20%7C%20Bun%201.1%2B-f9f1e1)](#install-and-start)
[![runtime dependencies: 0](https://img.shields.io/badge/runtime%20dependencies-0-brightgreen)](package.json)
[![Agent Skills: 25](https://img.shields.io/badge/Agent%20Skills-25-8a63d2)](skills)
[![MCP: 2026-07-28](https://img.shields.io/badge/MCP-2026--07--28-black)](docs/mcp.md)
**Website: [storebound.dev](https://storebound.dev) · Docs: [docs/](docs/README.md) · `bunx storebound init`**
**Website: [storebound.dev](https://storebound.dev) · Docs: [docs/](docs/README.md) · `npx storebound init`**
[![The storebound teaser: from an empty folder to Submit for review in under a minute (opens the video on storebound.dev)](https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/teaser-poster.webp)](https://storebound.dev/#teaser)
@ -80,16 +80,18 @@ Per-tool setup: [docs/getting-started.md](docs/getting-started.md#install-for-yo
## Install and start
You need [Bun](https://bun.sh) 1.1+ (and `ffmpeg` for icons, screenshots and video).
You need [Node](https://nodejs.org) 20+ or [Bun](https://bun.sh) 1.1+ (and `ffmpeg` for icons, screenshots and video).
```sh
cd my-app
bun add -d storebound playwright
bunx storebound init # --profile app (default) | subscription-app | game
bunx storebound install --agent claude # or codex, cursor, gemini, copilot, windsurf, opencode
bunx storebound check # offline: config, limits, Data safety, products, files
npm i -D storebound playwright # or: bun add -d storebound playwright
npx storebound init # --profile app (default) | subscription-app | game (Bun: bunx storebound init)
npx storebound install --agent claude # or codex, cursor, gemini, copilot, windsurf, opencode
npx storebound check # offline: config, limits, Data safety, products, files
```
Every `npx storebound ...` below works as `bunx storebound ...` too.
Then ask your agent: "take my app to Google Play and the App Store". Claude Code plugin, credentials, coming from
fastlane: [docs/getting-started.md](docs/getting-started.md). Desktop app: [storebound.dev/#download](https://storebound.dev/#download).

View file

@ -1,4 +1,8 @@
#!/bin/sh
# storebound launcher. On PATH while the Claude Code plugin is enabled; or symlink it anywhere.
# Bun runs the sources; without Bun, Node 20+ runs the npm bundle (dist/storebound.mjs, in the npm package).
here=$(cd "$(dirname "$0")" && pwd -P)
exec bun "$here/../cli/index.ts" "$@"
if command -v bun >/dev/null 2>&1; then exec bun "$here/../cli/index.ts" "$@"; fi
if [ -f "$here/../dist/storebound.mjs" ] && command -v node >/dev/null 2>&1; then exec node "$here/../dist/storebound.mjs" "$@"; fi
echo "storebound: needs Bun (https://bun.sh) or Node 20+ with the npm package (npx storebound)" >&2
exit 127

View file

@ -9,6 +9,7 @@ import { createPrivateKey, type KeyObject } from 'node:crypto';
import { credentialValue, inline } from '../lib/credentials';
import { ADS_API, adsHeaders, type AdsCreds, clientSecret, type J, TOKEN_URL, tokenForm } from '../lib/apple-ads';
import { errorBody } from '../lib/http';
import * as rt from '../lib/rt';
export const CRED_NAMES = ['APPLE_ADS_CLIENT_ID', 'APPLE_ADS_TEAM_ID', 'APPLE_ADS_KEY_ID', 'APPLE_ADS_PRIVATE_KEY'] as const;
@ -34,7 +35,7 @@ export function adsClient(root: string, o: { sleep?: (ms: number) => Promise<unk
const c = adsCreds(root);
let key: KeyObject;
try { key = createPrivateKey(c.pem); } catch { throw new Error('APPLE_ADS_PRIVATE_KEY is not a valid EC private key (PEM)'); }
const sleep = o.sleep ?? ((ms: number) => Bun.sleep(ms));
const sleep = o.sleep ?? ((ms: number) => rt.sleep(ms));
let cached = { token: '', until: 0 };
async function token(): Promise<string> {

View file

@ -21,6 +21,7 @@ import { STORAGE_READ_SCOPE } from './lib/google-auth';
import { done, log, printJson, warn } from './lib/log';
import { pct, textTable } from './lib/table';
import { googleClient } from './play/google';
import * as rt from './lib/rt';
const TAG = 'analytics';
const day = (d: Date) => d.toISOString().slice(0, 10);
@ -42,7 +43,7 @@ async function reportRows(c: Asc, report: J, start: string, tag: string): Promis
const r = await fetch(seg.attributes.url);
if (!r.ok) throw new Error(`segment ${seg.id}: HTTP ${r.status}`);
const gz = new Uint8Array(await r.arrayBuffer());
const raw = gz[0] === 0x1f && gz[1] === 0x8b ? Bun.gunzipSync(gz) : gz;
const raw = gz[0] === 0x1f && gz[1] === 0x8b ? rt.gunzipSync(gz) : gz;
// Apple documents an md5 checksum per segment without saying of which bytes: accept the file or its content.
if (seg.attributes.checksum && !checksumOk(gz, seg.attributes.checksum) && !checksumOk(raw, seg.attributes.checksum)) throw new Error(`segment ${seg.id}: md5 checksum mismatch`);
rows.push(...parseReport(decodeText(raw)));
@ -145,7 +146,7 @@ export async function run(ctx: Ctx): Promise<void> {
const series = mergeSeries(snaps);
const first = [series.apple[0]?.date, series.play[0]?.date].filter(Boolean).sort()[0] ?? snaps[0]!.window.start;
const paths = a.list('paths').length ? a.list('paths') : ['store', 'storebound.config.ts', ...(config.shots?.copy ? [config.shots.copy] : [])];
const g = Bun.spawnSync(['git', 'log', `--since=${first}`, '--date=short', '--format=%x1e%h%x1f%ad%x1f%s', '--name-only', '--', ...paths], { cwd: root, stdout: 'pipe', stderr: 'pipe' });
const g = rt.spawnSync(['git', 'log', `--since=${first}`, '--date=short', '--format=%x1e%h%x1f%ad%x1f%s', '--name-only', '--', ...paths], { cwd: root, stdout: 'pipe', stderr: 'pipe' });
if (g.exitCode !== 0) throw new Error(`git log: ${g.stderr.toString().trim()}`);
const events = parseGitLog(g.stdout.toString()).reverse();
log(TAG, `${snaps.length} snapshot(s), ${series.apple.length} App Store day(s), ${series.play.length} Play day(s), ${events.length} store change(s) since ${first}`);

View file

@ -10,6 +10,7 @@ import { PLAY_APIS, PLAY_OPS } from './play.gen';
import type { AppleBody, AppleOp, AppleParams, AppleResponse, ParamValue, PlayBody, PlayOp, PlayParams, PlayResponse } from './types';
import type { Store } from './gate';
import { errorBody } from '../lib/http';
import * as rt from '../lib/rt';
export interface QueryParam { name: string; required: boolean; repeated: boolean }
@ -203,7 +204,7 @@ const short = (url: string) => url.replace(/^https:\/\/[^/]+/, '').replace(/\?.*
export async function send(req: BuiltRequest, auth: Auth, o: SendOptions = {}): Promise<ApiResult> {
const doFetch = o.fetch ?? ((u: string, i: RequestInit) => fetch(u, i));
const sleep = o.sleep ?? ((ms: number) => Bun.sleep(ms));
const sleep = o.sleep ?? ((ms: number) => rt.sleep(ms));
const attempts = o.attempts ?? 5;
for (let attempt = 1; ; attempt++) {
const headers: Record<string, string> = { Authorization: await auth(req) };

View file

@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs';
import { ascKey } from '../lib/credentials';
import { errorBody } from '../lib/http';
import { AmbiguousWriteError, retrySafe } from '../lib/upsert';
import * as rt from '../lib/rt';
const b64 = (b: Buffer | string): string => Buffer.from(b).toString('base64url');
/** One App Store Connect API call may take at most this long (then retried when safe, else AmbiguousWriteError). */
@ -72,12 +73,12 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
signal: AbortSignal.timeout(ASC_REQUEST_TIMEOUT_MS), // a hung connection is a network error, never a silent wait
});
} catch (e) {
if (safe && attempt < 5) { await Bun.sleep(2000 * attempt); return asc(method, path, body, attempt + 1); }
if (safe && attempt < 5) { await rt.sleep(2000 * attempt); return asc(method, path, body, attempt + 1); }
if (!safe) throw new AmbiguousWriteError(`${method} ${path} -> no answer (${(e as Error).message})`);
throw e;
}
const t = await r.text();
if ((r.status === 429 || (r.status >= 500 && safe)) && attempt < 5) { await Bun.sleep(2000 * attempt); return asc(method, path, body, attempt + 1); }
if ((r.status === 429 || (r.status >= 500 && safe)) && attempt < 5) { await rt.sleep(2000 * attempt); return asc(method, path, body, attempt + 1); }
if (r.status >= 500 && !safe) throw new AmbiguousWriteError(`${method} ${path} -> ${r.status} ${errorBody(t)}`, r.status);
if (r.status === 404 && method === 'DELETE' && attempt > 1) return {};
if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${errorBody(t)}`);
@ -89,7 +90,7 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
for (let attempt = 1; ; attempt++) {
const r = await fetch('https://api.appstoreconnect.apple.com' + path, { headers: { Authorization: 'Bearer ' + jwt(), Accept: accept } });
const t = await r.text();
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await Bun.sleep(2000 * attempt); continue; }
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await rt.sleep(2000 * attempt); continue; }
if (!r.ok) throw new Error(`GET ${path} -> ${r.status} ${errorBody(t, 800)}`);
return t ? JSON.parse(t) : {};
}
@ -100,7 +101,7 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
async function bytes(path: string, accept = 'application/a-gzip'): Promise<Uint8Array | null> {
for (let attempt = 1; ; attempt++) {
const r = await fetch('https://api.appstoreconnect.apple.com' + path, { headers: { Authorization: 'Bearer ' + jwt(), Accept: accept } });
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await r.body?.cancel(); await Bun.sleep(2000 * attempt); continue; }
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await r.body?.cancel(); await rt.sleep(2000 * attempt); continue; }
if (r.status === 404) { await r.body?.cancel(); return null; }
if (!r.ok) throw new Error(`GET ${path.replace(/\?.*$/, '')} -> ${r.status} ${errorBody(await r.text(), 600)}`);
return new Uint8Array(await r.arrayBuffer());

View file

@ -26,6 +26,7 @@ import { archiveArgs, type AscAuth, exportArgs, flutterConfigArgs, ipaInfo, pick
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
import { ascClient } from './asc';
import { findBundleId } from './signing';
import * as rt from '../lib/rt';
const TAG = 'build';
@ -54,7 +55,7 @@ export function installedProfiles(bundleId: string, teamId: string | null, home
async function runLogged(argv: string[], cwd: string, logFile: string, show: string, env?: Record<string, string>): Promise<void> {
log(TAG, `$ ${show}`);
log(TAG, `full output: ${logFile}`);
const p = Bun.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', ...env } });
const p = rt.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', ...env } });
const chunks: string[] = [];
const keep = /(\berror:|\*\* (ARCHIVE|EXPORT|BUILD) (SUCCEEDED|FAILED) \*\*|requires a provisioning profile|No signing certificate|Signing for .* requires|Exported .* to)/;
const each = (line: string): void => { chunks.push(line); if (keep.test(line)) log(TAG, line.trim().slice(0, 300)); };
@ -137,7 +138,7 @@ export async function run(ctx: Ctx): Promise<void> {
done(TAG, 'build planned (dry run)');
return;
}
if (process.platform !== 'darwin' || !Bun.which('xcodebuild')) throw new Error('apple build needs macOS with Xcode (xcode-select --install is not enough: the full Xcode app)');
if (process.platform !== 'darwin' || !rt.which('xcodebuild')) throw new Error('apple build needs macOS with Xcode (xcode-select --install is not enough: the full Xcode app)');
const web = await webStep({ ...webOpts, dry: false });
for (const n of target.notes) log(TAG, n);
mkdirSync(out, { recursive: true });

View file

@ -17,6 +17,7 @@ import { done, log, warn } from '../lib/log';
import { reportMissing } from '../lib/store-lag';
import { IMAGE_SPECS, imageInfo } from '../shots/spec';
import { ascClient, md5 } from './asc';
import * as rt from '../lib/rt';
/** Every iPhone / iPad screenshot size App Store Connect accepts (portrait and landscape). */
export const REVIEW_SHOT_SIZES: [number, number][] = Object.values(IMAGE_SPECS)
@ -70,7 +71,7 @@ export async function run(ctx: Ctx): Promise<void> {
// Apple processes the asset after the commit: read the state back (COMPLETE, or FAILED with the reason)
let after: Shot | undefined, state = 'UPLOAD_COMPLETE';
for (let i = 0; i < 10; i++) {
await Bun.sleep(i ? 3000 : 1500);
await rt.sleep(i ? 3000 : 1500);
after = await shotOf(p.id); state = stateOf(after);
if (state === 'COMPLETE' || state === 'FAILED') break;
}

View file

@ -17,6 +17,7 @@ import { LAG_HINT, reportMissing } from '../lib/store-lag';
import { appleTerritory, nearestPrices, samePrice } from '../lib/money';
import { createOnce, findOrCreate, getById } from '../lib/upsert';
import { ascClient, type J } from './asc';
import * as rt from '../lib/rt';
/** Product type -> InAppPurchaseType (POST /v2/inAppPurchases). */
export const APPLE_IAP_TYPE = { consumable: 'CONSUMABLE', non_consumable: 'NON_CONSUMABLE', non_renewing: 'NON_RENEWING_SUBSCRIPTION' } as const;
@ -104,7 +105,7 @@ export async function run(ctx: Ctx): Promise<void> {
return;
}
if (created.length) {
await Bun.sleep(Number(process.env.STOREBOUND_LIST_SETTLE_MS ?? 3000));
await rt.sleep(Number(process.env.STOREBOUND_LIST_SETTLE_MS ?? 3000));
const listed = (await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)).map((x: J) => x.attributes.productId as string);
if (reportMissing('apple', 'new products', created, listed, { fail: false }).length) log('apple', 'wait a minute before apple iap-review, or it cannot see them');
}

View file

@ -24,6 +24,7 @@ import { textTable } from '../lib/table';
import { type Asc, ascClient, type J } from './asc';
import { describeReq, execute, type Req } from './jsonapi';
import { createOnce, isAmbiguous, upsertTiming } from '../lib/upsert';
import * as rt from '../lib/rt';
const TAG = 'signing';
const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install]';
@ -43,8 +44,8 @@ function localKeys(root: string): Map<string, string> {
/** SHA-1s of code signing identities in the keychain search list (macOS; empty elsewhere). Read-only. */
function keychainIdentities(): Set<string> {
if (process.platform !== 'darwin' || !Bun.which('security')) return new Set();
const r = Bun.spawnSync(['security', 'find-identity', '-v', '-p', 'codesigning'], { stdout: 'pipe', stderr: 'pipe' });
if (process.platform !== 'darwin' || !rt.which('security')) return new Set();
const r = rt.spawnSync(['security', 'find-identity', '-v', '-p', 'codesigning'], { stdout: 'pipe', stderr: 'pipe' });
return new Set(parseIdentities(r.stdout.toString()).map(i => i.sha1));
}
@ -77,7 +78,7 @@ function keychainImport(keyFile: string, cerFile: string, keychain: string | nul
if (process.platform !== 'darwin') throw new Error('--keychain: macOS only (on other systems keep the key in .storebound/signing)');
const kc = keychain ? ['-k', keychain] : [];
for (const cmd of [['security', 'import', keyFile, ...kc, '-t', 'priv', '-f', 'openssl', '-T', '/usr/bin/codesign', '-T', '/usr/bin/productbuild'], ['security', 'import', cerFile, ...kc, '-t', 'cert']]) {
const r = Bun.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe' });
const r = rt.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe' });
const msg = (r.stdout.toString() + r.stderr.toString()).trim();
if (r.exitCode !== 0 && !/already exists/i.test(msg)) throw new Error(`security import failed: ${msg.slice(0, 300)}`);
}

View file

@ -16,6 +16,7 @@ import { classifyReviewState, nextPollDelay, parseDuration, WAIT_EXIT } from '..
import { EMPTY_DRAFT } from '../lib/review-submit';
import { type Asc, ascClient, type J } from './asc';
import { notCancellable, readItems, type SubItem } from './submission';
import * as rt from '../lib/rt';
async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
const want = ctx.args.get('version');
@ -36,7 +37,7 @@ async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
delay = nextPollDelay(delay);
if (Date.now() + delay > deadline) { log('apple', 'timeout, still in review'); process.exit(WAIT_EXIT.timeout); }
log('apple', `next check in ${Math.round(delay / 1000)}s`);
await Bun.sleep(delay);
await rt.sleep(delay);
}
}

View file

@ -7,6 +7,7 @@ import { createOnce, findOrCreate } from '../lib/upsert';
import { EMPTY_DRAFT, resolveHint, type ResolveScope, toResolve } from '../lib/review-submit';
import type { Asc, J } from './asc';
import { one } from './jsonapi';
import * as rt from '../lib/rt';
/** The draft review submission of the app (READY_FOR_REVIEW, iOS), or null. */
export async function findDraft(c: Asc, appId: string): Promise<string | null> {
@ -139,7 +140,7 @@ export const submitTiming = {
delaysMs: [15_000, 30_000, 60_000, 120_000],
timeoutMs: 10 * 60_000,
heartbeatMs: 30_000,
sleep: (ms: number): Promise<void> => (ms > 0 ? Bun.sleep(ms) : Promise.resolve()),
sleep: (ms: number): Promise<void> => (ms > 0 ? rt.sleep(ms) : Promise.resolve()),
now: (): number => Date.now(),
};

View file

@ -31,6 +31,7 @@ import {
import { type Asc, ascClient, type J } from './asc';
import { describeReq, execute } from './jsonapi';
import { findOrCreate, getById } from '../lib/upsert';
import * as rt from '../lib/rt';
const TAG = 'testflight';
const USAGE = 'usage: storebound apple testflight status | groups [sync] | testers | what-to-test --build N | info | distribute --build N --group <names> | wait --build N | expire --keep N | --older-than D';
@ -68,7 +69,7 @@ export async function waitForBuild(c: Asc, appId: string, number: string, o: { m
if (o.once) return result(false);
delay = waitDelay(delay);
if (Date.now() + delay - started > o.timeoutMs) return result(true);
await Bun.sleep(delay);
await rt.sleep(delay);
} } finally { beat?.stop(); }
}

View file

@ -29,11 +29,12 @@ import { describeReq } from './jsonapi';
import { createOnce } from '../lib/upsert';
import { elapsed, heartbeat, type HeartbeatOptions, mb, share, withHeartbeat } from '../lib/progress';
import { pump } from '../lib/runner';
import * as rt from '../lib/rt';
const TAG = 'upload';
function md5File(path: string): string {
const h = new Bun.CryptoHasher('md5');
const h = rt.hasher('md5');
const fd = openSync(path, 'r');
try {
const buf = Buffer.alloc(8 * 1048576);
@ -81,7 +82,7 @@ export function uploadTimeout(sent: number, size: number, partsDone: number, par
export async function sendParts(o: SendPartsOptions): Promise<void> {
const say = o.say ?? ((m: string) => log(TAG, m));
const doFetch = o.fetch ?? ((u: string, i: RequestInit) => fetch(u, i));
const sleep = o.sleep ?? Bun.sleep;
const sleep = o.sleep ?? rt.sleep;
const now = o.now ?? Date.now;
const started = now();
const n = o.ops.length;
@ -159,7 +160,7 @@ async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<ty
if (Date.now() > deadline) throw Object.assign(new Error(`upload still ${st.state ?? 'pending'} at the timeout; the file is sent, App Store Connect is still checking it: storebound apple testflight wait --build ${info.build} --version ${info.version} goes on waiting`), { exit: WAIT_EXIT.timeout });
delay = nextPollDelay(delay, { first: 15_000, max: 60_000 });
log(TAG, `upload ${st.state ?? 'pending'}, next check in ${Math.round(delay / 1000)} s`);
await Bun.sleep(delay);
await rt.sleep(delay);
}
}
@ -172,14 +173,14 @@ const uploadsOf = (c: Asc, appId: string, info: { version: string; build: string
* stopped at the deadline (exit 7 with the hint).
*/
async function altoolUpload(root: string, ipa: string, deadline: number, o: HeartbeatOptions = {}): Promise<void> {
if (process.platform !== 'darwin' || !Bun.which('xcrun')) throw new Error('--via altool needs macOS with Xcode; use --via api elsewhere');
if (process.platform !== 'darwin' || !rt.which('xcrun')) throw new Error('--via altool needs macOS with Xcode; use --via api elsewhere');
const k = ascKeyFile(root);
try {
const argv = altoolArgs(ipa, k);
const size = statSync(ipa).size;
log(TAG, `$ ${showCmd(argv, k)}`);
log(TAG, `uploading ${mb(size)} with altool (it reports only at the end)`);
const p = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe' });
const p = rt.spawn(argv, { stdout: 'pipe', stderr: 'pipe' });
let timedOut = false;
const kill = setTimeout(() => { timedOut = true; p.kill(); }, Math.max(0, deadline - Date.now()));
const lines: string[] = [];

View file

@ -15,6 +15,7 @@ import { appleCheck, type Bytes, dataUrl, type Get, listAppleApps, listPlayApps,
import { textTable } from './lib/table';
import { ascClient } from './apple/asc';
import { REPORTING_API } from './play/google';
import * as rt from './lib/rt';
type Store = 'apple' | 'play';
@ -48,7 +49,7 @@ function googleGet(token: () => Promise<string>, base: string): Get {
for (let attempt = 0; ; attempt++) {
const r = await fetch(path.startsWith('http') ? path : base + path, { headers: { Authorization: 'Bearer ' + (await token()) } });
const t = await r.text();
if ((r.status === 429 || r.status >= 500) && attempt < 3) { await Bun.sleep(1500 * (attempt + 1)); continue; }
if ((r.status === 429 || r.status >= 500) && attempt < 3) { await rt.sleep(1500 * (attempt + 1)); continue; }
if (!r.ok) throw new Error(`GET ${path.replace(/^https:\/\/[^/]+/, '')} -> ${r.status} ${errorBody(t, 800)}`);
return t ? JSON.parse(t) : {};
}

View file

@ -38,6 +38,7 @@ import type { Ctx } from './lib/ctx';
import { done, log, printJson, warn } from './lib/log';
import { appleSections } from './lib/project-files';
import { textTable } from './lib/table';
import * as rt from './lib/rt';
const TAG = 'aso';
const SUBS = ['rank', 'competitors', 'suggest', 'audit'];
@ -64,7 +65,7 @@ function snapshots<T>(dir: string): { file: string; data: T }[] {
async function searchAll<P extends { storefront: string; keyword: string }>(plan: P[], limit: number, delay: number, each: (p: P, results: SearchApp[], i: number) => void): Promise<number> {
let read = 0;
for (const [i, p] of plan.entries()) {
if (i) await Bun.sleep(delay);
if (i) await rt.sleep(delay);
const r = await fetch(searchUrl(p.keyword, p.storefront, limit), { headers: { Accept: 'application/json' } });
if (r.status === 403 || r.status === 429) { warn(TAG, `Search API answered ${r.status} (rate limit): stopped after ${read} of ${plan.length}; try later or with a larger --delay`); break; }
if (!r.ok) { warn(TAG, `${p.storefront} "${p.keyword}": HTTP ${r.status}`); continue; }

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

View file

@ -13,6 +13,7 @@ import type { Args } from './lib/args';
import { CONFIG_NAMES } from './lib/config';
import { BUNDLE_ID, DOWNLOAD_LABEL, type Download, type DownloadKey, downloadsDir, downloadsFor, findInstalled, MANIFEST, type Manifest, manifestProblems, openCommand, openFolderLink, PAGE, PUBLIC_KEY } from './lib/desktop-dist';
import { verify } from './lib/minisign';
import * as rt from './lib/rt';
type Fetch = (url: string) => Promise<Response>;
@ -61,7 +62,7 @@ export async function run(args: Args): Promise<void> {
const link = isApp ? openFolderLink(dir) : null;
const cmd = openCommand(platform, installed, link);
if (args.has('json')) { console.log(JSON.stringify({ installed, link, command: cmd }, null, 2)); return; }
const p = Bun.spawn(cmd, { stdio: ['ignore', 'ignore', 'pipe'] });
const p = rt.spawn(cmd, { stdio: ['ignore', 'ignore', 'pipe'] });
if (platform === 'darwin') {
const code = await p.exited;
if (code !== 0) throw new Error(`open -b ${BUNDLE_ID} failed: ${(await new Response(p.stderr).text()).trim()}`);

View file

@ -10,6 +10,7 @@ import { CONFIG_NAMES } from './lib/config';
import { log } from './lib/log';
import { textTable } from './lib/table';
import { androidSdk, type AndroidSdkLookup, detectTools, findPlaywright, installCommands, type ToolStatus } from './lib/tools';
import * as rt from './lib/rt';
export interface DoctorRow {
id: ToolStatus['id'] | 'playwright';
@ -24,7 +25,7 @@ export interface DoctorRow {
url: string | null;
}
export interface DoctorResult {
storebound: string; bun: string; platform: string; project: string | null; tools: DoctorRow[]; missing: number;
storebound: string; bun: string | null; runtime: string; platform: string; project: string | null; tools: DoctorRow[]; missing: number;
/** The Android SDK `play build` hands to Gradle, where it came from, and every place looked at. */
androidSdk: AndroidSdkLookup;
}
@ -32,18 +33,18 @@ export interface DoctorResult {
export function doctor(cwd: string, env: Record<string, string | undefined> = process.env): DoctorResult {
const tools: DoctorRow[] = detectTools(env).map(t => ({ id: t.id, name: t.name, found: t.found, version: t.version, path: t.path, source: t.source, usedFor: t.usedFor,
install: t.found ? [] : installCommands(t.id), url: t.install.url }));
const pw = findPlaywright([cwd, join(import.meta.dir, '..')]);
const pw = findPlaywright([cwd, rt.packageRoot()]);
tools.splice(3, 0, { id: 'playwright', name: 'playwright', found: !!pw, version: null, path: pw, source: pw ? 'system' : null,
usedFor: 'screenshot renders and web captures from a terminal (the desktop app brings its own)', install: pw ? [] : installCommands('playwright'), url: 'https://playwright.dev/docs/intro' });
const project = CONFIG_NAMES.some(n => existsSync(join(cwd, n))) ? cwd : null;
const sdk = androidSdk([join(cwd, 'android'), cwd], env);
return { storebound: pkg.version, bun: Bun.version, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found).length, androidSdk: sdk };
return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found).length, androidSdk: sdk };
}
export async function run(args: Args): Promise<void> {
const r = doctor(resolve(args.str('dir', '.')));
if (args.has('json')) { console.log(JSON.stringify(r, null, 2)); return; }
log('doctor', `storebound ${r.storebound} · bun ${r.bun} · ${r.platform}${r.project ? ' · project config found' : ''}`);
log('doctor', `storebound ${r.storebound} · ${r.runtime} · ${r.platform}${r.project ? ' · project config found' : ''}`);
console.log(textTable(['tool', 'status', 'version', 'used for'], r.tools.map(t => [t.name, t.found ? (t.source === 'app' ? 'ok (app copy)' : 'ok') : 'missing', t.version ?? '', t.usedFor])));
log('doctor', r.androidSdk.path ? `Android SDK: ${r.androidSdk.path} (from ${r.androidSdk.source})`
: `Android SDK: not found (play build needs it). Checked: ${r.androidSdk.checked.join('; ')}`);

View file

@ -28,6 +28,7 @@ import { done, log, printJson, step, warn } from './lib/log';
import { childEnv, pump, selfCommand } from './lib/runner';
import { PREBUILD_DONE_ENV, prebuildFor, prebuildsDone } from './lib/web-assets';
import { parseYaml } from './lib/yaml';
import * as rt from './lib/rt';
const TAG = 'flow';
const EXT = ['.yml', '.yaml', '.json', '.ts'];
@ -41,7 +42,7 @@ export function projectRoot(start = process.cwd()): string {
}
async function readData(path: string): Promise<unknown> {
if (path.endsWith('.ts')) { const m = await import(path) as { default?: unknown }; return m.default; }
if (path.endsWith('.ts')) { const m = await rt.importConfigModule(path) as { default?: unknown }; return m.default; }
const text = readFileSync(path, 'utf8');
if (path.endsWith('.json')) return JSON.parse(text);
return parseYaml(text);
@ -64,7 +65,7 @@ export async function loadFlows(root: string): Promise<{ flows: Flow[]; errors:
/** Command names of this storebound (from `storebound help`), for `flow validate`. */
function commandNames(): Set<string> {
const r = Bun.spawnSync([...selfCommand(), 'help'], { stdout: 'pipe', stderr: 'pipe' });
const r = rt.spawnSync([...selfCommand(), 'help'], { stdout: 'pipe', stderr: 'pipe' });
const names = new Set<string>(['help', 'version']);
for (const l of r.stdout.toString().split('\n')) if (/^ {2}[a-z]/.test(l)) { const n = l.slice(2, 27).trim(); if (n && !n.includes(':')) names.add(n); }
return names;
@ -106,7 +107,7 @@ function describe(p: PlannedStep): string {
/** Runs one storebound step (or shell line) with stdin attached, so the command's own confirmation works; output indented. */
async function exec(p: PlannedStep, root: string, quiet: boolean, extraEnv: Record<string, string> = {}): Promise<{ code: number; last: string; out: string }> {
const cmd = p.shell !== undefined ? ['sh', '-c', p.shell] : [...selfCommand(), ...p.argv!];
const proc = Bun.spawn(cmd, { cwd: root, stdin: 'inherit', stdout: 'pipe', stderr: 'pipe', env: childEnv(extraEnv) });
const proc = rt.spawn(cmd, { cwd: root, stdin: 'inherit', stdout: 'pipe', stderr: 'pipe', env: childEnv(extraEnv) });
const lines: string[] = [];
let last = '';
const each = (line: string, kind: 'stdout' | 'stderr') => {

View file

@ -11,8 +11,9 @@ import type { Args } from './lib/args';
import { type AppProfile, PROFILES } from './lib/config';
import { log } from './lib/log';
import { TEMPLATE_TEXT } from './lib/template-files';
import * as rt from './lib/rt';
const T = join(import.meta.dir, '..', 'templates');
const T = join(rt.packageRoot(), 'templates');
export const FILES: [string, string][] = [
['storebound.config.ts', 'storebound.config.ts'],
['listing.md', 'store/listing.md'],

View file

@ -3,16 +3,20 @@
// [--no-skills] [--no-mcp] [--command "bunx storebound"]
// Skills are copied to the tool's skills folder (our own skill folders are updated, nothing else is touched). The MCP
// entry "storebound" is added to the tool's config file; every other entry stays as it is. Re-running is safe.
// MCP command: `bunx storebound mcp` when the project has storebound in node_modules, else this toolchain's
// bin/storebound by absolute path (override with --command).
// MCP command: `bunx storebound mcp` (`npx storebound mcp` when run with Node) when the project has storebound in
// node_modules; else, under Bun, this toolchain's bin/storebound by absolute path, under Node (`npx storebound
// install`: the toolchain sits in npx's cache, which can be cleared) `npx -y storebound@<this version> mcp`
// (override with --command).
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import type { Args } from './lib/args';
import { AGENTS, type AgentId, isAgent, type McpCommand, mergeCodexToml, mergeJsonServer } from './lib/agents';
import { log, warn } from './lib/log';
import * as rt from './lib/rt';
import pkg from '../package.json' with { type: 'json' };
const TOOLCHAIN = resolve(import.meta.dir, '..');
const TOOLCHAIN = rt.packageRoot();
/** Every file of a folder, relative path -> content, for comparing skill folders. */
function snapshot(dir: string, base = dir, out = new Map<string, string>()): Map<string, string> {
@ -25,9 +29,10 @@ function snapshot(dir: string, base = dir, out = new Map<string, string>()): Map
}
const same = (a: Map<string, string>, b: Map<string, string>) => a.size === b.size && [...a].every(([k, v]) => b.get(k) === v);
export function mcpCommand(project: string, override?: string): McpCommand {
export function mcpCommand(project: string, override?: string, runtime: 'bun' | 'node' = rt.runtime.name, version = pkg.version): McpCommand {
if (override) { const [command, ...args] = override.split(/\s+/).filter(Boolean); return { command: command!, args: [...args, 'mcp'] }; }
if (existsSync(join(project, 'node_modules', 'storebound', 'package.json'))) return { command: 'bunx', args: ['storebound', 'mcp'] };
if (existsSync(join(project, 'node_modules', 'storebound', 'package.json'))) return { command: runtime === 'bun' ? 'bunx' : 'npx', args: ['storebound', 'mcp'] };
if (runtime === 'node') return { command: 'npx', args: ['-y', `storebound@${version}`, 'mcp'] };
return { command: join(TOOLCHAIN, 'bin', 'storebound'), args: ['mcp'] };
}

View file

@ -2,6 +2,7 @@
// do (see credentials.ts). The file default-exports a plain object; `import type { ShipConfig }` is optional.
import { existsSync } from 'node:fs';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import * as rt from './rt';
import { validateCapture, validateRecord } from '../shots/devices';
import { type AppleDataTypeId, type AppleTypeAnswer, validateAppPrivacyTypes } from './app-privacy';
import { type AppEventDef, type CustomProductPageDef, validateCpp, validateEvents } from './cpp-events';
@ -1171,7 +1172,7 @@ export async function loadConfig(explicit?: string, start = process.cwd()): Prom
}
}
if (!path || !existsSync(path)) throw new Error('no storebound.config.ts found (run `storebound init` or pass --config)');
const mod = await import(path);
const mod = await rt.importConfigModule(path);
const config = (mod.default ?? mod) as ShipConfig;
const errors = validateConfig(config);
if (errors.length) throw new Error(`${path}:\n ` + errors.join('\n '));

View file

@ -9,7 +9,7 @@
// App content > Data safety > Export to CSV with nothing answered, saved over that file unchanged (docs/pitfalls.md 21).
import type { AccountsConfig, DataSafety, PlayPurpose } from './config';
import { csvCell, parseCsv } from './csv';
import FORM_TEXT from '../data/play-data-safety-form.csv' with { type: 'text' };
import { PLAY_DATA_SAFETY_FORM as FORM_TEXT } from './embedded';
export { csvCell };
@ -25,7 +25,7 @@ const pairKey = (q: string, r: string) => `${q}\u0000${r}`;
export function playForm(): Map<string, PlayFormRow> {
if (formCache) return formCache;
const m = new Map<string, PlayFormRow>();
for (const r of parseCsv(FORM_TEXT as unknown as string).slice(1)) {
for (const r of parseCsv(FORM_TEXT).slice(1)) {
const [question = '', response = '', , requirement = '', label = ''] = r;
m.set(pairKey(question, response), { question, response, requirement, label: label.trim() });
}

48
cli/lib/embedded.ts Normal file

File diff suppressed because one or more lines are too long

View file

@ -4,6 +4,7 @@
// hashes differ in few bits look alike to a person (and to a reviewer comparing store pages).
import { readFileSync } from 'node:fs';
import { inflateSync } from 'node:zlib';
import * as rt from './rt';
export interface Gray { w: number; h: number; px: Float64Array }
@ -128,9 +129,9 @@ export function hashFile(path: string): { hash: ImageHash | null; reason?: strin
try {
const buf = readFileSync(path);
if (buf.length > 8 && buf.readUInt32BE(0) === 0x89504e47) return { hash: hashGray(decodePng(buf)) };
const ffmpeg = Bun.which('ffmpeg');
const ffmpeg = rt.which('ffmpeg');
if (!ffmpeg) return { hash: null, reason: 'not a PNG and ffmpeg is not installed' };
const r = Bun.spawnSync([ffmpeg, '-v', 'error', '-i', path, '-frames:v', '1', '-vf', 'scale=32:32:flags=area,format=gray', '-f', 'rawvideo', '-'], { stdout: 'pipe', stderr: 'pipe', timeout: 20_000 });
const r = rt.spawnSync([ffmpeg, '-v', 'error', '-i', path, '-frames:v', '1', '-vf', 'scale=32:32:flags=area,format=gray', '-f', 'rawvideo', '-'], { stdout: 'pipe', stderr: 'pipe', timeout: 20_000 });
const px = r.stdout;
if (r.exitCode !== 0 || px.length !== 1024) return { hash: null, reason: `ffmpeg could not read it (${r.stderr.toString().trim().split('\n')[0]?.slice(0, 120) ?? r.exitCode})` };
return { hash: hashGray({ w: 32, h: 32, px: Float64Array.from(px) }) };

465
cli/lib/rt.ts Normal file
View file

@ -0,0 +1,465 @@
// The runtime layer: everything the CLI needs from its JavaScript runtime, written against Node's own modules so the
// same code runs on Node 20+ (`npx storebound`, the dist/storebound.mjs bundle) and on Bun (`bunx storebound`, the
// sources, the compiled desktop sidecar). The CLI never touches the `Bun` global directly; a test
// (tests/node-compat.test.ts) keeps it that way. The behaviour follows Bun's where callers rely on it: spawn's
// defaults (stdin ignored, stdout piped, stderr inherited), `exited` resolving to the exit code or 128 + the signal
// number, a missing executable throwing "Executable not found in $PATH" right away, spawnSync never limiting output.
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync, type StdioOptions } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { createServer } from 'node:http';
import * as nodeModule from 'node:module';
import { constants } from 'node:os';
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
import type { Readable } from 'node:stream';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { gunzipSync as zGunzipSync } from 'node:zlib';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const BUN: any = (globalThis as any).Bun;
/** True under Bun (sources, bunx, the compiled sidecar); false under Node (the npm bundle). */
export const isBun = BUN !== undefined;
/** Runtime name and version, for `doctor` and `system.version`. */
export const runtime = { name: isBun ? 'bun' : 'node', version: isBun ? String(BUN.version) : process.versions.node } as const;
/** Bun's version, or null under Node. */
export const bunVersion: string | null = isBun ? String(BUN.version) : null;
/** The entry script the runtime started (Bun.main; under Node the script in argv[1]). */
export const mainPath = (): string => (isBun ? String(BUN.main) : process.argv[1] ?? '');
// ---- time ----
export const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, Math.max(0, ms)));
/** Blocks the thread (only for short retries in cleanup code). */
export function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms));
}
// ---- files and paths ----
/** Folder of a module: `here(import.meta.url)` (Bun's import.meta.dir, which Node does not have). */
export const here = (url: string): string => dirname(fileURLToPath(url));
let rootCache: string | null = null;
/**
* The storebound package folder (package.json, templates/, skills/, cli/): found upwards from this module, which is
* cli/lib/rt.ts in the sources and dist/storebound.mjs in the npm bundle. In the compiled sidecar (no package on
* disk) it is the folder two levels up, as before.
*/
export function packageRoot(): string {
if (rootCache) return rootCache;
const start = here(import.meta.url);
for (let d = start; ; d = dirname(d)) {
try {
const pkg = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')) as { name?: string };
if (pkg.name === 'storebound') return (rootCache = d);
} catch { /* keep looking */ }
if (dirname(d) === d) break;
}
return (rootCache = resolve(start, '..', '..'));
}
/** `require.resolve(spec)` from the folder `dir` (Bun.resolveSync(spec, dir)); throws when it is not found. */
export function resolveFrom(spec: string, dir: string): string {
return nodeModule.createRequire(join(resolve(dir), '__storebound_resolve__.js')).resolve(spec);
}
/** Imports a module by file path (absolute paths become file: URLs, which Node needs on Windows). */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const importPath = (path: string): Promise<any> => import(pathToFileURL(path).href);
/**
* Imports a config module (storebound.config.ts / .js). Bun runs TypeScript itself; so does Node 22.18+ / 23.6+.
* Older Node (20, 22.x) refuses `.ts` (ERR_UNKNOWN_FILE_EXTENSION): the types are stripped (node:module's
* stripTypeScriptTypes when it exists, else the small stripper below, enough for a config file) and the result is
* imported from a data: URL.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function importConfigModule(path: string): Promise<any> {
if (isBun || !/\.[cm]?ts$/.test(path)) return importPath(path);
try {
return await withoutTypeStripWarning(() => importPath(path));
} catch (e) {
const code = (e as { code?: string }).code;
if (code !== 'ERR_UNKNOWN_FILE_EXTENSION' && code !== 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING' && !/Unknown file extension/.test(String(e))) throw e;
}
const src = readFileSync(path, 'utf8');
let js: string;
const strip = (nodeModule as unknown as { stripTypeScriptTypes?: (s: string) => string }).stripTypeScriptTypes;
try { js = typeof strip === 'function' ? withoutTypeStripWarningSync(() => strip(src)) : stripTypes(src); } catch { js = stripTypes(src); }
if (/^\s*import\s[^'"]*['"]\.{1,2}\//m.test(js)) throw new Error(`${path}: this Node (${process.versions.node}) cannot load a TypeScript config that imports other files; use Node 22.18+ or Bun, or a storebound.config.js`);
return import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`);
}
/** Node 22.18+ prints an ExperimentalWarning the first time it strips types; storebound's output stays clean. */
async function withoutTypeStripWarning<T>(f: () => Promise<T>): Promise<T> {
const emit = process.emitWarning;
process.emitWarning = ((w: string | Error, ...rest: unknown[]) => {
if (/Type Stripping|type stripping/i.test(String(typeof w === 'string' ? w : w.message))) return;
return (emit as (...a: unknown[]) => void).call(process, w, ...rest);
}) as typeof process.emitWarning;
try { return await f(); } finally { process.emitWarning = emit; }
}
function withoutTypeStripWarningSync<T>(f: () => T): T {
const emit = process.emitWarning;
process.emitWarning = (() => undefined) as typeof process.emitWarning;
try { return f(); } finally { process.emitWarning = emit; }
}
/**
* Removes the TypeScript a storebound config uses (type-only imports and exports, `: Type` on a declaration,
* `satisfies T`, `as T`) outside strings and comments. Not a TypeScript compiler: a fallback for Node versions
* without type stripping.
*/
export function stripTypes(src: string): string {
const parts: { code: boolean; text: string }[] = [];
let i = 0, start = 0;
const push = (code: boolean, end: number) => { if (end > start) parts.push({ code, text: src.slice(start, end) }); start = end; };
while (i < src.length) {
const c = src[i]!, n = src[i + 1];
if (c === '/' && n === '/') { push(true, i); const e = src.indexOf('\n', i); i = e < 0 ? src.length : e; push(false, i); continue; }
if (c === '/' && n === '*') { push(true, i); const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 2; push(false, i); continue; }
if (c === '"' || c === "'" || c === '`') {
push(true, i);
for (i++; i < src.length && src[i] !== c; i++) if (src[i] === '\\') i++;
i++; push(false, i); continue;
}
i++;
}
push(true, src.length);
const TYPE = String.raw`[A-Za-z_$][\w$.]*(?:<[^;=(){}]*?>)?(?:\[\])*(?:\s*\|\s*[A-Za-z_$][\w$.]*(?:<[^;=(){}]*?>)?(?:\[\])*)*`;
const out = parts.map(p => !p.code ? p.text : p.text
.replace(new RegExp(String.raw`\b(const|let|var)(\s+[A-Za-z_$][\w$]*)\s*:\s*${TYPE}\s*=`, 'g'), '$1$2 =')
.replace(new RegExp(String.raw`\s+satisfies\s+${TYPE}`, 'g'), '')
.replace(new RegExp(String.raw`\s+as\s+(?:const\b|${TYPE})`, 'g'), '')
.replace(/\{\s*type\s+/g, '{ ')
.replace(/,\s*type\s+(?=[A-Za-z_$])/g, ', ')).join('');
// type-only imports and exports, whose specifier string sits in its own (non-code) part
return out
.replace(/^\s*import\s+type\s[^;]*?from\s*(['"])[^'"]*\1\s*;?[ \t]*$/gm, '')
.replace(/^\s*export\s+type\s+\{[^}]*\}(\s*from\s*(['"])[^'"]*\2)?\s*;?[ \t]*$/gm, '')
.replace(/^\s*(export\s+)?(type\s+[A-Za-z_$][\w$]*(<[^>]*>)?\s*=[^;]*;|interface\s+[A-Za-z_$][\w$]*[^{]*\{[^}]*\})/gm, '');
}
// ---- hashing, compression ----
/** Incremental hash (Bun.CryptoHasher): `hasher('md5').update(buf).digest('hex')`. */
export const hasher = (algorithm: 'md5' | 'sha1' | 'sha256' | 'sha512') => createHash(algorithm);
export const gunzipSync = (data: Uint8Array): Uint8Array => new Uint8Array(zGunzipSync(data));
// ---- PATH lookup ----
/**
* Bun.which: the full path of `cmd` on PATH (`opts.PATH` instead of process.env.PATH), or null. A name with a path
* separator is checked as is. On Windows every PATHEXT extension is tried.
*/
export function which(cmd: string, opts: { PATH?: string; cwd?: string } = {}): string | null {
if (!cmd) return null;
const win = process.platform === 'win32';
const exts = win ? ['', ...(process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean).map(e => e.toLowerCase())] : [''];
const ok = (p: string): boolean => {
try {
const s = statSync(p);
if (!s.isFile()) return false;
return win || (s.mode & 0o111) !== 0;
} catch { return false; }
};
const tryExts = (base: string): string | null => {
for (const e of exts) {
if (win && e === '' && !/\.[^\\/.]+$/.test(base)) continue;
const p = base + e;
if (ok(p)) return p;
if (win && e && ok(base + e.toUpperCase())) return base + e.toUpperCase();
}
return null;
};
if (/[\\/]/.test(cmd)) return tryExts(isAbsolute(cmd) ? cmd : resolve(opts.cwd ?? process.cwd(), cmd));
const PATH = opts.PATH ?? process.env.PATH ?? (win ? process.env.Path : undefined) ?? '';
for (const dir of PATH.split(delimiter)) {
if (!dir) continue;
const hit = tryExts(join(dir.replace(/^"(.*)"$/, '$1'), cmd));
if (hit) return hit;
}
return null;
}
// ---- processes ----
type Io = 'pipe' | 'inherit' | 'ignore';
export interface SpawnOptions {
cwd?: string;
env?: Record<string, string | undefined>;
stdin?: Io | null;
stdout?: Io | null;
stderr?: Io | null;
/** [stdin, stdout, stderr]; wins over the single options. */
stdio?: [Io | null, Io | null, Io | null];
}
/** A running child, shaped like Bun's Subprocess (the parts storebound uses). */
export interface Subprocess {
readonly pid: number | undefined;
/** The piped output (an empty, closed stream when not piped). */
readonly stdout: ReadableStream<Uint8Array>;
readonly stderr: ReadableStream<Uint8Array>;
/** The exit code; 128 + the signal number when a signal ended it. Never rejects. */
readonly exited: Promise<number>;
readonly exitCode: number | null;
readonly signalCode: string | null;
readonly killed: boolean;
kill(signal?: NodeJS.Signals | number): void;
/** Lets this process exit while the child runs on. */
unref(): void;
}
const notFound = (cmd: string): Error => Object.assign(new Error(`Executable not found in $PATH: "${cmd}"`), { code: 'ENOENT', path: cmd });
/** Throws like Bun when the executable does not exist (Node would only emit an asynchronous 'error'). */
function checkExecutable(cmd: string | undefined, o: { cwd?: string; env?: Record<string, string | undefined> }): string {
if (!cmd) throw new Error('spawn: empty command');
const PATH = o.env ? (o.env.PATH ?? o.env.Path ?? '') : undefined;
if (!which(cmd, { ...(PATH !== undefined ? { PATH } : {}), ...(o.cwd ? { cwd: o.cwd } : {}) })) throw notFound(cmd);
return cmd;
}
const io = (v: Io | null | undefined, dflt: Io): Io => (v === null ? 'ignore' : v ?? dflt);
const envOf = (env: Record<string, string | undefined> | undefined): NodeJS.ProcessEnv | undefined =>
env ? Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] !== undefined)) : undefined;
/** A node Readable as a web ReadableStream of bytes (or an empty, closed stream for null). */
function webStream(r: Readable | null | undefined): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(c) {
if (!r) { c.close(); return; }
let ended = false;
const end = () => { if (!ended) { ended = true; try { c.close(); } catch { /* closed */ } } };
r.on('data', (d: Buffer | string) => { if (!ended) c.enqueue(typeof d === 'string' ? new TextEncoder().encode(d) : new Uint8Array(d)); });
r.on('end', end);
r.on('close', end);
r.on('error', () => end());
},
});
}
const signalNumber = (s: NodeJS.Signals | null): number => (s ? (constants.signals as Record<string, number>)[s] ?? 1 : 0);
/** Bun.spawn: argv[0] on PATH (opts.env.PATH when given), Bun's stdio defaults (stdin ignore, stdout pipe, stderr inherit). */
export function spawn(argv: string[], o: SpawnOptions = {}): Subprocess {
const cmd = checkExecutable(argv[0], o);
const [i0, o0, e0] = o.stdio ?? [o.stdin, o.stdout, o.stderr];
const stdio: StdioOptions = [io(i0, 'ignore'), io(o0, 'pipe'), io(e0, 'inherit')];
const child = nodeSpawn(cmd, argv.slice(1), { cwd: o.cwd, env: envOf(o.env) ?? process.env, stdio, windowsHide: true });
let exitCode: number | null = null, signalCode: string | null = null;
const exited = new Promise<number>(res => {
let done = false;
const finish = (code: number | null, sig: NodeJS.Signals | null) => {
if (done) return;
done = true;
exitCode = code; signalCode = sig;
res(code ?? (sig ? 128 + signalNumber(sig) : 1));
};
child.on('exit', finish);
child.on('error', () => { if (child.exitCode === null && child.signalCode === null && child.pid === undefined) finish(1, null); });
});
const stdout = webStream(child.stdout), stderr = webStream(child.stderr);
return {
get pid() { return child.pid; },
stdout, stderr, exited,
get exitCode() { return exitCode; },
get signalCode() { return signalCode; },
get killed() { return child.killed; },
kill(signal?: NodeJS.Signals | number) { try { child.kill(signal ?? 'SIGTERM'); } catch { /* gone */ } },
unref() {
child.unref();
for (const s of [child.stdin, child.stdout, child.stderr]) (s as unknown as { unref?: () => void } | null)?.unref?.();
},
};
}
export interface SpawnSyncOptions {
cwd?: string;
env?: Record<string, string | undefined>;
/** Bytes or text written to the child's stdin (then closed), or an stdio mode. */
stdin?: Uint8Array | string | Io | null;
stdout?: Io | null;
stderr?: Io | null;
/** Milliseconds; the child gets SIGTERM after it (exitCode null, signalCode 'SIGTERM'). */
timeout?: number;
}
export interface SyncResult { exitCode: number | null; signalCode: string | null; success: boolean; stdout: Buffer; stderr: Buffer }
/** Bun.spawnSync: never throws for a nonzero exit or a timeout, throws when the executable does not exist. */
export function spawnSync(argv: string[], o: SpawnSyncOptions = {}): SyncResult {
const cmd = checkExecutable(argv[0], o);
const input = typeof o.stdin === 'string' && !['pipe', 'inherit', 'ignore'].includes(o.stdin) ? o.stdin : o.stdin instanceof Uint8Array ? o.stdin : undefined;
const stdinMode: Io = input !== undefined ? 'pipe' : io(o.stdin as Io | null | undefined, 'ignore');
const r = nodeSpawnSync(cmd, argv.slice(1), {
cwd: o.cwd, env: envOf(o.env) ?? process.env, input, windowsHide: true,
stdio: [stdinMode, io(o.stdout, 'pipe'), io(o.stderr, 'pipe')], maxBuffer: 1 << 30,
...(o.timeout ? { timeout: o.timeout, killSignal: 'SIGTERM' as const } : {}),
});
const err = r.error as (Error & { code?: string }) | undefined;
if (err && err.code === 'ENOENT') throw notFound(argv[0]!);
if (err && err.code !== 'ETIMEDOUT' && r.status === null && r.signal === null) throw err;
const buf = (b: Buffer | string | null | undefined): Buffer => (b == null ? Buffer.alloc(0) : typeof b === 'string' ? Buffer.from(b) : b);
return { exitCode: r.status, signalCode: r.signal, success: r.status === 0, stdout: buf(r.stdout), stderr: buf(r.stderr) };
}
// ---- stdin ----
/** This process's stdin as a web stream (Bun.stdin.stream()). */
export const stdinStream = (): ReadableStream<Uint8Array> => webStream(process.stdin);
/** All of stdin as text (Bun.stdin.text()). */
export const stdinText = (): Promise<string> => new Response(stdinStream()).text();
// ---- a small HTTP server (Bun.serve) ----
export interface Server { readonly port: number; readonly hostname: string; stop(closeActive?: boolean): void }
/**
* Bun.serve on node:http: `fetch(Request) => Response` for local pages (GET and HEAD; the body is read whole).
* Resolves once listening; rejects when the port is taken.
*/
export function serve(o: { port?: number; hostname?: string; fetch(req: Request): Response | Promise<Response> }): Promise<Server> {
const hostname = o.hostname ?? '127.0.0.1';
const server = createServer((req, res) => {
void (async () => {
try {
const headers = new Headers();
for (const [k, v] of Object.entries(req.headers)) if (v !== undefined) headers.set(k, Array.isArray(v) ? v.join(', ') : v);
const r = await o.fetch(new Request(`http://${req.headers.host ?? `${hostname}:${port}`}${req.url ?? '/'}`, { method: req.method ?? 'GET', headers }));
const body = req.method === 'HEAD' ? null : Buffer.from(await r.arrayBuffer());
const h: Record<string, string> = {};
r.headers.forEach((v, k) => { h[k] = v; });
if (body) h['content-length'] = String(body.length);
res.writeHead(r.status, h);
res.end(body ?? undefined);
} catch (e) {
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' });
res.end(String((e as Error).message ?? e));
}
})();
});
let port = o.port ?? 0;
return new Promise((res, rej) => {
server.once('error', rej);
server.listen(port, hostname, () => {
server.off('error', rej);
const a = server.address();
if (a && typeof a === 'object') port = a.port;
res({
port, hostname,
stop(closeActive = false) { if (closeActive) server.closeAllConnections?.(); server.close(); },
});
});
});
}
/** A file as a Response body, or null when it does not exist (Bun.file + exists()). */
export function fileResponse(path: string, headers: Record<string, string> = {}): Response | null {
try {
if (!statSync(path).isFile()) return null;
return new Response(readFileSync(path), { headers });
} catch { return null; }
}
// ---- globs (Bun.Glob) ----
/** A glob pattern: `*` and `?` within one path segment, `**` across segments, `[abc]`, `{a,b}`. */
export class Glob {
readonly re: RegExp;
constructor(readonly pattern: string) { this.re = globRegExp(pattern); }
match(path: string): boolean { return this.re.test(path.replace(/\\/g, '/')); }
/** The matching paths under `cwd`, relative with forward slashes; dot entries only with `dot`; symlinked folders are not entered. */
*scanSync(o: { cwd?: string; onlyFiles?: boolean; dot?: boolean } = {}): Generator<string> {
const cwd = o.cwd ?? process.cwd();
const onlyFiles = o.onlyFiles ?? true;
const segs = this.pattern.replace(/\\/g, '/').split('/');
const deep = segs.includes('**') || segs.some(s => s.includes('**'));
// the literal leading segments: no need to walk anything above them
let fixed = 0;
while (fixed < segs.length - 1 && !/[*?[{]/.test(segs[fixed]!)) fixed++;
const prefix = segs.slice(0, fixed).join('/');
const maxDepth = deep ? Infinity : segs.length - fixed;
const self = this;
function* walk(rel: string, depth: number): Generator<string> {
let entries;
try { entries = readdirSync(join(cwd, rel), { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (!o.dot && e.name.startsWith('.')) continue;
const p = rel ? `${rel}/${e.name}` : e.name;
const dir = e.isDirectory();
if ((!onlyFiles || !dir) && self.re.test(p)) yield p;
if (dir && depth + 1 < maxDepth) yield* walk(p, depth + 1);
}
}
if (prefix && !existsSync(join(cwd, prefix))) return;
yield* walk(prefix, 0);
}
}
export function globRegExp(pattern: string): RegExp {
const p = pattern.replace(/\\/g, '/');
let re = '';
let braces = 0;
for (let i = 0; i < p.length; i++) {
const c = p[i]!;
if (c === '*') {
if (p[i + 1] === '*') {
const atStart = i === 0 || p[i - 1] === '/';
i++;
if (atStart && p[i + 1] === '/') { re += '(?:[^/]*/)*'; i++; }
else re += '.*';
} else re += '[^/]*';
} else if (c === '?') re += '[^/]';
else if (c === '[') {
const end = p.indexOf(']', i + 1);
if (end < 0) { re += '\\['; continue; }
let body = p.slice(i + 1, end);
if (body.startsWith('!')) body = '^' + body.slice(1);
re += `[${body.replace(/\\/g, '\\\\')}]`;
i = end;
} else if (c === '{') { braces++; re += '(?:'; }
else if (c === '}' && braces) { braces--; re += ')'; }
else if (c === ',' && braces) re += '|';
else re += c.replace(/[.+^$()|\\]/g, '\\$&');
}
return new RegExp(`^${re}$`);
}
// ---- SQLite (bun:sqlite under Bun, node:sqlite on Node 22.5+) ----
export interface SqliteDb {
all<T>(sql: string, ...params: unknown[]): T[];
get<T>(sql: string, ...params: unknown[]): T | undefined;
run(sql: string, ...params: unknown[]): void;
exec(sql: string): void;
close(): void;
}
/** Opens a SQLite file; throws when this runtime has no SQLite (Node before 22.5). */
export async function openSqlite(path: string): Promise<SqliteDb> {
const spec = isBun ? 'bun:sqlite' : 'node:sqlite';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any = await import(spec);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db: any = isBun ? new mod.Database(path) : new mod.DatabaseSync(path);
return {
all: (sql, ...p) => db.prepare(sql).all(...p),
get: (sql, ...p) => db.prepare(sql).get(...p) ?? undefined,
run: (sql, ...p) => { db.prepare(sql).run(...p); },
exec: sql => { db.exec(sql); },
close: () => db.close(),
};
}

View file

@ -1,16 +1,21 @@
// Runs one storebound command as a child process: the shared core of `storebound mcp` and `storebound serve --stdio`.
// Works from source (`bun cli/index.ts ...`) and from a `bun build --compile` binary (the desktop sidecar), where the
// binary re-executes itself. Output is streamed line by line (progress) and the `--json` result line is picked out.
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { RESULT_MARK } from './log';
import { toolPath } from './tools';
import * as rt from './rt';
/** True inside a `bun build --compile` executable (the entry module lives in Bun's embedded file system). */
export const compiled = (): boolean => /^(\/\$bunfs\/|[A-Z]:[\\/]~BUN[\\/])/.test(Bun.main);
export const compiled = (): boolean => /^(\/\$bunfs\/|[A-Z]:[\\/]~BUN[\\/])/.test(rt.mainPath());
/** Command prefix that starts this storebound: the compiled binary itself, or bun + cli/index.ts. */
export function selfCommand(): string[] {
return compiled() ? [process.execPath] : [process.execPath, join(import.meta.dir, '..', 'index.ts')];
if (compiled()) return [process.execPath];
// from the sources (cli/lib/runner.ts): bun cli/index.ts; from the npm bundle (dist/storebound.mjs): node <bundle>
const self = fileURLToPath(import.meta.url);
return [process.execPath, /\.ts$/.test(self) ? join(dirname(self), '..', 'index.ts') : self];
}
export interface RunOptions {
@ -62,7 +67,7 @@ export async function pump(stream: ReadableStream<Uint8Array>, kind: 'stdout' |
}
export function runCommand(argv: string[], o: RunOptions): Running {
const p = Bun.spawn([...selfCommand(), ...argv], { cwd: o.cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: childEnv(o.env, o.machine) });
const p = rt.spawn([...selfCommand(), ...argv], { cwd: o.cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: childEnv(o.env, o.machine) });
let cancelled = false;
const lines: string[] = [];
let data: unknown;

View file

@ -1,65 +1,4 @@
// Every file under templates/, embedded as text so `storebound init` also works from the compiled desktop sidecar
// (a `bun build --compile` binary has no templates folder next to it). tests/apps.test.ts checks this list against the
// folder: add a line here when a template is added.
import t0 from '../../templates/app-privacy.md' with { type: 'text' };
import t1 from '../../templates/appstore.md' with { type: 'text' };
import t2 from '../../templates/credentials.example.json' with { type: 'text' };
import t3 from '../../templates/data-safety.csv' with { type: 'text' };
import t4 from '../../templates/declarations.md' with { type: 'text' };
import t5 from '../../templates/listing.md' with { type: 'text' };
import t6 from '../../templates/privacy-policy.html' with { type: 'text' };
import t7 from '../../templates/profiles/app/app-privacy.md' with { type: 'text' };
import t8 from '../../templates/profiles/app/appstore.md' with { type: 'text' };
import t9 from '../../templates/profiles/app/declarations.md' with { type: 'text' };
import t10 from '../../templates/profiles/app/listing.md' with { type: 'text' };
import t11 from '../../templates/profiles/app/privacy-policy.html' with { type: 'text' };
import t12 from '../../templates/profiles/app/shots-copy.json' with { type: 'text' };
import t13 from '../../templates/profiles/app/storebound.config.ts' with { type: 'text' };
import t14 from '../../templates/profiles/subscription-app/app-privacy.md' with { type: 'text' };
import t15 from '../../templates/profiles/subscription-app/appstore.md' with { type: 'text' };
import t16 from '../../templates/profiles/subscription-app/declarations.md' with { type: 'text' };
import t17 from '../../templates/profiles/subscription-app/listing.md' with { type: 'text' };
import t18 from '../../templates/profiles/subscription-app/storebound.config.ts' with { type: 'text' };
import t19 from '../../templates/shots-copy.json' with { type: 'text' };
import t20 from '../../templates/storebound.config.ts' with { type: 'text' };
import t21 from '../../templates/testers.csv' with { type: 'text' };
import tWebAppstore from '../../templates/web/appstore.svg' with { type: 'text' };
import tWebGoogleplay from '../../templates/web/googleplay.svg' with { type: 'text' };
import tWebLanding from '../../templates/web/landing.html' with { type: 'text' };
import tWfAppRelease from '../../templates/profiles/app/workflows/release.yml' with { type: 'text' };
import tWfAppBeta from '../../templates/profiles/app/workflows/beta.yml' with { type: 'text' };
import tWfRelease from '../../templates/workflows/release.yml' with { type: 'text' };
import tWfBeta from '../../templates/workflows/beta.yml' with { type: 'text' };
/** Template text by its path under templates/ (forward slashes). */
export const TEMPLATE_TEXT: Record<string, string> = {
'app-privacy.md': t0 as unknown as string,
'appstore.md': t1 as unknown as string,
'credentials.example.json': t2 as unknown as string,
'data-safety.csv': t3 as unknown as string,
'declarations.md': t4 as unknown as string,
'listing.md': t5 as unknown as string,
'privacy-policy.html': t6 as unknown as string,
'profiles/app/app-privacy.md': t7 as unknown as string,
'profiles/app/appstore.md': t8 as unknown as string,
'profiles/app/declarations.md': t9 as unknown as string,
'profiles/app/listing.md': t10 as unknown as string,
'profiles/app/privacy-policy.html': t11 as unknown as string,
'profiles/app/shots-copy.json': t12 as unknown as string,
'profiles/app/storebound.config.ts': t13 as unknown as string,
'profiles/subscription-app/app-privacy.md': t14 as unknown as string,
'profiles/subscription-app/appstore.md': t15 as unknown as string,
'profiles/subscription-app/declarations.md': t16 as unknown as string,
'profiles/subscription-app/listing.md': t17 as unknown as string,
'profiles/subscription-app/storebound.config.ts': t18 as unknown as string,
'shots-copy.json': t19 as unknown as string,
'storebound.config.ts': t20 as unknown as string,
'testers.csv': t21 as unknown as string,
'web/appstore.svg': tWebAppstore as unknown as string,
'web/googleplay.svg': tWebGoogleplay as unknown as string,
'web/landing.html': tWebLanding as unknown as string,
'profiles/app/workflows/release.yml': tWfAppRelease as unknown as string,
'profiles/app/workflows/beta.yml': tWfAppBeta as unknown as string,
'workflows/release.yml': tWfRelease as unknown as string,
'workflows/beta.yml': tWfBeta as unknown as string,
};
// Every file under templates/, embedded as text so `storebound init` also works from the npm bundle and the compiled
// desktop sidecar (neither has a templates folder next to its code). The table is generated into lib/embedded.ts by
// tools/gen-embedded.ts (bun run ci checks it is fresh).
export { TEMPLATE_TEXT } from './embedded';

View file

@ -6,6 +6,7 @@
// Nothing is installed system-wide. Sources and licenses: docs/desktop.md.
import { existsSync, readFileSync } from 'node:fs';
import { delimiter, join } from 'node:path';
import * as rt from './rt';
export type ToolId = 'ffmpeg' | 'ffprobe' | 'chrome' | 'xcode' | 'simctl' | 'adb' | 'git';
@ -47,7 +48,7 @@ export function toolPath(env: Record<string, string | undefined> = process.env,
/** First line of `<cmd> <args>` (version banners), or null. */
function firstLine(cmd: string[], env: Record<string, string>): string | null {
try {
const r = Bun.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe', env, timeout: 5_000 });
const r = rt.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe', env, timeout: 5_000 });
if (r.exitCode !== 0) return null;
return (r.stdout.toString() || r.stderr.toString()).trim().split('\n')[0]?.trim() || null;
} catch { return null; }
@ -72,7 +73,7 @@ export function detectTools(env: Record<string, string | undefined> = process.en
const PATH = toolPath(env);
const spawnEnv: Record<string, string> = { ...Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] !== undefined)), PATH };
const appDirs = (env.STOREBOUND_TOOL_PATH ?? '').split(delimiter).filter(Boolean);
const which = (n: string): string | null => Bun.which(n, { PATH });
const which = (n: string): string | null => rt.which(n, { PATH });
const src = (p: string | null): ToolStatus['source'] => (!p ? null : appDirs.some(d => p.startsWith(d)) ? 'app' : 'system');
const bin = (id: ToolId, name: string, usedFor: string, install: ToolStatus['install'], versionArgs: string[]): ToolStatus => {
const path = which(id);
@ -206,7 +207,7 @@ export function androidSdkMissing(l: AndroidSdkLookup): string {
/** Where playwright resolves from (the project, then the storebound folder), or null. */
export function findPlaywright(bases: string[]): string | null {
for (const base of bases) {
try { return Bun.resolveSync('playwright', base); } catch { /* next */ }
try { return rt.resolveFrom('playwright', base); } catch { /* next */ }
}
return null;
}

View file

@ -13,6 +13,7 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { log, warn } from './log';
import * as rt from './rt';
/** A write whose outcome is unknown: it may or may not have been applied (timeout, connection reset, 5xx answer to a
* POST). The API clients throw this instead of retrying such a request. */
@ -58,7 +59,7 @@ export function retrySafe(method: string, path: string): boolean {
}
/** Waits between re-checks (STOREBOUND_UPSERT_WAIT_MS; the test preload sets 0). */
export const upsertTiming = { waitMs: Number(process.env.STOREBOUND_UPSERT_WAIT_MS ?? 3000), tries: 4, sleep: (ms: number): Promise<void> => (ms > 0 ? Bun.sleep(ms) : Promise.resolve()) };
export const upsertTiming = { waitMs: Number(process.env.STOREBOUND_UPSERT_WAIT_MS ?? 3000), tries: 4, sleep: (ms: number): Promise<void> => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) };
export interface CreateOnce<T> {
/** what is being created, for log lines and errors: `in-app purchase com.example.gems` */

View file

@ -18,6 +18,7 @@ import { dirname, join, relative, sep } from 'node:path';
import type { ShipConfig } from './config';
import { log, warn } from './log';
import { pump } from './runner';
import * as rt from './rt';
export type Store = 'apple' | 'play';
@ -138,7 +139,7 @@ export function lastSourceCommit(root: string, capDir = ''): number | null {
if (capDir) for (const d of ['ios', 'android']) ex.add(`:(exclude)${capDir}/${d}/`);
ex.add(':(exclude,glob)**/node_modules/**');
try {
const r = Bun.spawnSync(['git', 'log', '-1', '--format=%ct', '--', '.', ...ex], { cwd: root, stdout: 'pipe', stderr: 'pipe' });
const r = rt.spawnSync(['git', 'log', '-1', '--format=%ct', '--', '.', ...ex], { cwd: root, stdout: 'pipe', stderr: 'pipe' });
if (r.exitCode !== 0) return null;
const s = Number(r.stdout.toString().trim());
return s > 0 ? s * 1000 : null;
@ -179,7 +180,7 @@ export function prebuildsDone(env: Record<string, string | undefined> = process.
/** Runs a prebuild command in root with sh -c, streaming its output; throws on a nonzero exit. */
export async function runPrebuild(cmd: string, root: string, tag: string): Promise<void> {
log(tag, `prebuild: $ ${cmd}`);
const p = Bun.spawn(['sh', '-c', cmd], { cwd: root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env });
const p = rt.spawn(['sh', '-c', cmd], { cwd: root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env });
const tail: string[] = [];
const each = (line: string, kind: 'stdout' | 'stderr'): void => {
tail.push(line); if (tail.length > 20) tail.shift();

View file

@ -21,6 +21,7 @@ import { CONFIG_NAMES } from './config';
import { PROFILE_RE } from './credentials';
import type { StoreApp } from './desktop-protocol';
import { type IdProject, NO_OVERRIDES, type Overrides, type Pair } from './identity';
import * as rt from './rt';
export const WORKSPACE_NAMES = ['storebound.workspace.json', 'storebound.workspace.ts', 'storebound.workspace.js'];
/** Colours a studio can carry: the desktop's set (workspace.rs STUDIO_COLORS). */
@ -94,7 +95,7 @@ export async function loadWorkspace(from: string, explicit?: string): Promise<Lo
let raw: unknown;
if (file.endsWith('.json')) {
try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch (e) { throw new Error(`${file}: ${(e as Error).message}`); }
} else raw = ((await import(file)) as { default?: unknown }).default;
} else raw = ((await rt.importConfigModule(file)) as { default?: unknown }).default;
const problems = checkWorkspace(raw);
if (problems.length) throw new Error(`${file}:\n ${problems.join('\n ')}`);
return { file, root: resolve(file, '..'), ws: raw as Workspace };
@ -162,7 +163,7 @@ export const studiosOf = (ws: Workspace): Studio[] =>
(ws.studios ?? []).map((s, i) => ({ id: studioId(s), name: s.name, color: s.color ?? STUDIO_COLORS[i % STUDIO_COLORS.length]! }));
const isGlob = (p: string): boolean => /[*?[{]/.test(p);
const matchAny = (globs: string[], rel: string): boolean => globs.some(g => new Bun.Glob(g.replace(/\/+$/, '')).match(rel));
const matchAny = (globs: string[], rel: string): boolean => globs.some(g => new rt.Glob(g.replace(/\/+$/, '')).match(rel));
const hasConfig = (dir: string): boolean => CONFIG_NAMES.some(n => existsSync(join(dir, n)));
/**
@ -182,7 +183,7 @@ export function resolveApps(l: LoadedWorkspace): { apps: AppFolder[]; warnings:
const base = isAbsolute(p) ? '/' : root;
const pattern = isAbsolute(p) ? p.slice(1) : p;
let hits = 0;
for (const m of new Bun.Glob(pattern.replace(/\/+$/, '')).scanSync({ cwd: base, onlyFiles: false, dot: false })) {
for (const m of new rt.Glob(pattern.replace(/\/+$/, '')).scanSync({ cwd: base, onlyFiles: false, dot: false })) {
if (/(^|\/)(node_modules|\.git)(\/|$)/.test(m)) continue;
const dir = resolve(base, m);
try { if (!statSync(dir).isDirectory()) continue; } catch { continue; }
@ -249,7 +250,7 @@ export function selectApps<T extends { rel: string; name: string }>(apps: T[], n
const picked = new Set<T>();
for (const n of names) {
const lc = n.toLowerCase();
const hits = apps.filter(a => a.rel.toLowerCase() === lc || basename(a.rel).toLowerCase() === lc || a.name.toLowerCase() === lc || (isGlob(n) && new Bun.Glob(n).match(a.rel)));
const hits = apps.filter(a => a.rel.toLowerCase() === lc || basename(a.rel).toLowerCase() === lc || a.name.toLowerCase() === lc || (isGlob(n) && new rt.Glob(n).match(a.rel)));
if (!hits.length) unknown.push(n);
hits.forEach(h => picked.add(h));
}

View file

@ -22,6 +22,7 @@ import {
} from './lib/listing-translate';
import { textTable } from './lib/table';
import { resolveLanguages } from './lib/whats-new';
import * as rt from './lib/rt';
const TAG = 'listing';
const storeCode = (l: Language): string => l.apple ?? playCodes(l)[0] ?? l.id;
@ -60,7 +61,7 @@ async function translate(ctx: Ctx): Promise<void> {
const dry = a.has('dry');
const rows: Row[] = [];
const runHook = (text: string, l: Language, st: 'apple' | 'play', field: string, limit: number): string | null => {
const r = Bun.spawnSync(['sh', '-c', hook!], { stdin: new TextEncoder().encode(text), stdout: 'pipe', stderr: 'pipe',
const r = rt.spawnSync(['sh', '-c', hook!], { stdin: new TextEncoder().encode(text), stdout: 'pipe', stderr: 'pipe',
env: { ...process.env, STOREBOUND_SOURCE_LANG: storeCode(src), STOREBOUND_TARGET_LANG: st === 'apple' ? l.apple ?? l.id : playCodes(l)[0] ?? l.id, STOREBOUND_TARGET_LANG_ID: l.id,
STOREBOUND_STORE: st, STOREBOUND_FIELD: field, STOREBOUND_LIMIT: String(limit) } });
const out = r.stdout.toString().trim();

View file

@ -23,6 +23,7 @@
import pkg from '../package.json' with { type: 'json' };
import { runCommand, type Running } from './lib/runner';
import { listTools, TOOLS, toArgv, validateInput } from './mcp-tools';
import * as rt from './lib/rt';
/** Current (modern, stateless) revision. */
export const PROTOCOL = '2026-07-28';
@ -132,7 +133,7 @@ export async function serve(): Promise<void> {
console.error(`[storebound mcp] ${pkg.version} on stdio (MCP ${PROTOCOL}, legacy ${LEGACY.join(', ')}), project ${process.env.STOREBOUND_PROJECT || process.cwd()}`);
let buf = '';
const decoder = new TextDecoder();
const reader = Bun.stdin.stream().getReader();
const reader = rt.stdinStream().getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) break;

View file

@ -26,6 +26,7 @@ import { capacitorProject, webStep } from '../lib/web-assets';
import { androidSdk, androidSdkEnv, androidSdkMissing } from '../lib/tools';
import { playClient } from './client';
import { usedVersionCodes } from './next-version-code';
import * as rt from '../lib/rt';
const TAG = 'build';
@ -93,7 +94,7 @@ export async function run(ctx: Ctx): Promise<void> {
log(TAG, `$ ${plan.command}`);
const started = Date.now();
mkdirSync(out, { recursive: true });
const p = Bun.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...androidSdkEnv(sdk), ...signingEnv(signing) } });
const p = rt.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...androidSdkEnv(sdk), ...signingEnv(signing) } });
const lines: string[] = [];
const each = (line: string): void => {
lines.push(line);

View file

@ -4,6 +4,7 @@ import { tokenSource } from '../lib/google-auth';
import { log, warn } from '../lib/log';
import { errorBody } from '../lib/http';
import { AmbiguousWriteError, isAmbiguous, retrySafe } from '../lib/upsert';
import * as rt from '../lib/rt';
export type PlayClient = Awaited<ReturnType<typeof playClient>>;
@ -30,13 +31,13 @@ export async function playClient(root: string, packageName: string) {
body: body === undefined ? undefined : bin ? (body as unknown as BodyInit) : JSON.stringify(body),
});
} catch (e) {
if (safe && attempt < 4) { await Bun.sleep(2000 * (attempt + 1)); continue; }
if (safe && attempt < 4) { await rt.sleep(2000 * (attempt + 1)); continue; }
if (!safe) throw new AmbiguousWriteError(`${method} ${short()} -> no answer (${(e as Error).message})`);
throw e;
}
const text = await r.text();
if (r.ok) return (text ? JSON.parse(text) : {}) as T;
if ((r.status === 429 || (r.status >= 500 && safe)) && attempt < 4) { await Bun.sleep(2000 * (attempt + 1)); continue; }
if ((r.status === 429 || (r.status >= 500 && safe)) && attempt < 4) { await rt.sleep(2000 * (attempt + 1)); continue; }
// A wrong path casing answers with an HTML 404 page, not JSON: say so instead of dumping the HTML.
const msg = /^\s*</.test(text) ? `(HTML ${r.status} page: wrong path or casing?)` : errorBody(text);
if (r.status >= 500 && !safe) throw new AmbiguousWriteError(`${method} ${short()} -> ${r.status} ${msg}`, r.status);

View file

@ -13,11 +13,12 @@ import { dataSafetyCsv, dataSafetyProblem, dataSafetyRows } from '../lib/data-sa
import { DATA_SAFETY_UPLOADED } from '../lib/declarations';
import { done, log } from '../lib/log';
import { playClient } from './client';
import * as rt from '../lib/rt';
export const DATA_SAFETY_HASH = '.storebound/data-safety.sha256';
/** sha256 of the CSV for one package. Pure. */
export const dataSafetyHash = (pkg: string, csv: string): string => new Bun.CryptoHasher('sha256').update(`${pkg}\n${csv}`).digest('hex');
export const dataSafetyHash = (pkg: string, csv: string): string => rt.hasher('sha256').update(`${pkg}\n${csv}`).digest('hex');
/** The hash of the last upload of this package, null when none is recorded. */
export function lastUpload(root: string, pkg: string): { sha256: string; at: string } | null {

View file

@ -3,6 +3,7 @@
import { playServiceAccount } from '../lib/credentials';
import { tokenSource } from '../lib/google-auth';
import { errorBody } from '../lib/http';
import * as rt from '../lib/rt';
export const REPORTING_API = 'https://playdeveloperreporting.googleapis.com/v1beta1';
@ -12,7 +13,7 @@ export function googleClient(root: string, scope: string) {
for (let attempt = 0; ; attempt++) {
const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
body: body === undefined ? undefined : JSON.stringify(body) });
if ((r.status === 429 || r.status >= 500) && attempt < 4) { await r.body?.cancel(); await Bun.sleep(2000 * (attempt + 1)); continue; }
if ((r.status === 429 || r.status >= 500) && attempt < 4) { await r.body?.cancel(); await rt.sleep(2000 * (attempt + 1)); continue; }
return r;
}
}

View file

@ -26,6 +26,7 @@ import {
import { textTable } from './lib/table';
import { unzip } from './lib/zip';
import { googleClient } from './play/google';
import * as rt from './lib/rt';
const TAG = 'reports';
const USAGE = 'usage: storebound reports apple-sales | apple-finance --month YYYY-MM | play-earnings --month YYYY-MM | play-sales --month YYYY-MM';
@ -87,7 +88,7 @@ export async function run(ctx: Ctx): Promise<void> {
let gz: Uint8Array | null;
try { gz = await c.bytes(path); } catch (e) { const hint = appleReportHint((e as Error).message); throw new Error(hint ? `${(e as Error).message}\n${hint}` : (e as Error).message); }
if (!gz) { done(TAG, 'no report for that date (no sales then, or not published yet: daily reports come out the next day, monthly ones about a week later)'); if (a.has('json')) printJson({ report: name, empty: true }); return; }
const text = new TextDecoder().decode(gz[0] === 0x1f && gz[1] === 0x8b ? Bun.gunzipSync(gz as Uint8Array<ArrayBuffer>) : gz);
const text = new TextDecoder().decode(gz[0] === 0x1f && gz[1] === 0x8b ? rt.gunzipSync(gz as Uint8Array<ArrayBuffer>) : gz);
const rows = parseTsv(text);
const summary = sub === 'apple-sales' ? (a.str('type', 'SALES').toUpperCase() === 'SALES' ? summarizeAppleSales(rows) : []) : summarizeAppleFinance(rows);
const files = save(out, name, text, summary, csvOut);

View file

@ -30,6 +30,7 @@ import {
type RcState, type RcStep, resolveApps, wantedRevenueCat,
} from './lib/revenuecat';
import { textTable } from './lib/table';
import * as rt from './lib/rt';
const TAG = 'revenuecat';
const ORIGIN = 'https://api.revenuecat.com';
@ -63,7 +64,7 @@ export function rcClient(key: string, o: { retries?: number } = {}): RcClient {
const s = Math.min(60, Math.max(0, Number(r.headers.get('retry-after') ?? '5') || 0));
warn(TAG, `rate limited, waiting ${s}s`);
await r.body?.cancel();
await Bun.sleep(s * 1000);
await rt.sleep(s * 1000);
continue;
}
const text = await r.text();

View file

@ -27,6 +27,7 @@ import { RENDER_FILE_RE } from './lib/project-files';
import { androidSdk, detectTools } from './lib/tools';
import { compiled, pickCallEnv, runCommand, type RunOptions, type Running } from './lib/runner';
import { inputSchema, type ToolDef, TOOLS, toArgv, validateInput } from './mcp-tools';
import * as rt from './lib/rt';
type Id = string | number;
interface Msg { jsonrpc?: string; id?: Id | null; method?: string; params?: unknown }
@ -388,7 +389,7 @@ export class Session {
}
if (!this.guard) throw new RpcError(ERR.notInitialized, 'session.hello first');
switch (method) {
case 'system.version': return { version: pkg.version, compiled: compiled(), platform: process.platform, arch: process.arch, bun: Bun.version };
case 'system.version': return { version: pkg.version, compiled: compiled(), platform: process.platform, arch: process.arch, bun: rt.bunVersion, runtime: rt.runtime };
case 'system.methods': return { methods: describeMethods() };
case 'system.tools': return localTools();
case 'project.inspect': return this.inspect(checkProject(params.project), {}, id);
@ -525,7 +526,7 @@ export async function serveStdio(): Promise<void> {
console.error(`[storebound serve] ${pkg.version} on stdio (${DESKTOP_PROTOCOL}), waiting for session.hello`);
let buf = '';
const decoder = new TextDecoder();
const reader = Bun.stdin.stream().getReader();
const reader = rt.stdinStream().getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) break;

View file

@ -23,17 +23,18 @@ import { existsSync, readlinkSync } from 'node:fs';
import { hostname } from 'node:os';
import { join } from 'node:path';
import { compiled } from '../lib/runner';
import * as rt from '../lib/rt';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function chromium(root: string): Promise<any> {
for (const base of [root, join(import.meta.dir, '../..')]) {
for (const base of [root, rt.packageRoot()]) {
try {
const p = Bun.resolveSync('playwright', base);
const p = rt.resolveFrom('playwright', base);
// only an installed copy (node_modules): outside a project Bun auto-installs the newest playwright into its global
// cache (~/.bun/install/cache), whose browser build was never downloaded ("headless shell: not installed" on
// Linux CI, where no Google Chrome hides it); storebound's own copy is next
if (!isInstalledPackage(p)) continue;
return (await import(p)).chromium;
return (await rt.importPath(p)).chromium;
} catch { /* next */ }
}
// only reached in the compiled sidecar: the bundled playwright-core (not a runtime dependency of the npm package)
@ -68,7 +69,7 @@ export interface Candidate {
}
/**
* Switches for a Chromium storebound starts itself (Bun.spawn, not Playwright's launch, which already passes
* Switches for a Chromium storebound starts itself (rt.spawn, not Playwright's launch, which already passes
* --no-sandbox unless chromiumSandbox is set): Chromium refuses to run as root on Linux without --no-sandbox
* ("Running as root without --no-sandbox is not supported", crbug.com/638180), and inside a Docker / Podman container
* its user-namespace sandbox is usually blocked by seccomp, so CI containers (the Forgejo runner runs jobs as root)

View file

@ -37,6 +37,7 @@ import { adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, adbForceStopArgs, a
import { type CopyFile, copyText, type FrameDims, frameDims } from './lib';
import { captureWeb, defaultConcurrency, hashPaths, hashServed, planWeb, readCache, selectJobs, summaryLines, writeCaptureState } from './capture-web';
import { imageInfo } from './spec';
import * as rt from '../lib/rt';
type Device = 'phone' | 'tablet';
@ -56,7 +57,7 @@ export function adbPath(): string {
/** Throws the install hint when a tool is neither a path that exists nor on PATH. */
export function requireTool(cmd: string, name = cmd): void {
if (existsSync(cmd) || Bun.which(cmd)) return;
if (existsSync(cmd) || rt.which(cmd)) return;
throw new Error(TOOL_HINTS[name] ?? `${name} not found on PATH`);
}
@ -150,7 +151,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (source === 'ios-sim') {
const ios = need(cap.ios, 'shots.capture.ios');
if (!dry) { requireTool('xcrun'); requireTool('ffmpeg'); }
const list = dry && !Bun.which('xcrun') ? '{"devices":{}}' : sh('xcrun', ['simctl', 'list', 'devices', '-j']).out.toString();
const list = dry && !rt.which('xcrun') ? '{"devices":{}}' : sh('xcrun', ['simctl', 'list', 'devices', '-j']).out.toString();
for (const device of devices) {
const want = simTarget(ios, device);
const todo = shotsFor(cap, device, only);
@ -171,16 +172,16 @@ export async function run(ctx: Ctx): Promise<void> {
if (bootedByUs && warmup > 0) {
log('shots', `warming up ${ios.bundleId} (first launch after boot, ${warmup} ms; shots.capture.ios.warmupMs)`);
exec('xcrun', simLaunchArgs(sim.udid, ios.bundleId, { lang: langs[0]!, locale: localeOf(config, langs[0]!, 'ios-sim'), shot: '', baseArgs: ios.args }));
if (!dry) await Bun.sleep(warmup);
if (!dry) await rt.sleep(warmup);
}
for (const lang of langs) for (const [name, s] of todo) {
try {
const locale = localeOf(config, lang, 'ios-sim');
exec('xcrun', simLaunchArgs(sim.udid, ios.bundleId, { lang, locale, shot: name, baseArgs: ios.args, args: s.args }));
if (s.url) { if (!dry) await Bun.sleep(1500); exec('xcrun', simOpenUrlArgs(sim.udid, fill(s.url, { lang, locale, shot: name }))); }
if (s.url) { if (!dry) await rt.sleep(1500); exec('xcrun', simOpenUrlArgs(sim.udid, fill(s.url, { lang, locale, shot: name }))); }
const out = target(lang, device, name);
if (dry) { progress(`${lang}/${device}/${name}.png (${sim.name}, would wait ${s.delayMs ?? 2500} ms)`); continue; }
await Bun.sleep(s.delayMs ?? 2500);
await rt.sleep(s.delayMs ?? 2500);
const tmp = join(tmpDir, `sim-${name}.png`);
sh('xcrun', simScreenshotArgs(sim.udid, tmp));
storeFrame(tmp, out, cap.cropTop, device, dims[device]);
@ -221,7 +222,7 @@ export async function run(ctx: Ctx): Promise<void> {
exec(adb, adbStartArgs(and.package, { lang, locale, shot: name, url: s.url, activity: and.activity, extras: s.extras }, serial));
const out = target(lang, device, name);
if (dry) { progress(`${lang}/${device}/${name}.png (would wait ${s.delayMs ?? 2500} ms, screencap)`); continue; }
await Bun.sleep(s.delayMs ?? 2500);
await rt.sleep(s.delayMs ?? 2500);
const tmp = join(tmpDir, `adb-${name}.png`);
writeFileSync(tmp, sh(adb, adbScreencapArgs(serial)).out);
storeFrame(tmp, out, cap.cropTop, device, dims[device]);

View file

@ -27,6 +27,7 @@ import { ADB_REMOTE_CLIP, adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, ad
simStatusBarArgs, simStatusBarClearArgs, simTarget, SOURCES, type Source, trimArgs, validateRecord, concatArgs, concatList, type ScreencastFrame } from './devices';
import { frameDims } from './lib';
import { CLIP_RE, clipFiles } from './video';
import * as rt from '../lib/rt';
/** Clips to film, in config order (with their index for the file name), limited by --only. */
export function clipsToRecord(r: VideoRecord, only: string[] = []): [number, string, ClipSpec][] {
@ -137,7 +138,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (source === 'ios-sim') {
const ios = need(cap?.ios, 'shots.capture.ios');
if (!dry) requireTool('xcrun');
const list = dry && !Bun.which('xcrun') ? '{"devices":{}}' : sh('xcrun', ['simctl', 'list', 'devices', '-j']).out.toString();
const list = dry && !rt.which('xcrun') ? '{"devices":{}}' : sh('xcrun', ['simctl', 'list', 'devices', '-j']).out.toString();
const want = simTarget(ios, device);
if (!want) throw new Error(`no shots.capture.ios.${device === 'tablet' ? 'devices.tablet' : 'device'}`);
const sim = pickSimulator(list, want) ?? (dry ? { udid: want, name: want, state: 'Shutdown' } : null);
@ -151,19 +152,19 @@ export async function run(ctx: Ctx): Promise<void> {
if (bootedByUs && warmup > 0) {
log('shots', `warming up ${ios.bundleId} (${warmup} ms)`);
exec('xcrun', simLaunchArgs(sim.udid, ios.bundleId, { lang: langs[0]!, locale: localeOf(config, langs[0]!, 'ios-sim'), shot: '', baseArgs: ios.args }));
if (!dry) await Bun.sleep(warmup);
if (!dry) await rt.sleep(warmup);
}
for (const lang of langs) for (const [idx, name, cl] of todo) {
try {
const locale = localeOf(config, lang, 'ios-sim');
exec('xcrun', simLaunchArgs(sim.udid, ios.bundleId, { lang, locale, shot: name, baseArgs: ios.args, args: cl.args }));
if (cl.url) { if (!dry) await Bun.sleep(1500); exec('xcrun', simOpenUrlArgs(sim.udid, fill(cl.url, { lang, locale, shot: name }))); }
if (cl.url) { if (!dry) await rt.sleep(1500); exec('xcrun', simOpenUrlArgs(sim.udid, fill(cl.url, { lang, locale, shot: name }))); }
const out = outFor(lang, idx, name);
const raw = join(tmpDir, `sim-${name}.mp4`);
if (dry) { log('shots', `would run: xcrun ${simRecordArgs(sim.udid, raw).join(' ')} (${cl.seconds} s, then SIGINT)`); progress(`${lang}/${clipName(idx, name)}`); continue; }
await Bun.sleep(cl.delayMs ?? 2500);
const p = Bun.spawn(['xcrun', ...simRecordArgs(sim.udid, raw)], { stdout: 'ignore', stderr: 'pipe' });
await Bun.sleep(cl.seconds * 1000 + 700);
await rt.sleep(cl.delayMs ?? 2500);
const p = rt.spawn(['xcrun', ...simRecordArgs(sim.udid, raw)], { stdout: 'ignore', stderr: 'pipe' });
await rt.sleep(cl.seconds * 1000 + 700);
p.kill('SIGINT');
await p.exited;
if (!existsSync(raw)) throw new Error(`simctl recordVideo wrote nothing: ${(await new Response(p.stderr).text()).trim().slice(0, 300)}`);
@ -193,7 +194,7 @@ export async function run(ctx: Ctx): Promise<void> {
try {
exec(adb, adbForceStopArgs(and.package, serial));
exec(adb, adbStartArgs(and.package, { lang, locale, shot: name, url: cl.url, activity: and.activity, extras: cl.extras }, serial));
if (!dry) await Bun.sleep(cl.delayMs ?? 2500);
if (!dry) await rt.sleep(cl.delayMs ?? 2500);
exec(adb, adbRecordArgs(ADB_REMOTE_CLIP, cl.seconds, serial), { timeout: (cl.seconds + 30) * 1000 });
const raw = join(tmpDir, `adb-${name}.mp4`);
exec(adb, adbPullArgs(ADB_REMOTE_CLIP, raw, serial), { timeout: 120_000 });

View file

@ -13,7 +13,8 @@
// paths) are served from where they are at /fonts/local/... (local-fonts.ts); a file changed since `shots fonts` fails.
// Style: shotsStyle() = shots.style, else app for the app / subscription-app profiles (template-app.html,
// feature-app.html: calm gradient, device frame, headline + subline), else game (template.html, feature.html).
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { SHOTS_PAGES } from '../lib/embedded';
import { fontCodepoints } from './cmap';
import { readManifest } from './fonts';
import { type FontManifest, fontUsage, measureFonts, roleTexts, uncoveredChars } from './glyphs';
@ -27,6 +28,7 @@ import { shotsStyle } from '../lib/config';
import { type CopyFile, SIZES, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
import { faceFilePath, fontsRoute, resolveShotFonts, staleLocalFonts } from './local-fonts';
import { type RenderReport, mergeReport, readReport, resolvedTablets } from './report';
import * as rt from '../lib/rt';
const TYPES: Record<string, string> = { html: 'text/html; charset=utf-8', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
webp: 'image/webp', woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', css: 'text/css', svg: 'image/svg+xml' };
@ -113,9 +115,7 @@ export async function run(ctx: Ctx): Promise<void> {
for (const lang of langs) for (const f of manifest.langs[lang]?.fallbacks ?? [])
(manifest.faces[f.face]?.chosen ? log : warn)('shots', `${lang} ${f.role}: ${[...f.chars].join(' ')} from ${f.family}${manifest.faces[f.face]?.chosen ? '' : ' (automatic fallback: shots check will fail)'}`);
}
const tpl = import.meta.dir;
const server = Bun.serve({
const server = await rt.serve({
port: a.num('port', 8792), hostname: '127.0.0.1',
async fetch(req) {
const url = new URL(req.url);
@ -138,11 +138,9 @@ export async function run(ctx: Ctx): Promise<void> {
else if (p.startsWith('/frames/')) file = join(framesDir, p.slice(8));
else if (p.startsWith('/fonts/')) file = fontsRoute(p.slice(7), fontsDir, root, manifest) ?? '';
else if (p.startsWith('/out/')) file = join(outDir, p.slice(5));
else if (/^\/(template|feature)(-app)?\.html$/.test(p)) file = join(tpl, p.slice(1));
else if (/^\/(template|feature)(-app)?\.html$/.test(p)) return new Response(SHOTS_PAGES[p.slice(1)]!, { headers: { 'content-type': TYPES.html! } });
if (!file || file.includes('..')) return new Response('no', { status: 404 });
const f = Bun.file(file);
if (!(await f.exists())) return new Response('missing ' + p, { status: 404 });
return new Response(f, { headers: { 'content-type': TYPES[file.split('.').pop()!.toLowerCase()] ?? 'application/octet-stream' } });
return rt.fileResponse(file, { 'content-type': TYPES[file.split('.').pop()!.toLowerCase()] ?? 'application/octet-stream' }) ?? new Response('missing ' + p, { status: 404 });
},
});
const base = `http://127.0.0.1:${server.port}`;
@ -182,7 +180,7 @@ export async function run(ctx: Ctx): Promise<void> {
}
if (overflow.length) warn('shots', `${relative(root, out)}: ${overflow.join('; ')}`);
await page.screenshot({ path: tmp, clip: { x: 0, y: 0, width: w, height: h } });
const ff = Bun.spawn(['ffmpeg', '-v', 'error', '-y', '-i', tmp, '-pix_fmt', 'rgb24', out], { stderr: 'pipe' });
const ff = rt.spawn(['ffmpeg', '-v', 'error', '-y', '-i', tmp, '-pix_fmt', 'rgb24', out], { stderr: 'pipe' });
const code = await ff.exited;
rmSync(tmp, { force: true });
if (code !== 0) throw new Error('ffmpeg: ' + await new Response(ff.stderr).text());
@ -232,7 +230,7 @@ export async function run(ctx: Ctx): Promise<void> {
const lay = sheetLayout(rows);
const imgs = lay.tiles.map(t => `<img src="/out/${relative(outDir, t.file)}" style="position:absolute;left:${t.x}px;top:${t.y}px;width:${t.w}px;height:${t.h}px;border-radius:14px">`).join('');
const sheet = join(dir, '.sheet.html');
await Bun.write(sheet, `<!doctype html><body style="margin:0;background:#141418;width:${lay.w}px;height:${lay.h}px;position:relative">${imgs}
writeFileSync(sheet, `<!doctype html><body style="margin:0;background:#141418;width:${lay.w}px;height:${lay.h}px;position:relative">${imgs}
<script>Promise.all([...document.images].map(i=>i.decode())).then(()=>document.body.dataset.ready='1')</script></body>`);
await withPage(p => shoot(p, `${base}/out/${relative(outDir, sheet)}`, lay.w, lay.h, join(dir, 'sheet.png')));
rmSync(sheet, { force: true });
@ -241,7 +239,7 @@ export async function run(ctx: Ctx): Promise<void> {
for (const p of pages) await p?.page.context().close().catch(() => {});
await closeBrowser(browser);
server.stop(true);
if (Object.keys(report).length) await Bun.write(join(outDir, 'report.json'), JSON.stringify(mergeReport(readReport(outDir), report), null, 1) + '\n');
if (Object.keys(report).length) writeFileSync(join(outDir, 'report.json'), JSON.stringify(mergeReport(readReport(outDir), report), null, 1) + '\n');
}
const flagged = Object.values(report).filter(r => r.overflow.length).length;
if (flagged) warn('shots', `${flagged} images with text overflow flags: storebound shots check / shots gallery`);

View file

@ -21,6 +21,8 @@ import { captionsFor, type CopyFile, copyText, palette, renderDir, scriptFont }
import { FONT_TYPES, fontsRoute, resolveShotFonts } from './local-fonts';
import { ffprobe } from './report';
import { checkVideo, PREVIEW_RULES, VIDEO_TARGETS } from './spec';
import { SHOTS_PAGES } from '../lib/embedded';
import * as rt from '../lib/rt';
export const CLIP_RE = /\.(mp4|mov|m4v|webm)$/i;
@ -145,13 +147,13 @@ export async function run(ctx: Ctx): Promise<void> {
if (!dry) mkdirSync(tmp, { recursive: true });
// caption PNGs come from a local page (fonts from `shots fonts`), like the screenshots
let server: ReturnType<typeof Bun.serve> | null = null, browser: { close(): Promise<void>; newContext(o: unknown): Promise<{ newPage(): Promise<unknown> }> } | null = null;
let server: rt.Server | null = null, browser: { close(): Promise<void>; newContext(o: unknown): Promise<{ newPage(): Promise<unknown> }> } | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let page: any = null;
if (captions) {
const fontsDir = join(root, '.storebound', 'fonts');
if (!existsSync(join(fontsDir, 'fonts.css'))) warn('shots', 'no .storebound/fonts/fonts.css: run `storebound shots fonts` first (captions fall back to system fonts)');
server = Bun.serve({ port: a.num('port', 8793), hostname: '127.0.0.1', async fetch(req) {
server = await rt.serve({ port: a.num('port', 8793), hostname: '127.0.0.1', async fetch(req) {
const url = new URL(req.url);
if (url.pathname === '/caption.json') {
const lang = url.searchParams.get('lang') ?? '';
@ -162,11 +164,11 @@ export async function run(ctx: Ctx): Promise<void> {
return Response.json({ text: texts[Number(url.searchParams.get('i'))] ?? '', ink: style === 'app' ? '#111827' : pal.ink, rtl: rtl.get(lang),
fonts: { title: resolveShotFonts(shots, style, ctx.loaded.resolve, root).title.family, script: script ? `Store ${script.slug}` : null, titleStack: readManifest(fontsDir)?.langs[lang]?.title } });
}
if (url.pathname === '/caption.html') return new Response(Bun.file(join(import.meta.dir, 'caption.html')), { headers: { 'content-type': 'text/html; charset=utf-8' } });
if (url.pathname === '/caption.html') return new Response(SHOTS_PAGES['caption.html']!, { headers: { 'content-type': 'text/html; charset=utf-8' } });
if (url.pathname.startsWith('/fonts/')) {
const file = fontsRoute(decodeURIComponent(url.pathname.slice(7)), fontsDir, root, readManifest(fontsDir));
const f = file ? Bun.file(file) : null;
if (f && await f.exists()) return new Response(f, { headers: { 'content-type': FONT_TYPES[file!.split('.').pop()!.toLowerCase()] ?? 'font/woff2' } });
const f = file ? rt.fileResponse(file, { 'content-type': FONT_TYPES[file.split('.').pop()!.toLowerCase()] ?? 'font/woff2' }) : null;
if (f) return f;
}
return new Response('no', { status: 404 });
} });

View file

@ -1,6 +0,0 @@
// Template files imported as text (lib/template-files.ts, `with { type: 'text' }`).
declare module '*.md' { const text: string; export default text; }
declare module '*.csv' { const text: string; export default text; }
declare module '*.html' { const text: string; export default text; }
declare module '*.svg' { const text: string; export default text; }
declare module '*.yml' { const text: string; export default text; }

View file

@ -26,6 +26,7 @@ import {
applyLanguageNotes, checkLanguageNotes, checkNotes, formatNotes, type LangCheck, NOTES_LIMITS, type NotesFile, noteItems, notesFileText, parseNotesFile,
resolveLanguages, setAppleWhatsNew, setPlayWhatsNew,
} from './lib/whats-new';
import * as rt from './lib/rt';
const TAG = 'whats-new';
const storeCode = (l: Language): string => l.apple ?? playCodes(l)[0] ?? l.id;
@ -62,7 +63,7 @@ async function languagesFlow(ctx: Ctx, draft: { play: string; apple: string }):
const cur = existsSync(path) ? parseNotesFile(readFileSync(path, 'utf8'), l.id) : null;
if (cur && cur.status === 'translated' && !a.has('force')) { files.push({ l, f: cur, write: false, note: 'kept (translated; --force replaces it)' }); continue; }
if (hook && !a.has('dry')) {
const r = Bun.spawnSync(['sh', '-c', hook], { stdin: new TextEncoder().encode(text), stdout: 'pipe', stderr: 'pipe',
const r = rt.spawnSync(['sh', '-c', hook], { stdin: new TextEncoder().encode(text), stdout: 'pipe', stderr: 'pipe',
env: { ...process.env, STOREBOUND_SOURCE_LANG: storeCode(src!), STOREBOUND_TARGET_LANG: storeCode(l), STOREBOUND_TARGET_LANG_ID: l.id } });
const out = r.stdout.toString().trim();
if (r.exitCode !== 0 || !out) { warn(TAG, `${l.id}: --translate failed (exit ${r.exitCode}): ${r.stderr.toString().trim().slice(0, 200)}`); files.push({ l, f: { lang: l.id, source: src!.id, status: 'translate', text }, write: true, note: 'to translate (hook failed)' }); continue; }
@ -79,7 +80,7 @@ async function languagesFlow(ctx: Ctx, draft: { play: string; apple: string }):
}
function git(args: string[], cwd: string): string {
const r = Bun.spawnSync(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' });
const r = rt.spawnSync(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' });
if (r.exitCode !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr.toString().trim()}`);
return r.stdout.toString();
}

View file

@ -30,8 +30,9 @@ your AAB / build numbers and listing files.
password. Store writes are dry runs until you say otherwise, and the public steps (submit, release) stay with you.
Use an App Store Connect key with the App Manager role and a Play service account limited to your app.
**Why Bun?** One fast runtime with TypeScript, `fetch`, a test runner and a static server built in: no build step and
zero runtime dependencies. It needs Bun installed (it does not run on plain Node).
**Why Bun, and does it need Bun?** storebound is developed and tested on Bun (TypeScript, `fetch`, a test runner built in),
with zero runtime dependencies. You do not need Bun to use it: the npm package runs on Node 20+ (`npx storebound`),
and `bunx storebound` runs the same CLI on Bun 1.1+.
**Does it upload my video to YouTube?** No. Play only takes a YouTube URL, and uploading to YouTube needs your Google
account; `shots video` makes the file, you upload it and paste the URL into `play.video`.

View file

@ -2,12 +2,15 @@
## 5-minute quick start
You need [Bun](https://bun.sh) 1.1+ (and `ffmpeg` for icons, screenshots and video).
You need [Node](https://nodejs.org) 20+ or [Bun](https://bun.sh) 1.1+ (and `ffmpeg` for icons, screenshots and video).
With Node, run the commands as `npx storebound ...`; with Bun, as `bunx storebound ...` (used below). Both run the
same CLI.
```sh
cd my-app
bun add -d storebound playwright # or: bun add -d git+https://git.crt.fyi/CRT/storebound.git playwright
bunx storebound init # --profile app (default) | subscription-app | game: config, store/*.md, declarations, privacy policy, .storebound/ (gitignored)
npx storebound init # Node 20+ (npm i -D storebound playwright to pin it in the project)
bunx storebound init # or Bun (bun add -d storebound playwright, or bun add -d git+https://git.crt.fyi/CRT/storebound.git playwright)
# --profile app (default) | subscription-app | game: config, store/*.md, declarations, privacy policy, .storebound/ (gitignored)
# fill in storebound.config.ts and the store/ files
bunx storebound check # offline: config, copy limits, Data safety answers, products, files
```

View file

@ -2,7 +2,7 @@
"name": "storebound",
"version": "0.6.0",
"desktopVersion": "0.1.0",
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a Bun CLI, an MCP server and Agent Skills for your AI coding agent.",
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.",
"license": "MIT",
"author": "CRTkafa",
"type": "module",
@ -26,12 +26,13 @@
"android-vitals"
],
"bin": {
"storebound": "cli/index.ts"
"storebound": "dist/storebound.mjs"
},
"exports": {
".": "./cli/lib/config.ts"
},
"files": [
"dist/storebound.mjs",
"cli",
"templates",
"skills",
@ -51,6 +52,7 @@
"apps/*"
],
"engines": {
"node": ">=20",
"bun": ">=1.1"
},
"scripts": {
@ -58,9 +60,12 @@
"typecheck": "tsc --noEmit",
"adapters": "bun tools/gen-adapters.ts",
"scrub": "bun tools/scrub.ts",
"build:node": "bun tools/build-node.ts",
"smoke:node": "bun tools/node-smoke.ts",
"ci": "bun tools/ci.ts",
"ci:quick": "bun tools/ci.ts --quick",
"check": "bun run typecheck && bun test"
"check": "bun run typecheck && bun test",
"prepack": "bun tools/build-node.ts"
},
"peerDependencies": {
"playwright": ">=1.45"

View file

@ -65,10 +65,10 @@
</div>
<div class="install" role="group" aria-label="Install command">
<span class="prompt" aria-hidden="true">$</span>
<code id="install-cmd">bunx storebound init</code>
<button class="copy" type="button" data-copy="bunx storebound init" aria-label="Copy install command">Copy</button>
<code id="install-cmd">npx storebound init</code>
<button class="copy" type="button" data-copy="npx storebound init" aria-label="Copy install command">Copy</button>
</div>
<p class="install-note">Needs <a href="https://bun.sh" target="_blank" rel="noopener">Bun<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a> 1.1+. No account, no sign-up, no telemetry.</p>
<p class="install-note">Needs Node 20+ (or <a href="https://bun.sh" target="_blank" rel="noopener">Bun<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a> 1.1+: <code>bunx storebound init</code>). No account, no sign-up, no telemetry.</p>
</div>
<figure class="hero-video" id="teaser" aria-labelledby="teaser-cap">
@ -556,47 +556,47 @@ Rejection risks: 2 blocker, 3 high, 0 medium, 0 low
<div class="code"><button class="copy" type="button" data-copy-target="c-claude" aria-label="Copy Claude Code commands">Copy</button>
<pre id="c-claude"><code>/plugin marketplace add https://git.crt.fyi/CRT/storebound.git
/plugin install storebound@crtkafa</code></pre></div>
<p>Skills, MCP server and <code>storebound</code> on PATH. Or per project: <code>bunx storebound install <span class="nw">--agent claude</span></code></p>
<p>Skills, MCP server and <code>storebound</code> on PATH. Or per project: <code>npx storebound install <span class="nw">--agent claude</span></code></p>
</div>
<div class="panel" role="tabpanel" id="p-codex" aria-labelledby="tab-codex" tabindex="0">
<h4 class="panel-h">OpenAI Codex</h4>
<div class="code"><button class="copy" type="button" data-copy-target="c-codex" aria-label="Copy Codex command">Copy</button>
<pre id="c-codex"><code>bunx storebound install --agent codex</code></pre></div>
<pre id="c-codex"><code>npx storebound install --agent codex</code></pre></div>
<p>Skills in <code>.agents/skills</code>, MCP in <code>.codex/config.toml</code> (Codex reads it for trusted projects). The repo is also a Codex plugin, skills only.</p>
</div>
<div class="panel" role="tabpanel" id="p-gemini" aria-labelledby="tab-gemini" tabindex="0">
<h4 class="panel-h">Gemini CLI</h4>
<div class="code"><button class="copy" type="button" data-copy-target="c-gemini" aria-label="Copy Gemini CLI command">Copy</button>
<pre id="c-gemini"><code>gemini extensions install https://git.crt.fyi/CRT/storebound.git</code></pre></div>
<p>Skills, MCP server and <code>GEMINI.md</code>. Or <code>bunx storebound install --agent gemini</code></p>
<p>Skills, MCP server and <code>GEMINI.md</code>. Or <code>npx storebound install --agent gemini</code></p>
</div>
<div class="panel" role="tabpanel" id="p-cursor" aria-labelledby="tab-cursor" tabindex="0">
<h4 class="panel-h">Cursor</h4>
<div class="code"><button class="copy" type="button" data-copy-target="c-cursor" aria-label="Copy Cursor command">Copy</button>
<pre id="c-cursor"><code>bunx storebound install --agent cursor</code></pre></div>
<pre id="c-cursor"><code>npx storebound install --agent cursor</code></pre></div>
<p>Skills in <code>.agents/skills</code> and <code>.cursor/mcp.json</code>. The repo's <code>.cursor/rules/storebound.mdc</code> is an agent-requested rule you can copy.</p>
</div>
<div class="panel" role="tabpanel" id="p-copilot" aria-labelledby="tab-copilot" tabindex="0">
<h4 class="panel-h">VS Code / GitHub Copilot</h4>
<div class="code"><button class="copy" type="button" data-copy-target="c-copilot" aria-label="Copy Copilot command">Copy</button>
<pre id="c-copilot"><code>bunx storebound install --agent copilot</code></pre></div>
<pre id="c-copilot"><code>npx storebound install --agent copilot</code></pre></div>
<p>Skills in <code>.github/skills</code> and <code>.vscode/mcp.json</code>.</p>
</div>
<div class="panel" role="tabpanel" id="p-windsurf" aria-labelledby="tab-windsurf" tabindex="0">
<h4 class="panel-h">Windsurf (Devin Desktop)</h4>
<div class="code"><button class="copy" type="button" data-copy-target="c-windsurf" aria-label="Copy Windsurf command">Copy</button>
<pre id="c-windsurf"><code>bunx storebound install --agent windsurf</code></pre></div>
<pre id="c-windsurf"><code>npx storebound install --agent windsurf</code></pre></div>
<p>Skills in <code>.agents/skills</code>, MCP in your global <code>mcp_config.json</code>.</p>
</div>
<div class="panel" role="tabpanel" id="p-opencode" aria-labelledby="tab-opencode" tabindex="0">
<h4 class="panel-h">OpenCode</h4>
<div class="code"><button class="copy" type="button" data-copy-target="c-opencode" aria-label="Copy OpenCode command">Copy</button>
<pre id="c-opencode"><code>bunx storebound install --agent opencode</code></pre></div>
<pre id="c-opencode"><code>npx storebound install --agent opencode</code></pre></div>
<p>Skills in <code>.opencode/skills</code> and <code>opencode.json</code>.</p>
</div>
<div class="panel" role="tabpanel" id="p-agents" aria-labelledby="tab-agents" tabindex="0">
<h4 class="panel-h">Anything that reads AGENTS.md</h4>
<p>Copy or link <code>AGENTS.md</code> (rules and skill index) into your project and use the CLI. For an MCP client <code>install</code> does not know: command <code>bunx</code> with args <code>["storebound", "mcp"]</code>.</p>
<p>Copy or link <code>AGENTS.md</code> (rules and skill index) into your project and use the CLI. For an MCP client <code>install</code> does not know: command <code>npx</code> (or <code>bunx</code>) with args <code>["storebound", "mcp"]</code>.</p>
</div>
</div>
<p class="fine"><code>install</code> takes several agents at once (<code>--agent codex,cursor</code>), <code>--dry</code> to preview, and only touches its own <code>storebound</code> entry and skill folders. Re-running it is safe.</p>
@ -702,8 +702,8 @@ com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
</div>
<div class="st-panel" id="sp-1">
<div class="pv pv-term">
<div class="pv-bar"><span class="pv-k">Terminal</span><button class="copy" type="button" data-copy="bunx storebound init --profile app" aria-label="Copy step 1 command">Copy</button></div>
<pre><code><span class="p">$</span> bunx storebound init --profile app</code></pre>
<div class="pv-bar"><span class="pv-k">Terminal</span><button class="copy" type="button" data-copy="npx storebound init --profile app" aria-label="Copy step 1 command">Copy</button></div>
<pre><code><span class="p">$</span> npx storebound init --profile app</code></pre>
</div>
<div class="pv-row">
<span class="pv-label">Profiles</span>
@ -714,7 +714,7 @@ com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
<li><code>store/</code><span>listing text, questionnaire answers, screenshot captions</span></li>
<li><code>.storebound/</code><span>your keys, gitignored</span></li>
</ul>
<p class="pv-note">Needs <a href="https://bun.sh" target="_blank" rel="noopener">Bun<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a> 1.1+, plus <code>ffmpeg</code> for icons, screenshots and video.</p>
<p class="pv-note">Needs Node 20+ (or <a href="https://bun.sh" target="_blank" rel="noopener">Bun<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a> 1.1+ with <code>bunx</code>), plus <code>ffmpeg</code> for icons, screenshots and video.</p>
<div class="st-foot" hidden><span>Step 1 of 5</span><button class="st-next" type="button" data-next="1">Next: Describe the app once<span class="ic ic-caret-right" aria-hidden="true"></span></button></div>
</div>
</li>
@ -738,8 +738,8 @@ com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
};</code></pre>
</div>
<div class="pv pv-term">
<div class="pv-bar"><span class="pv-k">Terminal</span><button class="copy" type="button" data-copy="bunx storebound check" aria-label="Copy step 2 command">Copy</button></div>
<pre><code><span class="p">$</span> bunx storebound check</code></pre>
<div class="pv-bar"><span class="pv-k">Terminal</span><button class="copy" type="button" data-copy="npx storebound check" aria-label="Copy step 2 command">Copy</button></div>
<pre><code><span class="p">$</span> npx storebound check</code></pre>
</div>
<p class="pv-note">Store text lives next to it in <code>store/listing.md</code> and <code>store/appstore.md</code>, one section per language.</p>
<div class="st-foot" hidden><span>Step 2 of 5</span><button class="st-next" type="button" data-next="2">Next: Add your store keys<span class="ic ic-caret-right" aria-hidden="true"></span></button></div>
@ -773,8 +773,8 @@ com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
</div>
<div class="st-panel" id="sp-4">
<div class="pv pv-term">
<div class="pv-bar"><span class="pv-k">Terminal</span><button class="copy" type="button" data-copy="bunx storebound install --agent claude" aria-label="Copy step 4 command">Copy</button></div>
<pre><code><span class="p">$</span> bunx storebound install --agent claude</code></pre>
<div class="pv-bar"><span class="pv-k">Terminal</span><button class="copy" type="button" data-copy="npx storebound install --agent claude" aria-label="Copy step 4 command">Copy</button></div>
<pre><code><span class="p">$</span> npx storebound install --agent claude</code></pre>
</div>
<p class="pv-note">Or <code>codex</code>, <code>cursor</code>, <code>copilot</code>, <code>gemini</code>, <code>windsurf</code>, <code>opencode</code> (<a href="#agents">per tool</a>). No agent? Every step is a CLI command too.</p>
<ul class="asks2" aria-label="Things to ask">
@ -793,8 +793,8 @@ com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
</div>
<div class="st-panel" id="sp-5">
<div class="pv pv-term">
<div class="pv-bar"><span class="pv-k">Terminal</span><button class="copy" type="button" data-copy="bunx storebound validate" aria-label="Copy step 5 command">Copy</button></div>
<pre><code><span class="p">$</span> bunx storebound validate</code></pre>
<div class="pv-bar"><span class="pv-k">Terminal</span><button class="copy" type="button" data-copy="npx storebound validate" aria-label="Copy step 5 command">Copy</button></div>
<pre><code><span class="p">$</span> npx storebound validate</code></pre>
</div>
<ol class="gate" aria-label="The last step">
<li><b>Blockers, per store, in fix order</b><span>build, encryption, metadata, screenshots, review details, prices, products</span></li>
@ -840,7 +840,7 @@ com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
<div class="faq">
<details open>
<summary><h3>Do I need an AI agent?</h3></summary>
<div class="faq-a"><p>No. Every step is a CLI command too (<code>bunx storebound --help</code>), and the skills are Markdown checklists you can read yourself for the console-only forms. The agent integrations are optional.</p></div>
<div class="faq-a"><p>No. Every step is a CLI command too (<code>npx storebound --help</code>), and the skills are Markdown checklists you can read yourself for the console-only forms. The agent integrations are optional.</p></div>
</details>
<details>
<summary><h3>Will it press Submit for me?</h3></summary>
@ -868,7 +868,7 @@ com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
</details>
<details>
<summary><h3>Does it run on Windows or Linux?</h3></summary>
<div class="faq-a"><p>It runs wherever Bun 1.1+ runs; it does not run on plain Node. The store commands are API calls. Capturing from the iOS Simulator needs a Mac with Xcode; a web build, an Android emulator or a folder of frames works anywhere. Icons, screenshots and video also need <code>ffmpeg</code>.</p></div>
<div class="faq-a"><p>It runs on Node 20+ (<code>npx storebound</code>) and on Bun 1.1+ (<code>bunx storebound</code>). The store commands are API calls. Capturing from the iOS Simulator needs a Mac with Xcode; a web build, an Android emulator or a folder of frames works anywhere. Icons, screenshots and video also need <code>ffmpeg</code>.</p></div>
</details>
</div>
</div>

View file

@ -78,7 +78,9 @@ describe("first rule: do it, don't ask", () => {
/\bto check yourself\b/i,
];
const cliFiles = (dir: string): string[] => readdirSync(join(root, dir), { withFileTypes: true }).flatMap(e =>
e.isDirectory() ? (e.name === 'data' ? [] : cliFiles(`${dir}/${e.name}`)) : e.name.endsWith('.ts') ? [`${dir}/${e.name}`] : []);
e.isDirectory() ? (e.name === 'data' ? [] : cliFiles(`${dir}/${e.name}`))
// lib/embedded.ts is generated from templates/ (the owner's own files, not CLI output), like data/
: e.name.endsWith('.ts') && `${dir}/${e.name}` !== 'cli/lib/embedded.ts' ? [`${dir}/${e.name}`] : []);
test('CLI output never hands agent work to the owner', () => {
const hits: string[] = [];

153
tests/node-compat.test.ts Normal file
View file

@ -0,0 +1,153 @@
// The CLI runs on Node 20+ (npm bundle) and Bun: cli/ reaches the runtime only through cli/lib/rt.ts, and rt behaves
// like the Bun APIs it replaced (spawn defaults and exit codes, which, Glob, type stripping of a config).
import { describe, expect, test } from 'bun:test';
import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, relative } from 'node:path';
import * as rt from '../cli/lib/rt';
const ROOT = join(import.meta.dir, '..');
function cliSources(dir = join(ROOT, 'cli')): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap(e => {
const p = join(dir, e.name);
if (e.isDirectory()) return cliSources(p);
return e.name.endsWith('.ts') && !e.name.endsWith('.test.ts') ? [p] : [];
});
}
describe('cli/ is runtime neutral', () => {
test('no Bun global, bun: module, import.meta.dir or text import outside cli/lib/rt.ts', () => {
const bad: string[] = [];
for (const f of cliSources()) {
const rel = relative(ROOT, f);
if (rel === join('cli', 'lib', 'rt.ts')) continue;
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
if (/\bBun\.[A-Za-z]|['"]bun:[a-z]|import\.meta\.(dir|dirname|path|file|filename|main)\b|with \{ type: 'text' \}/.test(line)) bad.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
});
}
expect(bad).toEqual([]);
});
test('package.json: bin is the Node bundle, engines name Node 20, npm pack builds it', () => {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { bin: Record<string, string>; engines: Record<string, string>; files: string[]; scripts: Record<string, string> };
expect(pkg.bin.storebound).toBe('dist/storebound.mjs');
expect(pkg.engines.node).toBe('>=20');
expect(pkg.files).toContain('dist/storebound.mjs');
expect(pkg.scripts.prepack).toContain('tools/build-node.ts');
});
});
describe('rt: processes', () => {
test('spawn: Bun defaults, exit code, 128 + signal, streams', async () => {
const p = rt.spawn(['sh', '-c', 'echo out; echo err >&2; exit 3'], { stderr: 'pipe' });
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]);
expect([out, err, code, p.exitCode]).toEqual(['out\n', 'err\n', 3, 3]);
const s = rt.spawn(['sleep', '10']);
s.kill();
expect(await s.exited).toBe(143);
expect(s.signalCode).toBe('SIGTERM');
const i = rt.spawn(['sleep', '10']);
i.kill('SIGINT');
expect(await i.exited).toBe(130);
});
test('a missing executable throws right away, like Bun', () => {
expect(() => rt.spawn(['storebound-no-such-tool'])).toThrow('Executable not found in $PATH: "storebound-no-such-tool"');
expect(() => rt.spawnSync(['storebound-no-such-tool'])).toThrow('Executable not found in $PATH');
});
test('spawnSync: stdin bytes, env, exit code, timeout without a throw', () => {
const r = rt.spawnSync(['sh', '-c', 'tr a-z A-Z; echo "$X" >&2; exit 2'], { stdin: new TextEncoder().encode('hi'), env: { ...process.env, X: 'x1' }, stdout: 'pipe', stderr: 'pipe' });
expect([r.stdout.toString(), r.stderr.toString(), r.exitCode, r.success]).toEqual(['HI', 'x1\n', 2, false]);
const t = rt.spawnSync(['sleep', '5'], { timeout: 100 });
expect([t.exitCode, t.signalCode, t.success]).toEqual([null, 'SIGTERM', false]);
});
test('which: PATH lookup with an explicit PATH, executables only', () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-which-'));
try {
writeFileSync(join(dir, 'tool-x'), '#!/bin/sh\n');
chmodSync(join(dir, 'tool-x'), 0o755);
writeFileSync(join(dir, 'plain-y'), '');
expect(rt.which('tool-x', { PATH: dir })).toBe(join(dir, 'tool-x'));
expect(rt.which('plain-y', { PATH: dir })).toBeNull();
expect(rt.which('tool-x', { PATH: '' })).toBeNull();
expect(rt.which('sh')).toBe(Bun.which('sh'));
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('hasher and gunzip match Bun', () => {
expect(rt.hasher('sha256').update('a').update('b').digest('hex')).toBe(new Bun.CryptoHasher('sha256').update('ab').digest('hex'));
expect(new TextDecoder().decode(rt.gunzipSync(Bun.gzipSync(new TextEncoder().encode('zip'))))).toBe('zip');
});
});
describe('rt: Glob like Bun.Glob', () => {
const cases: [string, string][] = [
['apps/*', 'apps/a'], ['apps/*', 'apps/a/b'], ['apps/**', 'apps/a/b'], ['**/game', 'game'], ['**/game', 'x/y/game'],
['studio-*/app?', 'studio-1/app2'], ['studio-*/app?', 'studio-1/app22'], ['{a,b}/c', 'b/c'], ['{a,b}/c', 'd/c'],
['[ab]x', 'bx'], ['[!ab]x', 'cx'], ['[!ab]x', 'ax'], ['*.ts', 'a.ts'], ['*.ts', 'dir/a.ts'], ['a/**/z', 'a/z'], ['a/**/z', 'a/b/c/z'],
];
for (const [g, p] of cases) test(`${g} vs ${p}`, () => expect(new rt.Glob(g).match(p)).toBe(new Bun.Glob(g).match(p)));
test('scanSync finds the same folders, no dot entries', () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-glob-'));
try {
for (const d of ['apps/one', 'apps/two/deep', 'apps/.hidden', 'other/three']) mkdirSync(join(dir, d), { recursive: true });
writeFileSync(join(dir, 'apps/file.txt'), '');
for (const g of ['apps/*', 'apps/**', '*/*', 'apps/t*']) {
const mine = [...new rt.Glob(g).scanSync({ cwd: dir, onlyFiles: false, dot: false })].sort();
const bun = [...new Bun.Glob(g).scanSync({ cwd: dir, onlyFiles: false, dot: false })].sort();
expect(mine, g).toEqual(bun);
}
} finally { rmSync(dir, { recursive: true, force: true }); }
});
});
describe('rt: configs on Node without type stripping', () => {
test('stripTypes turns every shipped config into JavaScript with the same value', async () => {
const files = ['templates/storebound.config.ts', ...readdirSync(join(ROOT, 'templates/profiles')).map(p => `templates/profiles/${p}/storebound.config.ts`), 'examples/demo-game/storebound.config.ts', 'examples/demo-app/storebound.config.ts']
.filter(f => { try { readFileSync(join(ROOT, f)); return true; } catch { return false; } });
expect(files.length).toBeGreaterThan(3);
for (const f of files) {
const js = rt.stripTypes(readFileSync(join(ROOT, f), 'utf8'));
const viaJs = (await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`)).default;
const viaTs = (await import(join(ROOT, f))).default;
expect(JSON.stringify(viaJs), f).toBe(JSON.stringify(viaTs));
}
});
test('stripTypes keeps strings and comments that look like types', () => {
const js = rt.stripTypes("import type { X } from 'x';\n// keep: a as B\nconst c: X = { s: 'a satisfies B', n: 1 as number } satisfies X;\nexport default c as X;\n");
expect(js).toContain("// keep: a as B");
expect(js).toContain("'a satisfies B'");
expect(js).not.toContain('import type');
expect(js).toContain('const c = {');
expect(js).toContain('n: 1 }');
expect(js).toContain('export default c;');
});
});
describe('rt: serve', () => {
test('fetch handler, file responses, 404', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-serve-'));
writeFileSync(join(dir, 'a.txt'), 'file body');
const s = await rt.serve({ port: 0, hostname: '127.0.0.1', fetch(req) {
const u = new URL(req.url);
if (u.pathname === '/json') return Response.json({ q: u.searchParams.get('q') });
return rt.fileResponse(join(dir, u.pathname.slice(1)), { 'content-type': 'text/plain' }) ?? new Response('no', { status: 404 });
} });
try {
const base = `http://127.0.0.1:${s.port}`;
expect(await (await fetch(`${base}/json?q=1`)).json()).toEqual({ q: '1' });
const f = await fetch(`${base}/a.txt`);
expect([f.status, f.headers.get('content-type'), await f.text()]).toEqual([200, 'text/plain', 'file body']);
expect((await fetch(`${base}/missing`)).status).toBe(404);
} finally { s.stop(true); rmSync(dir, { recursive: true, force: true }); }
});
test('packageRoot is the storebound folder', () => {
expect(rt.packageRoot()).toBe(ROOT);
});
});

View file

@ -432,9 +432,12 @@ describe('install', () => {
test('mcp command choice', () => {
const dir = tmp();
expect(mcpCommand(dir).command).toMatch(/bin\/storebound$/);
// run with Node (npx storebound install): the toolchain sits in npx's cache, so the pinned package instead
expect(mcpCommand(dir, undefined, 'node', '1.2.3')).toEqual({ command: 'npx', args: ['-y', 'storebound@1.2.3', 'mcp'] });
mkdirSync(join(dir, 'node_modules/storebound'), { recursive: true });
writeFileSync(join(dir, 'node_modules/storebound/package.json'), '{}');
expect(mcpCommand(dir)).toEqual({ command: 'bunx', args: ['storebound', 'mcp'] });
expect(mcpCommand(dir, undefined, 'node')).toEqual({ command: 'npx', args: ['storebound', 'mcp'] });
expect(mcpCommand(dir, 'npx -y storebound')).toEqual({ command: 'npx', args: ['-y', 'storebound', 'mcp'] });
});
});

43
tools/build-node.ts Normal file
View 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)`);
}

View file

@ -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

View file

@ -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
View 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`);
}

View file

@ -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
View 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');