storebound/cli/lib/app-status.ts
CRT 2165322c02 CLI workspace for many apps and studios, credential profiles, release trains (release status / plan)
- storebound.workspace.json: app folders (globs), studios with accounts by credential profile, ignore list, the
  desktop's pairing overrides; ws init | list | status | check | validate | run | apps import | desktop export/import
- credential profiles in ~/.storebound/credentials/<profile>.json or env, --account <profile> on any command
- release status / release plan <version>, table or --json; MCP workspace_list, workspace_status, release_status,
  release_plan (read-only)
- pairing rules (cli/lib/identity.ts), release lanes (cli/lib/train.ts) and the per-app status read
  (cli/lib/app-status.ts) shared with the desktop instead of copied
- tests with sanitized fixtures, README, skills, CHANGELOG
2026-10-11 23:35:45 +08:00

61 lines
3.9 KiB
TypeScript

// 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, appleProductIds, appleReleases, catalogParity, playProductIds, playReleases, releaseTrains } from './portfolio';
import type { RunResult } from './runner';
export type Exec = (argv: string[], cwd: string) => Promise<RunResult>;
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: <reason>" when the config does not load. */
export async function inspectApp(dir: string, exec: Exec): Promise<ProjectSummary> {
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<AppStatus> {
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] };
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<T, R>(items: T[], limit: number, fn: (x: T, i: number) => Promise<R>): Promise<R[]> {
const out = new Array<R>(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;
}