- 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
144 lines
10 KiB
TypeScript
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');
|
|
}
|