// Multi-app workspace (storebound ws), credential profiles and the desktop state import / export. Sanitized fixtures: // example.com ids, placeholder account ids, no keys. import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ascKey, profileEnv, profileStatus } from '../cli/lib/credentials'; import type { StoreApp } from '../cli/lib/desktop-protocol'; import { accountId, accountsOf, checkWorkspace, type DesktopState, desktopProjectId, desktopStateFile, fromDesktopState, loadWorkspace, mergeDesktopState, overridesOf, resolveApps, selectApps, toDesktopState, type Workspace, } from '../cli/lib/workspace'; import { listTools, TOOLS, toArgv } from '../cli/mcp-tools'; import { DESKTOP_TOOLS } from '../cli/serve'; import { appEnv, discoverApps, runPolicy, workspaceApps } from '../cli/workspace'; const CLI = join(import.meta.dir, '..', 'cli', 'index.ts'); const SENTINEL = 'SENTINEL-KEY-VALUE-42'; /** An inline service account (not a path), so profileEnv keeps it as it is. */ const PLAY_INLINE = JSON.stringify({ sentinel: SENTINEL }); let root = ''; let creds = ''; const cfg = (name: string, id: string) => JSON.stringify({ app: { name, privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com/', contactEmail: 'dev@example.com' }, languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }], apple: { bundleId: id }, play: { packageName: id }, }); const WS: Workspace = { apps: ['games/*', { path: 'apps/notes', apple: 'solo-apple' }], studios: [{ name: 'Acme Games', color: 'indigo', apps: ['games/*'], accounts: [{ store: 'apple', profile: 'acme-apple' }, { store: 'play', profile: 'acme-play' }] }], accounts: [{ store: 'apple', profile: 'solo-apple', label: 'Solo' }], ignore: ['games/old-*'], overrides: { pairs: [], unpairs: [], links: [], appStudio: [] }, }; /** A clean env for CLI children: no store keys of the machine running the tests. */ function env(extra: Record = {}): Record { const e: Record = {}; for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !/^(ASC_|PLAY_SERVICE|STOREBOUND_PROFILE|REVIEW_DEMO)/.test(k)) e[k] = v; return { ...e, STOREBOUND_CREDENTIALS_DIR: creds, NO_COLOR: '1', ...extra }; } function cli(args: string[], cwd = root, extra: Record = {}) { const r = Bun.spawnSync(['bun', CLI, ...args], { cwd, env: env(extra), stdout: 'pipe', stderr: 'pipe' }); return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() }; } beforeAll(() => { root = mkdtempSync(join(tmpdir(), 'sb-ws-')); creds = join(root, '..', `${root.split('/').pop()}-creds`); mkdirSync(creds, { recursive: true }); for (const [dir, name, id] of [['games/puzzle', 'Puzzle', 'com.example.puzzle'], ['games/racer', 'Racer', 'com.example.racer'], ['games/old-tetra', 'Old', 'com.example.oldtetra'], ['apps/notes', 'Notes', 'com.example.notes']] as const) { mkdirSync(join(root, dir), { recursive: true }); writeFileSync(join(root, dir, 'storebound.config.json'), cfg(name, id)); } mkdirSync(join(root, 'games', 'not-an-app'), { recursive: true }); writeFileSync(join(root, 'storebound.workspace.json'), JSON.stringify(WS, null, 2)); writeFileSync(join(creds, 'acme-apple.json'), JSON.stringify({ ASC_KEY_ID: SENTINEL, ASC_ISSUER_ID: SENTINEL, ASC_P8: 'keys/acme.p8' })); writeFileSync(join(creds, 'acme-play.json'), JSON.stringify({ PLAY_SERVICE_ACCOUNT: PLAY_INLINE })); process.env.STOREBOUND_CREDENTIALS_DIR = creds; }); afterAll(() => { delete process.env.STOREBOUND_CREDENTIALS_DIR; rmSync(root, { recursive: true, force: true }); rmSync(creds, { recursive: true, force: true }); }); describe('workspace file', () => { test('a valid file passes; secrets, bad profiles and duplicates are refused', () => { expect(checkWorkspace(WS)).toEqual([]); expect(checkWorkspace({ apps: [], accounts: [{ store: 'apple', profile: 'x', ASC_P8: 'k' }] }).join(' ')).toContain('secrets never go here'); expect(checkWorkspace({ apps: [], accounts: [{ store: 'apple', profile: '../etc' }] }).join(' ')).toContain('profile'); expect(checkWorkspace({ apps: [], accounts: [{ store: 'play', profile: 'a' }], studios: [{ name: 'S', accounts: [{ store: 'play', profile: 'a' }] }] }).join(' ')).toContain('listed twice'); expect(checkWorkspace({ apps: 'x' })).toContain('apps: a list of app folders or globs ("apps/*")'); expect(checkWorkspace({ apps: [], studios: [{ name: 'S', color: 'pink' }] }).join(' ')).toContain('color'); expect(checkWorkspace({ apps: [], concurrency: 20 })).toContain('concurrency: 1 to 8'); }); test('account ids: the desktop id when given, else stable from store + profile (16 hex)', () => { const a = accountId({ store: 'apple', profile: 'acme-apple' }); expect(a).toMatch(/^[0-9a-f]{16}$/); expect(accountId({ store: 'apple', profile: 'acme-apple' })).toBe(a); expect(accountId({ store: 'play', profile: 'acme-apple' })).not.toBe(a); expect(accountId({ store: 'play', profile: 'x', id: '1111aaaa2222bbbb' })).toBe('1111aaaa2222bbbb'); expect(accountsOf(WS).map(x => [x.profile, x.studio])).toEqual([['acme-apple', 'acme-games'], ['acme-play', 'acme-games'], ['solo-apple', null]]); }); test('globs, ignore list and account resolution', async () => { const l = await loadWorkspace(join(root, 'games', 'puzzle')); expect(l.root).toBe(root); const { apps, warnings } = resolveApps(l); expect(apps.map(a => [a.rel, a.ignored, a.studio, a.appleProfile, a.playProfile])).toEqual([ ['apps/notes', false, null, 'solo-apple', 'acme-play'], // own apple profile; the only play account ['games/old-tetra', true, 'acme-games', 'acme-apple', 'acme-play'], ['games/puzzle', false, 'acme-games', 'acme-apple', 'acme-play'], ['games/racer', false, 'acme-games', 'acme-apple', 'acme-play'], ]); expect(warnings).toEqual([]); // games/not-an-app has no config: skipped by the glob without a warning const o = overridesOf(l.ws, apps); expect(o.appStudio).toContainEqual({ key: 'project:games/puzzle', studio: 'acme-games' }); expect(o.ignored).toContain('project:games/old-tetra'); expect(selectApps(apps, ['puzzle', 'games/r*']).picked.map(a => a.rel)).toEqual(['games/puzzle', 'games/racer']); expect(selectApps(apps, ['nope']).unknown).toEqual(['nope']); }); test('ws init discovers app folders as globs', () => { expect(discoverApps(root)).toEqual(['apps/notes', 'games/*']); }); }); describe('pairing (the desktop rules, shared)', () => { test('folders take their store apps; leftover iOS + Android pair; account ignore hides store apps', async () => { const l = await loadWorkspace(root); const [appleAcc, playAcc] = accountsOf(l.ws); const ios = (id: string, bundleId: string, name: string): StoreApp => ({ store: 'ios', id, bundleId, name, platforms: ['IOS'] }); const and = (pkg: string, name: string): StoreApp => ({ store: 'android', id: pkg, bundleId: pkg, name, platforms: ['ANDROID'] }); const cache = (a: { store: 'apple' | 'play'; profile: string }, apps: StoreApp[]) => { mkdirSync(join(root, '.storebound', 'workspace'), { recursive: true }); writeFileSync(join(root, '.storebound', 'workspace', `apps-${a.store}-${a.profile}.json`), JSON.stringify({ ...a, account: '', at: '2026-10-01T00:00:00Z', apps })); }; try { cache(appleAcc!, [ios('6400000101', 'com.example.puzzle', 'Puzzle'), ios('6400000102', 'com.example.blocks.ios', 'Blocks')]); cache(playAcc!, [and('com.example.puzzle', 'Puzzle'), and('com.example.blocks', 'Blocks'), and('com.example.solo', 'Solo Game')]); const summaries = new Map([['games/puzzle', { apple: { bundleId: 'com.example.puzzle', appId: null }, play: { packageName: 'com.example.puzzle' } } as never]]); const { apps } = workspaceApps(l, summaries); const puzzle = apps.find(a => a.rel === 'games/puzzle')!; expect([puzzle.unified.pairing, puzzle.unified.ios?.app.id, puzzle.unified.android?.app.id]).toEqual(['folder', '6400000101', 'com.example.puzzle']); const blocks = apps.find(a => a.name === 'Blocks')!; expect([blocks.virtual, blocks.unified.pairing, blocks.studio]).toEqual([true, 'close', 'acme-games']); expect(apps.find(a => a.name === 'Solo Game')!.unified.pairing).toBe('single'); // the store-only app reads with both studio accounts' profiles; values stay in the env object const e = appEnv(blocks.unified, accountsOf(l.ws)); expect(Object.keys(e.env).sort()).toEqual(['ASC_ISSUER_ID', 'ASC_KEY_ID', 'ASC_P8', 'PLAY_SERVICE_ACCOUNT', 'STOREBOUND_PROFILE']); expect(e.env.STOREBOUND_PROFILE).toBe(''); expect(e.env.ASC_P8).toBe(join(creds, 'keys', 'acme.p8')); // relative to the profile folder // the console ids of the studio's accounts (not secrets) reach console fill as env const withIds = { ...l.ws, studios: l.ws.studios!.map(s => ({ ...s, accounts: s.accounts?.map(a => (a.store === 'play' ? { ...a, developerId: '4444' } : { ...a, teamId: 'ABCDE12345' })) })) }; const e2 = appEnv(blocks.unified, accountsOf(withIds)); expect([e2.env.STOREBOUND_PLAY_DEVELOPER_ID, e2.env.STOREBOUND_APPLE_TEAM_ID]).toEqual(['4444', 'ABCDE12345']); expect(checkWorkspace(withIds)).toEqual([]); expect(checkWorkspace({ accounts: [{ store: 'apple', profile: 'x', developerId: '1' }] }).join()).toContain('developerId'); // an unpair keeps them apart const split = workspaceApps({ ...l, ws: { ...l.ws, overrides: { unpairs: [{ ios: blocks.unified.ios!.key, android: blocks.unified.android!.key }] } } }, summaries); expect(split.apps.filter(a => a.name === 'Blocks').map(a => a.unified.pairing)).toEqual(['single', 'single']); // ignoring the play account drops its store apps const ign = workspaceApps({ ...l, ws: { ...l.ws, ignore: [...(l.ws.ignore ?? []), 'account:acme-play'] } }, summaries); expect(ign.apps.some(a => a.name === 'Solo Game')).toBe(false); } finally { rmSync(join(root, '.storebound'), { recursive: true, force: true }); } }); }); describe('credential profiles', () => { test('file, env override, status without values, and --account precedence', () => { const saved = { ...process.env }; try { process.env.STOREBOUND_CREDENTIALS_DIR = creds; expect(profileEnv('acme-play')).toEqual({ PLAY_SERVICE_ACCOUNT: PLAY_INLINE }); process.env.STOREBOUND_PROFILE_ACME_PLAY_PLAY_SERVICE_ACCOUNT = '/keys/other.json'; expect(profileEnv('acme-play').PLAY_SERVICE_ACCOUNT).toBe('/keys/other.json'); expect(profileStatus('acme-play')).toEqual({ profile: 'acme-play', source: 'env', apple: false, play: true }); expect(profileStatus('nobody')).toEqual({ profile: 'nobody', source: 'missing', apple: false, play: false }); expect(JSON.stringify(profileStatus('acme-apple'))).not.toContain(SENTINEL); expect(() => profileEnv('../x')).toThrow('credential profile'); // env wins over everything; then a named profile (--account), then the project file const pem = ['-----BEGIN PRIVATE KEY-----', 'abc', '-----END PRIVATE KEY-----'].join('\n'); writeFileSync(join(creds, 'pick.json'), JSON.stringify({ ASC_KEY_ID: 'PICKED', ASC_ISSUER_ID: 'iss', ASC_P8: pem })); process.env.STOREBOUND_PROFILE = 'pick'; expect(ascKey(root).keyId).toBe('PICKED'); process.env.ASC_KEY_ID = 'FROM-ENV'; process.env.ASC_ISSUER_ID = 'iss-env'; expect(ascKey(root).keyId).toBe('FROM-ENV'); delete process.env.STOREBOUND_PROFILE; process.env.ASC_P8 = pem; expect(ascKey(root).keyId).toBe('FROM-ENV'); } finally { for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k]; Object.assign(process.env, saved); } }); }); describe('desktop state <-> workspace', () => { const state = JSON.parse(readFileSync(join(import.meta.dir, 'fixtures', 'workspace', 'desktop-state.json'), 'utf8')) as DesktopState; test('import: folders, studios, accounts as profiles, overrides with folder names', () => { const { ws, profiles } = fromDesktopState(state, '/Users/you/studio'); expect(ws.apps).toEqual([{ path: 'games/puzzle', name: 'Puzzle Quest', apple: 'acme', play: 'acme-play' }, '/Users/you/elsewhere/notes']); expect(profiles).toEqual([{ profile: 'acme-play', store: 'play', label: 'Acme Play' }, { profile: 'acme', store: 'apple', label: 'Acme' }, { profile: 'acme-play-2', store: 'play', label: 'Acme' }]); expect(ws.studios).toEqual([{ id: 'f00dfeedf00dfeed', name: 'Acme Games', color: 'teal', accounts: [ { store: 'play', profile: 'acme-play', label: 'Acme Play', id: '1111aaaa2222bbbb' }, { store: 'apple', profile: 'acme', label: 'Acme', id: '3333cccc4444dddd' }] }]); expect(ws.accounts).toEqual([{ store: 'play', profile: 'acme-play-2', label: 'Acme', id: '5555eeee6666ffff' }]); expect(ws.ignore).toEqual(['account:acme-play-2', 'project:games/puzzle', 'app:ios:3333cccc4444dddd:6400000199']); expect(ws.overrides?.links).toEqual([{ key: 'android:5555eeee6666ffff:com.example.notes', project: '/Users/you/elsewhere/notes' }]); expect(ws.overrides?.appStudio).toEqual([{ key: 'project:/Users/you/elsewhere/notes', studio: 'f00dfeedf00dfeed' }]); expect(checkWorkspace(ws)).toEqual([]); expect(JSON.stringify(ws)).not.toMatch(/PRIVATE KEY|service_account/); }); test('export: the desktop format (version 2), account ids kept, project ids stable per path', async () => { const l = await loadWorkspace(root); const { apps } = resolveApps(l); const s = toDesktopState(l.ws, apps, '2026-10-01T00:00:00Z'); expect(s.version).toBe(2); expect(s.projects.map(p => p.id)).toEqual(apps.map(a => desktopProjectId(a.path))); expect(s.projects.every(p => /^[0-9a-f]{16}$/.test(p.id))).toBe(true); expect(s.accounts.map(a => [a.label, a.kind, a.studio])).toEqual([['acme-apple', 'apple', 'acme-games'], ['acme-play', 'play', 'acme-games'], ['Solo', 'apple', null]]); expect(s.overrides.ignored).toContain(`project:${desktopProjectId(join(root, 'games', 'old-tetra'))}`); expect(s.overrides.appStudio).toContainEqual({ key: `project:${desktopProjectId(join(root, 'games', 'puzzle'))}`, studio: 'acme-games' }); expect(JSON.stringify(s)).not.toContain(SENTINEL); // the desktop's state.json shape: every key its Rust State reads expect(Object.keys(s).sort()).toEqual(['accounts', 'overrides', 'projects', 'studios', 'version']); }); test('merge: nothing removed, existing projects keep their ids and accounts', () => { const incoming: DesktopState = { version: 2, projects: [{ ...state.projects[0]!, id: 'ffffffffffffffff', appleAccount: null }, { id: 'eeeeeeeeeeeeeeee', path: '/Users/you/new', name: 'new', playAccount: null, appleAccount: null, added: '' }], accounts: [state.accounts[0]!, { id: '9999999999999999', label: 'New', kind: 'apple', created: '', studio: null }], studios: [], overrides: { pairs: [], unpairs: [], links: [], appStudio: [{ key: 'project:ffffffffffffffff', studio: 'x' }], ignored: ['project:ffffffffffffffff'] } }; const { state: m, added } = mergeDesktopState(state, incoming); expect(added).toEqual({ projects: 1, accounts: 1, studios: 0, overrides: 1 }); expect(m.projects[0]).toEqual(state.projects[0]!); expect(m.overrides.ignored.filter(x => x === 'project:0a1b2c3d4e5f6071')).toHaveLength(1); expect(m.settings).toEqual(state.settings); }); test('state file location per platform', () => { expect(desktopStateFile({ STOREBOUND_DESKTOP_DATA: '/d' })).toBe('/d/state.json'); expect(desktopStateFile({}, 'darwin')).toContain(join('Application Support', 'dev.storebound.desktop', 'state.json')); expect(desktopStateFile({ XDG_DATA_HOME: '/x' }, 'linux')).toBe('/x/dev.storebound.desktop/state.json'); }); }); describe('ws run policy', () => { test('reads run, writes get --dry, owner steps never', () => { expect(runPolicy(['validate', '--play'])).toEqual({ ok: true, write: false }); expect(runPolicy(['inspect'])).toEqual({ ok: true, write: false }); expect(runPolicy(['apple', 'testflight', 'status'])).toEqual({ ok: true, write: false }); expect(runPolicy(['play', 'listing'])).toEqual({ ok: true, write: true }); expect(runPolicy(['health', '--weekly'])).toEqual({ ok: true, write: true }); expect(runPolicy(['health'])).toEqual({ ok: true, write: false }); expect(runPolicy(['prices', 'import'])).toEqual({ ok: true, write: true }); for (const bad of [['play', 'release', 'x.aab'], ['apple', 'release', '--phased', 'complete'], ['play', 'rollout', '--complete'], ['reviews', 'send'], ['apple', 'ppo', 'start'], ['apple', 'testflight', 'distribute', '--build', '3'], ['play', 'listing', '--yes'], ['apple', 'status', '--cancel-submission', '1'], ['ws', 'list'], ['rm', '-rf'], []]) { expect(runPolicy(bad).ok).toBe(false); } }); }); describe('MCP', () => { test('workspace and release tools: read-only, in MCP, not in the desktop profile', () => { const names = ['workspace_list', 'workspace_status', 'release_status', 'release_plan']; for (const n of names) { const t = TOOLS.find(x => x.name === n)!; expect(t.write).toBe(false); expect((listTools().find(x => x.name === n)!.annotations as { readOnlyHint: boolean }).readOnlyHint).toBe(true); expect(DESKTOP_TOOLS.some(x => x.name === n)).toBe(false); } expect(toArgv(TOOLS.find(x => x.name === 'release_plan')!, { version: '2.4.0', app: 'puzzle', json: true }).argv).toEqual(['release', 'plan', '2.4.0', '--app', 'puzzle', '--json']); expect(toArgv(TOOLS.find(x => x.name === 'workspace_status')!, { offline: true }).argv).toEqual(['ws', 'status', '--offline']); }); }); describe('CLI end to end (offline)', () => { test('ws list --json: apps, studios, accounts; never a key value', () => { const r = cli(['ws', 'list', '--json'], join(root, 'games')); expect(r.code).toBe(0); const d = JSON.parse(r.out); expect(d.apps.map((a: { name: string }) => a.name)).toEqual(['Notes', 'Puzzle', 'Racer']); expect(d.accounts.map((a: { profile: string; source: string }) => [a.profile, a.source])).toEqual([['acme-apple', 'file'], ['acme-play', 'file'], ['solo-apple', 'missing']]); expect(r.out + r.err).not.toContain(SENTINEL); expect(cli(['ws', 'list', '--all', '--json']).out).toContain('"Old"'); }); test('ws status --offline --json and ws check', () => { const r = cli(['ws', 'status', '--offline', '--json', '--apps', 'puzzle,racer']); const d = JSON.parse(r.out); expect(d.live).toBe(false); expect(d.apps.map((a: { name: string; status: { summary: { name: string } } }) => [a.name, a.status.summary.name])).toEqual([['Puzzle', 'Puzzle'], ['Racer', 'Racer']]); expect(d.apps[0].status.release.source).toBe('none'); const c = cli(['ws', 'check', '--json']); expect(JSON.parse(c.out).check).toHaveLength(3); expect(c.code).toBe(1); // the fixture apps have no listing files yet }); test('ws run: needs --apps / --all, adds --dry to writes, refuses owner steps', () => { expect(cli(['ws', 'run', 'inspect']).err).toContain('--apps a,b or --all'); const i = cli(['ws', 'run', 'inspect', '--all', '--json']); expect(i.code).toBe(0); expect(JSON.parse(i.out).apps.map((a: { data: { name: string } }) => a.data.name)).toEqual(['Notes', 'Puzzle', 'Racer']); const w = cli(['ws', 'run', 'play', 'listing', '--apps', 'puzzle']); expect(w.out).toContain('storebound play listing --dry'); const bad = cli(['ws', 'run', 'play', 'rollout', '--complete', '--all']); expect(bad.code).toBe(1); expect(bad.err).toContain('owner step'); }); test('ws desktop export / import round trip through files', () => { const out = join(root, '.storebound', 'state.json'); mkdirSync(join(root, '.storebound'), { recursive: true }); expect(cli(['ws', 'desktop', 'export', '--out', out]).code).toBe(0); const s = JSON.parse(readFileSync(out, 'utf8')) as DesktopState; expect(s.projects).toHaveLength(4); const back = join(root, '.storebound', 'ws.json'); const r = cli(['ws', 'desktop', 'import', '--state', out, '--out', back]); expect(r.code).toBe(0); const ws = JSON.parse(readFileSync(back, 'utf8')) as Workspace; expect(checkWorkspace(ws)).toEqual([]); expect(ws.apps).toHaveLength(4); expect(ws.ignore).toContain('project:games/old-tetra'); // merge into a desktop state: dry unless --apply const target = join(root, '.storebound', 'desktop'); mkdirSync(target, { recursive: true }); const m = cli(['ws', 'desktop', 'export', '--merge'], root, { STOREBOUND_DESKTOP_DATA: target }); expect(m.out).toContain('dry run'); expect(cli(['ws', 'desktop', 'export', '--merge', '--apply'], root, { STOREBOUND_DESKTOP_DATA: target }).code).toBe(0); expect((JSON.parse(readFileSync(join(target, 'state.json'), 'utf8')) as DesktopState).projects).toHaveLength(4); }); test('release status in an app folder without keys: unreadable, never "not there"', () => { const r = cli(['release', 'status', '--json'], join(root, 'games', 'puzzle')); expect(r.code).toBe(1); const d = JSON.parse(r.out); expect(d.apps[0].storeErrors.ios).toContain('ASC_KEY_ID'); expect(d.apps[0].trains).toEqual([]); const p = cli(['release', 'plan', '1.0.0', '--no-validate', '--json'], join(root, 'games', 'puzzle')); const plan = JSON.parse(p.out).apps[0].plan; expect(plan.unknown.join(' ')).toContain('App Store state not read'); expect(plan.done).toBe(false); }); });