storebound/cli/workspace.ts
CRT d2ce185441 One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list
- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts
  keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release
  order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG
- storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S,
  Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only
  with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download;
  the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json
- credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads
  them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI
  made; precedence env > named profile > project file > default profile; config `account`, "account" in the
  project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files
  copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use
- app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop,
  apps folders | add | remove, ws init and ws without a workspace file
- desktop: notice when a folder pins another storebound version; storebound-desktop --version
- tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust),
  keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
2026-10-13 06:17:37 +08:00

473 lines
31 KiB
TypeScript

// `storebound ws`: many apps and studios from one folder (storebound.workspace.json; model: cli/lib/workspace.ts).
// ws init [--dry] [--force] find the app folders below and write storebound.workspace.json
// (they join this machine's app folder list too)
// Without a storebound.workspace.json in this folder or above, ws works over this machine's app folders
// (~/.storebound/projects.json: `storebound apps folders`, the desktop app's list).
// ws list [--all] [--json] apps (folders + store-only apps from `ws apps import`), studios, accounts
// ws status [--offline] [--apps a,b] [--json] portfolio table: apps x stores (release trains, catalog, check), read
// in parallel (concurrency from the workspace, default 4)
// ws check | ws validate [--apps a,b] [--json] the offline check / read-only store readiness of every app
// ws run <command> [args] [--apps a,b | --all] [--apply]
// any read command per app; write commands run as dry runs unless
// --apply; submit / release / rollout / send / start / stop never
// ws apps import --account <profile> [--json] the apps the account sees -> local cache, paired with the folders
// (the desktop's rules: cli/lib/identity.ts)
// ws desktop export [--out f | --merge [--apply]] the workspace as the desktop app's state (labels, never keys)
// ws desktop import [--state f] [--out f] [--dry] [--force]
// the desktop's studios, accounts, folders and overrides -> workspace
// Credentials: per account a credential profile (cli/lib/credentials.ts), handed to each app's child command as env.
// Nothing here prints a secret.
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join, relative, resolve, sep } from 'node:path';
import type { Args } from './lib/args';
import { emptyStatus, fillAppStatus, lastLine, pool } from './lib/app-status';
import { CONFIG_NAMES } from './lib/config';
import { PROFILE_KEYS, profileEnv, profileStatus } from './lib/credentials';
import type { AppStatus, AppsListResult } from './lib/desktop-protocol';
import { buildApps, type UnifiedApp } from './lib/identity';
import { log, printJson, warn } from './lib/log';
import { runCommand, type RunResult } from './lib/runner';
import { textTable } from './lib/table';
import { laneOf, laneText } from './lib/train';
import {
type Account, accountsOf, type AppFolder, cacheFile, type DesktopState, desktopStateFile, fromDesktopState, type LoadedWorkspace, loadWorkspace, mergeDesktopState,
findWorkspace, overridesOf, readStoreApps, resolveApps, selectApps, type Store, type StoreAppsCache, studiosOf, toDesktopState, type Workspace, WORKSPACE_NAMES,
} from './lib/workspace';
import { FORBIDDEN_ARGS, TOOLS } from './mcp-tools';
import { addFolder, readRegistry, registryFile } from './lib/registry';
import { checkVirtual, VIRTUAL_READS, virtualDir } from './serve';
/** One app of the workspace: a folder or a store-only app, with the credentials env its commands get. */
export interface WsApp { key: string; rel: string; name: string; studio: string | null; dir: string | null; virtual: boolean; ignored: boolean; unified: UnifiedApp<AppFolder>; summary: AppStatus['summary'] }
const USAGE = `usage: storebound ws <init | list | status | check | validate | run <command> | apps import --account <profile> | desktop export | desktop import>
common: --workspace <file or folder> --apps a,b --json --concurrency 1-8`;
// ---- building blocks (exported for tests) ----
/** Credentials env for one app: the apple account's ASC_* and the play account's PLAY_* from their profiles. */
export function appEnv(u: UnifiedApp<AppFolder>, accounts: Account[]): { env: Record<string, string>; problems: string[] } {
// with workspace accounts the child gets exactly their keys (no other profile); without, the app's own choice
// (config `account`, its .storebound "account", the machine's default profile) applies as in a terminal
const env: Record<string, string> = {};
const problems: string[] = [];
const profileOf = (store: Store): string | null => {
const fromFolder = store === 'apple' ? u.project?.appleProfile : u.project?.playProfile;
if (fromFolder) return fromFolder;
const ref = store === 'apple' ? u.ios : u.android;
return ref ? accounts.find(a => a.id === ref.account)?.profile ?? null : null;
};
// the console ids of the store accounts (not secrets): console fill picks the developer account / team with them
for (const store of ['apple', 'play'] as const) {
const p = profileOf(store);
const acc = p ? accounts.find(a => a.store === store && a.profile === p) : undefined;
if (store === 'play' && acc?.developerId) env.STOREBOUND_PLAY_DEVELOPER_ID = acc.developerId;
if (store === 'apple' && acc?.teamId) env.STOREBOUND_APPLE_TEAM_ID = acc.teamId;
}
for (const store of ['apple', 'play'] as const) {
const p = profileOf(store);
if (!p) continue;
env.STOREBOUND_PROFILE = '';
try {
const all = profileEnv(p);
const keys = store === 'apple' ? ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8'] : ['PLAY_SERVICE_ACCOUNT'];
const got = keys.filter(k => all[k]);
if (!got.length) problems.push(`credential profile ${p}: no ${store === 'apple' ? 'ASC_KEY_ID / ASC_ISSUER_ID' : 'PLAY_SERVICE_ACCOUNT'}`);
for (const k of got) env[k] = all[k]!;
} catch (e) { problems.push((e as Error).message); }
}
return { env, problems };
}
/** Folder apps and store-only apps of the workspace, paired by the desktop's rules. */
export function workspaceApps(l: LoadedWorkspace, summaries: Map<string, AppStatus['summary']>): { apps: WsApp[]; accounts: Account[]; warnings: string[] } {
const { apps: folders, warnings } = resolveApps(l);
const accounts = accountsOf(l.ws);
const unified = buildApps<AppFolder>({
projects: folders, summaryOf: p => summaries.get(p.id) ?? null, accounts,
storeApps: readStoreApps(l.root, accounts), overrides: overridesOf(l.ws, folders),
});
const apps: WsApp[] = unified.map(u => ({
key: u.key, rel: u.project ? u.project.rel : u.target, name: u.project ? (summaries.get(u.project.id)?.name ?? u.name) : u.name, studio: u.studio,
dir: u.project?.path ?? null, virtual: !u.project, ignored: u.ignored, unified: u, summary: u.project ? summaries.get(u.project.id) ?? null : null,
}));
return { apps, accounts, warnings };
}
/** The directory a command for this app runs in: its folder, or a generated config for a store-only app. */
export function appDir(a: WsApp): string {
if (a.dir) return a.dir;
const u = a.unified;
return virtualDir(checkVirtual({ name: u.name, ...(u.ios ? { apple: { bundleId: u.ios.app.bundleId, appId: u.ios.app.id } } : {}), ...(u.android ? { play: { packageName: u.android.app.id } } : {}) }));
}
const words = (argv: string[]): string[] => { const w: string[] = []; for (const a of argv) { if (a.startsWith('-')) break; w.push(a); } return w; };
/**
* Whether `ws run` may run this command, and whether it writes (then it gets --dry unless --apply). Allowed: the MCP
* tool set (cli/mcp-tools.ts) plus `inspect`; never anything that submits, releases, rolls out, sends, starts / stops,
* removes or invites, and never --yes.
*/
export function runPolicy(argv: string[]): { ok: true; write: boolean } | { ok: false; why: string } {
const w = words(argv);
if (!w.length) return { ok: false, why: 'no command (e.g. ws run validate --play)' };
const bad = argv.find(a => FORBIDDEN_ARGS.includes(a) || ['--manual-release', 'submit', 'publish', 'distribute', '--phased'].includes(a));
if (bad) return { ok: false, why: `"${bad}" is an owner step: run it in the app folder yourself` };
if (w.includes('release') || w.includes('rollout')) return { ok: false, why: 'release and rollout commands are owner steps: run them in the app folder yourself' };
if (w[0] === 'ws') return { ok: false, why: 'ws inside ws run' };
if (w.length === 1 && w[0] === 'inspect') return { ok: true, write: false };
const cmdWords = (c: string[]) => words(c);
const matches = TOOLS.filter(t => { const cw = cmdWords(t.command); return cw.length <= w.length && cw.every((x, i) => w[i] === x); });
if (!matches.length) return { ok: false, why: `${w.join(' ')}: not a command ws run offers (read commands and dry runs of the MCP tool set, plus inspect)` };
const longest = Math.max(...matches.map(t => cmdWords(t.command).length));
const same = matches.filter(t => cmdWords(t.command).length === longest);
// the variant whose extra flags are all present (health --weekly), else the plain one
const variant = same.filter(t => t.command.slice(longest).every(f => argv.includes(f))).sort((a, b) => b.command.length - a.command.length)[0] ?? same[0]!;
const write = variant.write || (variant.args?.({}) ?? []).includes('--dry');
return { ok: true, write };
}
// ---- output helpers ----
const storeCell = (s: AppStatus, store: 'ios' | 'android'): string => {
const err = s.storeErrors[store];
if (err) return 'unreadable';
const has = store === 'ios' ? s.summary?.apple : s.summary?.play;
if (!has) return '';
if (s.release.source !== 'live') return 'offline';
const r = s.release.current?.[store] ?? null;
return r ? `${r.version} ${laneText(laneOf(store, r))}` : 'not live';
};
const nextCell = (s: AppStatus): string => {
const n = s.release.next;
if (!n) return '';
const part = (store: 'ios' | 'android') => { const r = n[store]; return r ? `${store === 'ios' ? 'iOS' : 'Android'} ${laneText(laneOf(store, r))}` : ''; };
return `${n.version}: ${[part('ios'), part('android')].filter(Boolean).join(', ')}`;
};
const catalogCell = (s: AppStatus): string => {
const c = s.catalog;
if (!c) return '';
if (!c.products && !c.subscriptions) return '-';
const issues = c.onlyIos.length + c.onlyAndroid.length + c.missingIos.length + c.missingAndroid.length;
return `${c.products + c.subscriptions} items${c.checked === 'live' ? (issues ? `, ${issues} mismatched` : ', in parity') : ''}`;
};
/** The workspace file in this folder or above; without one, the app folders of this machine (cli/lib/registry.ts:
* the desktop app's list too), each with its own account choice. */
async function loadAll(args: Args): Promise<LoadedWorkspace> {
if (args.get('workspace') || findWorkspace(process.cwd())) return loadWorkspace(process.cwd(), args.get('workspace'));
const reg = readRegistry();
if (!reg.projects.length) return loadWorkspace(process.cwd());
return { file: registryFile(), root: homedir(), ws: { apps: reg.projects.map(p => ({ path: p.path, ...(p.name ? { name: p.name } : {}) })) } };
}
function concurrencyOf(args: Args, ws: Workspace): number {
const n = args.num('concurrency', ws.concurrency ?? 4);
if (!Number.isInteger(n) || n < 1 || n > 8) throw new Error('--concurrency: 1 to 8');
return n;
}
function exec(env: Record<string, string>) {
return (argv: string[], cwd: string): Promise<RunResult> => runCommand(argv, { cwd, env, machine: true }).result;
}
/** Offline summaries of the folder apps (inspect --json), for pairing and names. */
async function summariesOf(folders: AppFolder[], n: number): Promise<Map<string, AppStatus['summary']>> {
const m = new Map<string, AppStatus['summary']>();
await pool(folders, n, async f => {
const r = await runCommand(['inspect', '--json'], { cwd: f.path, machine: true }).result;
if (r.code === 0 && r.data) m.set(f.id, r.data as AppStatus['summary']);
});
return m;
}
async function prepared(args: Args, o: { includeIgnored?: boolean } = {}) {
const l = await loadAll(args);
const n = concurrencyOf(args, l.ws);
const { apps: folders } = resolveApps(l);
const summaries = await summariesOf(folders, n);
const { apps, accounts, warnings } = workspaceApps(l, summaries);
const visible = apps.filter(a => o.includeIgnored || !a.ignored);
const { picked, unknown } = selectApps(visible, args.list('apps'));
if (unknown.length) throw new Error(`--apps: no app named ${unknown.join(', ')} (ws list shows the names)`);
return { l, n, apps: picked, all: apps, accounts, warnings };
}
// ---- commands ----
/** App folders below `root` (depth <= 4), as the globs ws init writes: "parent/*" when a folder holds several. */
export function discoverApps(root: string, depth = 4): string[] {
const found: string[] = [];
const walk = (dir: string, d: number) => {
if (CONFIG_NAMES.some(n => existsSync(join(dir, n))) && dir !== root) { found.push(relative(root, dir).split(sep).join('/')); return; }
if (d === 0) return;
let entries: string[] = [];
try { entries = readdirSync(dir); } catch { return; }
for (const e of entries.sort()) {
if (e.startsWith('.') || e === 'node_modules' || e === 'build' || e === 'dist') continue;
const p = join(dir, e);
try { if (statSync(p).isDirectory()) walk(p, d - 1); } catch { /* unreadable */ }
}
};
walk(root, depth);
const byParent = new Map<string, string[]>();
for (const f of found) { const parent = f.includes('/') ? f.slice(0, f.lastIndexOf('/')) : ''; byParent.set(parent, [...(byParent.get(parent) ?? []), f]); }
const out: string[] = [];
for (const [parent, list] of byParent) out.push(...(list.length > 1 ? [parent ? `${parent}/*` : '*'] : list));
return out.sort();
}
async function init(args: Args): Promise<void> {
const root = process.cwd();
const file = join(root, 'storebound.workspace.json');
const existing = WORKSPACE_NAMES.map(n => join(root, n)).find(existsSync);
if (existing && !args.has('force')) throw new Error(`${existing} exists (--force overwrites)`);
const apps = discoverApps(root);
const ws: Workspace = { apps, studios: [], accounts: [], ignore: [], overrides: { pairs: [], unpairs: [], links: [], appStudio: [] }, concurrency: 4 };
const text = JSON.stringify(ws, null, 2) + '\n';
if (args.has('dry')) { console.log(text); log('ws', `dry run: ${file} not written (${apps.length} app folder pattern(s))`); return; }
writeFileSync(file, text);
const gi = join(root, '.gitignore');
const giText = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
if (!/^\.storebound\/?$/m.test(giText)) writeFileSync(gi, giText + (giText && !giText.endsWith('\n') ? '\n' : '') + '.storebound/\n');
log('ws', `wrote ${file}: ${apps.length ? apps.join(', ') : 'no app folders found yet (add them under "apps")'}`);
// the folders also join this machine's list (storebound apps folders, the desktop app)
const found = resolveApps({ file, root, ws }).apps;
for (const f of found) addFolder(f.path);
if (found.length) log('ws', `${found.length} folder(s) in this machine's app list too (${registryFile().replace(homedir(), '~')})`);
log('ws', 'next: add studios and accounts by credential profile (~/.storebound/credentials/<profile>.json), then storebound ws list');
}
async function list(args: Args): Promise<void> {
const { l, apps, accounts, warnings } = await prepared(args, { includeIgnored: args.has('all') });
const studios = studiosOf(l.ws);
const sName = (id: string | null) => (id ? studios.find(s => s.id === id)?.name ?? id : '');
const profiles = accounts.map(a => ({ ...profileStatus(a.profile), store: a.store, id: a.id, studio: a.studio, label: a.label ?? null }));
if (args.has('json')) {
printJson({ workspace: l.file, studios, accounts: profiles,
apps: apps.map(a => ({ name: a.name, key: a.key, folder: a.dir, studio: a.studio, pairing: a.unified.pairing, ignored: a.ignored,
ios: a.unified.ios ? { account: a.unified.ios.account, appId: a.unified.ios.app.id, bundleId: a.unified.ios.app.bundleId } : null,
android: a.unified.android ? { account: a.unified.android.account, packageName: a.unified.android.app.id } : null,
accounts: { apple: a.unified.project?.appleProfile ?? null, play: a.unified.project?.playProfile ?? null } })),
warnings });
return;
}
log('ws', `${l.file}: ${apps.length} app(s), ${studios.length} studio(s), ${accounts.length} account(s)`);
console.log(textTable(['app', 'studio', 'folder', 'iOS', 'Android', 'accounts', 'pairing'], apps.map(a => {
const u = a.unified;
// the config's ids; "(store app <id>)" once `ws apps import` found the app on the account
const ios = [a.summary?.apple?.bundleId ?? u.ios?.app.bundleId, u.ios ? `(store ${u.ios.app.id})` : ''].filter(Boolean).join(' ');
const android = [a.summary?.play?.packageName ?? u.android?.app.id, u.android ? '(store)' : ''].filter(Boolean).join(' ');
return [a.name + (a.ignored ? ' (ignored)' : ''), sName(a.studio), a.dir ? a.rel : '(store only)', ios, android,
[u.project?.appleProfile, u.project?.playProfile].filter(Boolean).join(', '), u.pairing];
})));
if (profiles.length) {
console.log('');
console.log(textTable(['account', 'store', 'studio', 'profile', 'keys'], profiles.map(p => [p.label ?? p.profile, p.store, sName(p.studio), p.profile,
p.source === 'missing' ? 'missing' : p.source === 'invalid' ? `invalid: ${p.error}` : (p.store === 'apple' ? p.apple : p.play) ? `ok (${p.source})` : `no ${p.store} keys in it`])));
}
for (const w of warnings) warn('ws', w);
}
export interface WsStatusRow { name: string; key: string; folder: string | null; studio: string | null; status: AppStatus; problems: string[] }
async function status(args: Args): Promise<void> {
const { l, n, apps, accounts } = await prepared(args);
const live = !args.has('offline');
const studios = studiosOf(l.ws);
let done = 0;
const rows = await pool(apps, n, async (a): Promise<WsStatusRow> => {
const e = emptyStatus(a.dir ?? a.key);
if (a.virtual) e.virtual = true;
const { env, problems } = appEnv(a.unified, accounts);
try { await fillAppStatus(e, appDir(a), { live, virtual: a.virtual, exec: exec(env) }); } catch (err) { e.errors.push((err as Error).message); }
if (!args.has('json')) log('ws', `${++done}/${apps.length} ${a.name}`);
return { name: a.name, key: a.key, folder: a.dir, studio: a.studio, status: e, problems: live ? problems : [] };
});
if (args.has('json')) { printJson({ workspace: l.file, live, apps: rows, at: new Date().toISOString() }); }
else {
const sName = (id: string | null) => (id ? studios.find(s => s.id === id)?.name ?? id : '');
console.log(textTable(['app', 'studio', 'App Store', 'Google Play', 'next', 'catalog', 'check', 'notes'], rows.map(r => {
const s = r.status;
const notes = [...r.problems, ...s.errors, ...Object.entries(s.storeErrors).map(([k, v]) => `${k}: ${v}`)].join('; ');
return [r.name, sName(r.studio), storeCell(s, 'ios'), storeCell(s, 'android'), nextCell(s), catalogCell(s),
s.check ? (s.check.ok ? `ok${s.check.warnings.length ? ` (${s.check.warnings.length} warn)` : ''}` : `${s.check.problems.length} problem(s)`) : '', notes.slice(0, 160)];
})));
if (!live) log('ws', 'offline: store states not read (drop --offline to read both stores)');
}
if (rows.some(r => !r.status.ok)) process.exitCode = 1;
}
/** Runs one command in every picked app (bounded parallel), output buffered per app. */
async function runEach(args: Args, argv: string[], o: { json: boolean; label: string }) {
const { n, apps, accounts } = await prepared(args);
// store-only apps (no folder) take the store reads the desktop allows them (serve.ts VIRTUAL_READS) and inspect
const w = words(argv);
const storeRead = w.join(' ') === 'inspect' || TOOLS.some(t => VIRTUAL_READS.has(t.name) && words(t.command).join(' ') === w.join(' '));
const folderOnly = !storeRead;
const targets = apps.filter(a => !a.virtual || !folderOnly);
const skipped = apps.filter(a => !targets.includes(a));
const results = await pool(targets, n, async a => {
const { env, problems } = appEnv(a.unified, accounts);
const r = await runCommand(o.json ? [...argv, '--json'] : argv, { cwd: appDir(a), env, machine: o.json }).result;
return { app: a, r, problems };
});
return { results, skipped };
}
async function checkAll(args: Args, kind: 'check' | 'validate'): Promise<void> {
const extra = kind === 'validate' ? [...(args.has('apple') ? ['--apple'] : []), ...(args.has('play') ? ['--play'] : []), ...(args.has('no-health') ? ['--no-health'] : [])] : [];
const { results, skipped } = await runEach(args, [kind, ...extra], { json: true, label: kind });
const rows = results.map(({ app, r, problems }) => {
const d = r.data as { ok?: boolean; problems?: string[]; warnings?: string[]; blockers?: number; findings?: { level: string; msg: string; store: string }[] } | undefined;
if (kind === 'check') return { app: app.name, folder: app.dir, ok: !!d?.ok, problems: d?.problems ?? (d ? [] : [lastLine(r.out) || `exit ${r.code}`]), warnings: d?.warnings ?? [], credentialProblems: [] as string[] };
const blockers = d?.findings?.filter(f => f.level === 'blocker') ?? [];
return { app: app.name, folder: app.dir, ok: !!d && !d.blockers, blockers: d ? d.blockers ?? 0 : null, findings: d?.findings ?? [], error: d ? null : lastLine(r.out) || `exit ${r.code}`, first: blockers[0] ? `${blockers[0].store}: ${blockers[0].msg}` : '', credentialProblems: problems };
});
if (args.has('json')) printJson({ [kind]: rows, skipped: skipped.map(a => a.name) });
else {
if (kind === 'check') console.log(textTable(['app', 'result', 'problems', 'warnings', 'first problem'], rows.map(x => { const c = x as { app: string; ok: boolean; problems: string[]; warnings: string[] }; return [c.app, c.ok ? 'ok' : 'PROBLEMS', c.problems.length, c.warnings.length, (c.problems[0] ?? '').slice(0, 100)]; })));
else console.log(textTable(['app', 'result', 'blockers', 'first blocker'], rows.map(x => { const v = x as { app: string; ok: boolean; blockers: number | null; first: string; error: string | null }; return [v.app, v.blockers === null ? 'unreadable' : v.ok ? 'ready' : 'BLOCKED', v.blockers ?? '?', (v.error ?? v.first).slice(0, 110)]; })));
for (const x of rows) for (const p of x.credentialProblems) warn('ws', `${x.app}: ${p}`);
if (skipped.length) log('ws', `store-only apps skipped (no app folder): ${skipped.map(a => a.name).join(', ')}`);
}
if (rows.some(x => !x.ok)) process.exitCode = 1;
}
async function runCmd(args: Args): Promise<void> {
// the command line after `ws run`, minus the ws options
const raw = process.argv.slice(process.argv.indexOf('run') + 1);
const forward: string[] = [];
const WS_VALUE = new Set(['--apps', '--concurrency', '--workspace']), WS_FLAG = new Set(['--all', '--apply']);
for (let i = 0; i < raw.length; i++) {
const a = raw[i]!;
const name = a.split('=')[0]!;
if (WS_VALUE.has(name)) { if (!a.includes('=')) i++; continue; }
if (WS_FLAG.has(a)) continue;
forward.push(a);
}
const p = runPolicy(forward);
if (!p.ok) throw new Error(`ws run: ${p.why}`);
if (!args.has('all') && !args.list('apps').length) throw new Error('ws run: say which apps: --apps a,b or --all');
const argv = p.write && !args.has('apply') && !forward.includes('--dry') ? [...forward, '--dry'] : forward;
const json = forward.includes('--json');
const { results, skipped } = await runEach(args, argv.filter(a => a !== '--json'), { json, label: 'run' });
if (json) printJson({ command: `storebound ${argv.join(' ')}`, apps: results.map(({ app, r }) => ({ app: app.name, folder: app.dir, exitCode: r.code, data: r.data ?? null, output: r.data === undefined ? r.out : undefined })), skipped: skipped.map(a => a.name) });
else {
for (const { app, r, problems } of results) {
console.log(`\n== ${app.name}${app.dir ? ` (${app.rel})` : ' (store only)'}: storebound ${argv.join(' ')} [exit ${r.code}]`);
for (const x of problems) warn('ws', x);
if (r.out) console.log(r.out);
}
if (skipped.length) log('ws', `store-only apps skipped (the command needs an app folder): ${skipped.map(a => a.name).join(', ')}`);
if (p.write && !args.has('apply')) log('ws', 'dry run: nothing was changed. After the owner agreed, the same command with --apply runs it.');
}
if (results.some(x => x.r.code !== 0)) process.exitCode = 1;
}
let neutral: string | null = null;
const neutralDir = (): string => {
if (!neutral) { neutral = join(tmpdir(), `storebound-ws-${process.pid}`); mkdirSync(neutral, { recursive: true }); }
return neutral;
};
async function appsImport(args: Args): Promise<void> {
const l = await loadAll(args);
const profile = args.get('account');
if (!profile) throw new Error('ws apps import --account <credential profile>');
const accounts = accountsOf(l.ws);
const mine = accounts.filter(a => a.profile === profile);
const st = profileStatus(profile);
if (st.source === 'missing' || st.source === 'invalid') throw new Error(`credential profile ${profile}: ${st.error ?? 'not found (~/.storebound/credentials/<profile>.json or STOREBOUND_PROFILE_<NAME>_* env)'}`);
const stores: Store[] = mine.length ? mine.map(a => a.store) : [...(st.apple ? ['apple' as const] : []), ...(st.play ? ['play' as const] : [])];
if (!mine.length) warn('ws', `profile ${profile} is not listed in the workspace: add { "store": "${stores[0] ?? 'apple'}", "profile": "${profile}" } to accounts (or a studio) so its apps show up`);
const env = profileEnv(profile);
const pass: Record<string, string> = { STOREBOUND_PROFILE: '' };
for (const k of PROFILE_KEYS) if (env[k]) pass[k] = env[k]!;
const imported: { store: Store; count: number; warnings: string[] }[] = [];
for (const store of stores) {
const r = await runCommand(['apps', 'list', '--store', store, '--json'], { cwd: neutralDir(), env: pass, machine: true }).result;
if (r.code !== 0 || !r.data) throw new Error(`${profile} (${store}): ${lastLine(r.out) || `exit ${r.code}`}`);
const res = r.data as AppsListResult;
const acc = mine.find(a => a.store === store);
const cache: StoreAppsCache = { profile, store, account: acc?.id ?? '', at: res.at, apps: res.apps.map(a => ({ ...a, icon: null })) };
const f = cacheFile(l.root, { store, profile });
mkdirSync(resolve(f, '..'), { recursive: true });
writeFileSync(f, JSON.stringify(cache, null, 2) + '\n');
imported.push({ store, count: res.apps.length, warnings: res.warnings });
log('ws', `${profile} (${store}): ${res.apps.length} app(s) -> ${relative(l.root, f)}`);
}
// the pairing with the folders, as ws list shows it from now on
const { apps: folders } = resolveApps(l);
const { apps } = workspaceApps(l, await summariesOf(folders, concurrencyOf(args, l.ws)));
const touched = apps.filter(a => [a.unified.ios?.account, a.unified.android?.account].some(id => id && mine.some(m => m.id === id)));
if (args.has('json')) { printJson({ imported, apps: touched.map(a => ({ name: a.name, folder: a.dir, pairing: a.unified.pairing, ios: a.unified.ios?.app.bundleId ?? null, android: a.unified.android?.app.id ?? null, ignored: a.ignored })) }); return; }
console.log(textTable(['app', 'folder', 'iOS', 'Android', 'pairing'], touched.map(a => [a.name + (a.ignored ? ' (ignored)' : ''), a.dir ? a.rel : '(store only)', a.unified.ios?.app.bundleId ?? '', a.unified.android?.app.id ?? '', a.unified.pairing])));
for (const i of imported) for (const w of i.warnings) warn('ws', w);
log('ws', 'wrong pair? add it to overrides.unpairs (or pairs / links) in the workspace file: the same rules as the desktop');
}
async function desktop(args: Args): Promise<void> {
const sub = args._[1];
if (sub === 'export') {
const l = await loadAll(args);
const { apps } = resolveApps(l);
const state = toDesktopState(l.ws, apps);
if (!args.has('merge')) {
const out = args.get('out');
if (!out) { printJson(state); return; }
if (args.has('dry')) { log('ws', `dry run: ${out} not written`); printJson(state); return; }
writeFileSync(resolve(out), JSON.stringify(state, null, 2) + '\n');
log('ws', `wrote ${out}: ${state.projects.length} app folder(s), ${state.accounts.length} account(s), ${state.studios.length} studio(s)`);
return;
}
const file = args.get('state') ? resolve(args.get('state')!) : desktopStateFile();
const cur: DesktopState = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) as DesktopState : { version: 2, projects: [], accounts: [], studios: [], overrides: { pairs: [], unpairs: [], links: [], appStudio: [], ignored: [] } };
const { state: merged, added } = mergeDesktopState(cur, state);
log('ws', `${file}: +${added.projects} app folder(s), +${added.accounts} account(s), +${added.studios} studio(s), +${added.overrides} override(s)`);
if (!args.has('apply')) { log('ws', 'dry run: nothing written. Quit the desktop app, then run again with --apply (a backup of state.json is kept).'); return; }
mkdirSync(resolve(file, '..'), { recursive: true });
if (existsSync(file)) copyFileSync(file, `${file}.ws-${new Date().toISOString().replace(/[:.]/g, '-')}.bak`);
writeFileSync(file, JSON.stringify(merged, null, 2) + '\n');
log('ws', 'merged. New accounts need their keys in the desktop (Accounts > the account > Add key): the desktop keeps keys in the OS keychain, not in profiles.');
return;
}
if (sub === 'import') {
const file = args.get('state') ? resolve(args.get('state')!) : desktopStateFile();
if (!existsSync(file)) throw new Error(`no desktop state at ${file} (--state <file>)`);
const root = process.cwd();
const { ws, profiles } = fromDesktopState(JSON.parse(readFileSync(file, 'utf8')) as DesktopState, root);
const out = resolve(args.get('out') ?? join(root, 'storebound.workspace.json'));
const text = JSON.stringify({ ...ws, concurrency: 4 }, null, 2) + '\n';
if (args.has('dry')) { console.log(text); log('ws', `dry run: ${out} not written`); }
else {
if (existsSync(out) && !args.has('force')) throw new Error(`${out} exists (--force overwrites, --out writes elsewhere)`);
writeFileSync(out, text);
log('ws', `wrote ${out}: ${ws.apps.length} app folder(s), ${(ws.studios ?? []).length} studio(s), ${profiles.length} account(s)`);
}
const missing = profiles.filter(p => profileStatus(p.profile).source === 'missing');
if (missing.length) {
log('ws', `credential profiles to create (the desktop's keychain is not read; put each account's key in its file):`);
for (const p of missing) console.log(` ${p.label} (${p.store}): ~/.storebound/credentials/${p.profile}.json ${p.store === 'apple' ? '{ "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8" }' : '{ "PLAY_SERVICE_ACCOUNT" }'}`);
}
return;
}
throw new Error('ws desktop export [--out f | --merge [--apply]] | ws desktop import [--state f] [--out f]');
}
export async function run(args: Args): Promise<void> {
const sub = args._[0];
switch (sub) {
case 'init': return init(args);
case 'list': return list(args);
case 'status': return status(args);
case 'check': return checkAll(args, 'check');
case 'validate': return checkAll(args, 'validate');
case 'run': return runCmd(args);
case 'apps': if (args._[1] === 'import') return appsImport(args); break;
case 'desktop': return desktop(args);
}
console.error(USAGE);
process.exit(2);
}