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:
parent
4933279987
commit
4eac8e8cd8
3 changed files with 90 additions and 4 deletions
|
|
@ -12,6 +12,7 @@ export const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
|
|||
'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'],
|
||||
'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'],
|
||||
'watch': [() => import('./watch'), 'store events for every app on this machine: rejected, approved, live, rollout halted, crash threshold, low reviews; loops every --every 15m or --once; sinks --notify os|ntfy|telegram|webhook; test sends a test event; --dry, --dir <folder>, --json (read-only on the stores)'],
|
||||
'status': [() => import('./status'), '[--app <name|store id|folder>] [--json]: per platform the live and next version, build, track, review state and rollout %, without uploading (read-only; read by ship pipelines, docs/ship-step.md)'],
|
||||
'events': [() => import('./events'), '[--since 24h] [--app <name>] [--kind rejected] [--json]: the events watch recorded in ~/.storebound/events.jsonl (read-only)'],
|
||||
'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'],
|
||||
'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only | folders | add [<folder>] | remove <folder>: the app folders of this machine, the same list as the desktop app | ignore|unignore <name|id> [--store apple|play]: leave a dormant app (or one store side) out of watch, portfolio reads and the desktop lists; storebound only, the store is untouched'],
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
72
cli/status.ts
Normal file
72
cli/status.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// What is on the stores now, per platform, without uploading anything (the read of a ship pipeline before a step).
|
||||
// storebound status [--app <name|store id|folder>] [--json]
|
||||
// Per app and platform: the version reaching users (live) and the one above it (next), each with its build, track,
|
||||
// store state, review state and rollout %. The same reads as `storebound release status` (apple status / play status
|
||||
// through cli/lib/app-status.ts). --app takes an app folder (path), a workspace app name, or a store id (bundle id,
|
||||
// package name, App Store app id); without it: this app folder, or every app of the workspace. Read-only; exit 1
|
||||
// when a store could not be read. --json: one JSON line on stdout.
|
||||
import type { Args } from './lib/args';
|
||||
import type { AppStatus, StoreRelease } from './lib/desktop-protocol';
|
||||
import { log, printJson, warn } from './lib/log';
|
||||
import { pool } from './lib/app-status';
|
||||
import { storeNow } from './lib/train';
|
||||
import { textTable } from './lib/table';
|
||||
import { readLive, targets } from './release';
|
||||
|
||||
export interface ReleaseState {
|
||||
version: string | null;
|
||||
build: string | null;
|
||||
track: string | null;
|
||||
/** The store's own state word (READY_FOR_SALE, WAITING_FOR_REVIEW, completed, inProgress ...). */
|
||||
state: string;
|
||||
phase: StoreRelease['phase'];
|
||||
/** The state word while the release is with the store's review (sent, in review, approved, rejected), else null. */
|
||||
reviewState: string | null;
|
||||
/** Staged rollout (Play) / phased release (App Store) share in %, null without one. */
|
||||
rolloutPct: number | null;
|
||||
}
|
||||
export interface PlatformStatus { platform: 'ios' | 'android'; store: 'apple' | 'play'; live: ReleaseState | null; next: ReleaseState | null; error?: string }
|
||||
|
||||
const REVIEW = new Set(['sent', 'review', 'approved', 'rejected']);
|
||||
|
||||
const stateOf = (r: StoreRelease | null): ReleaseState | null => r && {
|
||||
version: r.version, build: r.build, track: r.track ?? (r.store === 'ios' ? 'app-store' : null), state: r.state, phase: r.phase,
|
||||
reviewState: REVIEW.has(r.phase) ? r.state : null,
|
||||
rolloutPct: r.fraction === null || r.fraction === undefined ? null : Math.round(r.fraction * 1000) / 10,
|
||||
};
|
||||
|
||||
/** Per platform of an app: live + next release from its status read. Pure. */
|
||||
export function platformStatus(s: AppStatus): PlatformStatus[] {
|
||||
const out: PlatformStatus[] = [];
|
||||
const all = s.release.all ?? [];
|
||||
for (const platform of ['ios', 'android'] as const) {
|
||||
const on = platform === 'ios' ? s.summary?.apple : s.summary?.play;
|
||||
const error = s.storeErrors[platform];
|
||||
if (!on && !error) continue;
|
||||
const { now, next } = storeNow(all, platform);
|
||||
out.push({ platform, store: platform === 'ios' ? 'apple' : 'play', live: stateOf(now), next: stateOf(next), ...(error ? { error } : {}) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const cell = (r: ReleaseState | null): string => !r ? '-' : `${r.version ?? '?'}${r.build ? ` (${r.build})` : ''} ${r.state}${r.rolloutPct !== null && r.rolloutPct < 100 ? ` ${r.rolloutPct}%` : ''}${r.track && r.track !== 'app-store' && r.track !== 'production' ? ` [${r.track}]` : ''}`;
|
||||
|
||||
export async function run(args: Args): Promise<void> {
|
||||
const { list, concurrency } = await targets(args);
|
||||
const read = await pool(list, concurrency, async t => ({ t, s: await readLive(t) }));
|
||||
const apps = read.map(({ t, s }) => ({ app: t.name, folder: t.virtual ? null : t.dir, platforms: platformStatus(s), errors: [...t.problems, ...s.errors], warnings: s.warnings ?? [] }));
|
||||
if (args.has('json')) {
|
||||
const data = { apps, at: new Date().toISOString() };
|
||||
if (process.env.STOREBOUND_MACHINE === '1') printJson(data); else process.stdout.write(JSON.stringify(data) + '\n');
|
||||
} else {
|
||||
const rows: string[][] = [];
|
||||
for (const a of apps) {
|
||||
if (!a.platforms.length) rows.push([a.app, '', a.errors[0] ? `error: ${a.errors[0]}` : 'not on a store yet', '']);
|
||||
for (const p of a.platforms) rows.push([a.app, p.store === 'apple' ? 'App Store' : 'Google Play', p.error ? `unreadable: ${p.error}` : cell(p.live), p.error ? '' : cell(p.next)]);
|
||||
for (const w of [...a.warnings, ...a.errors.slice(a.platforms.length ? 0 : 1)]) warn('status', `${a.app}: ${w}`);
|
||||
}
|
||||
console.log(textTable(['app', 'store', 'live', 'next'], rows));
|
||||
log('status', 'read-only; `storebound release status` shows both stores on one train');
|
||||
}
|
||||
if (apps.some(a => a.errors.length || a.platforms.some(p => p.error))) process.exitCode = 1;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue