- specs/: Apple's App Store Connect OpenAPI 4.5.1 and the Google discovery docs for androidpublisher v3 and playdeveloperreporting v1beta1, pinned with URL, date and SHA-256 (specs/SOURCES.json) - tools/gen-api.ts: one row per operation (cli/api/apple.gen.ts 1303, cli/api/play.gen.ts 170), a scan of which operations the commands already call (cli/api/wrapped.gen.ts) and docs/api-coverage.md; CI checks freshness - cli/api/client.ts + types.ts: generic URL / parameter building, retries with Retry-After, pagination, types derived from the rows; auth reuses ascJwt and the Play service account - cli/api/gate.ts: GET runs, writes dry unless --yes in a TTY, owner-only operations need the id typed, nothing written under MCP or serve; MCP gets the read-only api_get - storebound api list | coverage | apple|play <op|METHOD path>; skill store-api, README, CHANGELOG, tests
201 lines
15 KiB
TypeScript
201 lines
15 KiB
TypeScript
// Desktop D3 write actions: every write the window offers is a dry run unless the host's confirm token for exactly
|
|
// that method, project and arguments comes with it (single use, short-lived); owner actions (rollout share, phased
|
|
// release, sending one approved reply) additionally answer the command's own confirmation, and only with such a token.
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { mint } from '../cli/lib/confirm-token';
|
|
import { confirm } from '../cli/lib/ctx';
|
|
import { ERR } from '../cli/lib/desktop-protocol';
|
|
import { parseDraft } from '../cli/lib/reviews';
|
|
import { setReplyText } from '../cli/lib/project-files';
|
|
import { childEnv, type RunOptions, type Running } from '../cli/lib/runner';
|
|
import { TOOLS } from '../cli/mcp-tools';
|
|
import { describeMethods, methodName, OWNER_FORBIDDEN_ARGS, OWNER_TOOLS, Session } from '../cli/serve';
|
|
import { toArgv } from '../cli/mcp-tools';
|
|
|
|
type J = Record<string, unknown>;
|
|
const DEMO = join(import.meta.dir, '..', 'examples', 'demo-game');
|
|
const SECRET_HEX = Array.from({ length: 32 }, (_, i) => (i + 7).toString(16).padStart(2, '0')).join('');
|
|
const SECRET = Buffer.from(SECRET_HEX, 'hex');
|
|
|
|
async function session(now = () => Date.now()) {
|
|
const calls: { argv: string[]; o: RunOptions }[] = [];
|
|
const run = (argv: string[], o: RunOptions): Running => { calls.push({ argv, o }); return { result: Promise.resolve({ code: 0, out: 'done', cancelled: false }), cancel: () => {} }; };
|
|
const s = new Session(() => {}, run, now);
|
|
let id = 0;
|
|
const call = async (method: string, params: unknown = {}) => (await s.handle({ jsonrpc: '2.0', id: ++id, method, params }))! as J;
|
|
await call('session.hello', { secret: SECRET_HEX });
|
|
return { calls, call };
|
|
}
|
|
const err = (r: J) => r.error as { code: number; data?: J };
|
|
|
|
/** The writes the D3 screens offer, with arguments as the window sends them. */
|
|
const D3_WRITES: [string, J][] = [
|
|
['play.listing', {}], ['apple.listing', { version: '2.4.0' }], ['play.images', { only: 'en-US' }], ['apple.screenshots', {}], ['apple.previews', {}],
|
|
['play.products', {}], ['apple.iap', {}], ['play.subscriptions', {}], ['apple.subscriptions', {}],
|
|
['apple.testflight_distribute', { build: '52', groups: 'Studio team' }], ['apple.testflight_what_to_test', { build: '52', text: 'New combo meter' }],
|
|
['reviews.reply', { file: 'play-r-p1.md', text: 'Thanks! The fix is in 2.4.0.' }], ['reviews.approve', { file: 'play-r-p1.md' }],
|
|
['shots.render', {}], ['shots.video', {}], ['shots.gallery', {}], ['icons', {}],
|
|
['owner.play_rollout', { action: 'percent', percent: 50 }], ['owner.play_rollout', { action: 'halt' }], ['owner.ios_phased', { action: 'pause', version: '2.3.0' }],
|
|
['owner.ios_phased', { action: 'resume' }], ['owner.reply_send', { file: 'play-r-p1.md' }], ['owner.testflight_testers', { groups: 'Friends and family' }],
|
|
// full CLI coverage: price sheet, Data safety, app info, PPO, analytics
|
|
['owner.prices_import', {}], ['owner.prices_import', { csv: '/tmp/sheets/prices autumn.csv', store: 'play' }], ['prices.export', {}],
|
|
['play.data_safety', {}], ['apple.appinfo', {}], ['apple.review_details', {}], ['apple.encryption', {}],
|
|
['apple.ppo_create', { variants: 'b', traffic: 30 }], ['apple.ppo_upload', { variant: 'b', experiment: 'storebound b' }],
|
|
['owner.ppo_start', { experiment: 'storebound b' }], ['owner.ppo_stop', {}], ['analytics.snapshot', {}], ['analytics.setup', {}],
|
|
];
|
|
|
|
describe('D3 writes: token binding for every method the window offers', () => {
|
|
for (const [method, args] of D3_WRITES) {
|
|
test(`${method} ${JSON.stringify(args)}`, async () => {
|
|
let now = 1_000_000;
|
|
const s = await session(() => now);
|
|
const p = { project: DEMO, args };
|
|
// dry run by default, and without a token
|
|
const dry = await s.call(method, p);
|
|
expect(err(dry)).toBeUndefined();
|
|
expect(s.calls.at(-1)!.argv).toContain('--dry');
|
|
expect(s.calls.at(-1)!.o.env?.STOREBOUND_OWNER_CONFIRMED).toBeUndefined();
|
|
expect(err(await s.call(method, { ...p, dryRun: false })).code).toBe(ERR.confirmRequired);
|
|
// a token for other arguments, another method or another project is refused
|
|
const other = method === 'play.listing' ? 'play.images' : 'play.listing';
|
|
expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: mint(SECRET, other, p, { now }) })).code).toBe(ERR.confirmInvalid);
|
|
expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: mint(SECRET, method, { project: '/elsewhere', args }, { now }) })).code).toBe(ERR.confirmInvalid);
|
|
expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: mint(SECRET, method, { project: DEMO, args: { ...args, x: 1 } }, { now }) })).code).toBe(ERR.confirmInvalid);
|
|
expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: mint(Buffer.alloc(32, 1), method, p, { now }) })).code).toBe(ERR.confirmInvalid);
|
|
// the right token runs it for real, once
|
|
const n = s.calls.length;
|
|
const token = mint(SECRET, method, p, { now });
|
|
const real = await s.call(method, { ...p, dryRun: false, confirmToken: token, credentials: { PLAY_SERVICE_ACCOUNT: '{}' } });
|
|
expect(err(real)).toBeUndefined();
|
|
expect((real.result as J).dryRun).toBe(false);
|
|
const c = s.calls.at(-1)!;
|
|
expect(s.calls.length).toBe(n + 1);
|
|
expect(c.argv).not.toContain('--dry');
|
|
expect(c.o.env?.PLAY_SERVICE_ACCOUNT).toBe('{}');
|
|
expect(c.o.env?.STOREBOUND_OWNER_CONFIRMED).toBe(method.startsWith('owner.') ? '1' : undefined);
|
|
expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: token })).data).toEqual({ reason: 'replayed' });
|
|
// and expires
|
|
const late = mint(SECRET, method, p, { now, ttlMs: 1000 });
|
|
now += 1000;
|
|
expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: late })).data).toEqual({ reason: 'expired' });
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('owner actions', () => {
|
|
test('exactly these, desktop only, kind owner, never a submit / release request / cancel / external TestFlight flag', () => {
|
|
expect(OWNER_TOOLS.map(t => methodName(t.name)).sort()).toEqual(['owner.ios_phased', 'owner.play_rollout', 'owner.ppo_start', 'owner.ppo_stop', 'owner.prices_import', 'owner.reply_send', 'owner.testflight_testers']);
|
|
for (const t of OWNER_TOOLS) expect(TOOLS.some(x => x.name === t.name)).toBe(false);
|
|
const ds = describeMethods().filter(m => m.method.startsWith('owner.'));
|
|
for (const d of ds) expect(d.kind).toBe('owner');
|
|
const samples: J[] = [
|
|
{ action: 'percent', percent: 5 }, { action: 'halt' }, { action: 'resume', percent: 10 }, { action: 'complete', track: 'beta' },
|
|
{ action: 'on' }, { action: 'pause', version: '1.2.3' }, { action: 'resume' }, { action: 'complete' }, { file: 'apple-123.md' }, {}, { groups: 'Studio team,Friends' },
|
|
];
|
|
for (const t of OWNER_TOOLS) for (const s of samples) {
|
|
let argv: string[];
|
|
try { argv = toArgv(t, s).argv; } catch { continue; }
|
|
for (const a of argv) expect(OWNER_FORBIDDEN_ARGS).not.toContain(a);
|
|
}
|
|
});
|
|
test('argv: rollout share in percent -> fraction, halt / resume / complete, track; phased actions; one reply', () => {
|
|
const t = (name: string) => OWNER_TOOLS.find(x => x.name === name)!;
|
|
expect(toArgv(t('owner_play_rollout'), { action: 'percent', percent: 20, dry_run: false }).argv).toEqual(['play', 'rollout', '--fraction', '0.2', '--track', 'production']);
|
|
expect(toArgv(t('owner_play_rollout'), { action: 'halt' }).argv).toEqual(['play', 'rollout', '--halt', '--track', 'production', '--dry']);
|
|
expect(toArgv(t('owner_play_rollout'), { action: 'resume', percent: 50, dry_run: false }).argv).toEqual(['play', 'rollout', '--resume', '--fraction', '0.5', '--track', 'production']);
|
|
expect(toArgv(t('owner_play_rollout'), { action: 'complete', percent: 50, track: 'beta', dry_run: false }).argv).toEqual(['play', 'rollout', '--complete', '--track', 'beta']);
|
|
expect(toArgv(t('owner_ios_phased'), { action: 'resume', version: '2.3.0', dry_run: false }).argv).toEqual(['apple', 'release', '--phased', 'resume', '--version', '2.3.0']);
|
|
expect(toArgv(t('owner_reply_send'), { file: 'play-r1.md', dry_run: false }).argv).toEqual(['reviews', 'send', '--file', 'play-r1.md']);
|
|
expect(toArgv(t('owner_testflight_testers'), { groups: 'Studio team', dry_run: false }).argv).toEqual(['apple', 'testflight', 'testers', '--group', 'Studio team']);
|
|
expect(toArgv(t('owner_testflight_testers'), {}).argv).toEqual(['apple', 'testflight', 'testers', '--dry']);
|
|
});
|
|
test('bad arguments never reach a command', async () => {
|
|
const s = await session();
|
|
for (const [m, args] of [['owner.play_rollout', { action: 'manual' }], ['owner.play_rollout', { action: 'percent', percent: 100 }], ['owner.play_rollout', { action: 'percent', percent: 0.5 }],
|
|
['owner.ios_phased', { action: 'release' }], ['owner.ios_phased', { action: 'on', version: '--manual-release' }], ['owner.reply_send', { file: '../../etc/passwd' }],
|
|
['owner.reply_send', {}], ['reviews.reply', { file: 'play-1.md', text: '--yes' }],
|
|
['owner.prices_import', { csv: '--yes.csv' }], ['owner.prices_import', { csv: 'prices.txt' }], ['owner.prices_import', { only: 'a,--yes' }], ['owner.ppo_stop', { experiment: '--yes' }],
|
|
['project.render', { file: '../../etc/passwd.png' }], ['project.render', { file: '/etc/x.png' }], ['project.render', { file: 'en/ios69/01.txt' }], ['reviews.reply', { file: 'play-1.md', text: 'x'.repeat(6001) }]] as [string, J][])
|
|
expect(err(await s.call(m, { project: DEMO, args })).code).toBe(ERR.invalidParams);
|
|
expect(s.calls.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('full CLI coverage: new methods', () => {
|
|
test('argv of the price sheet, PPO start / stop and the offline reads', async () => {
|
|
const t = (name: string) => OWNER_TOOLS.find(x => x.name === name)!;
|
|
expect(toArgv(t('owner_prices_import'), { csv: '/tmp/p.csv', dry_run: false }).argv).toEqual(['prices', 'import', '--csv', '/tmp/p.csv']);
|
|
expect(toArgv(t('owner_ppo_start'), { experiment: 'storebound b' }).argv).toEqual(['apple', 'ppo', 'start', '--experiment', 'storebound b', '--dry']);
|
|
expect(toArgv(t('owner_ppo_stop'), { dry_run: false }).argv).toEqual(['apple', 'ppo', 'stop']);
|
|
const s = await session();
|
|
for (const [m, args, argv] of [
|
|
['project.declarations', {}, ['inspect', '--declarations', '--json']], ['project.listing_text', {}, ['inspect', '--listing-text', '--json']],
|
|
['project.renders', { lang: 'en' }, ['shots', 'gallery', '--lang', 'en', '--json']], ['project.render', { file: 'b/en/ios69/01.png' }, ['inspect', '--render', 'b/en/ios69/01.png', '--json']],
|
|
['prices.current', { with_automatic: true }, ['prices', 'export', '--with-automatic', '--json']], ['prices.diff', { csv: '/tmp/p.csv' }, ['prices', 'import', '--csv', '/tmp/p.csv', '--dry', '--json']],
|
|
] as [string, J, string[]][]) {
|
|
expect(err(await s.call(m, { project: DEMO, args }))).toBeUndefined();
|
|
expect(s.calls.at(-1)!.argv).toEqual(argv);
|
|
}
|
|
// the fastlane import runs only on a folder without a config, and never through write_with_confirm's project list
|
|
expect(err(await s.call('import.fastlane', { project: DEMO, args: {} })).code).toBe(ERR.invalidParams);
|
|
const empty = mkdtempSync(join(tmpdir(), 'sb-fl-'));
|
|
expect(err(await s.call('import.fastlane', { project: empty, args: {} }))).toBeUndefined();
|
|
expect(s.calls.at(-1)!.argv).toEqual(['import', 'fastlane', '--dry']);
|
|
});
|
|
});
|
|
|
|
describe('the owner flag', () => {
|
|
const saved = { ...process.env };
|
|
afterEach(() => { for (const k of ['STOREBOUND_SERVE', 'STOREBOUND_OWNER_CONFIRMED']) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } });
|
|
const ctx = { loaded: {} as never, args: { has: (n: string) => n === 'yes', get: () => undefined, str: (_: string, d: string) => d, num: (_: string, d: number) => d, list: () => [], many: () => [], _: [] } };
|
|
test('under serve: no by default, even with --yes; yes only with the owner flag', async () => {
|
|
process.env.STOREBOUND_SERVE = '1';
|
|
delete process.env.STOREBOUND_OWNER_CONFIRMED;
|
|
expect(await confirm(ctx, 'Resume?')).toBe(false);
|
|
process.env.STOREBOUND_OWNER_CONFIRMED = '1';
|
|
expect(await confirm(ctx, 'Resume?')).toBe(true);
|
|
});
|
|
test('a child never inherits it; it comes only per call', () => {
|
|
process.env.STOREBOUND_OWNER_CONFIRMED = '1';
|
|
expect(childEnv({}, true).STOREBOUND_OWNER_CONFIRMED).toBeUndefined();
|
|
expect(childEnv({ STOREBOUND_OWNER_CONFIRMED: '1' }, true).STOREBOUND_OWNER_CONFIRMED).toBe('1');
|
|
});
|
|
});
|
|
|
|
describe('reply drafts: write the reply', () => {
|
|
const draft = ['---', 'store: play', 'review_id: r-p1', 'rating: 2', 'approved: true', 'sent: null', '---', '# ★★☆☆☆ Crashes', '', '> Game closes at a 20x combo.', '', '## Reply', '',
|
|
'<!-- Write the reply below. -->', '', 'Old text', ''].join('\n');
|
|
test('replaces the reply, keeps the review, resets the approval', () => {
|
|
const next = setReplyText(draft, ' Sorry! Fixed in 2.4.0.\r\nThanks for the level number. ');
|
|
const d = parseDraft('/x/play-r-p1.md', next);
|
|
expect(d.reply).toBe('Sorry! Fixed in 2.4.0.\nThanks for the level number.');
|
|
expect(d.approved).toBe(false);
|
|
expect(next).toContain('> Game closes at a 20x combo.');
|
|
expect(next).not.toContain('Old text');
|
|
expect(parseDraft('/x', setReplyText(next, 'Again <!-- hidden -->')).reply).toBe('Again');
|
|
});
|
|
test('sent drafts and other files are not edited', () => {
|
|
expect(() => setReplyText(draft.replace('sent: null', 'sent: 2026-10-01T00:00:00Z'), 'x')).toThrow();
|
|
expect(() => setReplyText('# not a draft', 'x')).toThrow();
|
|
});
|
|
test('CLI: reviews reply writes one draft, dry run first', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sb-reply-'));
|
|
const cfg = readFileSync(join(DEMO, 'storebound.config.ts'), 'utf8');
|
|
writeFileSync(join(dir, 'storebound.config.ts'), cfg);
|
|
const replies = join(dir, '.storebound', 'replies');
|
|
await Bun.$`mkdir -p ${replies}`.quiet();
|
|
writeFileSync(join(replies, 'play-r-p1.md'), draft);
|
|
const cli = join(import.meta.dir, '..', 'cli', 'index.ts');
|
|
const dry = await Bun.$`bun ${cli} reviews reply play-r-p1.md --text ${'New reply'} --dry`.cwd(dir).quiet().nothrow();
|
|
expect(dry.exitCode).toBe(0);
|
|
expect(readFileSync(join(replies, 'play-r-p1.md'), 'utf8')).toBe(draft);
|
|
const real = await Bun.$`bun ${cli} reviews reply play-r-p1.md --text ${'New reply'}`.cwd(dir).quiet().nothrow();
|
|
expect(real.exitCode).toBe(0);
|
|
expect(parseDraft('x', readFileSync(join(replies, 'play-r-p1.md'), 'utf8'))).toMatchObject({ reply: 'New reply', approved: false });
|
|
const outside = await Bun.$`bun ${cli} reviews reply ../../x.md --text y`.cwd(dir).quiet().nothrow();
|
|
expect(outside.exitCode).not.toBe(0);
|
|
});
|
|
});
|