103 lines
6.7 KiB
TypeScript
Executable file
103 lines
6.7 KiB
TypeScript
Executable file
#!/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 { withJsonOutput } from './lib/log';
|
|
import { toolPath } from './lib/tools';
|
|
import { COMMANDS, PLAIN } from './commands';
|
|
import FLAGS from './data/flags.json';
|
|
|
|
const COMMON = 'common options: --config <path> --dry (no writes) --only <ids> --yes (skip confirmations) --json --account <profile> (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 <command> [options] (storebound <command> --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/<profile>.json (same names), picked with --account <profile>
|
|
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<string, string[]>)[key] ?? [`storebound ${key} [options]`];
|
|
const flags = (FLAGS.commands as Record<string, string[]>)[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 <command> ...), 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} <command> [options] (storebound ${g} <command> --help: its usage and options)\n\n${keys.map(k => line([k, describe(k)])).join('\n')}\n\n${COMMON}`;
|
|
|
|
async function main(): Promise<void> {
|
|
// 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;
|
|
// the release acceptance matrix: store hosts answered by a local double (loopback only, cli/lib/store-mock-env.ts)
|
|
if (process.env.STOREBOUND_STORE_MOCK) (await import('./lib/store-mock-env')).installStoreMock();
|
|
// 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 <profile>: 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 <credential profile>');
|
|
process.env.STOREBOUND_PROFILE = name;
|
|
argv.splice(i, argv[i]!.includes('=') ? 1 : 2);
|
|
}
|
|
}
|
|
// npx / bunx run a cached copy: start the newest storebound instead (cli/lib/self-update.ts)
|
|
// never for mcp / serve: a client waits for the handshake on stdout, and a second npx download can outlast its timeout
|
|
if (argv[0] !== 'mcp' && argv[0] !== 'serve') { const { runNewest } = await import('./lib/self-update'); await runNewest((await import('../package.json')).version, process.argv.slice(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<string, string[]>)[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); }
|
|
}
|
|
// --json: stdout is only the JSON, every log line goes to stderr (lib/log.ts withJsonOutput)
|
|
|
|
const json = (f: () => Promise<void>): Promise<void> => (rest.includes('--json') && key !== 'mcp' && key !== 'serve' ? withJsonOutput(f) : f());
|
|
if (PLAIN[key]) { const plain = PLAIN[key]![0]; return json(async () => (await plain()).run(parseArgs(rest))); }
|
|
const args = parseArgs(rest);
|
|
await json(async () => {
|
|
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); });
|