CLI runs on Node 20+: npx storebound (bunx keeps working)
- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot, config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it - tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci - package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20 - bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help, init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/ - install writes npx MCP commands when run with Node; bin/storebound falls back to Node - docs: npx storebound init (Node 20+) or bunx storebound init
This commit is contained in:
parent
614c32bcba
commit
36a8bf9625
73 changed files with 1195 additions and 251 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
|
|
@ -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
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -10,12 +10,12 @@ in-app products and prices, the compliance forms, even the consoles that have no
|
|||
[](https://www.npmjs.com/package/storebound)
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](https://bun.sh)
|
||||
[](#install-and-start)
|
||||
[](package.json)
|
||||
[](skills)
|
||||
[](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`**
|
||||
|
||||
[](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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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) : {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import type { ShipConfig } from '../lib/config';
|
|||
import type { FormId } from '../lib/console-answers';
|
||||
import { hasToken, type Name, NAV_BASES, type NavBases, type NavStep, navPlan, playAppIdOf, playDeveloperId, switchAllowed } from '../lib/console-forms';
|
||||
import { ACTION_MS, accName, findAny, type Locator, navClick, navigate, WAITS, type NavResult, type Page, poll, realRole, safeClick, signInPage, SignInSeen, type Stop, vis } from './fill';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
// ---- the cache ----
|
||||
|
||||
|
|
@ -235,7 +236,7 @@ export async function switchTeam(page: Page, name: string, listed: string[], ms:
|
|||
if (!switchAllowed(real, role, listed)) { await closeMenu(page); return false; }
|
||||
await it.loc.click({ timeout: ACTION_MS });
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: ms }).catch(() => { /* same page, or slow: read what is there */ });
|
||||
await Bun.sleep(WAITS.settleMs); // the switched team's page settles
|
||||
await rt.sleep(WAITS.settleMs); // the switched team's page settles
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { openAcross, withAccountIds } from './accounts';
|
|||
import { appleAppId, withAppleAppId } from './app-id';
|
||||
import { bareUrl } from './debug';
|
||||
import { type ConsoleBrowser, GOOGLE_REJECTED, googleRejected, openConsoleBrowser, parseUseChromeSession } from './session';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export type Page = any;
|
||||
|
|
@ -686,7 +687,7 @@ export async function poll<T>(ms: number, fn: () => Promise<T | null>, stop?: St
|
|||
try { const r = await fn(); if (r !== null) return r; } catch { /* navigating */ }
|
||||
if (Date.now() >= end) return null;
|
||||
if (page && WAITS.quietMs > 0 && await quietFor(page) >= WAITS.quietMs) return null;
|
||||
await Bun.sleep(WAITS.pollMs);
|
||||
await rt.sleep(WAITS.pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -989,7 +990,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
// the type's last step was saved: what comes next is the App Privacy page again
|
||||
const closing = !!t && def.sections.every((x, i) => x.type !== t || done.has(i));
|
||||
progress(t && !closing ? `the "${t}" dialog step after "${name}"` : `the page after "${t ? `${t}: ` : ''}${name}"`);
|
||||
if (o.debugEveryStep) { const p = active(); if (p) void Bun.sleep(tick).then(() => dump(p, t && !/^Set Up /.test(name) ? `${t} after ${name}` : `after ${name}`)).catch(() => { /* gone */ }); }
|
||||
if (o.debugEveryStep) { const p = active(); if (p) void rt.sleep(tick).then(() => dump(p, t && !/^Set Up /.test(name) ? `${t} after ${name}` : `after ${name}`)).catch(() => { /* gone */ }); }
|
||||
};
|
||||
const meta = formMeta(form, config), res = consoleResult(form, config);
|
||||
const active = (): Page | undefined => b.pages().at(-1);
|
||||
|
|
@ -1056,7 +1057,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
}
|
||||
} else if (p && reached(p)) { if (++ok >= 2) { if (told) say('signed in'); lastWhy = ''; return true; } }
|
||||
else ok = 0;
|
||||
await Bun.sleep(tick);
|
||||
await rt.sleep(tick);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1297,13 +1298,13 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
while (!b.isClosed() && !finished()) {
|
||||
const p = active();
|
||||
if (!await watch(p)) break;
|
||||
if (!p) { await Bun.sleep(tick); continue; }
|
||||
if (!p) { await rt.sleep(tick); continue; }
|
||||
const w = await signInReason(p).catch(() => ({ rule: 'the page could not be read', url: bareUrl(p.url()) }));
|
||||
if (w) {
|
||||
sayWhy(w);
|
||||
if (!signedOut && plan.length) signInHint();
|
||||
signedOut = true;
|
||||
await Bun.sleep(tick); continue;
|
||||
await rt.sleep(tick); continue;
|
||||
}
|
||||
// sign-in finished while watching: open the form again from step 1 (nothing filled yet)
|
||||
if (signedOut) {
|
||||
|
|
@ -1328,13 +1329,13 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
}
|
||||
}
|
||||
let s: FillStep;
|
||||
try { s = await fillPage(p, def, pageAnswers, done, false, currentType); } catch { await Bun.sleep(tick); continue; } // navigating
|
||||
try { s = await fillPage(p, def, pageAnswers, done, false, currentType); } catch { await rt.sleep(tick); continue; } // navigating
|
||||
if (s.section !== null) {
|
||||
// a page still loading can miss a field for a moment: only a miss that lasts (3 rounds, at least WAITS.missMs) is a UI change
|
||||
if (s.fallback && (++misses < 3 || Date.now() - firstMiss < WAITS.missMs)) { if (misses === 1) firstMiss = Date.now(); await Bun.sleep(tick); continue; }
|
||||
if (s.fallback && (++misses < 3 || Date.now() - firstMiss < WAITS.missMs)) { if (misses === 1) firstMiss = Date.now(); await rt.sleep(tick); continue; }
|
||||
misses = 0;
|
||||
// the miss lasts: fill every field that was found, leave the missing ones to the owner (only all-missing is a UI change)
|
||||
if (s.fallback) { try { s = await fillPage(p, def, pageAnswers, done, true, currentType); } catch { await Bun.sleep(tick); continue; } }
|
||||
if (s.fallback) { try { s = await fillPage(p, def, pageAnswers, done, true, currentType); } catch { await rt.sleep(tick); continue; } }
|
||||
if (!s.fallback && s.missing.length) warn(TAG, `${s.title}: could not find ${s.missing.map(id => labels.get(id) ?? id).join(', ')} on the page (the console UI may have changed): filling ${s.missing.length === 1 ? 'it' : 'them'} is left to ${agentPart(form)}`);
|
||||
if (s.fallback) {
|
||||
fallback = true;
|
||||
|
|
@ -1367,7 +1368,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
continue; // more of the form may be on this page
|
||||
}
|
||||
if (waitFor) {
|
||||
if (!await answered(waitFor.pending)) { await Bun.sleep(tick); continue; }
|
||||
if (!await answered(waitFor.pending)) { await rt.sleep(tick); continue; }
|
||||
say(`"${waitFor.title}" answered: storebound goes on`);
|
||||
progress(`the next part after "${waitFor.title}"`);
|
||||
waitFor = null;
|
||||
|
|
@ -1376,7 +1377,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
pendingAdvance = false;
|
||||
const moved = await advance(p).catch(() => false);
|
||||
if (moved === 'draft') pendingAdvance = true; // Next follows the saved draft
|
||||
if (moved) { await Bun.sleep(tick); continue; }
|
||||
if (moved) { await rt.sleep(tick); continue; }
|
||||
}
|
||||
// an App Privacy dialog step with nothing to answer (the "Let's define two important terms" page between linked and
|
||||
// tracking): no inputs, only Back / Cancel / Next -> Next is navigation
|
||||
|
|
@ -1386,10 +1387,10 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
if (info && (infoRounds += 1) >= 2) {
|
||||
infoRounds = 0;
|
||||
const next = await controlWhere(info, FLOW_NAMES.next, () => true, dataTypes);
|
||||
if (next && await flowClick(next, form, dataTypes, say, clicked)) { say('an information-only step of the dialog: storebound pressed Next'); progress('the next step of the dialog'); await Bun.sleep(tick); continue; }
|
||||
if (next && await flowClick(next, form, dataTypes, say, clicked)) { say('an information-only step of the dialog: storebound pressed Next'); progress('the next step of the dialog'); await rt.sleep(tick); continue; }
|
||||
}
|
||||
if (privacy) await privacyStep(p).catch(() => { /* navigating */ });
|
||||
await Bun.sleep(tick);
|
||||
await rt.sleep(tick);
|
||||
}
|
||||
if (timedOut) {
|
||||
say(`nothing storebound knows happened for ${mins(idleMs)}: it stops here and closes the window${o.debugDir && dumps ? ` (debug: ${o.debugDir})` : ''}. Answers saved as drafts stay; run it again when the page is fixed.`);
|
||||
|
|
@ -1403,7 +1404,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
// the owner reviews and presses the final button, then closes the window: a reminder every 10 heartbeats, at most finalMs
|
||||
const end = Date.now() + finalMs;
|
||||
while (!b.isClosed() && Date.now() < end) {
|
||||
await Promise.race([b.closed, Bun.sleep(Math.min(10 * heartbeatMs, Math.max(0, end - Date.now())))]);
|
||||
await Promise.race([b.closed, rt.sleep(Math.min(10 * heartbeatMs, Math.max(0, end - Date.now())))]);
|
||||
if (!b.isClosed() && Date.now() < end) say('still open: storebound waits for you to review, press the final button and close the window');
|
||||
}
|
||||
if (!b.isClosed()) { say(`the window has been open for ${mins(finalMs)} after the fill: storebound closes it now`); await b.close(); }
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync
|
|||
import { homedir, tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { chromiumFor, installedBrowsers, type LaunchDeps, persistentExecutable, profileHolder, sandboxArgs } from '../shots/browser';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
|
|
@ -135,15 +136,15 @@ export function cookieApplies(hostKey: string, hosts: string[] = CONSOLE_HOSTS):
|
|||
|
||||
/** Deletes every cookie of the copied DB that the consoles never get. Returns counts only (never values or names). */
|
||||
export async function filterCookies(dbPath: string, hosts: string[] = CONSOLE_HOSTS): Promise<{ kept: number; removed: number }> {
|
||||
const { Database } = await import('bun:sqlite');
|
||||
const db = new Database(dbPath);
|
||||
// bun:sqlite under Bun, node:sqlite on Node 22.5+; without SQLite (Node 20) this throws and the caller says so
|
||||
const db = await rt.openSqlite(dbPath);
|
||||
try {
|
||||
const keys = (db.query('SELECT DISTINCT host_key AS h FROM cookies').all() as { h: string }[]).map(r => r.h);
|
||||
const keys = db.all<{ h: string }>('SELECT DISTINCT host_key AS h FROM cookies').map(r => r.h);
|
||||
const drop = keys.filter(k => !cookieApplies(k, hosts));
|
||||
const count = (db.query('SELECT COUNT(*) AS n FROM cookies').get() as { n: number }).n;
|
||||
const del = db.prepare('DELETE FROM cookies WHERE host_key = ?');
|
||||
db.transaction(() => { for (const k of drop) del.run(k); })();
|
||||
const kept = (db.query('SELECT COUNT(*) AS n FROM cookies').get() as { n: number }).n;
|
||||
const count = Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM cookies')!.n);
|
||||
db.exec('BEGIN');
|
||||
try { for (const k of drop) db.run('DELETE FROM cookies WHERE host_key = ?', k); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; }
|
||||
const kept = Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM cookies')!.n);
|
||||
db.exec('VACUUM');
|
||||
return { kept, removed: count - kept };
|
||||
} finally { db.close(); }
|
||||
|
|
@ -163,7 +164,7 @@ export async function copySession(src: string, opts: { profile?: string; hosts?:
|
|||
const dir = mkdtempSync(join(opts.tmpRoot ?? tmpdir(), 'storebound-chrome-'));
|
||||
const cleanup = () => {
|
||||
// Chrome may still write a file while it exits: retry, and only stop once the folder is gone
|
||||
for (let i = 0; i < 5 && existsSync(dir); i++) { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } catch { Bun.sleepSync(100); } }
|
||||
for (let i = 0; i < 5 && existsSync(dir); i++) { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } catch { rt.sleepSync(100); } }
|
||||
};
|
||||
try {
|
||||
for (const [s, d] of files) { mkdirSync(dirname(join(dir, d)), { recursive: true }); copyFileSync(join(src, s), join(dir, d)); }
|
||||
|
|
@ -234,7 +235,7 @@ export interface OpenOpts {
|
|||
|
||||
/** Runs a graceful close, at most `ms`; errors (already closed) are ignored. */
|
||||
async function gracefully(fn: () => Promise<unknown>, ms = 10_000): Promise<void> {
|
||||
await Promise.race([Promise.resolve().then(fn).catch(() => { /* already closed */ }), Bun.sleep(ms)]);
|
||||
await Promise.race([Promise.resolve().then(fn).catch(() => { /* already closed */ }), rt.sleep(ms)]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -284,14 +285,14 @@ async function startOwnChrome(root: string, mode: 'copy' | 'profile', exe: { lab
|
|||
const portFile = join(userDataDir, 'DevToolsActivePort');
|
||||
rmSync(portFile, { force: true });
|
||||
const args = consoleChromeArgs(userDataDir, { headless: o.headless ?? false });
|
||||
const proc = Bun.spawn([exe.path, ...args], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' });
|
||||
const proc = rt.spawn([exe.path, ...args], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' });
|
||||
let exited = false;
|
||||
const gone = proc.exited.then(() => { exited = true; });
|
||||
const end = Date.now() + (o.launchTimeoutMs ?? 30_000);
|
||||
let endpoint: string | null = null;
|
||||
while (!endpoint && !exited && Date.now() < end) { endpoint = devToolsEndpoint(userDataDir); if (!endpoint) await Bun.sleep(100); }
|
||||
while (!endpoint && !exited && Date.now() < end) { endpoint = devToolsEndpoint(userDataDir); if (!endpoint) await rt.sleep(100); }
|
||||
if (!endpoint) {
|
||||
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, Bun.sleep(5000)]); }
|
||||
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, rt.sleep(5000)]); }
|
||||
throw new Error(`${exe.label} did not start${exited ? ` (exit code ${proc.exitCode})` : ' in time'}`);
|
||||
}
|
||||
let browser: any;
|
||||
|
|
@ -299,18 +300,18 @@ async function startOwnChrome(root: string, mode: 'copy' | 'profile', exe: { lab
|
|||
const pw = o.launchDeps?.pw ?? await chromiumFor(root);
|
||||
browser = await pw.connectOverCDP(endpoint, { timeout: Math.max(5000, end - Date.now()) });
|
||||
} catch (e) {
|
||||
proc.kill('SIGTERM'); await Promise.race([gone, Bun.sleep(5000)]);
|
||||
proc.kill('SIGTERM'); await Promise.race([gone, rt.sleep(5000)]);
|
||||
throw new Error(`${exe.label}: could not attach (${(e as Error).message.split('\n')[0]})`, { cause: e });
|
||||
}
|
||||
const context = browser.contexts()[0] ?? await browser.newContext();
|
||||
const close = async () => {
|
||||
if (exited) return;
|
||||
await gracefully(async () => { const s = await browser.newBrowserCDPSession(); await s.send('Browser.close'); });
|
||||
await Promise.race([gone, Bun.sleep(10_000)]);
|
||||
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, Bun.sleep(5000)]); }
|
||||
await Promise.race([gone, rt.sleep(10_000)]);
|
||||
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, rt.sleep(5000)]); }
|
||||
};
|
||||
let closed = false;
|
||||
const done = gone.then(async () => { closed = true; cleanup(); await Bun.sleep(300); cleanup(); });
|
||||
const done = gone.then(async () => { closed = true; cleanup(); await rt.sleep(300); cleanup(); });
|
||||
const page = await ownTab(context);
|
||||
const watch = (p: any) => p.on('close', () => { if (!exited && context.pages().length === 0) void close(); });
|
||||
for (const p of context.pages()) watch(p);
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ import type { Ctx } from '../lib/ctx';
|
|||
import { isPlanForm, PLAN_FORM_IDS } from '../lib/console-answers';
|
||||
import { verifyAgeRatingApi, verifyAppRecordApi, type VerifyResult, verifyTable, verifyText } from '../lib/console-verify';
|
||||
import { printJson } from '../lib/log';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
async function readText(src: string): Promise<string> {
|
||||
if (src === '-' || src === 'true') return Bun.stdin.text();
|
||||
if (src === '-' || src === 'true') return rt.stdinText();
|
||||
return readFileSync(src, 'utf8');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}`);
|
||||
|
|
|
|||
|
|
@ -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('; ')}`);
|
||||
|
|
|
|||
|
|
@ -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') => {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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'] };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 '));
|
||||
|
|
|
|||
|
|
@ -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
48
cli/lib/embedded.ts
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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
465
cli/lib/rt.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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` */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
} });
|
||||
|
|
|
|||
6
cli/text-modules.d.ts
vendored
6
cli/text-modules.d.ts
vendored
|
|
@ -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; }
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
153
tests/node-compat.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
43
tools/build-node.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env bun
|
||||
// Builds the npm package's CLI: one Node ESM bundle, dist/storebound.mjs (package.json "bin"), so `npx storebound`
|
||||
// runs on Node 20+ and `bunx storebound` keeps working. Zero runtime dependencies: playwright stays an optional peer
|
||||
// resolved from the project at run time (playwright-core only in the compiled sidecar), so it is left out.
|
||||
// bun tools/build-node.ts write dist/storebound.mjs (npm pack / publish run it: prepack)
|
||||
// bun tools/build-node.ts --out <f> write elsewhere (tests)
|
||||
// The bundle is checked for this machine's home and checkout paths (like the sidecar): nothing private may ship.
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { privateStrings, ROOT } from './build-sidecars';
|
||||
|
||||
/** Packages the CLI only imports at run time from the project (or never under Node). */
|
||||
export const NODE_EXTERNALS = ['playwright', 'playwright-core', 'electron', 'chromium-bidi'];
|
||||
export const NODE_BUNDLE = join(ROOT, 'dist', 'storebound.mjs');
|
||||
|
||||
export function buildNode(out = NODE_BUNDLE): { path: string; bytes: number } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-node-'));
|
||||
try {
|
||||
const tmp = join(dir, 'storebound.mjs');
|
||||
const p = Bun.spawnSync(['bun', 'build', 'cli/index.ts', '--target=node', '--format=esm', ...NODE_EXTERNALS.flatMap(e => ['--external', e]), '--outfile', tmp],
|
||||
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
|
||||
if (p.exitCode !== 0) throw new Error(`bun build failed\n${p.stderr.toString()}${p.stdout.toString()}`);
|
||||
// the entry's own `#!/usr/bin/env bun` and Bun's `// @bun` marker go; node runs this file
|
||||
const body = readFileSync(tmp, 'utf8').replace(/^#!.*\n/, '').replace(/^\/\/ @bun[^\n]*\n/, '');
|
||||
const js = `#!/usr/bin/env node\n${body}`;
|
||||
const bunGlobal = /\bBun\.[a-zA-Z]/.exec(js);
|
||||
if (bunGlobal) throw new Error(`the Node bundle uses the Bun global (${bunGlobal[0]}...): go through cli/lib/rt.ts`);
|
||||
const leaked = privateStrings().filter(s => js.includes(s));
|
||||
if (leaked.length) throw new Error(`the Node bundle contains private paths (${leaked.length}); refusing to write it`);
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
writeFileSync(out, js);
|
||||
chmodSync(out, 0o755);
|
||||
return { path: out, bytes: statSync(out).size };
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const i = process.argv.indexOf('--out');
|
||||
const t = Date.now();
|
||||
const r = buildNode(i >= 0 ? resolve(process.argv[i + 1]!) : NODE_BUNDLE);
|
||||
console.log(`node bundle -> ${relative(process.cwd(), r.path) || r.path} (${(r.bytes / 1e6).toFixed(2)} MB, ${((Date.now() - t) / 1000).toFixed(1)}s)`);
|
||||
}
|
||||
13
tools/ci.ts
13
tools/ci.ts
|
|
@ -33,6 +33,14 @@ const add = (t: Task) => tasks.push(t);
|
|||
const tsc = (name: string, project: string) => add({ name, cmd: ['bunx', 'tsc', '--noEmit', '--incremental', '-p', project,
|
||||
'--tsBuildInfoFile', join(cache, `${name.replace(/\W+/g, '-')}.tsbuildinfo`)] });
|
||||
const junitDir = mkdtempSync(join(tmpdir(), 'sb-ci-'));
|
||||
const node = Bun.which('node');
|
||||
|
||||
/** The npm bundle (dist/storebound.mjs, Node 20+) and its smoke test with the real `node` (tools/node-smoke.ts). */
|
||||
function addNode() {
|
||||
add({ name: 'node bundle', cmd: ['bun', 'tools/build-node.ts'] });
|
||||
if (node) add({ name: 'node smoke', cmd: ['bun', 'tools/node-smoke.ts', '--node', node], after: ['node bundle'] });
|
||||
else console.log('ci: no `node` on PATH: the Node smoke test of the npm bundle is skipped');
|
||||
}
|
||||
|
||||
function addTests(files: string[]) {
|
||||
planShards(files).forEach((shard, i, all) => {
|
||||
|
|
@ -53,7 +61,9 @@ if (quick) {
|
|||
console.log(`ci:quick: ${changed === null ? 'no origin/main to diff against: every test' : `${changed.length} changed file(s), ${related.length} related test file(s)`}`);
|
||||
if (far.length) console.log(`ci:quick: left out ${far.length} browser suite(s) reached only through shared modules (bun run ci runs them): ${far.join(' ')}`);
|
||||
add({ name: 'adapters', cmd: ['bun', 'tools/gen-adapters.ts', '--check'] });
|
||||
add({ name: 'embedded', cmd: ['bun', 'tools/gen-embedded.ts', '--check'] });
|
||||
tsc('typecheck', '.');
|
||||
if (touches(/^(cli\/|templates\/|tools\/(build-node|node-smoke|gen-embedded)\.ts$|package\.json$)/)) addNode();
|
||||
if (touches(/^apps\/desktop\//)) tsc('desktop typecheck', 'apps/desktop');
|
||||
if (related.length) addTests(related);
|
||||
add({ name: 'scrub', cmd: ['bun', 'tools/scrub.ts'] });
|
||||
|
|
@ -63,6 +73,9 @@ if (quick) {
|
|||
if (!cargo) console.log('ci: cargo not found (or STOREBOUND_CI_RUST=0): desktop Rust tests and clippy skipped');
|
||||
else if (!rust) console.log('ci: apps/desktop/src-tauri and the sidecar build unchanged against origin/main: desktop Rust tests and clippy skipped (CI=full runs them)');
|
||||
add({ name: 'adapters', cmd: ['bun', 'tools/gen-adapters.ts', '--check'] });
|
||||
// templates/, cli/shots/*.html and Play's form export, embedded in cli/lib/embedded.ts (every runtime and build)
|
||||
add({ name: 'embedded', cmd: ['bun', 'tools/gen-embedded.ts', '--check'] });
|
||||
addNode();
|
||||
// the App Store Connect / Google Play operation tables, the wrapped-by scan and docs/api-coverage.md match specs/ and cli/
|
||||
add({ name: 'api tables', cmd: ['bun', 'tools/gen-api.ts', '--check'] });
|
||||
// one desktop version: package.json desktopVersion = apps/desktop/package.json = tauri.conf.json = Cargo.toml / .lock
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export function generate(root: string): Map<string, string> {
|
|||
const cli = [
|
||||
'## Tools',
|
||||
'',
|
||||
'- CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`.',
|
||||
'- CLI: `storebound <command>` (`storebound help`; outside the plugin: `npx storebound` on Node 20+ or `bunx storebound` on Bun); config `storebound.config.ts`, credentials in env or `.storebound/`.',
|
||||
'- MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed.',
|
||||
'- Readiness: `storebound scan` (offline rejection risks in the project files), `storebound check` (offline), `storebound validate` (read-only, both stores).',
|
||||
'- Every endpoint: `storebound api apple|play <operationId>` (skill store-api). GETs run; writes are dry unless the owner adds `--yes` in a terminal; MCP has the read-only `api_get`.',
|
||||
|
|
|
|||
60
tools/gen-embedded.ts
Normal file
60
tools/gen-embedded.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
#!/usr/bin/env bun
|
||||
// Writes cli/lib/embedded.ts: the text files the CLI ships inside its code, so they work on every runtime and in every
|
||||
// build without a files folder next to it: the Node bundle (dist/storebound.mjs), the compiled desktop sidecar
|
||||
// (`bun build --compile`) and the sources. Bun's `import ... with { type: 'text' }` did this before, but Node cannot
|
||||
// run that. Embedded: every file under templates/ (storebound init, web init), the shots pages (cli/shots/*.html,
|
||||
// served by shots render / video) and Play's Data safety form export (cli/data/play-data-safety-form.csv).
|
||||
// bun tools/gen-embedded.ts write cli/lib/embedded.ts
|
||||
// bun tools/gen-embedded.ts --check exit 1 when the file is stale (bun run ci)
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
export const OUT = join(ROOT, 'cli/lib/embedded.ts');
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap(e => {
|
||||
if (e.name === '.DS_Store') return [];
|
||||
const p = join(dir, e.name);
|
||||
return e.isDirectory() ? walk(p) : [p];
|
||||
}).sort();
|
||||
}
|
||||
|
||||
const rel = (base: string, p: string) => relative(base, p).split('\\').join('/');
|
||||
const table = (entries: [string, string][]): string => entries.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`).join('\n');
|
||||
|
||||
export function generate(root = ROOT): string {
|
||||
const templates = walk(join(root, 'templates')).map(p => [rel(join(root, 'templates'), p), readFileSync(p, 'utf8')] as [string, string]);
|
||||
const shots = readdirSync(join(root, 'cli/shots')).filter(f => f.endsWith('.html')).sort().map(f => [f, readFileSync(join(root, 'cli/shots', f), 'utf8')] as [string, string]);
|
||||
const form = readFileSync(join(root, 'cli/data/play-data-safety-form.csv'), 'utf8');
|
||||
return [
|
||||
'// GENERATED by tools/gen-embedded.ts from templates/, cli/shots/*.html and cli/data/play-data-safety-form.csv.',
|
||||
'// Do not edit: change those files and run `bun tools/gen-embedded.ts` (bun run ci checks it is fresh).',
|
||||
'/* eslint-disable */',
|
||||
'',
|
||||
'/** Every file under templates/, by its path there (forward slashes). */',
|
||||
'export const TEMPLATE_TEXT: Record<string, string> = {',
|
||||
table(templates),
|
||||
'};',
|
||||
'',
|
||||
'/** The pages shots render / video load (cli/shots/*.html), by file name. */',
|
||||
'export const SHOTS_PAGES: Record<string, string> = {',
|
||||
table(shots),
|
||||
'};',
|
||||
'',
|
||||
"/** Play Console's Data safety form export (cli/data/play-data-safety-form.csv). */",
|
||||
`export const PLAY_DATA_SAFETY_FORM = ${JSON.stringify(form)};`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const want = generate();
|
||||
let have = '';
|
||||
try { have = readFileSync(OUT, 'utf8'); } catch { /* missing */ }
|
||||
if (process.argv.includes('--check')) {
|
||||
if (have !== want) { console.error(`stale: ${rel(ROOT, OUT)} (run bun tools/gen-embedded.ts)`); process.exit(1); }
|
||||
console.log(`${rel(ROOT, OUT)}: fresh`);
|
||||
} else if (have !== want) { writeFileSync(OUT, want); console.log(`wrote ${rel(ROOT, OUT)}`); }
|
||||
else console.log(`${rel(ROOT, OUT)}: unchanged`);
|
||||
}
|
||||
|
|
@ -49,7 +49,8 @@ function scan(entry: string, errors: string[]): Set<string> {
|
|||
if (seen.has(f)) continue;
|
||||
seen.add(f);
|
||||
const rel = relative(CLI, f);
|
||||
if (rel === 'index.ts' || rel === 'commands.ts') continue;
|
||||
// lib/embedded.ts holds file texts (templates, shots pages: their own scripts read URL params), not flag reads
|
||||
if (rel === 'index.ts' || rel === 'commands.ts' || rel === join('lib', 'embedded.ts')) continue;
|
||||
const src = readFileSync(f, 'utf8');
|
||||
for (const m of src.matchAll(READ)) if (!NOT_ARGS.test(m[1]!)) flags.add(m[3]!);
|
||||
for (const fl of DYNAMIC[rel] ?? []) flags.add(fl);
|
||||
|
|
|
|||
124
tools/node-smoke.ts
Normal file
124
tools/node-smoke.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env bun
|
||||
// Smoke test of the npm bundle (dist/storebound.mjs) with the real `node` binary: the CLI must run on Node 20+.
|
||||
// bun tools/node-smoke.ts [--bundle dist/storebound.mjs] [--node <path>]
|
||||
// Offline only: --version, help, check on examples/demo-game (same exit code and problems as under Bun), scan on the
|
||||
// teaser fixture, release plan --help, init + check in an empty folder (the TypeScript config the template writes),
|
||||
// and an MCP initialize handshake over stdio with tools/list and one tools/call (a child run through the bundle).
|
||||
// Every step has a timeout; on a failure the step's output is printed.
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { ROOT } from './build-sidecars';
|
||||
import { NODE_BUNDLE } from './build-node';
|
||||
|
||||
const arg = (n: string) => { const i = process.argv.indexOf(n); return i >= 0 ? process.argv[i + 1] : undefined; };
|
||||
const bundle = resolve(arg('--bundle') ?? NODE_BUNDLE);
|
||||
const node = arg('--node') ?? Bun.which('node');
|
||||
if (!node) { console.error('node-smoke: no `node` on PATH'); process.exit(1); }
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { version: string; engines?: { node?: string } };
|
||||
const env: Record<string, string | undefined> = { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', STOREBOUND_NO_UPDATE_CHECK: '1' };
|
||||
|
||||
interface Out { code: number | null; out: string; err: string }
|
||||
function run(cmd: string[], cwd = ROOT, stdin?: string, timeout = 60_000): Out {
|
||||
const r = Bun.spawnSync(cmd, { cwd, env, stdout: 'pipe', stderr: 'pipe', timeout, ...(stdin !== undefined ? { stdin: new TextEncoder().encode(stdin) } : {}) });
|
||||
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
|
||||
}
|
||||
const sb = (args: string[], cwd = ROOT, stdin?: string) => run([node, bundle, ...args], cwd, stdin);
|
||||
|
||||
let failed = 0;
|
||||
function step(name: string, f: () => string | void): void {
|
||||
const t = Date.now();
|
||||
try {
|
||||
const note = f();
|
||||
console.log(`ok ${name}${note ? `: ${note}` : ''} (${Date.now() - t} ms)`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(`FAIL ${name}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
function expectOk(r: Out, what: string, code = 0): void {
|
||||
if (r.code !== code) throw new Error(`${what}: exit ${r.code} (want ${code})\n--- stdout\n${r.out.slice(-2000)}\n--- stderr\n${r.err.slice(-2000)}`);
|
||||
}
|
||||
/** Output without timestamps and absolute paths, to compare a Node and a Bun run. */
|
||||
const norm = (s: string) => s.replace(/^\d\d:\d\d:\d\d /gm, '').replace(/\/\S*\/storebound\.config\.ts/g, '<config>').trim();
|
||||
|
||||
const nodeVersion = run([node, '--version']).out.trim();
|
||||
console.log(`node-smoke: ${bundle.replace(ROOT + '/', '')} with node ${nodeVersion} (package engines: node ${pkg.engines?.node ?? '?'})`);
|
||||
|
||||
step('--version', () => {
|
||||
const r = sb(['--version']);
|
||||
expectOk(r, '--version');
|
||||
if (r.out.trim() !== pkg.version) throw new Error(`printed ${JSON.stringify(r.out.trim())}, package.json says ${pkg.version}`);
|
||||
return r.out.trim();
|
||||
});
|
||||
|
||||
step('help', () => {
|
||||
const r = sb(['help']);
|
||||
expectOk(r, 'help');
|
||||
if (!r.out.includes('usage: storebound <command>')) throw new Error('no usage line');
|
||||
return `${r.out.split('\n').length} lines`;
|
||||
});
|
||||
|
||||
step('check (examples/demo-game, same result as Bun)', () => {
|
||||
const cwd = join(ROOT, 'examples/demo-game');
|
||||
const n = sb(['check'], cwd);
|
||||
const b = run(['bun', join(ROOT, 'cli/index.ts'), 'check'], cwd);
|
||||
if (n.code !== b.code) throw new Error(`node exit ${n.code}, bun exit ${b.code}\n${n.out}\n${n.err}`);
|
||||
if (norm(n.out + n.err) !== norm(b.out + b.err)) throw new Error(`output differs from Bun's\n--- node\n${norm(n.out + n.err)}\n--- bun\n${norm(b.out + b.err)}`);
|
||||
return `exit ${n.code}, ${(n.out + n.err).match(/error: .*/)?.[0] ?? 'no problems'}`;
|
||||
});
|
||||
|
||||
step('scan (tests/fixtures/scan/teaser)', () => {
|
||||
const cwd = join(ROOT, 'tests/fixtures/scan/teaser');
|
||||
const r = sb(['scan', '--json'], cwd);
|
||||
const b = run(['bun', join(ROOT, 'cli/index.ts'), 'scan', '--json'], cwd);
|
||||
if (r.code !== b.code) throw new Error(`node exit ${r.code}, bun exit ${b.code}\n${r.err}`);
|
||||
const j = JSON.parse(r.out) as { findings?: unknown[] };
|
||||
if (JSON.stringify(j) !== JSON.stringify(JSON.parse(b.out))) throw new Error('scan --json differs from Bun\'s');
|
||||
return `exit ${r.code}, ${Array.isArray(j.findings) ? `${j.findings.length} findings` : 'json ok'}`;
|
||||
});
|
||||
|
||||
step('release plan --help', () => {
|
||||
const r = sb(['release', 'plan', '--help']);
|
||||
expectOk(r, 'release plan --help');
|
||||
if (!/storebound release/.test(r.out + r.err)) throw new Error('no usage');
|
||||
});
|
||||
|
||||
step('init + inspect in an empty folder (TypeScript config)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-node-init-'));
|
||||
try {
|
||||
const i = sb(['init'], dir);
|
||||
expectOk(i, 'init');
|
||||
if (!readFileSync(join(dir, 'storebound.config.ts'), 'utf8').includes('ShipConfig')) throw new Error('no storebound.config.ts written');
|
||||
const c = sb(['inspect', '--json'], dir);
|
||||
expectOk(c, 'inspect --json after init');
|
||||
return `config loads: ${(JSON.parse(c.out) as { name?: string }).name}`;
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
step('mcp: initialize, tools/list, tools/call over stdio', () => {
|
||||
const lines = [
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'node-smoke', version: '1' } } },
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'check', arguments: {} } },
|
||||
].map(m => JSON.stringify(m)).join('\n') + '\n';
|
||||
const prev = env.STOREBOUND_PROJECT;
|
||||
env.STOREBOUND_PROJECT = join(ROOT, 'examples/demo-game');
|
||||
let r: Out;
|
||||
try { r = sb(['mcp'], ROOT, lines); } finally { if (prev === undefined) delete env.STOREBOUND_PROJECT; else env.STOREBOUND_PROJECT = prev; }
|
||||
expectOk(r, 'mcp');
|
||||
const res = new Map(r.out.trim().split('\n').filter(Boolean).map(l => JSON.parse(l) as { id: number; result?: Record<string, unknown>; error?: unknown }).map(m => [m.id, m]));
|
||||
const init = res.get(1)?.result as { protocolVersion?: string; serverInfo?: { name: string; version: string } } | undefined;
|
||||
if (init?.serverInfo?.name !== 'storebound' || init.serverInfo.version !== pkg.version) throw new Error(`initialize: ${JSON.stringify(res.get(1))}`);
|
||||
const tools = (res.get(2)?.result as { tools?: unknown[] } | undefined)?.tools ?? [];
|
||||
if (tools.length < 10) throw new Error(`tools/list: ${tools.length} tools`);
|
||||
const call = res.get(3)?.result as { isError?: boolean; content?: { text: string }[] } | undefined;
|
||||
// demo-game's check reports 2 problems (exit 1): what counts is that the child ran through the bundle and loaded the config
|
||||
const text = call?.content?.[0]?.text ?? '';
|
||||
if (!/config ok/.test(text) || !/\[exit [01]\]/.test(text)) throw new Error(`tools/call check: ${JSON.stringify(res.get(3)).slice(0, 1500)}`);
|
||||
return `protocol ${init.protocolVersion}, ${tools.length} tools, check ran as a child of the bundle (${text.match(/\[exit \d+\]/)![0]})`;
|
||||
});
|
||||
|
||||
if (failed) { console.error(`node-smoke: ${failed} step(s) failed`); process.exit(1); }
|
||||
console.log('node-smoke: all steps passed');
|
||||
Loading…
Add table
Add a link
Reference in a new issue