- 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
61 lines
3.9 KiB
TypeScript
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;
|
|
}
|