// One app's portfolio status: identity, offline config check, catalog parity and (live) the release trains from // `apple status --json` / `play status --json`. Shared by the desktop's `portfolio.status` (cli/serve.ts) and the CLI // workspace (`storebound ws status`, `storebound release status`), so all of them read an app the same way. // Every read is a storebound child command; `exec` runs it (serve tracks it for cancellation, the CLI just awaits it). import type { AppStatus, ProjectSummary } from './desktop-protocol'; import { appIdentity, appleFirstProductWarnings, applePlatformWarnings, appleProductIds, appleReleases, catalogParity, playProductIds, playReleases, releaseTrains } from './portfolio'; import type { RunResult } from './runner'; export type Exec = (argv: string[], cwd: string) => Promise; export const lastLine = (s: string): string => s.trim().split('\n').filter(Boolean).at(-1)?.replace(/^error:\s*/, '') ?? ''; export const emptyStatus = (project: string): AppStatus => ({ project, ok: false, app: null, summary: null, check: null, release: { source: 'none', current: null, next: null }, catalog: null, storeErrors: {}, errors: [] }); /** `inspect --json` of a folder; throws "project: " when the config does not load. */ export async function inspectApp(dir: string, exec: Exec): Promise { const r = await exec(['inspect', '--json'], dir); if (r.cancelled) throw new Error('Request cancelled'); if (r.code !== 0 || !r.data) throw new Error(`project: ${lastLine(r.out) || 'config could not be loaded'}`); return r.data as ProjectSummary; } /** * Fills `e` for the app in `dir`: summary, identity, check (skipped for a store-only app), and with `live` the release * trains and the live catalog parity. Errors are thrown; the caller records them in `e.errors`. */ export async function fillAppStatus(e: AppStatus, dir: string, o: { live: boolean; virtual?: boolean; exec: Exec }): Promise { const summary = e.summary = await inspectApp(dir, o.exec); e.app = appIdentity(summary); e.catalog = catalogParity(summary); if (o.virtual) e.check = { ok: true, problems: [], warnings: [] }; else { const c = await o.exec(['check', '--json'], dir); const cd = c.data as { ok?: boolean; problems?: string[]; warnings?: string[] } | undefined; e.check = cd ? { ok: !!cd.ok, problems: cd.problems ?? [], warnings: cd.warnings ?? [] } : { ok: false, problems: [lastLine(c.out) || `check exit ${c.code}`], warnings: [] }; } if (o.live) { const store = async (argv: string[]): Promise<{ data?: unknown; error?: string }> => { const r = await o.exec(argv, dir); return r.code === 0 && r.data !== undefined ? { data: r.data } : { error: lastLine(r.out) || `exit ${r.code}` }; }; const [apple, play] = await Promise.all([summary.apple ? store(['apple', 'status', '--json']) : undefined, summary.play ? store(['play', 'status', '--json']) : undefined]); if (apple?.error) e.storeErrors.ios = apple.error; if (play?.error) e.storeErrors.android = play.error; const ios = apple?.data ? appleReleases(apple.data) : [], android = play?.data ? playReleases(play.data) : []; e.release = { source: 'live', ...releaseTrains(ios, android), all: [...ios, ...android] }; // first in-app purchases left out of the review: a warning on the release status and in the desktop's checks const firstWarn = apple?.data ? [...appleFirstProductWarnings(apple.data), ...applePlatformWarnings(apple.data)] : []; if (firstWarn.length) e.warnings = firstWarn; if (e.check) for (const w of firstWarn) if (!e.check.warnings.includes(w)) e.check.warnings.push(w); e.catalog = catalogParity(summary, apple?.data ? appleProductIds(apple.data) : undefined, play?.data ? playProductIds(play.data) : undefined); } e.ok = e.check.ok && !e.storeErrors.ios && !e.storeErrors.android && (e.catalog?.ok ?? true); return e; } /** Runs `fn` over `items`, at most `limit` at a time; results in input order. */ export async function pool(items: T[], limit: number, fn: (x: T, i: number) => Promise): Promise { const out = new Array(items.length); let next = 0; const n = Math.max(1, Math.min(limit, items.length)); await Promise.all(Array.from({ length: n }, async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i]!, i); } })); return out; }