storebound/cli/index.ts
CRT 710c3a53d0 README, docs and site: one story (the whole job, no avoidable rejections, the desktop app, your AI, install)
README is a short front page (about 120 lines) with one real visual per section; every former section moved
unchanged into docs/*.md with a docs/README.md index (journey, rejections, getting-started, commands, growth,
studios, store-art, listing-languages, api, skills, mcp, safety, faq, project). The site tells the same story on one
page: the teaser in the hero, real CLI output as text, the desktop app as a section with light/dark captures of eight
screens and the per-system downloads (desktop/latest.json); /desktop/ redirects to /#download. New capture tools:
tools/site-desktop-shots.ts (fixture-mode desktop frames) and tools/site-term-shots.ts (real CLI output as text and
WebP). tests/docs-links.test.ts checks README, docs and site links resolve.
2026-10-13 03:29:59 +08:00

92 lines
5.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 { 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;
// 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);
}
}
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); }
}
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); });