#!/usr/bin/env bun // storebound: ship a mobile app or game to Google Play and the App Store from the terminal. // Run `storebound help` for the command list. Config: storebound.config.ts; credentials: env or .storebound/. // `--help` / `-h` anywhere prints the usage of the command (or of a group like `storebound shots`) and exits 0 before // anything loads; an unknown --flag (cli/data/flags.json, tools/gen-flags.ts) exits 2 with the usage, also before. import { parseArgs } from './lib/args'; import { toolPath } from './lib/tools'; import { COMMANDS, PLAIN } from './commands'; import FLAGS from './data/flags.json'; const COMMON = 'common options: --config --dry (no writes) --only --yes (skip confirmations) --json --account (credential profile)'; const line = ([k, d]: [string, string]): string => ` ${k.padEnd(25)} ${d}`; const describe = (k: string): string => (PLAIN[k] ?? COMMANDS[k])![1]; function help(): void { console.log(`storebound · take mobile apps and games from nothing to Submit for review on Google Play and the App Store usage: storebound [options] (storebound --help: its usage and options) ${Object.entries(PLAIN).map(([k, [, d]]) => line([k, d])).join('\n')} ${Object.entries(COMMANDS).map(([k, [, d]]) => line([k, d])).join('\n')} ${COMMON} credentials: PLAY_SERVICE_ACCOUNT, ASC_KEY_ID, ASC_ISSUER_ID, ASC_P8, ANDROID_KEYSTORE(_PASSWORD) / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD, REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD (env or .storebound/credentials.json); profiles for many accounts: ~/.storebound/credentials/.json (same names), picked with --account docs: README.md, docs/ and the skills/ folder of the toolchain`); } /** Usage of one command: its header usage lines (or the table line), description and accepted options. */ function commandUsage(key: string): string { const usage = (FLAGS.usage as Record)[key] ?? [`storebound ${key} [options]`]; const flags = (FLAGS.commands as Record)[key] ?? []; return [ `usage: ${usage[0]}`, ...usage.slice(1).map(u => ` ${u}`), '', ` ${describe(key)}`, '', ...(key === 'ws' ? ['options: the options of the command it runs (ws run ...), plus --apps a,b | --all, --apply'] : flags.length ? [`options: ${flags.map(f => '--' + f).join(' ')}`] : []), COMMON, ].join('\n'); } /** Usage of a command group (`storebound shots`): its commands. */ const groupUsage = (g: string, keys: string[]): string => `usage: storebound ${g} [options] (storebound ${g} --help: its usage and options)\n\n${keys.map(k => line([k, describe(k)])).join('\n')}\n\n${COMMON}`; async function main(): Promise { // tests (tests/cli-help.test.ts) run the CLI with STOREBOUND_NO_NETWORK=1: any request fails loudly if (process.env.STOREBOUND_NO_NETWORK === '1') globalThis.fetch = (async (u: unknown) => { throw new Error(`network disabled (STOREBOUND_NO_NETWORK): ${String(u)}`); }) as unknown as typeof fetch; // tools (adb, emulator, ffmpeg…) are also looked up where they usually live, for apps started without a shell PATH process.env.PATH = toolPath(process.env); const argv = process.argv.slice(2); // --account : the credential profile for this run (ws apps import reads its own --account) if (argv[0] !== 'ws') { const i = argv.findIndex(x => x === '--account' || x.startsWith('--account=')); if (i >= 0) { const name = argv[i]!.includes('=') ? argv[i]!.slice(10) : argv[i + 1]; if (!name || name.startsWith('--')) throw new Error('--account '); process.env.STOREBOUND_PROFILE = name; argv.splice(i, argv[i]!.includes('=') ? 1 : 2); } } const [a, b] = argv; if (!a || a === 'help' || a === '--help' || a === '-h') return help(); if (a === 'version' || a === '--version') return console.log((await import('../package.json')).version); const known = (k: string): boolean => !!(PLAIN[k] ?? COMMANDS[k]); const key = known(`${a} ${b}`) ? `${a} ${b}` : known(a) ? a : ''; const group = Object.keys({ ...PLAIN, ...COMMANDS }).filter(k => k.startsWith(a + ' ')); if (argv.includes('--help') || argv.includes('-h')) { if (key) return console.log(commandUsage(key)); if (group.length) return console.log(groupUsage(a, group)); } if (!key) { if (group.length) { console.error(`${b ? `unknown command: ${a} ${b}` : `storebound ${a}: which command?`}\n\n${groupUsage(a, group)}`); process.exit(2); } console.error(`unknown command: ${argv.slice(0, 2).join(' ')}\n`); help(); process.exit(2); } const rest = argv.slice(key.split(' ').length); const accepted = (FLAGS.commands as Record)[key]; if (accepted) { const ok = new Set([...FLAGS.global, ...accepted]); const bad = [...new Set(rest.filter(x => /^--[^-]/.test(x)).map(x => x.slice(2).split('=')[0]!).filter(f => !ok.has(f)))]; if (bad.length) { console.error(`unknown option${bad.length > 1 ? 's' : ''} for storebound ${key}: ${bad.map(f => '--' + f).join(', ')}\n\n${commandUsage(key)}`); process.exit(2); } } if (PLAIN[key]) return (await PLAIN[key]![0]()).run(parseArgs(rest)); const args = parseArgs(rest); const { loadConfig } = await import('./lib/config'); const loaded = await loadConfig(args.get('config')); // one writer per app and store (cli/lib/lock.ts): dry runs and reads take no lock const { acquireLock, lockKeys } = await import('./lib/lock'); for (const k of lockKeys(key, args, loaded.config)) acquireLock(loaded.root, k, argv.filter(x => x !== '--force-unlock').join(' '), { force: args.has('force-unlock') }); await (await COMMANDS[key]![0]()).run({ loaded, args }); } main().catch(e => { console.error(`\nerror: ${(e as Error).message}`); process.exit(1); });