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:
parent
714352ec18
commit
3cea2af4e1
37 changed files with 3020 additions and 54 deletions
|
|
@ -13,15 +13,8 @@ import { type ItemPlan, itemLines, planItem } from '../lib/listing-plan';
|
|||
import { done, log, printJson, warn } from '../lib/log';
|
||||
import { ascClient } from './asc';
|
||||
|
||||
/** Every question of the current 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,
|
||||
};
|
||||
export { AGE_RATING_NONE } from '../lib/store-catalogs';
|
||||
import { AGE_RATING_NONE } from '../lib/store-catalogs';
|
||||
|
||||
const CATEGORY_RELS = ['primaryCategory', 'primarySubcategoryOne', 'primarySubcategoryTwo', 'secondaryCategory'] as const;
|
||||
const str = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
|
|||
'ads': [() => import('./ads'), 'scan --ios <path>: AdAttributionKit / SKAdNetwork checks of Info.plist (network ids, postback copy endpoints, attribution windows, cooldown) and sources; --json'],
|
||||
};
|
||||
export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
||||
'edit': [() => import('./edit'), 'edit the app folder\'s sources of truth: --ops \'<json>\' (config values by path, listing fields per store and language, the App Review contact), dry run with the diff and the checks first; --show --json lists what the desktop editors show'],
|
||||
'inspect': [() => import('./inspect'), 'offline summary of the config (name, store ids, languages, products, subscriptions); --json'],
|
||||
'check': [() => import('./check'), 'validate config, copy files, data safety answers, products, subscriptions and the app profile (offline); --aso keyword audit'],
|
||||
'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'],
|
||||
|
|
|
|||
|
|
@ -394,6 +394,10 @@
|
|||
"doctor": [
|
||||
"dir"
|
||||
],
|
||||
"edit": [
|
||||
"ops",
|
||||
"show"
|
||||
],
|
||||
"flow": [
|
||||
"apply",
|
||||
"from",
|
||||
|
|
@ -918,6 +922,7 @@
|
|||
"only-keywords",
|
||||
"only-missing",
|
||||
"only-terms",
|
||||
"ops",
|
||||
"originality",
|
||||
"out",
|
||||
"param",
|
||||
|
|
@ -948,6 +953,7 @@
|
|||
"sdk-dirs",
|
||||
"search",
|
||||
"seconds",
|
||||
"show",
|
||||
"signature",
|
||||
"since",
|
||||
"size",
|
||||
|
|
@ -1188,6 +1194,10 @@
|
|||
"doctor": [
|
||||
"storebound doctor [--dir .] [--json]"
|
||||
],
|
||||
"edit": [
|
||||
"storebound edit --ops '<json>' [--dry] [--json]",
|
||||
"storebound edit --show --json every value the desktop's editors show (listings, config sections, git state)"
|
||||
],
|
||||
"flow": [
|
||||
"storebound flow list [--json] the flows of this project and their steps",
|
||||
"storebound flow validate [--json] definitions, sub-flow cycles, commands, variables; exit 1 on a problem",
|
||||
|
|
|
|||
118
cli/edit.ts
Normal file
118
cli/edit.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// Edit the app folder's sources of truth from the desktop (or a terminal): config values by path, listing fields per
|
||||
// store and language, the App Review contact. Local files only; nothing goes to a store.
|
||||
// storebound edit --ops '<json>' [--dry] [--json]
|
||||
// storebound edit --show --json every value the desktop's editors show (listings, config sections, git state)
|
||||
// --ops is one edit or a list:
|
||||
// { "kind": "config", "path": "app.supportUrl", "value": "https://example.com/help" }
|
||||
// { "kind": "config", "path": "products[id=com.example.no_ads].listings.en.title", "value": "No ads" }
|
||||
// { "kind": "config", "path": "apple.info.reviewNotes", "remove": true }
|
||||
// { "kind": "listing", "store": "apple", "lang": "en-US", "field": "subtitle", "value": "Lists that stay tidy" }
|
||||
// { "kind": "listing", "store": "play", "lang": "tr-TR", "field": "short", "value": "..." }
|
||||
// { "kind": "contact", "field": "email", "value": "review@example.com" } (.storebound/credentials.json, masked)
|
||||
// Every run plans first: old -> new per edit, the diff per file and the checks (store limits, required fields, keyword
|
||||
// and metadata rules of `check --aso` / `scan`, URLs, and the whole config validated as `check` loads it). --dry stops
|
||||
// there. An error stops the write; the files are written only when every check passes (comments, order and
|
||||
// formatting of the files stay as they are: only the edited values change). Never commits: a git folder shows the
|
||||
// changed files as uncommitted.
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { appleName, validateConfig } from './lib/config';
|
||||
import type { Ctx } from './lib/ctx';
|
||||
import { type EditPlan, type EditSources, getByPath, parseOps, planEdits, planLines } from './lib/edit';
|
||||
import { editableValues, gitState } from './lib/edit-values';
|
||||
import { done, log, printJson, warn } from './lib/log';
|
||||
import * as rt from './lib/rt';
|
||||
|
||||
const TAG = 'edit';
|
||||
|
||||
export function sources(ctx: Ctx): EditSources {
|
||||
const { config, root, path, resolve } = ctx.loaded;
|
||||
const rel = (f: string): string => relative(root, f) || f;
|
||||
const file = (f: string) => ({ file: f, rel: rel(f), text: existsSync(f) ? readFileSync(f, 'utf8') : null });
|
||||
const contact = join(root, '.storebound', 'credentials.json');
|
||||
return {
|
||||
config: { file: path, rel: rel(path), text: readFileSync(path, 'utf8') },
|
||||
play: config.play ? file(resolve(config.play.listing ?? 'store/listing.md')) : null,
|
||||
apple: config.apple ? file(resolve(config.apple.listing ?? 'store/appstore.md')) : null,
|
||||
contact: file(contact),
|
||||
appName: config.app.name,
|
||||
appleName: appleName(config),
|
||||
};
|
||||
}
|
||||
|
||||
/** The edited config loads and validates like `check` loads it (a temp file next to it, so relative imports work). */
|
||||
async function validateConfigText(path: string, text: string): Promise<string[]> {
|
||||
const ext = path.slice(path.lastIndexOf('.'));
|
||||
const tmp = join(dirname(path), `.storebound-edit-${process.pid}-${Date.now().toString(36)}${ext}`);
|
||||
writeFileSync(tmp, text);
|
||||
try {
|
||||
if (ext === '.json') return validateConfig(JSON.parse(text));
|
||||
const mod = await rt.importConfigModule(tmp);
|
||||
return validateConfig(mod.default ?? mod);
|
||||
} catch (e) {
|
||||
return [`the edited config does not load: ${(e as Error).message.split('\n')[0]}`];
|
||||
} finally {
|
||||
rmSync(tmp, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes a file through a temp file and a rename, keeping the old file's mode (credentials.json stays 0600). */
|
||||
function writeAtomic(file: string, text: string): void {
|
||||
let mode: number | null = null;
|
||||
try { mode = statSync(file).mode & 0o777; } catch { /* new */ }
|
||||
mkdirSync(dirname(file), { recursive: true, ...(file.includes(`${join('.storebound', '')}`) ? { mode: 0o700 } : {}) });
|
||||
const tmp = `${file}.storebound-${process.pid}.tmp`;
|
||||
writeFileSync(tmp, text, { mode: mode ?? (file.endsWith('credentials.json') ? 0o600 : 0o644) });
|
||||
if (mode !== null) chmodSync(tmp, mode);
|
||||
renameSync(tmp, file);
|
||||
}
|
||||
|
||||
export async function plan(ctx: Ctx, opsJson: string): Promise<{ plan: EditPlan; texts: Map<string, string> }> {
|
||||
const src = sources(ctx);
|
||||
const ops = parseOps(opsJson);
|
||||
const r = planEdits(ops, src, p => getByPath(ctx.loaded.config, p));
|
||||
const cfgText = r.texts.get(src.config.file);
|
||||
if (cfgText !== undefined) for (const e of await validateConfigText(src.config.file, cfgText)) {
|
||||
r.plan.issues.push({ level: 'error', field: 'config', where: src.config.rel, msg: e });
|
||||
r.plan.errors++;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const a = ctx.args;
|
||||
const { root } = ctx.loaded;
|
||||
if (a.has('show')) { printJson(editableValues(ctx.loaded)); return; }
|
||||
const opsJson = a.get('ops');
|
||||
if (!opsJson) throw new Error('usage: storebound edit --ops \'<json>\' [--dry] [--json] | storebound edit --show --json');
|
||||
const { plan: p, texts } = await plan(ctx, opsJson);
|
||||
const dry = a.has('dry');
|
||||
// with --json in a terminal, stdout is the JSON result only: the lines go to stderr (under `serve` the result is a
|
||||
// marked line, and the lines stay on stdout: the confirm dialog shows them)
|
||||
const toErr = a.has('json') && process.env.STOREBOUND_MACHINE !== '1';
|
||||
const say = (f: (tag: string, msg: string) => void) => (msg: string): void => { if (toErr) console.error(`[${TAG}] ${msg}`); else f(TAG, msg); };
|
||||
const sDone = say(done), sLog = say(log), sWarn = say(warn);
|
||||
const lines = planLines(p);
|
||||
for (const l of lines) (toErr ? console.error : console.log)(l);
|
||||
const git = () => gitState(root, p.files.map(f => f.file));
|
||||
if (p.errors) {
|
||||
if (a.has('json')) printJson({ ...p, written: false, dry, git: git() });
|
||||
throw new Error(`nothing written: ${p.errors} error${p.errors === 1 ? '' : 's'} (see above)`);
|
||||
}
|
||||
if (!p.files.length) {
|
||||
if (a.has('json')) printJson({ ...p, written: false, dry, git: git() });
|
||||
sDone('nothing to change: the files already hold these values');
|
||||
return;
|
||||
}
|
||||
if (dry) {
|
||||
if (a.has('json')) printJson({ ...p, written: false, dry, git: git() });
|
||||
sDone(`${p.changes.length} change(s) in ${p.files.map(f => f.rel).join(', ')}; nothing written (--dry)`);
|
||||
return;
|
||||
}
|
||||
for (const f of p.files) writeAtomic(f.file, texts.get(f.file)!);
|
||||
const g = git();
|
||||
if (a.has('json')) printJson({ ...p, written: true, dry: false, git: g });
|
||||
for (const f of p.files) sLog(`wrote ${f.rel}`);
|
||||
if (g.repo && g.uncommitted.length) sWarn(`uncommitted in git: ${g.uncommitted.join(', ')} (storebound never commits)`);
|
||||
sDone(`${p.changes.length} change(s) saved. Next: apply them to the stores (the desktop's Apply, or play listing / apple listing ...)`);
|
||||
}
|
||||
|
|
@ -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
297
cli/lib/config-path.ts
Normal 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' : '');
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
83
cli/lib/edit-values.ts
Normal 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
188
cli/lib/edit.ts
Normal 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
119
cli/lib/listing-edit.ts
Normal 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
114
cli/lib/listing-rules.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
44
cli/lib/store-catalogs.ts
Normal 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;
|
||||
10
cli/serve.ts
10
cli/serve.ts
|
|
@ -51,12 +51,12 @@ export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|com
|
|||
/** Read tools whose command prints a structured result with --json. */
|
||||
export const JSON_TOOLS = new Set(['check', 'check_aso', 'aso_audit', 'aso_suggest', 'aso_competitors', 'scan', 'validate', 'health', 'health_issue', 'reviews_list', 'prices_diff', 'analytics_compare', 'apple_status',
|
||||
'apple_next_build', 'play_status', 'play_next_version_code', 'shots_check', 'apple_testflight_status', 'apple_ppo_status', 'catalog_diff',
|
||||
'project_icon', 'project_listings', 'reviews_drafts', 'project_declarations', 'project_listing_text', 'project_renders', 'project_render', 'prices_current']);
|
||||
'project_icon', 'project_listings', 'reviews_drafts', 'project_declarations', 'project_listing_text', 'project_renders', 'project_render', 'prices_current', 'project_editable']);
|
||||
|
||||
/** Console forms `storebound console fill` knows (cli/console/fill.ts). */
|
||||
export const CONSOLE_FORMS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'];
|
||||
/** Write tools that print a structured result with --json in the dry run and the real run (console.fill: the answers). */
|
||||
export const JSON_WRITES = new Set(['console_fill', 'owner_apple_submit', 'owner_apple_resubmit']);
|
||||
export const JSON_WRITES = new Set(['console_fill', 'owner_apple_submit', 'owner_apple_resubmit', 'project_edit']);
|
||||
/** Write tools whose command may ask its own question (confirm()): a verified host token answers it, as for owner actions. */
|
||||
const TOKEN_ANSWERS = new Set(['console_fill']);
|
||||
|
||||
|
|
@ -98,6 +98,12 @@ export const DESKTOP_ONLY: ToolDef[] = [
|
|||
description: 'One rendered image of the renders folder as a data: URL (at most 12 MB), for the desktop gallery.',
|
||||
props: { file: { type: 'string', pattern: RENDER_FILE_RE.source, description: 'Path relative to the renders folder, as project.renders names it' } },
|
||||
required: ['file'], args: i => ['--render', String(i.file)] },
|
||||
{ name: 'project_editable', title: 'Editable values', command: ['edit', '--show'], write: false, network: false,
|
||||
description: 'What the editors show: listing fields per store and language as the listing files hold them, the config sections the editors change, the App Review contact (masked), the store limits and whether the files are uncommitted in git.' },
|
||||
{ name: 'project_edit', title: 'Save edits to the app folder', command: ['edit'], write: true, network: false, idempotent: true,
|
||||
description: 'Writes edits of the app folder\'s sources of truth: config values by path (the rest of the file, comments and formatting kept), listing fields per store and language (store/listing.md, store/appstore.md), the App Review contact (.storebound/credentials.json). The dry run shows old -> new, the diff per file and the checks (store limits, required fields, keyword and metadata rules, the whole config validated); an error stops the write. Nothing goes to a store and nothing is committed.',
|
||||
props: { ops: { type: 'string', pattern: '^[\\[{][\\s\\S]{1,400000}$', description: 'The edits as JSON (one edit or a list; see `storebound edit`)' } },
|
||||
required: ['ops'], args: i => ['--ops', String(i.ops)] },
|
||||
{ name: 'console_fill', title: 'Fill in a console form', command: ['console', 'fill'], write: true, network: true,
|
||||
description: 'Fills one store console form that has no API (App Store app record, age rating, App Privacy; Play content rating, target audience) from the config in a visible Chromium the owner signs in to; uncertain fields are highlighted. It moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save in its dialogs; content rating: Save draft) and never submits or publishes: the owner reviews every field and presses the final button (Publish, Submit, Save or Create). The dry run lists the answers; the real run lasts until the owner closes the browser.',
|
||||
props: { form: { type: 'string', enum: CONSOLE_FORMS, description: 'The console form' }, store: { type: 'string', enum: ['apple', 'play'], description: 'Default: from the form' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue