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
|
|
@ -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[] = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue