- apple testflight wait --build N (progress, timeout, exit 0/1/7) and expire --keep N | --older-than D (dry run, asks) - apple iap-submit: IAP / subscription / group versions into a review submission; first of a type only with the app version (--with-version), --no-submit keeps the draft; read-only MCP plan - apple listing pull, play listing pull (diff, --write), catalog pull (config proposal, never rewritten) - whats-new --languages / check / apply: one file per language, agent or hook translation, per-store limits - apple offer-codes, win-back, promoted, preorder; Play pre-registration as a guide (no API) - apple cpp and apple events: list, create, upload, submit (asks) - reports: Apple sales / finance, Play earnings / sales into .storebound/reports with summary and CSV - triage: TestFlight crash / feedback and Play error reports grouped by signature, device, OS - aso rank: iTunes Search API ranks and competitors, rate limited - ads scan: AdAttributionKit / SKAdNetwork Info.plist and source checks - tests against recorded, sanitized answers; skills, README, CHANGELOG
107 lines
6.6 KiB
TypeScript
107 lines
6.6 KiB
TypeScript
// Recorded-response harness for command tests: throwaway API keys, a fetch router that answers like App Store Connect,
|
|
// Google and the iTunes Search API did (sanitized: every id, name and email is a placeholder), a temp project with a
|
|
// config, and capture of stdout / stderr / process.exit. Nothing here reaches the network.
|
|
import { generateKeyPairSync } from 'node:crypto';
|
|
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
import { parseArgs } from '../../cli/lib/args';
|
|
import type { Loaded, ShipConfig } from '../../cli/lib/config';
|
|
import type { Ctx } from '../../cli/lib/ctx';
|
|
|
|
export type J = Record<string, unknown>;
|
|
export interface Call { method: string; url: string; path: string; body: unknown }
|
|
type Answer = Response | J | ((c: Call) => Response | J | Promise<Response | J>);
|
|
export interface Route { method?: string; match: RegExp; answer: Answer; times?: number }
|
|
|
|
export const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
|
|
export const notFound = () => json({ errors: [{ id: '00000000-0000-0000-0000-000000000000', status: '404', code: 'NOT_FOUND', title: 'The specified resource does not exist', detail: 'There is no resource' }] }, 404);
|
|
|
|
const saved = { ...process.env };
|
|
const realFetch = globalThis.fetch;
|
|
|
|
export function ascEnv(): void {
|
|
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
process.env.ASC_KEY_ID = 'ABCDE12345';
|
|
process.env.ASC_ISSUER_ID = '00000000-0000-0000-0000-000000000000';
|
|
process.env.ASC_P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
}
|
|
|
|
export function playEnv(): void {
|
|
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' });
|
|
}
|
|
|
|
/** Installs the router; unknown requests fail the test loudly (answer 599 with the path). Returns the call log. */
|
|
export function mockFetch(routes: Route[]): Call[] {
|
|
const calls: Call[] = [];
|
|
const left = routes.map(r => ({ ...r, left: r.times ?? Infinity }));
|
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
const url = String(input instanceof Request ? input.url : input);
|
|
const method = (init?.method ?? 'GET').toUpperCase();
|
|
const raw = init?.body;
|
|
let body: unknown = raw;
|
|
if (typeof raw === 'string') { try { body = JSON.parse(raw); } catch { body = raw; } }
|
|
const path = url.replace(/^https:\/\/[^/]+/, '');
|
|
const c: Call = { method, url, path, body };
|
|
if (url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'ya29.test', expires_in: 3600 });
|
|
calls.push(c);
|
|
const r = left.find(x => x.left > 0 && (x.method ?? 'GET') === method && x.match.test(decodeURIComponent(path)));
|
|
if (!r) return new Response(`not recorded: ${method} ${path}`, { status: 599 });
|
|
r.left--;
|
|
const a = typeof r.answer === 'function' ? await r.answer(c) : r.answer;
|
|
return a instanceof Response ? a : json(a);
|
|
}) as typeof fetch;
|
|
return calls;
|
|
}
|
|
|
|
export function restore(): void {
|
|
globalThis.fetch = realFetch;
|
|
for (const k of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'PLAY_SERVICE_ACCOUNT', 'ASC_VENDOR_NUMBER']) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; }
|
|
}
|
|
|
|
export const BASE: ShipConfig = {
|
|
app: { name: 'Example Notes', profile: 'app', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' },
|
|
languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'de', apple: 'de-DE', play: 'de-DE' }, { id: 'tr', apple: 'tr', play: 'tr-TR' }],
|
|
apple: { bundleId: 'com.example.notes', appId: '6400000101' },
|
|
play: { packageName: 'com.example.notes', reportsBucket: 'pubsite_prod_rev_01234567890123456789' },
|
|
};
|
|
|
|
/** A temp project folder with optional files; returns a Ctx factory for that config. */
|
|
export function project(config: ShipConfig = BASE, files: Record<string, string | Uint8Array> = {}): { root: string; ctx: (argv: string[], booleans?: string[]) => Ctx; loaded: Loaded } {
|
|
const root = mkdtempSync(join(tmpdir(), 'sb-ops-'));
|
|
for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); }
|
|
const loaded: Loaded = { config, root, path: join(root, 'storebound.config.ts'), resolve: (p: string) => (isAbsolute(p) ? p : join(root, p)) };
|
|
return { root, loaded, ctx: (argv, booleans = []) => ({ loaded, args: parseArgs(argv, booleans) }) };
|
|
}
|
|
|
|
/** Runs fn with console output captured and process.exit turned into a thrown { exit } (the code is returned). */
|
|
export async function capture(fn: () => Promise<void>): Promise<{ out: string; err: string; exit: number | null }> {
|
|
const out: string[] = [], err: string[] = [];
|
|
const log = console.log, warn = console.warn, error = console.error, exit = process.exit, write = process.stdout.write;
|
|
console.log = (...a: unknown[]) => { out.push(a.map(String).join(' ')); };
|
|
console.warn = (...a: unknown[]) => { err.push(a.map(String).join(' ')); };
|
|
console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ')); };
|
|
process.stdout.write = ((s: string) => { out.push(String(s)); return true; }) as typeof process.stdout.write;
|
|
let code: number | null = null;
|
|
process.exit = ((c?: number) => { code = c ?? 0; throw new ExitSignal(code); }) as typeof process.exit;
|
|
const before = process.exitCode;
|
|
process.exitCode = 0;
|
|
try { await fn(); } catch (e) { if (!(e instanceof ExitSignal)) { restoreIo(); throw e; } }
|
|
restoreIo();
|
|
if (code === null && process.exitCode) code = Number(process.exitCode);
|
|
process.exitCode = before ?? 0;
|
|
return { out: out.join('\n'), err: err.join('\n'), exit: code };
|
|
function restoreIo(): void { console.log = log; console.warn = warn; console.error = error; process.exit = exit; process.stdout.write = write; }
|
|
}
|
|
|
|
class ExitSignal extends Error { constructor(readonly code: number) { super(`exit ${code}`); } }
|
|
|
|
/** The --json object printed by a command (printJson pretty-prints one object; log lines may follow). */
|
|
export function jsonOut(out: string): J {
|
|
const i = out.indexOf('\n{\n') >= 0 ? out.indexOf('\n{\n') + 1 : out.indexOf('{\n');
|
|
if (i < 0) throw new Error(`no JSON in output:\n${out.slice(0, 500)}`);
|
|
// find the matching closing brace at column 0
|
|
const end = out.indexOf('\n}', i);
|
|
return JSON.parse(out.slice(i, end + 2)) as J;
|
|
}
|