diff --git a/cli/commands.ts b/cli/commands.ts index fb036bb..3655643 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -12,6 +12,7 @@ export const PLAIN: Record Promise, 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 , --json (read-only on the stores)'], + 'status': [() => import('./status'), '[--app ] [--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 ] [--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 [] | remove : the app folders of this machine, the same list as the desktop app | ignore|unignore [--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'], diff --git a/cli/release.ts b/cli/release.ts index fc217c1..134900b 100644 --- a/cli/release.ts +++ b/cli/release.ts @@ -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; problems: string[] } +export interface Target { name: string; dir: string; virtual: boolean; env: Record; problems: string[] } const USAGE = 'usage: storebound release status [--app a,b] [--versions] [--json] | release plan [--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 : 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 : 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) => (argv: string[], cwd: string): Promise => runCommand(argv, { cwd, env, machine: true }).result; -async function readLive(t: Target): Promise { +export async function readLive(t: Target): Promise { 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); } diff --git a/cli/status.ts b/cli/status.ts new file mode 100644 index 0000000..a242f14 --- /dev/null +++ b/cli/status.ts @@ -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 ] [--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 { + 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; +}