// `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 [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 [--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; summary: AppStatus['summary'] } const USAGE = `usage: storebound ws | apps import --account | desktop export | desktop import> common: --workspace --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, accounts: Account[]): { env: Record; 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 = {}; 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): { apps: WsApp[]; accounts: Account[]; warnings: string[] } { const { apps: folders, warnings } = resolveApps(l); const accounts = accountsOf(l.ws); const unified = buildApps({ 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 { 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) { return (argv: string[], cwd: string): Promise => 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> { const m = new Map(); 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(); 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 { 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/.json), then storebound ws list'); } async function list(args: Args): Promise { 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 )" 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 { 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 => { 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 { 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 { // 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 { const l = await loadAll(args); const profile = args.get('account'); if (!profile) throw new Error('ws apps import --account '); 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/.json or STOREBOUND_PROFILE__* 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 = { 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 { 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 )`); 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 { 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); }