desktop editors: edit in place what App Store Connect and Play Console let the owner edit (listings per language for both stores with live counters and the check / scan rules, store details, App info, age rating, App Review, Play store settings, Data safety and App Privacy answers, promotion blocks, catalog names / prices / offers, TestFlight information, scene order); storebound edit (project.edit) saves into the config by path keeping comments and formatting, the listing files and the review contact, dry run with the diff and the checks first; Apply shows each store's dry run in the window; unsaved edits survive tab switches with undo / redo; tests/edit.test.ts, apps/desktop/tests/edits.test.ts, boot check editor case

This commit is contained in:
CRTkafa 2026-10-14 00:35:29 +08:00
parent 714352ec18
commit 3cea2af4e1
37 changed files with 3020 additions and 54 deletions

View file

@ -79,16 +79,8 @@ const coveredBy = (w: string, set: Iterable<string>, locale: string): boolean =>
// ---------------------------------------------------------------------------------------------------------------
// Competitors (iTunes Search API)
/** App Store Connect category id -> iTunes genre id (the "genreIds" of Search API results). Apple's genre ids. */
export const GENRE_IDS: Record<string, string> = {
BOOKS: '6018', BUSINESS: '6000', DEVELOPER_TOOLS: '6026', EDUCATION: '6017', ENTERTAINMENT: '6016', FINANCE: '6015', FOOD_AND_DRINK: '6023', GAMES: '6014',
GRAPHICS_AND_DESIGN: '6027', HEALTH_AND_FITNESS: '6013', LIFESTYLE: '6012', MAGAZINES_AND_NEWSPAPERS: '6021', MEDICAL: '6020', MUSIC: '6011', NAVIGATION: '6010',
NEWS: '6009', PHOTO_AND_VIDEO: '6008', PRODUCTIVITY: '6007', REFERENCE: '6006', SHOPPING: '6024', SOCIAL_NETWORKING: '6005', SPORTS: '6004', STICKERS: '6025',
TRAVEL: '6003', UTILITIES: '6002', WEATHER: '6001',
GAMES_ACTION: '7001', GAMES_ADVENTURE: '7002', GAMES_CASUAL: '7003', GAMES_BOARD: '7004', GAMES_CARD: '7005', GAMES_CASINO: '7006', GAMES_FAMILY: '7009',
GAMES_MUSIC: '7011', GAMES_PUZZLE: '7012', GAMES_RACING: '7013', GAMES_ROLE_PLAYING: '7014', GAMES_SIMULATION: '7015', GAMES_SPORTS: '7016',
GAMES_STRATEGY: '7017', GAMES_TRIVIA: '7018', GAMES_WORD: '7019',
};
export { GENRE_IDS } from './store-catalogs';
import { GENRE_IDS } from './store-catalogs';
export interface CompetitorApp {
rank: number; id: string; name: string; developer: string; ratings: number; rating: number | null;

297
cli/lib/config-path.ts Normal file
View file

@ -0,0 +1,297 @@
// One value of storebound.config.ts / .js / .json set by its path, as a text edit: the rest of the file (comments,
// order, quotes, formatting) stays byte for byte. Used by `storebound edit` (the desktop's editors). Pure.
//
// A path is dot-separated keys with array selectors: `app.supportUrl`, `apple.info.categories.primary`,
// `products[id=com.example.no_ads].listings.en.title`, `subscriptions[0].listings.tr.name`,
// `apple.testflight.groups[name=Friends].internal`. A selector `[key=value]` picks the array element whose `key` is that
// string; `[n]` the n-th element. Keys may be quoted in the file ('pt-BR': ...) or not.
// Setting `undefined` removes the property. Missing objects on the way are created; a missing array element is an
// error (adding a product is a config change for the owner's editor, not a path edit). A value on the way that is
// not a plain literal (a variable, a function call, a spread) is an error: the owner edits that in the file.
import { matchBrace, toTs } from './scan/config-edit';
export type PathPart = { key: string } | { index: number } | { match: string; eq: string };
/** `a.b[id=x].c[0]` -> parts. Throws on a malformed path. */
export function parsePath(path: string): PathPart[] {
const out: PathPart[] = [];
let i = 0;
while (i < path.length) {
if (path[i] === '.') { i++; continue; }
if (path[i] === '[') {
const end = path.indexOf(']', i);
if (end < 0) throw new Error(`path ${path}: unclosed [`);
const inner = path.slice(i + 1, end);
if (/^\d+$/.test(inner)) out.push({ index: Number(inner) });
else {
const m = /^([A-Za-z_$][\w$]*)=(.+)$/.exec(inner);
if (!m) throw new Error(`path ${path}: [${inner}] is neither [n] nor [key=value]`);
out.push({ match: m[1]!, eq: m[2]! });
}
i = end + 1;
continue;
}
const m = /^[A-Za-z0-9_$-]+/.exec(path.slice(i));
if (!m) throw new Error(`path ${path}: unexpected "${path[i]}"`);
out.push({ key: m[0] });
i += m[0].length;
}
if (!out.length) throw new Error('empty path');
return out;
}
export const pathText = (parts: PathPart[]): string =>
parts.map((p, i) => ('key' in p ? `${i ? '.' : ''}${p.key}` : 'index' in p ? `[${p.index}]` : `[${p.match}=${p.eq}]`)).join('');
// ---- a small scanner for object and array literals ----
const isWs = (c: string | undefined): boolean => c === ' ' || c === '\t' || c === '\n' || c === '\r';
/** Index after whitespace and comments from `i`. */
function skip(t: string, i: number): number {
for (;;) {
while (i < t.length && isWs(t[i])) i++;
if (t[i] === '/' && t[i + 1] === '/') { const n = t.indexOf('\n', i); i = n < 0 ? t.length : n + 1; continue; }
if (t[i] === '/' && t[i + 1] === '*') { const n = t.indexOf('*/', i + 2); i = n < 0 ? t.length : n + 2; continue; }
return i;
}
}
/** End (exclusive) of the string literal starting at `i`. */
function stringEnd(t: string, i: number): number {
const q = t[i]!;
for (let j = i + 1; j < t.length; j++) {
if (t[j] === '\\') { j++; continue; }
if (t[j] === q) return j + 1;
}
throw new Error('unterminated string in the config');
}
/** End (exclusive, trailing whitespace and comments not included) of the expression starting at `i`: up to the next
* `,` or closing bracket at depth 0. */
function exprEnd(t: string, i: number): number {
let last = i;
let j = i;
while (j < t.length) {
const c = t[j]!;
if (c === '/' && (t[j + 1] === '/' || t[j + 1] === '*')) { j = skip(t, j); continue; }
if (isWs(c)) { j++; continue; }
if (c === ',' || c === '}' || c === ']' || c === ')') return last;
if (c === '"' || c === '\'' || c === '`') { j = stringEnd(t, j); last = j; continue; }
if (c === '{' || c === '[' || c === '(') {
const close = matchBrace(t, j);
if (close < 0) throw new Error('unbalanced brackets in the config');
j = close + 1; last = j; continue;
}
j++; last = j;
}
return last;
}
interface Member { key: string | null; start: number; valueStart: number; valueEnd: number; end: number }
/** Members of the object (or elements of the array) whose opening bracket is at `open`. `end`: after the member's
* trailing comma, if any. key null: a spread, a computed key or an array element. */
function members(t: string, open: number): { list: Member[]; close: number } {
const close = matchBrace(t, open);
if (close < 0) throw new Error('unbalanced brackets in the config');
const isArray = t[open] === '[';
const list: Member[] = [];
let i = skip(t, open + 1);
while (i < close) {
const start = i;
let key: string | null = null;
let valueStart = i;
if (!isArray) {
if (t.startsWith('...', i)) { key = null; valueStart = i; }
else {
let k: string;
let after: number;
if (t[i] === '"' || t[i] === '\'') { after = stringEnd(t, i); k = JSON.parse(t[i] === '"' ? t.slice(i, after) : `"${t.slice(i + 1, after - 1).replace(/\\'/g, '\'').replace(/"/g, '\\"')}"`); }
else if (t[i] === '[') { after = matchBrace(t, i) + 1; k = ''; }
else { const m = /^[A-Za-z0-9_$]+/.exec(t.slice(i)); if (!m) throw new Error(`unexpected "${t[i]}" in the config`); k = m[0]; after = i + k.length; }
key = t[i] === '[' ? null : k;
const colon = skip(t, after);
if (t[colon] === ':') valueStart = skip(t, colon + 1);
else if (t[colon] === '(') { key = null; valueStart = i; } // a method
else valueStart = i; // shorthand `{ name }`: the value is the identifier
}
}
const valueEnd = exprEnd(t, valueStart);
let end = skip(t, valueEnd);
if (t[end] === ',') end++;
list.push({ key, start, valueStart, valueEnd, end });
i = skip(t, end);
if (end === valueEnd && i < close) throw new Error('could not read the config (a member without a comma)');
}
return { list, close };
}
/** The opening brace of the object the config exports: `export default {`, `export default defineConfig({`,
* `module.exports = {`, or the `const x = {` that `export default x` names. */
export function rootObject(t: string): number {
const direct = /(?:export\s+default|module\.exports\s*=)\s*(?:[A-Za-z_$][\w$]*\s*\(\s*)?\{/.exec(t);
if (direct) return direct.index + direct[0].length - 1;
const named = /export\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(t) ?? /module\.exports\s*=\s*([A-Za-z_$][\w$]*)/.exec(t);
if (named) {
const decl = new RegExp(`(?:const|let|var)\\s+${named[1]}\\b[^=]*=\\s*(?:[A-Za-z_$][\\w$]*\\s*\\(\\s*)?\\{`).exec(t);
if (decl) return decl.index + decl[0].length - 1;
}
throw new Error('could not find the exported config object (export default { ... })');
}
/** Indentation of the line that `at` is on. */
function lineIndent(t: string, at: number): string {
const ls = t.lastIndexOf('\n', at - 1) + 1;
return /^[ \t]*/.exec(t.slice(ls))![0];
}
/** One indentation step of the file (2 spaces by default). */
function step(t: string): string {
const m = /\n([ \t]+)\S/.exec(t);
return m ? (m[1]!.startsWith('\t') ? '\t' : m[1]!.length >= 4 && m[1]!.length % 4 === 0 && !/\n {2}\S/.test(t) ? ' ' : ' ') : ' ';
}
/** A literal for `v` in the style of the value it replaces (the quote character of an old string). */
function literal(v: unknown, ind: string, old?: string): string {
if (typeof v === 'string' && old && (old[0] === '"' || old[0] === '`') && !v.includes('\n')) {
if (old[0] === '"') return JSON.stringify(v);
if (!/[`$\\]/.test(v)) return `\`${v}\``;
}
return toTs(v, ind);
}
/** Is the text at [s, e) a literal we may read through or replace (no identifiers, calls or spreads)? */
const plain = (src: string): boolean => /^(['"`]|-?\d|true\b|false\b|null\b|undefined\b|\{|\[)/.test(src.trim());
/** The config text with the value at `path` set (undefined: removed). */
export function setByPath(text: string, file: string, path: string | PathPart[], value: unknown): string {
const parts = typeof path === 'string' ? parsePath(path) : path;
if (file.endsWith('.json')) return setJson(text, parts, value);
let open = rootObject(text);
for (let n = 0; n < parts.length; n++) {
const part = parts[n]!;
const last = n === parts.length - 1;
const { list, close } = members(text, open);
const isArray = text[open] === '[';
let hit: Member | undefined;
if ('key' in part) {
if (isArray) throw new Error(`${pathText(parts.slice(0, n))} is a list; use [n] or [key=value]`);
hit = [...list].reverse().find(m => m.key === part.key);
} else {
if (!isArray) throw new Error(`${pathText(parts.slice(0, n))} is not a list`);
if ('index' in part) hit = list[part.index];
else hit = list.find(m => {
const v = text.slice(m.valueStart, m.valueEnd);
if (!v.startsWith('{')) return false;
const inner = members(text, m.valueStart).list.find(x => x.key === part.match);
if (!inner) return false;
const s = text.slice(inner.valueStart, inner.valueEnd);
return plain(s) && /^['"`]/.test(s) && evalString(s) === part.eq;
});
if (!hit) throw new Error(`${pathText(parts.slice(0, n + 1))}: no such element in the config`);
}
if (last) {
if (value === undefined) {
if (!hit) return text;
return removeMember(text, hit, list, open, close);
}
if (hit) {
const old = text.slice(hit.valueStart, hit.valueEnd);
if (hit.valueStart === hit.start && !isArray) throw new Error(`${pathText(parts)} is written as a shorthand property; change it in the file`);
if (!plain(old)) throw new Error(`${pathText(parts)} is not a plain value in the config (${old.slice(0, 40)}); change it in the file`);
return text.slice(0, hit.valueStart) + literal(value, lineIndent(text, hit.start), old) + text.slice(hit.valueEnd);
}
return insertMember(text, open, close, list, (part as { key: string }).key, value);
}
if (!hit) {
if (value === undefined) return text;
// build the rest of the path as nested objects (only keys can be created)
let v: unknown = value;
for (let k = parts.length - 1; k > n; k--) {
const p = parts[k]!;
if (!('key' in p)) throw new Error(`${pathText(parts.slice(0, k + 1))}: no such element in the config`);
v = { [p.key]: v };
}
return insertMember(text, open, close, list, (part as { key: string }).key, v);
}
const src = text.slice(hit.valueStart, hit.valueEnd);
if (!src.startsWith('{') && !src.startsWith('[')) throw new Error(`${pathText(parts.slice(0, n + 1))} is not a plain object in the config (${src.slice(0, 40)}); change it in the file`);
open = hit.valueStart;
}
return text;
}
/** The value of a string literal (single, double or backtick quotes without ${}). */
function evalString(s: string): string {
const q = s[0];
if (q === '"') return JSON.parse(s) as string;
const body = s.slice(1, -1);
return body.replace(/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g, (_m, e: string) => {
if (e[0] === 'u') return String.fromCodePoint(parseInt(e[1] === '{' ? e.slice(2, -1) : e.slice(1), 16));
if (e[0] === 'x') return String.fromCharCode(parseInt(e.slice(1), 16));
return ({ n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', '0': '\0' } as Record<string, string>)[e] ?? e;
});
}
function insertMember(t: string, open: number, close: number, list: Member[], key: string, value: unknown): string {
const k = /^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key.replace(/'/g, '\\\'')}'`;
const body = t.slice(open + 1, close);
if (!body.includes('\n')) {
const lit = toTs(value, lineIndent(t, open));
const inner = body.trim().replace(/,$/, '');
return `${t.slice(0, open + 1)} ${inner ? `${inner}, ` : ''}${k}: ${lit} ${t.slice(close)}`;
}
const lastM = list.at(-1);
const ind = lastM ? lineIndent(t, lastM.start) : lineIndent(t, open) + step(t);
const lit = toTs(value, ind);
if (lastM) {
const needComma = t.slice(lastM.valueEnd, lastM.end).indexOf(',') < 0;
// after the last member's comma and any comment on the same line
let at = lastM.end;
const nl = t.indexOf('\n', at);
if (nl >= 0 && nl < close && /^[ \t]*(\/\/[^\n]*)?$/.test(t.slice(at, nl))) at = nl;
const pre = needComma ? t.slice(0, lastM.valueEnd) + ',' + t.slice(lastM.valueEnd, at) : t.slice(0, at);
return `${pre}\n${ind}${k}: ${lit},${t.slice(at)}`;
}
return `${t.slice(0, open + 1)}\n${ind}${k}: ${lit},${t.slice(open + 1)}`;
}
function removeMember(t: string, m: Member, list: Member[], open: number, close: number): string {
const ls = t.lastIndexOf('\n', m.start - 1) + 1;
const ownLine = /^[ \t]*$/.test(t.slice(ls, m.start));
let end = m.end;
const nl = t.indexOf('\n', end);
if (ownLine && nl >= 0 && nl <= close && /^[ \t]*(\/\/[^\n]*)?$/.test(t.slice(end, nl))) end = nl + 1;
const start = ownLine ? ls : m.start;
let out = t.slice(0, start) + t.slice(end);
if (list.length === 1 && !t.slice(open, close).includes('\n')) out = t.slice(0, open + 1) + t.slice(close);
else if (m === list.at(-1) && t.slice(m.valueEnd, m.end).indexOf(',') < 0 && list.length > 1) {
// the previous member keeps a trailing comma: valid in TS and JS
}
return out;
}
function setJson(text: string, parts: PathPart[], value: unknown): string {
const root = JSON.parse(text) as Record<string, unknown>;
let cur: unknown = root;
for (let n = 0; n < parts.length; n++) {
const p = parts[n]!;
const last = n === parts.length - 1;
if ('key' in p) {
if (typeof cur !== 'object' || cur === null || Array.isArray(cur)) throw new Error(`${pathText(parts.slice(0, n))} is not an object`);
const o = cur as Record<string, unknown>;
if (last) { if (value === undefined) delete o[p.key]; else o[p.key] = value; break; }
if (o[p.key] === undefined) { if (value === undefined) return text; o[p.key] = {}; }
cur = o[p.key];
} else {
if (!Array.isArray(cur)) throw new Error(`${pathText(parts.slice(0, n))} is not a list`);
const i = 'index' in p ? p.index : cur.findIndex(x => x && typeof x === 'object' && (x as Record<string, unknown>)[p.match] === p.eq);
if (i < 0 || i >= cur.length) throw new Error(`${pathText(parts.slice(0, n + 1))}: no such element in the config`);
if (last) { if (value === undefined) cur.splice(i, 1); else cur[i] = value; break; }
cur = cur[i];
}
}
const ind = /\n([ \t]+)"/.exec(text)?.[1] ?? ' ';
return JSON.stringify(root, null, ind) + (text.endsWith('\n') ? '\n' : '');
}

View file

@ -850,14 +850,9 @@ export interface ConsoleConfig {
appContent?: AppContentConfig;
}
/** Play Console's game categories (Store settings > App category, app type Game). */
export const PLAY_GAME_CATEGORIES = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing',
'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word'] as const;
/** Play Console's app categories (Store settings > App category, app type App). */
export const PLAY_APP_CATEGORIES = ['Art & Design', 'Auto & Vehicles', 'Beauty', 'Books & Reference', 'Business', 'Comics', 'Communication', 'Dating',
'Education', 'Entertainment', 'Events', 'Finance', 'Food & Drink', 'Health & Fitness', 'House & Home', 'Libraries & Demo', 'Lifestyle',
'Maps & Navigation', 'Medical', 'Music & Audio', 'News & Magazines', 'Parenting', 'Personalization', 'Photography', 'Productivity', 'Shopping',
'Social', 'Sports', 'Tools', 'Travel & Local', 'Video Players & Editors', 'Weather'] as const;
export { PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES } from './store-catalogs';
import { PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES } from './store-catalogs';
/** App Store category ids with an exact Play counterpart. */
const APPLE_TO_PLAY: Record<string, string> = {
GAMES_ACTION: 'Action', GAMES_ADVENTURE: 'Adventure', GAMES_BOARD: 'Board', GAMES_CARD: 'Card', GAMES_CASINO: 'Casino', GAMES_CASUAL: 'Casual',

View file

@ -413,3 +413,44 @@ export interface ConsoleFillResult {
/** Real run only: fields filled, fields highlighted, whether it fell back to highlighting only, where it stopped. */
filled?: number; highlighted?: number; fallback?: unknown; stoppedAt?: string | null;
}
// ---- Editors (console parity, docs/console-parity.md) ----
/** One listing language of one store as the listing file holds it (project.editable). `exists`: the file has a section. */
export interface EditableListing { lang: string; language: string; status: string | null; exists: boolean; values: Record<string, string>; codes?: string[] }
/** project.editable (`edit --show --json`): what the desktop's editors show and change. */
export interface EditableResult {
root: string;
files: { config: string; play: string | null; apple: string | null; contact: string };
format: 'ts' | 'js' | 'json';
/** The app folder is a git work tree; which of the editable files have uncommitted changes. storebound never commits. */
git: { repo: boolean; uncommitted: string[] };
appName: string;
appleName: string;
languages: { id: string; play: string[]; apple: string | null; rtl: boolean }[];
play: EditableListing[] | null;
apple: EditableListing[] | null;
/** The config sections the editors change, as the config holds them (plain data). */
config: {
app: Record<string, unknown>; apple: Record<string, unknown> | null; play: Record<string, unknown> | null;
products: Record<string, unknown>[]; subscriptions: Record<string, unknown>[];
dataSafety: Record<string, unknown> | null; appPrivacy: Record<string, unknown> | null; reviewAccess: Record<string, unknown> | null;
accounts: Record<string, unknown> | null; social: Record<string, unknown> | null;
/** Screenshot scenes in store order and the feature graphic (shots). */
shots: { scenes: { id: string; frames?: { src: string }[] }[]; feature: Record<string, unknown> | null } | null;
};
/** App Review contact from .storebound/credentials.json, masked (personal data); null when not set. */
contact: { firstName: string; lastName: string; phone: string; email: string } | null;
/** The App Review demo account's user name, masked; the password is never read out. */
demoAccount: string | null;
limits: Record<string, Record<string, number> | number>;
}
/** project.edit (`edit --ops --json`): the plan (and with a confirm token, the write). */
export interface EditResult {
changes: { what: string; file: string; from: string | null; to: string | null; also?: string[] }[];
files: { file: string; rel: string; created: boolean; diff: string[]; masked?: boolean }[];
issues: { level: 'error' | 'warn' | 'info'; field: string; where: string; msg: string }[];
errors: number;
written: boolean;
dry: boolean;
git: { repo: boolean; uncommitted: string[] };
}

83
cli/lib/edit-values.ts Normal file
View file

@ -0,0 +1,83 @@
// What the desktop's editors show (`storebound edit --show --json`, serve method project.editable): the listing
// fields per store and language as the files hold them, the config sections the editors change, the App Review
// contact (masked: personal data), the store limits, and whether the files are uncommitted in git. Offline.
import { existsSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { APPLE_IAP_LIMITS, APPLE_LIMITS, PLAY_LIMITS } from './copy';
import { appleName, type Loaded, playCodes } from './config';
import { maskUser, reviewDemoAccount } from './credentials';
import type { EditableResult } from './desktop-protocol';
import { appleSection, playSection } from './listing-edit';
import { NOTES_MAX_BYTES } from './review-details';
import * as rt from './rt';
import { APPLE_SUB_LIMITS, PLAY_SUB_LIMITS } from './subscriptions';
/** Is `root` in a git work tree, and which of `files` have uncommitted changes (or are untracked)? */
export function gitState(root: string, files: string[]): { repo: boolean; uncommitted: string[] } {
try {
const inside = rt.spawnSync(['git', '-C', root, 'rev-parse', '--is-inside-work-tree'], { timeout: 5000 });
if (!inside.success || inside.stdout.toString().trim() !== 'true') return { repo: false, uncommitted: [] };
if (!files.length) return { repo: true, uncommitted: [] };
const st = rt.spawnSync(['git', '-C', root, 'status', '--porcelain', '--untracked-files=all', '--', ...files.map(f => relative(root, f) || f)], { timeout: 5000 });
if (!st.success) return { repo: true, uncommitted: [] };
const out = st.stdout.toString().split('\n').filter(Boolean).map(l => l.slice(3).replace(/^"|"$/g, ''));
// porcelain paths are relative to the repository top: keep the file names the window knows
const rels = files.map(f => relative(root, f) || f);
return { repo: true, uncommitted: rels.filter(r => out.some(o => o === r || o.endsWith(`/${r}`))) };
} catch {
return { repo: false, uncommitted: [] };
}
}
const mask = (field: string, v: string): string => (!v ? '' : field === 'email' ? maskUser(v) : field === 'phone' ? `${v.slice(0, 3)}***` : `${v[0]}***`);
export function editableValues(l: Loaded): EditableResult {
const { config, root, path, resolve } = l;
const rel = (f: string): string => relative(root, f) || f;
const playFile = config.play ? resolve(config.play.listing ?? 'store/listing.md') : null;
const appleFile = config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : null;
const playMd = playFile && existsSync(playFile) ? readFileSync(playFile, 'utf8') : null;
const appleMd = appleFile && existsSync(appleFile) ? readFileSync(appleFile, 'utf8') : null;
const contactFile = join(root, '.storebound', 'credentials.json');
let contact: EditableResult['contact'] = null;
try {
const c = existsSync(contactFile) ? (JSON.parse(readFileSync(contactFile, 'utf8')) as { reviewContact?: Record<string, string> }).reviewContact : undefined;
if (c) contact = { firstName: mask('firstName', c.firstName ?? ''), lastName: mask('lastName', c.lastName ?? ''), phone: mask('phone', c.phone ?? ''), email: mask('email', c.email ?? '') };
} catch { /* unreadable: the declarations card names it */ }
let demo: string | null = null;
try { const d = reviewDemoAccount(root); demo = d ? maskUser(d.username) : null; } catch { /* unreadable */ }
const play: EditableResult['play'] = [];
const apple: EditableResult['apple'] = [];
for (const lang of config.languages) {
if (config.play) for (const code of playCodes(lang)) {
const s = playMd ? playSection(playMd, code) : null;
play.push({ lang: code, language: lang.id, codes: s?.codes ?? [code], status: s?.status ?? null, exists: !!s, values: s?.values ?? {} });
}
if (config.apple && lang.apple) {
const s = appleMd ? appleSection(appleMd, lang.apple) : null;
apple.push({ lang: lang.apple, language: lang.id, status: s?.status ?? null, exists: !!s, values: s?.values ?? {} });
}
}
const files = [path, ...(playFile ? [playFile] : []), ...(appleFile ? [appleFile] : [])];
const { signing: _s, build: _b, adhoc: _a, ...appleRest } = config.apple ?? ({} as NonNullable<typeof config.apple>);
const { build: _pb, ...playRest } = config.play ?? ({} as NonNullable<typeof config.play>);
return {
root,
files: { config: rel(path), play: playFile ? rel(playFile) : null, apple: appleFile ? rel(appleFile) : null, contact: rel(contactFile) },
format: path.endsWith('.json') ? 'json' : path.endsWith('.js') ? 'js' : 'ts',
git: gitState(root, files),
appName: config.app.name,
appleName: appleName(config),
languages: config.languages.map(x => ({ id: x.id, play: playCodes(x), apple: x.apple ?? null, rtl: !!x.rtl })),
play: config.play ? play : null,
apple: config.apple ? apple : null,
config: JSON.parse(JSON.stringify({
app: config.app, apple: config.apple ? appleRest : null, play: config.play ? playRest : null, products: config.products ?? [], subscriptions: config.subscriptions ?? [],
dataSafety: config.dataSafety ?? null, appPrivacy: config.appPrivacy ?? null, reviewAccess: config.reviewAccess ?? null, accounts: config.accounts ?? null, social: config.social ?? null,
shots: config.shots ? { scenes: config.shots.scenes, feature: config.shots.feature ?? null } : null,
})) as EditableResult['config'],
contact,
demoAccount: demo,
limits: { play: PLAY_LIMITS, apple: APPLE_LIMITS, iap: APPLE_IAP_LIMITS, appleSub: APPLE_SUB_LIMITS, playSub: PLAY_SUB_LIMITS, reviewNotesBytes: NOTES_MAX_BYTES },
};
}

188
cli/lib/edit.ts Normal file
View file

@ -0,0 +1,188 @@
// The desktop's editors write through here (`storebound edit`, serve method project.edit): a list of edits of the
// app folder's sources of truth, planned as text edits, checked, shown as a diff, and written only when the checks
// pass. Sources:
// - storebound.config.ts / .js / .json: one value by path (config-path.ts), the rest of the file unchanged
// - store/listing.md (Google Play) and store/appstore.md (App Store): one field block (listing-edit.ts)
// - .storebound/credentials.json reviewContact (App Review contact: personal data, never in the config or a log line)
// The plan itself is pure (texts in, texts out); cli/edit.ts reads and writes the files.
import { compactDiff } from './diff';
import { setByPath } from './config-path';
import type { AppleField } from './copy';
import { appleSection, isAppleField, isPlayField, playSection, setAppleField, setPlayField } from './listing-edit';
import { appleLocaleIssues, emailIssue, type FieldIssue, playLangIssues, urlIssue } from './listing-rules';
export type EditOp =
| { kind: 'config'; path: string; value?: unknown; remove?: boolean }
| { kind: 'listing'; store: 'play' | 'apple'; lang: string; field: string; value: string }
| { kind: 'contact'; field: ContactField; value: string };
export type ContactField = 'firstName' | 'lastName' | 'phone' | 'email';
export const CONTACT_FIELDS: readonly ContactField[] = ['firstName', 'lastName', 'phone', 'email'];
export interface EditChange { what: string; file: string; from: string | null; to: string | null; also?: string[] }
export interface EditIssue extends FieldIssue { where: string }
export interface EditFile { file: string; rel: string; created: boolean; diff: string[]; masked?: boolean }
export interface EditPlan { changes: EditChange[]; files: EditFile[]; issues: EditIssue[]; errors: number }
/** What the plan needs to know about the folder: the files by role and their current text. */
export interface EditSources {
config: { file: string; rel: string; text: string };
play: { file: string; rel: string; text: string | null } | null;
apple: { file: string; rel: string; text: string | null } | null;
contact: { file: string; rel: string; text: string | null };
appName: string;
appleName: string;
}
/** Checks one edit's shape (it comes from the window or a terminal). Throws with the reason. */
export function checkOp(o: unknown, i: number): EditOp {
if (!o || typeof o !== 'object' || Array.isArray(o)) throw new Error(`edit ${i + 1}: an object`);
const e = o as Record<string, unknown>;
const str = (k: string, max: number): string => {
if (typeof e[k] !== 'string' || (e[k] as string).length > max) throw new Error(`edit ${i + 1}: ${k} must be text (at most ${max} characters)`);
return e[k] as string;
};
if (e.kind === 'config') {
const path = str('path', 300);
if (!/^[A-Za-z_$][\w$-]*(\.[A-Za-z0-9_$-]+|\[\d+\]|\[[A-Za-z_$][\w$]*=[^\]]{1,200}\])*$/.test(path)) throw new Error(`edit ${i + 1}: path ${path} is not a config path`);
if (/^(account|scan)\b/.test(path)) throw new Error(`edit ${i + 1}: ${path} is not edited from here`);
if (e.remove !== undefined && typeof e.remove !== 'boolean') throw new Error(`edit ${i + 1}: remove is true or false`);
if (e.value === undefined && e.remove !== true) throw new Error(`edit ${i + 1}: a value (or remove: true)`);
checkValue(e.value, `edit ${i + 1}`, 0);
return { kind: 'config', path, ...(e.remove === true ? { remove: true } : { value: e.value }) };
}
if (e.kind === 'listing') {
if (e.store !== 'play' && e.store !== 'apple') throw new Error(`edit ${i + 1}: store play or apple`);
const lang = str('lang', 20);
if (!/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang)) throw new Error(`edit ${i + 1}: ${lang} is not a language code`);
const field = str('field', 30);
if (e.store === 'play' ? !isPlayField(field) : !isAppleField(field)) throw new Error(`edit ${i + 1}: ${String(e.store)} has no field ${field}`);
return { kind: 'listing', store: e.store, lang, field, value: str('value', 20_000) };
}
if (e.kind === 'contact') {
const field = str('field', 20) as ContactField;
if (!CONTACT_FIELDS.includes(field)) throw new Error(`edit ${i + 1}: contact field ${field}`);
return { kind: 'contact', field, value: str('value', 200) };
}
throw new Error(`edit ${i + 1}: kind config, listing or contact`);
}
/** Plain JSON data only (no functions), not too deep or big. */
function checkValue(v: unknown, at: string, depth: number): void {
if (depth > 8) throw new Error(`${at}: value nested too deep`);
if (v === null || v === undefined || typeof v === 'boolean' || typeof v === 'string') return;
if (typeof v === 'number') { if (!Number.isFinite(v)) throw new Error(`${at}: a finite number`); return; }
if (Array.isArray(v)) { if (v.length > 500) throw new Error(`${at}: list too long`); for (const x of v) checkValue(x, at, depth + 1); return; }
if (typeof v === 'object') { const e = Object.entries(v as Record<string, unknown>); if (e.length > 300) throw new Error(`${at}: too many keys`); for (const [, x] of e) checkValue(x, at, depth + 1); return; }
throw new Error(`${at}: plain data only`);
}
export const parseOps = (json: string): EditOp[] => {
let raw: unknown;
try { raw = JSON.parse(json); } catch (e) { throw new Error(`--ops: not JSON (${(e as Error).message})`); }
const list = Array.isArray(raw) ? raw : [raw];
if (!list.length) throw new Error('--ops: no edits');
if (list.length > 400) throw new Error('--ops: at most 400 edits at once');
return list.map(checkOp);
};
const show = (v: unknown): string | null => (v === undefined || v === null ? null : typeof v === 'string' ? v : JSON.stringify(v));
const maskValue = (field: ContactField, v: string): string => (!v ? '' : field === 'email' ? `${v[0]}***${v.slice(v.indexOf('@'))}` : field === 'phone' ? `${v.slice(0, 3)}***` : `${v[0]}***`);
/** Values the config paths of the editors hold: URL and email fields get their format checked. */
const URL_PATHS = /^(app\.(privacyPolicyUrl|termsUrl|supportUrl|marketingUrl|website)|accounts\.deletionUrl|dataSafety\.partialDeletionUrl|play\.video)$/;
const EMAIL_PATHS = /^app\.contactEmail$/;
/**
* The plan of a list of edits: new file texts, what changes (old -> new), the diff per file and the checks. Pure.
* `getPath` reads a value of the loaded config by path (for the "from" side of config edits).
*/
export function planEdits(ops: EditOp[], src: EditSources, getPath: (path: string) => unknown): { plan: EditPlan; texts: Map<string, string> } {
const texts = new Map<string, string>();
const before = new Map<string, string | null>();
const rel = new Map<string, string>();
const read = (f: { file: string; rel: string; text: string | null }): string => {
if (!texts.has(f.file)) { texts.set(f.file, f.text ?? ''); before.set(f.file, f.text); rel.set(f.file, f.rel); }
return texts.get(f.file)!;
};
const changes: EditChange[] = [];
const issues: EditIssue[] = [];
const touched = { play: new Set<string>(), apple: new Set<string>() };
const onlyWhatToTest = new Map<string, boolean>();
let contact: Record<string, unknown> | null = null;
for (const op of ops) {
if (op.kind === 'config') {
const t = read(src.config);
const value = op.remove ? undefined : op.value;
const next = setByPath(t, src.config.file, op.path, value);
texts.set(src.config.file, next);
changes.push({ what: op.path, file: src.config.rel, from: show(getPath(op.path)), to: show(value) });
if (typeof value === 'string' && URL_PATHS.test(op.path)) { const u = urlIssue(value); if (u) issues.push({ level: 'error', field: op.path, where: op.path, msg: u }); }
if (typeof value === 'string' && EMAIL_PATHS.test(op.path)) { const u = emailIssue(value); if (u) issues.push({ level: 'error', field: op.path, where: op.path, msg: u }); }
} else if (op.kind === 'listing') {
const f = op.store === 'play' ? src.play : src.apple;
if (!f) throw new Error(`this app has no ${op.store === 'play' ? 'Google Play' : 'App Store'} section in the config`);
const t = read(f);
const r = op.store === 'play' ? setPlayField(t, op.lang, op.field as never, op.value) : setAppleField(t, op.lang, op.field as AppleField | 'whatToTest', op.value);
texts.set(f.file, r.md);
changes.push({ what: `${op.store === 'play' ? 'Google Play' : 'App Store'} ${op.lang} ${op.field}`, file: f.rel, from: r.from, to: op.value.trim() || null, ...(r.codes.length > 1 ? { also: r.codes.filter(c => c !== op.lang) } : {}) });
touched[op.store].add(op.lang);
if (op.store === 'apple') onlyWhatToTest.set(op.lang, (onlyWhatToTest.get(op.lang) ?? true) && op.field === 'whatToTest');
} else {
read(src.contact);
contact ??= src.contact.text ? JSON.parse(src.contact.text) as Record<string, unknown> : {};
const rc = { ...((contact.reviewContact ?? {}) as Record<string, string>) };
const from = rc[op.field] ?? '';
rc[op.field] = op.value.trim();
contact.reviewContact = rc;
if (op.field === 'email') { const u = emailIssue(op.value.trim()); if (u) issues.push({ level: 'error', field: 'email', where: 'App Review contact', msg: u }); }
texts.set(src.contact.file, JSON.stringify(contact, null, 2) + '\n');
changes.push({ what: `App Review contact ${op.field}`, file: src.contact.rel, from: from ? maskValue(op.field, from) : null, to: op.value.trim() ? maskValue(op.field, op.value.trim()) : null });
}
}
// the touched languages as they would be saved, with the checks of `check` / `check --aso` / `scan`
if (src.play) for (const lang of touched.play) {
const s = playSection(texts.get(src.play.file) ?? '', lang);
if (s) for (const x of playLangIssues(lang, s.values, src.appName)) issues.push({ ...x, where: `Google Play ${lang}` });
}
if (src.apple) for (const loc of touched.apple) {
const s = appleSection(texts.get(src.apple.file) ?? '', loc);
if (s && onlyWhatToTest.get(loc)) { const n = [...(s.values.whatToTest ?? '')].length; if (n > 4000) issues.push({ level: 'error', field: 'whatToTest', where: `TestFlight ${loc}`, msg: `What to Test is ${n} characters; TestFlight takes 4000` }); continue; }
if (s) for (const x of appleLocaleIssues(loc, s.values, src.appleName)) issues.push({ ...x, where: `App Store ${loc}` });
}
const files: EditFile[] = [];
for (const [file, after] of texts) {
const b = before.get(file) ?? null;
if (b === after) continue;
const masked = file === src.contact.file;
files.push({ file, rel: rel.get(file)!, created: b === null, diff: masked ? [] : compactDiff(b ?? '', after) , ...(masked ? { masked: true } : {}) });
}
for (const [file] of [...texts]) if (!files.some(f => f.file === file)) texts.delete(file);
return { plan: { changes, files, issues, errors: issues.filter(i => i.level === 'error').length }, texts };
}
/** A value of a plain object by config path (`products[id=x].listings.en.title`); undefined when missing. */
export function getByPath(root: unknown, path: string): unknown {
let cur: unknown = root;
const re = /([A-Za-z0-9_$-]+)|\[(\d+)\]|\[([A-Za-z_$][\w$]*)=([^\]]+)\]/g;
for (let m = re.exec(path); m; m = re.exec(path)) {
if (cur === null || cur === undefined) return undefined;
if (m[1] !== undefined) cur = (cur as Record<string, unknown>)[m[1]];
else if (m[2] !== undefined) cur = Array.isArray(cur) ? cur[Number(m[2])] : undefined;
else cur = Array.isArray(cur) ? cur.find(x => x && typeof x === 'object' && (x as Record<string, unknown>)[m![3]!] === m![4]) : undefined;
}
return cur;
}
/** Text lines of a plan for a terminal and the confirm dialog (personal data masked). */
export function planLines(p: EditPlan): string[] {
const cut = (s: string | null): string => (s === null ? '(none)' : s.length > 70 ? `"${s.slice(0, 67).replace(/\n/g, ' ')}..."` : `"${s.replace(/\n/g, ' ')}"`);
const out: string[] = [];
for (const c of p.changes) out.push(`${c.what}: ${cut(c.from)} -> ${cut(c.to)}${c.also?.length ? ` (the same section also serves ${c.also.join(', ')})` : ''}`);
for (const i of p.issues) out.push(`${i.level === 'error' ? 'ERROR' : i.level === 'warn' ? 'warn ' : 'hint '} [${i.where}] ${i.msg}`);
for (const f of p.files) {
out.push(`--- ${f.rel}${f.created ? ' (new file)' : ''}`);
if (f.masked) out.push(' (personal data: not shown)');
else out.push(...f.diff.map(l => ` ${l}`));
}
return out;
}

119
cli/lib/listing-edit.ts Normal file
View file

@ -0,0 +1,119 @@
// One field of the listing files (store/listing.md for Google Play, store/appstore.md for the App Store) set as a text
// edit: only the fenced block of that field changes; headings, prose, comments and the other sections stay as they
// are. Used by `storebound edit` (the desktop's listing editor). Pure.
import { APPLE_LIMITS, type AppleField, PLAY_LIMITS } from './copy';
export type PlayField = 'title' | 'short' | 'full' | 'whatsNew';
export const PLAY_FIELDS: readonly PlayField[] = ['title', 'short', 'full', 'whatsNew'];
export const APPLE_FIELDS: readonly AppleField[] = ['name', 'subtitle', 'promotionalText', 'keywords', 'description', 'whatsNew'];
/** TestFlight "What to Test" per locale lives in the App Store listing file too (`apple testflight what-to-test`). */
export const WHAT_TO_TEST = 'whatToTest';
export const WHAT_TO_TEST_MAX = 4000;
type AppleEditField = AppleField | typeof WHAT_TO_TEST;
/** The heading a field's block sits under (the parsers in copy.ts match these) and the one a new block gets. */
const PLAY_HEAD: Record<PlayField, { re: RegExp; heading: string }> = {
title: { re: /[^\n]*\btitle\b/i, heading: `Title (max ${PLAY_LIMITS.title})` },
short: { re: /[^\n]*\bshort\b/i, heading: `Short description (max ${PLAY_LIMITS.short})` },
full: { re: /[^\n]*\bfull\b/i, heading: `Full description (max ${PLAY_LIMITS.full})` },
whatsNew: { re: /[^\n]*(?:what'?s new|release notes)/i, heading: `What's new (max ${PLAY_LIMITS.whatsNew})` },
};
const fence = (t: string): string => '```\n' + t + '\n```';
const blockRe = (heading: RegExp): RegExp => new RegExp(`(^### (?:${heading.source})[^\\n]*\\n)\`\`\`[^\\n]*\\n([\\s\\S]*?)\\n?\`\`\`\\n?`, 'm' + (heading.flags.includes('i') ? 'i' : ''));
/** One section with `text` as the block under `heading` (replaced, appended, or removed when text is empty). */
function setBlock(sec: string, heading: RegExp, newHeading: string, text: string): { sec: string; from: string | null } {
const re = blockRe(heading);
const m = re.exec(sec);
const from = m ? m[2]!.trim() : null;
if (!text) {
if (!m) return { sec, from };
// the heading line, its block and one blank line after it
const start = m.index, end = m.index + m[0].length;
const rest = sec.slice(end).replace(/^\n/, '');
// the last block of the section: the section ends as it did before the block (one newline)
if (!rest.trim()) return { sec: sec.slice(0, start).replace(/\s*$/, '\n'), from };
return { sec: sec.slice(0, start) + rest, from };
}
if (m) return { sec: sec.slice(0, m.index) + m[1] + fence(text) + '\n' + sec.slice(m.index + m[0].length), from };
const body = sec.replace(/\s*$/, '');
return { sec: `${body}\n\n### ${newHeading}\n${fence(text)}\n\n`, from: null };
}
export interface ListingEditResult { md: string; from: string | null; codes: string[]; created: boolean }
/** Sets one Play field of the section that serves `lang` (all its codes change: one section can serve several Play
* languages). A language without a section gets a new one at the end. */
export function setPlayField(md: string, lang: string, field: PlayField, text: string): ListingEditResult {
const parts = md.split(/^(?=## )/m);
const v = text.replace(/\r\n/g, '\n').trim();
for (let i = 0; i < parts.length; i++) {
const m = /^<!--\s*play:\s*([^>]+?)\s*-->$/m.exec(parts[i]!);
if (!m || !parts[i]!.startsWith('## ')) continue;
const codes = m[1]!.split(',').map(s => s.trim()).filter(Boolean);
if (!codes.includes(lang)) continue;
const r = setBlock(parts[i]!, PLAY_HEAD[field].re, PLAY_HEAD[field].heading, v);
parts[i] = r.sec;
return { md: parts.join(''), from: r.from, codes, created: false };
}
if (!v) return { md, from: null, codes: [lang], created: false };
const sec = `## ${lang}\n<!-- play: ${lang} -->\n\n### ${PLAY_HEAD[field].heading}\n${fence(v)}\n`;
return { md: `${md.replace(/\s*$/, '')}\n\n${sec}`, from: null, codes: [lang], created: true };
}
/** Sets one App Store field of the `locale` section (a missing section is added at the end). */
export function setAppleField(md: string, locale: string, field: AppleEditField, text: string): ListingEditResult {
const parts = md.split(/^(?=## )/m);
const v = text.replace(/\r\n/g, '\n').trim();
for (let i = 0; i < parts.length; i++) {
const sec = parts[i]!;
if (!sec.startsWith('## ')) continue;
if (sec.slice(3).split(/[\s·]/)[0]!.trim() !== locale) continue;
const r = setBlock(sec, new RegExp(`${field}\\b`), field, v);
parts[i] = r.sec;
return { md: parts.join(''), from: r.from, codes: [locale], created: false };
}
if (!v) return { md, from: null, codes: [locale], created: false };
return { md: `${md.replace(/\s*$/, '')}\n\n## ${locale}\n\n### ${field}\n${fence(v)}\n`, from: null, codes: [locale], created: true };
}
export const isPlayField = (f: string): f is PlayField => (PLAY_FIELDS as readonly string[]).includes(f);
export const isAppleField = (f: string): f is AppleEditField => f in APPLE_LIMITS || f === WHAT_TO_TEST;
/** The fields of the Play section that serves `lang` (null: no section). */
export function playSection(md: string, lang: string): { codes: string[]; heading: string; values: Partial<Record<PlayField, string>>; status: string | null } | null {
for (const sec of md.split(/^(?=## )/m)) {
if (!sec.startsWith('## ')) continue;
const m = /^<!--\s*play:\s*([^>]+?)\s*-->$/m.exec(sec);
if (!m) continue;
const codes = m[1]!.split(',').map(s => s.trim()).filter(Boolean);
if (!codes.includes(lang)) continue;
const values: Partial<Record<PlayField, string>> = {};
for (const f of PLAY_FIELDS) { const b = blockRe(PLAY_HEAD[f].re).exec(sec); if (b) values[f] = b[2]!.trim(); }
return { codes, heading: sec.slice(3).split('\n')[0]!.trim(), values, status: translationStatus(sec) };
}
return null;
}
/** The fields of one App Store locale section (null: no section). */
export function appleSection(md: string, locale: string): { heading: string; values: Partial<Record<AppleEditField, string>>; status: string | null } | null {
for (const sec of md.split(/^(?=## )/m)) {
if (!sec.startsWith('## ') || sec.slice(3).split(/[\s·]/)[0]!.trim() !== locale) continue;
const values: Partial<Record<AppleEditField, string>> = {};
for (const f of [...APPLE_FIELDS, WHAT_TO_TEST] as const) { const b = blockRe(new RegExp(`${f}\\b`)).exec(sec); if (b) values[f] = b[2]!.trim(); }
return { heading: sec.slice(3).split('\n')[0]!.trim(), values, status: translationStatus(sec) };
}
return null;
}
/** `listing translate` marks: status=translate (not translated yet) or status=draft (machine draft, not reviewed). */
function translationStatus(sec: string): string | null {
return /<!--\s*storebound-translation\s+from=\S+\s+status=(\w+)/.exec(sec)?.[1] ?? null;
}
/** The store limit of a listing field (characters). */
export function listingLimit(store: 'play' | 'apple', field: string): number | null {
if (store === 'apple') return field === WHAT_TO_TEST ? WHAT_TO_TEST_MAX : field in APPLE_LIMITS ? APPLE_LIMITS[field as AppleField] : null;
return isPlayField(field) ? PLAY_LIMITS[field] : null;
}

114
cli/lib/listing-rules.ts Normal file
View file

@ -0,0 +1,114 @@
// Checks of one listing language as the owner types it: the store limits (characters), the fields the parsers need,
// and the metadata rules `scan` and `check --aso` apply to the whole file (other platforms named in App Store text,
// other apps' names or prices in the name / subtitle / keywords, placeholder text, beta wording, keyword stuffing).
// Pure, no Node imports: the desktop window runs it on every keystroke, `storebound edit` before it writes.
import { auditAppleKeywords, auditPlayListing } from './aso';
import { APPLE_LIMITS, type AppleCopy, len, PLAY_LIMITS } from './copy';
export type IssueLevel = 'error' | 'warn' | 'info';
export interface FieldIssue { level: IssueLevel; field: string; msg: string }
/** Other apps and trademarks that get App Store metadata rejected (2.3.7) in the name, subtitle and keywords. */
export const COMPETITORS = ['instagram', 'tiktok', 'whatsapp', 'facebook', 'snapchat', 'youtube', 'netflix', 'spotify', 'telegram', 'messenger', 'twitter', 'pinterest', 'uber', 'airbnb',
'amazon', 'chatgpt', 'openai', 'minecraft', 'roblox', 'fortnite', 'pubg', 'candy crush', 'clash of clans', 'pokemon', 'pokémon', 'among us', 'subway surfers', 'temple run',
'angry birds', 'tetris', 'wordle', 'duolingo', 'zoom', 'gmail', 'google maps', 'waze', 'shazam', 'tinder', 'notion', 'evernote', 'photoshop', 'canva', 'capcut', 'iphone', 'ipad', 'apple'];
/** Other mobile platforms or stores in App Store text (2.3.10). */
export const OTHER_PLATFORMS = /\b(Android|Google Play|Play Store|Galaxy Store|AppGallery|Huawei|Windows Phone|BlackBerry|Amazon Appstore)\b/;
// Developer markers count only in capitals ("TODO:", "FIXME"): "todo" is a real word and keyword (a to-do app).
export const PLACEHOLDER_MARKERS = /\bTODO\b|\bTBD\b|\bFIXME\b|\bXXX\b/;
export const PLACEHOLDER_TEXT = /lorem ipsum|\bplaceholder\b|your app (name|description) here|coming soon|First paragraph:|Short hook under the name|One or two sentences you can change|İsmin altındaki kısa kanca|Yeni sürüm göndermeden istediğin zaman|\bExample (Game|App|Pro|Studio)\b/i;
/** Demo, beta and trial wording (Apple 2.2: those belong on TestFlight). */
export const BETA_WORDS = /\b(beta|demo|trial version|test (version|build|app)|testing build|early access|prototype|preview (build|version)|alpha (build|version))\b/i;
/** Prices and superlatives in the App Store name, subtitle or keywords (2.3.7). */
export const PRICE_CLAIMS = /\b(free|best|#1|no\.? ?1|top|cheap|sale|\d+% off|\$\d)\b/i;
export const competitorRe = (): RegExp => new RegExp(`(^|[\\s,|/–-])(${COMPETITORS.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?=$|[\\s,|/–-])`, 'i');
export const PLAY_REQUIRED = ['short', 'full'] as const;
export const APPLE_REQUIRED = ['subtitle', 'promotionalText', 'keywords', 'description'] as const;
/**
* Issues of one App Store locale (the values as they would be saved). `appName`: what the name falls back to
* (apple.name, else app.name). Errors stop a save (the store would refuse it, or the listing file would no longer
* parse); warnings and hints do not.
*/
export function appleLocaleIssues(locale: string, v: AppleCopy, appName: string): FieldIssue[] {
const out: FieldIssue[] = [];
for (const f of APPLE_REQUIRED) if (!(v[f] ?? '').trim()) out.push({ level: 'error', field: f, msg: `${locale}: ${f} is required` });
for (const [f, lim] of Object.entries(APPLE_LIMITS) as [keyof typeof APPLE_LIMITS, number][]) {
const n = len(v[f] ?? '');
if (n > lim) out.push({ level: 'error', field: f, msg: `${f} is ${n} characters; the App Store takes ${lim}` });
}
const name = (v.name ?? appName).trim();
if (!v.name && len(name) > APPLE_LIMITS.name) out.push({ level: 'error', field: 'name', msg: `the name falls back to "${name}" (${len(name)} > ${APPLE_LIMITS.name}); give this locale its own name` });
if (v.keywords) {
// auditAppleKeywords: the 100-character limit, spaces around commas, duplicates, words the name / subtitle index
for (const a of auditAppleKeywords(locale, v, appName)) {
if (/characters > 100/.test(a.msg)) continue; // the limit above already says it
out.push({ level: a.level, field: 'keywords', msg: a.msg.replace(/^keywords:\s*/, '') });
}
}
const comp = competitorRe();
for (const f of ['name', 'subtitle', 'keywords'] as const) {
const t = f === 'name' ? name : v[f] ?? '';
if (!t) continue;
const c = comp.exec(t);
if (c) out.push({ level: 'warn', field: f, msg: `"${c[2]}" is another app's name or a trademark; Apple rejects it in the ${f} (2.3.7)` });
const p = PRICE_CLAIMS.exec(t);
if (p) out.push({ level: 'warn', field: f, msg: `"${p[0]}": prices and claims do not belong in the ${f} (2.3.7)` });
}
for (const f of ['name', 'subtitle'] as const) {
const t = f === 'name' ? name : v[f] ?? '';
if (/^([^,|]{1,24}[,|]){5,}/.test(t)) out.push({ level: 'warn', field: f, msg: `reads like a keyword list; the ${f} is a short phrase (2.3.7)` });
}
for (const f of ['name', 'subtitle', 'promotionalText', 'keywords', 'description', 'whatsNew'] as const) {
const t = f === 'name' ? (v.name ?? '') : v[f] ?? '';
if (!t) continue;
const o = OTHER_PLATFORMS.exec(t);
if (o) out.push({ level: 'warn', field: f, msg: `"${o[0]}": other platforms or stores in App Store text get it rejected (2.3.10)` });
if (PLACEHOLDER_MARKERS.test(t) || PLACEHOLDER_TEXT.test(t)) out.push({ level: 'warn', field: f, msg: 'placeholder text (template or TODO) is still in it' });
const b = f === 'whatsNew' ? null : BETA_WORDS.exec(t);
if (b && !/\bdemo account\b|\bdemo (video|mode)\b/i.test(t)) out.push({ level: 'warn', field: f, msg: `"${b[0]}": demos, betas and trials belong on TestFlight (2.2)` });
}
return out;
}
export interface PlayValues { title?: string; short?: string; full?: string; whatsNew?: string }
/** Issues of one Google Play language (title falls back to app.name). */
export function playLangIssues(lang: string, v: PlayValues, appName: string): FieldIssue[] {
const out: FieldIssue[] = [];
for (const f of PLAY_REQUIRED) if (!(v[f] ?? '').trim()) out.push({ level: 'error', field: f, msg: `${lang}: the ${f} description is required` });
const title = (v.title ?? '').trim() || appName;
for (const [f, lim] of Object.entries(PLAY_LIMITS) as [keyof typeof PLAY_LIMITS, number][]) {
const n = len(f === 'title' ? title : v[f] ?? '');
if (n > lim) out.push({ level: 'error', field: f, msg: f === 'title' && !v.title ? `the title falls back to "${title}" (${n} > ${lim}); give this language its own title` : `${f} is ${n} characters; Google Play takes ${lim}` });
}
if (v.short && v.full) {
for (const a of auditPlayListing({ lang, title, short: v.short, full: v.full, whatsNew: v.whatsNew ?? '' })) {
const field = /^full description/.test(a.msg) ? 'full' : /^title \/ short/.test(a.msg) ? 'short' : /^title/.test(a.msg) ? 'title' : /^short/.test(a.msg) ? 'short' : 'full';
out.push({ level: a.level, field, msg: a.msg });
}
}
for (const f of ['title', 'short', 'full', 'whatsNew'] as const) {
const t = v[f] ?? '';
if (t && (PLACEHOLDER_MARKERS.test(t) || PLACEHOLDER_TEXT.test(t))) out.push({ level: 'warn', field: f, msg: 'placeholder text (template or TODO) is still in it' });
}
return out;
}
/** An http(s) URL the stores take (empty: no issue; required fields say so themselves). */
export function urlIssue(v: string): string | null {
if (!v) return null;
try {
const u = new URL(v);
if (u.protocol !== 'https:' && u.protocol !== 'http:') return 'an http(s) address';
if (u.protocol === 'http:') return 'use https (the stores want a secure page)';
if (!u.hostname.includes('.')) return 'a public address (with a domain)';
return null;
} catch { return 'not a web address (https://...)'; }
}
export function emailIssue(v: string): string | null {
if (!v) return null;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : 'not an email address';
}

View file

@ -30,7 +30,7 @@ const ID = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
export function toTs(v: unknown, ind = ''): string {
const next = ind + ' ';
if (v === null || v === undefined) return 'undefined';
if (typeof v === 'string') return `'${v.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`;
if (typeof v === 'string') return `'${v.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')}'`;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
if (Array.isArray(v)) {
const parts = v.map(x => toTs(x, next));

View file

@ -33,6 +33,7 @@
// error of Play Console (no policy page; developer.android.com/build/configure-app-module on choosing the id).
// The other Google Play rules (sensitive permissions, prominent disclosure, Families, advertising ID, 16 KB pages,
// testing for personal accounts, Play metadata, ads, payments, impersonation) are in play-rules.ts with their sources.
import { BETA_WORDS, competitorRe, OTHER_PLATFORMS, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules';
import type { ShipConfig } from '../config';
import { allSubscriptions } from '../subscriptions';
import { sdkOfProjectPath } from '../privacy-files';
@ -127,13 +128,6 @@ export const SIGNALS = {
};
const ATT_SDKS_SHOW_PROMPT = new Set(['applovin', 'levelplay']);
const COMPETITORS = ['instagram', 'tiktok', 'whatsapp', 'facebook', 'snapchat', 'youtube', 'netflix', 'spotify', 'telegram', 'messenger', 'twitter', 'pinterest', 'uber', 'airbnb',
'amazon', 'chatgpt', 'openai', 'minecraft', 'roblox', 'fortnite', 'pubg', 'candy crush', 'clash of clans', 'pokemon', 'pokémon', 'among us', 'subway surfers', 'temple run',
'angry birds', 'tetris', 'wordle', 'duolingo', 'zoom', 'gmail', 'google maps', 'waze', 'shazam', 'tinder', 'notion', 'evernote', 'photoshop', 'canva', 'capcut', 'iphone', 'ipad', 'apple'];
const OTHER_PLATFORMS = /\b(Android|Google Play|Play Store|Galaxy Store|AppGallery|Huawei|Windows Phone|BlackBerry|Amazon Appstore)\b/;
// Developer markers count only in capitals ("TODO:", "FIXME"): "todo" is a real word and keyword (a to-do app).
const PLACEHOLDER_MARKERS = /\bTODO\b|\bTBD\b|\bFIXME\b|\bXXX\b/;
const PLACEHOLDER_TEXT = /lorem ipsum|\bplaceholder\b|your app (name|description) here|coming soon|First paragraph:|Short hook under the name|One or two sentences you can change|İsmin altındaki kısa kanca|Yeni sürüm göndermeden istediğin zaman|\bExample (Game|App|Pro|Studio)\b/i;
const PLACEHOLDER = { test: (l: string) => PLACEHOLDER_MARKERS.test(l) || PLACEHOLDER_TEXT.test(l) } as RegExp;
const FGS_PERMISSION: Record<string, string> = {
camera: 'CAMERA', connectedDevice: 'CONNECTED_DEVICE', dataSync: 'DATA_SYNC', health: 'HEALTH', location: 'LOCATION', mediaPlayback: 'MEDIA_PLAYBACK',
@ -368,12 +362,12 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
if (other.length)
add({ rule: 'other-platforms', guideline: '2.3.10', store: 'apple', severity: 'high', title: 'Other mobile platforms or stores named in the App Store metadata or screenshot captions', evidence: other.slice(0, 4),
fix: 'Remove "Android", "Google Play" and other platforms or stores from the App Store text and screenshots (Play listing text is separate: store/listing.md).', skill: 'aso-basics' });
const words = new RegExp(`(^|[\\s,|/–-])(${COMPETITORS.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?=$|[\\s,|/–-])`, 'i');
const words = competitorRe();
const kw = [...listingHits(inp.listings.apple, words, { field: /^(keywords|name|subtitle)$/ })];
if (kw.length)
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'high', title: 'Other apps\' names or trademarks in the name, subtitle or keywords', evidence: kw.slice(0, 4),
fix: 'Use only terms that describe your app; popular app names, trademarks and Apple product names in keywords get the metadata rejected.', skill: 'aso-basics' });
const price = listingHits(inp.listings.apple, /\b(free|best|#1|no\.? ?1|top|cheap|sale|\d+% off|\$\d)\b/i, { field: /^(name|subtitle|keywords)$/ });
const price = listingHits(inp.listings.apple, PRICE_CLAIMS, { field: /^(name|subtitle|keywords)$/ });
if (price.length)
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'medium', title: 'Prices or claims ("free", "best", "#1") in the name, subtitle or keywords', evidence: price.slice(0, 3),
fix: 'Keep prices and superlatives out of the name, subtitle and keywords (keywords already match "free" searches only by category).', skill: 'aso-basics' });
@ -381,7 +375,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
if (stuffed.length)
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'medium', title: 'Keyword list in the name or subtitle', evidence: stuffed.slice(0, 2), fix: 'Name and subtitle are a short phrase; keywords belong in the keywords field.', skill: 'aso-basics' });
// 2.2: demos, betas and trial versions belong on TestFlight, not on the App Store (the name and the listing say so)
const BETA = /\b(beta|demo|trial version|test (version|build|app)|testing build|early access|prototype|preview (build|version)|alpha (build|version))\b/i;
const BETA = BETA_WORDS;
const betaName: Hit[] = [];
if (c?.app?.name && BETA.test(c.app.name)) betaName.push(...cfg(c.app.name, `app.name: ${c.app.name}`));
if (c?.apple?.name && c.apple.name !== c.app?.name && BETA.test(c.apple.name)) betaName.push(...cfg(c.apple.name, `apple.name: ${c.apple.name}`));

44
cli/lib/store-catalogs.ts Normal file
View file

@ -0,0 +1,44 @@
// Fixed lists of the consoles' choice fields, shared by the CLI and the desktop's editors (pure, no Node imports):
// App Store categories (with the iTunes genre ids), Google Play categories, the App Store age rating questions with
// their "nothing" answers, Play target audience age groups.
/** App Store Connect category id -> iTunes genre id (the "genreIds" of Search API results). Apple's genre ids. */
export const GENRE_IDS: Record<string, string> = {
BOOKS: '6018', BUSINESS: '6000', DEVELOPER_TOOLS: '6026', EDUCATION: '6017', ENTERTAINMENT: '6016', FINANCE: '6015', FOOD_AND_DRINK: '6023', GAMES: '6014',
GRAPHICS_AND_DESIGN: '6027', HEALTH_AND_FITNESS: '6013', LIFESTYLE: '6012', MAGAZINES_AND_NEWSPAPERS: '6021', MEDICAL: '6020', MUSIC: '6011', NAVIGATION: '6010',
NEWS: '6009', PHOTO_AND_VIDEO: '6008', PRODUCTIVITY: '6007', REFERENCE: '6006', SHOPPING: '6024', SOCIAL_NETWORKING: '6005', SPORTS: '6004', STICKERS: '6025',
TRAVEL: '6003', UTILITIES: '6002', WEATHER: '6001',
GAMES_ACTION: '7001', GAMES_ADVENTURE: '7002', GAMES_CASUAL: '7003', GAMES_BOARD: '7004', GAMES_CARD: '7005', GAMES_CASINO: '7006', GAMES_FAMILY: '7009',
GAMES_MUSIC: '7011', GAMES_PUZZLE: '7012', GAMES_RACING: '7013', GAMES_ROLE_PLAYING: '7014', GAMES_SIMULATION: '7015', GAMES_SPORTS: '7016',
GAMES_STRATEGY: '7017', GAMES_TRIVIA: '7018', GAMES_WORD: '7019',
};
/** App Store primary / secondary categories (no GAMES_* subcategories). */
export const APPLE_CATEGORIES = Object.keys(GENRE_IDS).filter(k => !k.startsWith('GAMES_'));
/** App Store game subcategories (primarySubOne / primarySubTwo when the primary is GAMES). */
export const APPLE_GAME_SUBCATEGORIES = Object.keys(GENRE_IDS).filter(k => k.startsWith('GAMES_'));
/** Play Console's game categories (Store settings > App category, app type Game). */
export const PLAY_GAME_CATEGORIES = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing',
'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word'] as const;
/** Play Console's app categories (Store settings > App category, app type App). */
export const PLAY_APP_CATEGORIES = ['Art & Design', 'Auto & Vehicles', 'Beauty', 'Books & Reference', 'Business', 'Comics', 'Communication', 'Dating',
'Education', 'Entertainment', 'Events', 'Finance', 'Food & Drink', 'Health & Fitness', 'House & Home', 'Libraries & Demo', 'Lifestyle',
'Maps & Navigation', 'Medical', 'Music & Audio', 'News & Magazines', 'Parenting', 'Personalization', 'Photography', 'Productivity', 'Shopping',
'Social', 'Sports', 'Tools', 'Travel & Local', 'Video Players & Editors', 'Weather'] as const;
/** Every question of the current App Store age rating declaration at its "nothing" answer. */
export const AGE_RATING_NONE: Record<string, string | boolean | null> = {
advertising: false, ageAssurance: false, alcoholTobaccoOrDrugUseOrReferences: 'NONE', contests: 'NONE', gambling: false,
gamblingSimulated: 'NONE', gunsOrOtherWeapons: 'NONE', healthOrWellnessTopics: false, horrorOrFearThemes: 'NONE', lootBox: false,
matureOrSuggestiveThemes: 'NONE', medicalOrTreatmentInformation: 'NONE', messagingAndChat: false, parentalControls: false,
profanityOrCrudeHumor: 'NONE', socialMedia: false, socialMediaAgeRestricted: false, sexualContentGraphicAndNudity: 'NONE', sexualContentOrNudity: 'NONE', unrestrictedWebAccess: false,
userGeneratedContent: false, violenceCartoonOrFantasy: 'NONE', violenceRealistic: 'NONE',
violenceRealisticProlongedGraphicOrSadistic: 'NONE', kidsAgeBand: null,
};
/** The answers of a frequency question (App Store Connect's None / Infrequent or mild / Frequent or intense). */
export const AGE_FREQUENCY = ['NONE', 'INFREQUENT_OR_MILD', 'FREQUENT_OR_INTENSE'] as const;
/** The Kids Category age band (null: not in the Kids Category). */
export const KIDS_AGE_BANDS = ['FIVE_AND_UNDER', 'SIX_TO_EIGHT', 'NINE_TO_ELEVEN'] as const;
/** Play target audience age groups (App content > Target audience and content). */
export const PLAY_AGE_GROUPS = ['0-5', '6-8', '9-12', '13-15', '16-17', '18+'] as const;