CLI runs on Node 20+: npx storebound (bunx keeps working)

- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and
  "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot,
  config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it
- tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded
  (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci
- package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20
- bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help,
  init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/
- install writes npx MCP commands when run with Node; bin/storebound falls back to Node
- docs: npx storebound init (Node 20+) or bunx storebound init
This commit is contained in:
CRTkafa 2026-10-13 04:03:09 +08:00
parent 614c32bcba
commit 36a8bf9625
73 changed files with 1195 additions and 251 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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