storebound status: per platform live and next version, build, track, review state and rollout % without uploading (read-only, one JSON line); release status --app also takes an app folder or a store id

This commit is contained in:
CRTkafa 2026-10-23 13:13:15 +08:00
parent 4933279987
commit 4eac8e8cd8
3 changed files with 90 additions and 4 deletions

View file

@ -23,7 +23,7 @@ import { findWorkspace, loadWorkspace, resolveApps, selectApps } from './lib/wor
import { appDir, appEnv, workspaceApps } from './workspace';
import { findFastlaneTraces, traceMessage } from './lib/fastlane-traces';
interface Target { name: string; dir: string; virtual: boolean; env: Record<string, string>; problems: string[] }
export interface Target { name: string; dir: string; virtual: boolean; env: Record<string, string>; problems: string[] }
const USAGE = 'usage: storebound release status [--app a,b] [--versions] [--json] | release plan <version> [--app a] [--no-validate] [--json]';
@ -35,8 +35,14 @@ function configDir(from: string): string | null {
}
/** The apps to read: this app folder, or the workspace's apps (filtered by --app). */
async function targets(args: Args): Promise<{ list: Target[]; concurrency: number }> {
export async function targets(args: Args): Promise<{ list: Target[]; concurrency: number }> {
const cwd = process.cwd();
// --app <folder>: an app folder given by path (the ship pipeline's checkout)
const asked = args.list('app');
if (asked.length === 1 && existsSync(resolve(cwd, asked[0]!)) && configDir(resolve(cwd, asked[0]!)) === resolve(cwd, asked[0]!)) {
const dir = resolve(cwd, asked[0]!);
return { list: [{ name: dir.split(/[\\/]/).pop() ?? dir, dir, virtual: false, env: {}, problems: [] }], concurrency: 1 };
}
const here = configDir(cwd);
const wsFile = findWorkspace(cwd, args.get('workspace'));
// an app folder wins unless --app asks for the workspace (or the workspace file is closer than the config)
@ -53,14 +59,21 @@ async function targets(args: Args): Promise<{ list: Target[]; concurrency: numbe
if (r.code === 0 && r.data) summaries.set(f.id, r.data as AppStatus['summary']);
});
const { apps, accounts } = workspaceApps(l, summaries);
const { picked, unknown } = selectApps(apps.filter(a => !a.ignored), args.list('app'));
const live = apps.filter(a => !a.ignored);
const sel = selectApps(live, args.list('app'));
// --app <store id>: a bundle id, package name or App Store app id finds the app too
const ids = (a: (typeof live)[number]): string[] => [a.unified.ios?.app.bundleId, a.unified.ios?.app.id, a.unified.android?.app.id, a.summary?.apple?.bundleId, a.summary?.apple?.appId, a.summary?.play?.packageName]
.filter((x): x is string => !!x).map(x => x.toLowerCase());
const byId = sel.unknown.map(n => ({ n, hits: live.filter(a => ids(a).includes(n.toLowerCase())) }));
const picked = live.filter(a => sel.picked.includes(a) || byId.some(b => b.hits.includes(a)));
const unknown = byId.filter(b => !b.hits.length).map(b => b.n);
if (unknown.length) throw new Error(`--app: no app named ${unknown.join(', ')} (storebound ws list shows the names)`);
return { list: picked.map(a => { const { env, problems } = appEnv(a.unified, accounts); return { name: a.name, dir: appDir(a), virtual: a.virtual, env, problems }; }), concurrency: n };
}
const exec = (env: Record<string, string>) => (argv: string[], cwd: string): Promise<RunResult> => runCommand(argv, { cwd, env, machine: true }).result;
async function readLive(t: Target): Promise<AppStatus> {
export async function readLive(t: Target): Promise<AppStatus> {
const e = emptyStatus(t.dir);
if (t.virtual) e.virtual = true;
try { await fillAppStatus(e, t.dir, { live: true, virtual: t.virtual, exec: exec(t.env) }); } catch (err) { e.errors.push((err as Error).message); }