storebound/cli/whats-new.ts
CRT b63f2fdfbc Store operations: TestFlight wait / expire, IAP review submissions, listing pulls, release notes per language, offers, custom product pages, in-app events, reports, triage, keyword ranks, AdAttributionKit scan
- apple testflight wait --build N (progress, timeout, exit 0/1/7) and expire --keep N | --older-than D (dry run, asks)
- apple iap-submit: IAP / subscription / group versions into a review submission; first of a type only with the app
  version (--with-version), --no-submit keeps the draft; read-only MCP plan
- apple listing pull, play listing pull (diff, --write), catalog pull (config proposal, never rewritten)
- whats-new --languages / check / apply: one file per language, agent or hook translation, per-store limits
- apple offer-codes, win-back, promoted, preorder; Play pre-registration as a guide (no API)
- apple cpp and apple events: list, create, upload, submit (asks)
- reports: Apple sales / finance, Play earnings / sales into .storebound/reports with summary and CSV
- triage: TestFlight crash / feedback and Play error reports grouped by signature, device, OS
- aso rank: iTunes Search API ranks and competitors, rate limited
- ads scan: AdAttributionKit / SKAdNetwork Info.plist and source checks
- tests against recorded, sanitized answers; skills, README, CHANGELOG
2026-10-11 23:58:17 +08:00

144 lines
10 KiB
TypeScript

// Draft user-facing release notes from the git log and write them into the listing files (text only; nothing is
// sent to a store). A human (or an agent with the whats-new skill) rewrites and translates before `play release` /
// `apple listing` push them.
// storebound whats-new [--since v1.1.0] [--lang en-US] [--all] [--hook "text"] [--dry]
// --since: default the latest tag (git describe --tags --abbrev=0); --lang: the Play code / App Store locale to write
// (default: the Play default language and the first App Store locale); --all: write the same draft into every
// language (to translate afterwards; `storebound check` warns while they are identical).
//
// Many languages, one file each (the CLI never calls a translation service and needs no key for one):
// storebound whats-new --languages en-US,de-DE,tr [--dir store/whats-new] [--translate "<command>"] [--force] [--dry] [--json]
// the draft goes into the first language's file (status=source); every other language gets a file with the
// source text and status=translate for your agent to translate (whats-new skill), or the output of --translate:
// a command of yours run once per language with the source text on stdin and STOREBOUND_SOURCE_LANG /
// STOREBOUND_TARGET_LANG (App Store locale, else Play code, else language id) in the environment; its stdout is
// the translation. Existing translated files are kept unless --force.
// storebound whats-new check [--dir store/whats-new] [--json] limits per store (Play 500, App Store 4000), untranslated files
// storebound whats-new apply [--dir store/whats-new] [--dry] [--json] checked files -> both listing files
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Language } from './lib/config';
import { playCodes } from './lib/config';
import type { Ctx } from './lib/ctx';
import { done, log, printJson, warn } from './lib/log';
import { textTable } from './lib/table';
import {
applyLanguageNotes, checkLanguageNotes, checkNotes, formatNotes, type LangCheck, NOTES_LIMITS, type NotesFile, noteItems, notesFileText, parseNotesFile,
resolveLanguages, setAppleWhatsNew, setPlayWhatsNew,
} from './lib/whats-new';
const TAG = 'whats-new';
const storeCode = (l: Language): string => l.apple ?? playCodes(l)[0] ?? l.id;
/** The language files of a notes folder, in config order. */
function readDir(dir: string, langs: Language[]): { l: Language; f: NotesFile }[] {
if (!existsSync(dir)) throw new Error(`no ${dir} (storebound whats-new --languages ... writes it)`);
const names = new Set(readdirSync(dir).filter(n => n.endsWith('.md')).map(n => n.slice(0, -3)));
return langs.filter(l => names.has(l.id)).map(l => ({ l, f: parseNotesFile(readFileSync(join(dir, `${l.id}.md`), 'utf8'), l.id) }));
}
function checkAll(files: { l: Language; f: NotesFile }[]): LangCheck[] {
const src = files.find(x => x.f.status === 'source');
return files.map(x => checkLanguageNotes(x.l, x.f, src && src !== x ? src.f.text : undefined));
}
function printChecks(checks: LangCheck[]): void {
console.log(textTable(['language', 'chars', 'play (500)', 'app store (4000)', 'problems'], checks.map(c => [c.lang, c.chars,
c.play ? (c.play.ok ? 'ok' : 'OVER') : '-', c.apple ? (c.apple.ok ? 'ok' : 'OVER') : '-', c.problems.join('; ')])));
}
async function languagesFlow(ctx: Ctx, draft: { play: string; apple: string }): Promise<void> {
const { config, resolve } = ctx.loaded;
const a = ctx.args;
const dir = resolve(a.get('dir') ?? 'store/whats-new');
const langs = resolveLanguages(config.languages, a.list('languages'));
const [src, ...rest] = langs;
// the source text: the shorter Play draft when the source language is on Play (it has to fit 500)
const text = playCodes(src!).length ? draft.play : draft.apple;
const files: { l: Language; f: NotesFile; write: boolean; note: string }[] = [{ l: src!, f: { lang: src!.id, source: src!.id, status: 'source', text }, write: true, note: 'draft (source)' }];
const hook = a.get('translate');
for (const l of rest) {
const path = join(dir, `${l.id}.md`);
const cur = existsSync(path) ? parseNotesFile(readFileSync(path, 'utf8'), l.id) : null;
if (cur && cur.status === 'translated' && !a.has('force')) { files.push({ l, f: cur, write: false, note: 'kept (translated; --force replaces it)' }); continue; }
if (hook && !a.has('dry')) {
const r = Bun.spawnSync(['sh', '-c', hook], { stdin: new TextEncoder().encode(text), stdout: 'pipe', stderr: 'pipe',
env: { ...process.env, STOREBOUND_SOURCE_LANG: storeCode(src!), STOREBOUND_TARGET_LANG: storeCode(l), STOREBOUND_TARGET_LANG_ID: l.id } });
const out = r.stdout.toString().trim();
if (r.exitCode !== 0 || !out) { warn(TAG, `${l.id}: --translate failed (exit ${r.exitCode}): ${r.stderr.toString().trim().slice(0, 200)}`); files.push({ l, f: { lang: l.id, source: src!.id, status: 'translate', text }, write: true, note: 'to translate (hook failed)' }); continue; }
files.push({ l, f: { lang: l.id, source: src!.id, status: 'translated', text: out }, write: true, note: 'translated by the hook' });
} else files.push({ l, f: { lang: l.id, source: src!.id, status: 'translate', text }, write: true, note: hook ? 'would run the --translate hook' : 'to translate (your agent: whats-new skill)' });
}
const checks = checkAll(files.map(x => ({ l: x.l, f: x.f })));
if (a.has('json')) printJson({ dir, dryRun: a.has('dry'), files: files.map((x, i) => ({ lang: x.l.id, file: join(dir, `${x.l.id}.md`), status: x.f.status, write: x.write, note: x.note, check: checks[i] })) });
else { for (const x of files) log(TAG, `${join(dir, `${x.l.id}.md`)}: ${x.note}`); printChecks(checks); }
if (a.has('dry')) { done(TAG, 'dry run: nothing written'); return; }
mkdirSync(dir, { recursive: true });
for (const x of files) if (x.write) writeFileSync(join(dir, `${x.l.id}.md`), notesFileText(x.f));
done(TAG, `${files.filter(x => x.write).length} file(s) in ${dir}; translate the status=translate ones, then storebound whats-new apply`);
}
function git(args: string[], cwd: string): string {
const r = Bun.spawnSync(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' });
if (r.exitCode !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr.toString().trim()}`);
return r.stdout.toString();
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
if (a._[0] === 'check' || a._[0] === 'apply') {
const dir = resolve(a.get('dir') ?? 'store/whats-new');
const files = readDir(dir, config.languages);
if (!files.length) throw new Error(`${dir}: no <language id>.md files`);
const checks = checkAll(files);
const bad = checks.filter(c => c.problems.some(p => !p.startsWith('first line')));
if (a._[0] === 'check' || bad.length) {
if (a.has('json')) printJson({ dir, checks, ok: !bad.length });
else printChecks(checks);
if (bad.length) { process.exitCode = 1; done(TAG, `${bad.length} language(s) not ready: ${bad.map(c => c.lang).join(', ')}${a._[0] === 'apply' ? '; nothing written' : ''}`); return; }
if (a._[0] === 'check') { done(TAG, `${checks.length} language(s) within the store limits`); return; }
}
const playFile = config.play ? resolve(config.play.listing ?? 'store/listing.md') : undefined;
const appleFile = config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : undefined;
let md: { play?: string; apple?: string } = { play: playFile ? readFileSync(playFile, 'utf8') : undefined, apple: appleFile ? readFileSync(appleFile, 'utf8') : undefined };
const changed: string[] = [];
for (const { l, f } of files) { const r = applyLanguageNotes(md, l, f.text); md = { play: r.play, apple: r.apple }; changed.push(...r.changed); }
if (a.has('json')) printJson({ dir, checks, dryRun: a.has('dry'), changed });
else { printChecks(checks); log(TAG, `sections: ${changed.join(', ') || 'none (no matching listing sections)'}`); }
if (a.has('dry')) { done(TAG, 'dry run: listing files not written'); return; }
if (playFile && md.play !== undefined) writeFileSync(playFile, md.play);
if (appleFile && md.apple !== undefined) writeFileSync(appleFile, md.apple);
done(TAG, `release notes of ${files.length} language(s) written into the listing files; push them with play release / apple listing`);
return;
}
const since = a.get('since') ?? (() => { try { return git(['describe', '--tags', '--abbrev=0'], root).trim(); } catch { return ''; } })();
const range = since ? `${since}..HEAD` : 'HEAD';
const subjects = git(['log', range, '--no-merges', '--format=%s'], root).split('\n').filter(Boolean);
const items = noteItems(subjects);
log('whats-new', `${subjects.length} commits since ${since || 'the first commit'} -> ${items.length} user-facing items`);
const hook = a.get('hook');
const play = formatNotes(items, { hook, limit: NOTES_LIMITS.play });
const apple = formatNotes(items, { hook, limit: NOTES_LIMITS.apple });
for (const [store, text] of [['play', play], ['apple', apple]] as const) for (const p of checkNotes(text, store)) warn('whats-new', `${store}: ${p}`);
if (a.has('languages')) return languagesFlow(ctx, { play, apple });
if (a.has('json')) printJson({ since: since || null, commits: subjects.length, items, play, apple, limits: NOTES_LIMITS });
else console.log(`--- draft (Play, ${[...play].length}/500) ---\n${play}\n---`);
if (a.has('dry')) return;
const all = a.has('all');
if (config.play) {
const f = resolve(config.play.listing ?? 'store/listing.md');
const lang = all ? '*' : a.get('lang') ?? config.play.defaultLanguage ?? 'en-US';
const r = setPlayWhatsNew(readFileSync(f, 'utf8'), lang, play);
if (r.changed.length) { writeFileSync(f, r.md); log('whats-new', `${f}: ${r.changed.join(', ')}`); } else warn('whats-new', `${f}: no section for ${lang}`);
}
if (config.apple) {
const f = resolve(config.apple.listing ?? 'store/appstore.md');
const first = config.apple.primaryLocale ?? config.languages.find(l => l.apple)?.apple ?? 'en-US';
const locale = all ? '*' : a.get('lang') ?? first;
const r = setAppleWhatsNew(readFileSync(f, 'utf8'), locale, apple);
if (r.changed.length) { writeFileSync(f, r.md); log('whats-new', `${f}: ${r.changed.join(', ')}`); } else warn('whats-new', `${f}: no section for ${locale}`);
}
log('whats-new', 'draft written: edit it, translate every language, then storebound check');
}