store-only apps edit their listing on the store (storebound listing live / set: old -> new per field with the checks, a PATCH of only the edited fields; serve listing.set is the one store-only write, its token bound to the store ids; policy.rs STORE_ONLY_WRITES, write_with_confirm for store targets); screenshot captions per language and scene edited as a text edit of the shots copy JSON (JSON files keep their layout); Play content rating, target audience and App content answers in App info; docs/console-parity.md after the editors (73 rows: 55 complete, 18 with the reason) and tests/console-parity.test.ts keeping it honest
This commit is contained in:
parent
3cea2af4e1
commit
7ac028421a
33 changed files with 852 additions and 157 deletions
|
|
@ -28,8 +28,10 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'appEvents_createInstance': 'apple/events',
|
||||
'appEvents_getInstance': 'apple/events',
|
||||
'appEvents_localizations_getToManyRelated': 'apple/events',
|
||||
'appInfoLocalizations_createInstance': 'listing-store',
|
||||
'appInfoLocalizations_updateInstance': 'listing-store',
|
||||
'appInfos_ageRatingDeclaration_getToOneRelated': 'apple/appinfo console/verify validate',
|
||||
'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing onboard validate',
|
||||
'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing listing-store onboard validate',
|
||||
'appInfos_getInstance': 'apple/appinfo',
|
||||
'appInfos_updateInstance': 'apple/appinfo',
|
||||
'appPreviewSets_appPreviews_getToManyRelated': 'apple/previews',
|
||||
|
|
@ -51,11 +53,13 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'appStoreVersionExperimentsV2_updateInstance': 'lib/ppo',
|
||||
'appStoreVersionLocalizations_appPreviewSets_getToManyRelated': 'apple/previews',
|
||||
'appStoreVersionLocalizations_appScreenshotSets_getToManyRelated': 'apple/screenshots validate',
|
||||
'appStoreVersionLocalizations_createInstance': 'listing-store',
|
||||
'appStoreVersionLocalizations_updateInstance': 'listing-store',
|
||||
'appStoreVersionPhasedReleases_createInstance': 'apple/release',
|
||||
'appStoreVersionPhasedReleases_updateInstance': 'apple/release',
|
||||
'appStoreVersionReleaseRequests_createInstance': 'apple/release',
|
||||
'appStoreVersions_appStoreReviewDetail_getToOneRelated': 'apple/review-details onboard validate',
|
||||
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots onboard validate',
|
||||
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots listing-store onboard validate',
|
||||
'appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated': 'apple/release apple/status',
|
||||
'appStoreVersions_build_getToOneRelated': 'apple/listing apple/resubmit validate',
|
||||
'appStoreVersions_build_updateToOneRelationship': 'apple/listing apple/resubmit',
|
||||
|
|
@ -70,7 +74,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'apps_appPricePoints_getToManyRelated': 'apple/appinfo',
|
||||
'apps_appPriceSchedule_getToOneRelated': 'apple/appinfo validate',
|
||||
'apps_appStoreVersionExperimentsV2_getToManyRelated': 'apple/ppo',
|
||||
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/iap-submit apple/listing apple/release apple/resubmit apple/review-details apple/status lib/store-apps onboard validate',
|
||||
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/iap-submit apple/listing apple/release apple/resubmit apple/review-details apple/status lib/store-apps listing-store onboard validate',
|
||||
'apps_betaAppLocalizations_getToManyRelated': 'apple/testflight',
|
||||
'apps_betaAppReviewDetail_getToOneRelated': 'apple/testflight',
|
||||
'apps_betaFeedbackCrashSubmissions_getToManyRelated': 'health',
|
||||
|
|
@ -217,8 +221,9 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'androidpublisher.edits.images.list': 'apps play/images validate',
|
||||
'androidpublisher.edits.images.upload': 'play/images',
|
||||
'androidpublisher.edits.insert': 'apps onboard play/client validate',
|
||||
'androidpublisher.edits.listings.list': 'play/listing play/status validate',
|
||||
'androidpublisher.edits.listings.update': 'play/listing',
|
||||
'androidpublisher.edits.listings.list': 'listing-store play/listing play/status validate',
|
||||
'androidpublisher.edits.listings.patch': 'listing-store',
|
||||
'androidpublisher.edits.listings.update': 'listing-store play/listing',
|
||||
'androidpublisher.edits.tracks.get': 'play/release play/rollout play/upload',
|
||||
'androidpublisher.edits.tracks.list': 'play/next-version-code play/status validate',
|
||||
'androidpublisher.edits.tracks.update': 'play/release play/rollout play/upload',
|
||||
|
|
|
|||
|
|
@ -439,6 +439,7 @@
|
|||
],
|
||||
"listing": [
|
||||
"all",
|
||||
"fields",
|
||||
"force",
|
||||
"from",
|
||||
"lang",
|
||||
|
|
@ -849,6 +850,7 @@
|
|||
"end",
|
||||
"experiment",
|
||||
"expo",
|
||||
"fields",
|
||||
"file",
|
||||
"flavor",
|
||||
"floor",
|
||||
|
|
@ -1230,7 +1232,8 @@
|
|||
"listing": [
|
||||
"storebound listing translate --to de-DE,ja [--from en-US] [--store apple|play] [--translate \"<command>\"] [--force] [--dry] [--json]",
|
||||
"storebound listing check [--json] limits, keyword rules, untranslated and draft sections; exit 1 on a problem",
|
||||
"storebound listing reviewed --lang de-DE[,ja] | --all [--dry] a human read the draft: remove its marker"
|
||||
"storebound listing reviewed --lang de-DE[,ja] | --all [--dry] a human read the draft: remove its marker",
|
||||
"storebound listing live [--json] | set --store play|apple --lang <code> --fields '<json>' [--dry] the texts straight on"
|
||||
],
|
||||
"onboard": [
|
||||
"storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog]"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
// { "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)
|
||||
// { "kind": "shotsCopy", "lang": "en", "scene": "lists", "field": "title", "line": 0, "value": "Every list," }
|
||||
// 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
|
||||
|
|
@ -35,6 +36,7 @@ export function sources(ctx: Ctx): EditSources {
|
|||
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),
|
||||
shotsCopy: config.shots?.copy ? file(resolve(config.shots.copy)) : null,
|
||||
appName: config.app.name,
|
||||
appleName: appleName(config),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -152,8 +152,21 @@ function step(t: string): string {
|
|||
return m ? (m[1]!.startsWith('\t') ? '\t' : m[1]!.length >= 4 && m[1]!.length % 4 === 0 && !/\n {2}\S/.test(t) ? ' ' : ' ') : ' ';
|
||||
}
|
||||
|
||||
/** JSON files are edited as text too (their own formatting, e.g. one scene per line in the shots copy file, stays):
|
||||
* values as JSON, keys in double quotes, no trailing commas. Set for the duration of one setByPath call. */
|
||||
let JSON_MODE = false;
|
||||
|
||||
/** A JSON literal: one line when short, else indented like the line it sits on. */
|
||||
function jsonLit(v: unknown, ind: string): string {
|
||||
const one = JSON.stringify(v);
|
||||
if (one.length <= 100 || typeof v !== 'object' || v === null) return one;
|
||||
return JSON.stringify(v, null, 2).split('\n').map((l, i) => (i ? ind + l : l)).join('\n');
|
||||
}
|
||||
const lit = (v: unknown, ind: string): string => (JSON_MODE ? jsonLit(v, ind) : toTs(v, ind));
|
||||
|
||||
/** 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 (JSON_MODE) return jsonLit(v, ind);
|
||||
if (typeof v === 'string' && old && (old[0] === '"' || old[0] === '`') && !v.includes('\n')) {
|
||||
if (old[0] === '"') return JSON.stringify(v);
|
||||
if (!/[`$\\]/.test(v)) return `\`${v}\``;
|
||||
|
|
@ -167,8 +180,13 @@ const plain = (src: string): boolean => /^(['"`]|-?\d|true\b|false\b|null\b|unde
|
|||
/** 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);
|
||||
JSON_MODE = file.endsWith('.json');
|
||||
try { return setInText(text, parts, value); } finally { JSON_MODE = false; }
|
||||
}
|
||||
|
||||
function setInText(text: string, parts: PathPart[], value: unknown): string {
|
||||
let open = JSON_MODE ? text.search(/\S/) : rootObject(text);
|
||||
if (JSON_MODE && text[open] !== '{') throw new Error('the JSON file is not an object');
|
||||
for (let n = 0; n < parts.length; n++) {
|
||||
const part = parts[n]!;
|
||||
const last = n === parts.length - 1;
|
||||
|
|
@ -235,26 +253,27 @@ function evalString(s: string): string {
|
|||
}
|
||||
|
||||
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 k = JSON_MODE ? JSON.stringify(key) : /^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key.replace(/'/g, '\\\'')}'`;
|
||||
const end = JSON_MODE ? '' : ',';
|
||||
const body = t.slice(open + 1, close);
|
||||
if (!body.includes('\n')) {
|
||||
const lit = toTs(value, lineIndent(t, open));
|
||||
const l1 = lit(value, lineIndent(t, open));
|
||||
const inner = body.trim().replace(/,$/, '');
|
||||
return `${t.slice(0, open + 1)} ${inner ? `${inner}, ` : ''}${k}: ${lit} ${t.slice(close)}`;
|
||||
return `${t.slice(0, open + 1)} ${inner ? `${inner}, ` : ''}${k}: ${l1} ${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);
|
||||
const l2 = lit(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;
|
||||
let at = needComma ? lastM.valueEnd : 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 `${pre}\n${ind}${k}: ${l2}${end}${t.slice(at)}`;
|
||||
}
|
||||
return `${t.slice(0, open + 1)}\n${ind}${k}: ${lit},${t.slice(open + 1)}`;
|
||||
return `${t.slice(0, open + 1)}\n${ind}${k}: ${l2}${end}${t.slice(open + 1)}`;
|
||||
}
|
||||
|
||||
function removeMember(t: string, m: Member, list: Member[], open: number, close: number): string {
|
||||
|
|
@ -266,32 +285,14 @@ function removeMember(t: string, m: Member, list: Member[], open: number, close:
|
|||
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 (JSON_MODE && m === list.at(-1) && list.length > 1) {
|
||||
// JSON has no trailing comma: drop the one after the member that is now the last
|
||||
const prev = list[list.length - 2]!;
|
||||
const comma = t.indexOf(',', prev.valueEnd);
|
||||
if (comma >= 0 && comma < prev.end) out = out.slice(0, comma) + out.slice(comma + 1);
|
||||
}
|
||||
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' : '');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -420,7 +420,9 @@ export interface EditableListing { lang: string; language: string; status: strin
|
|||
/** 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 };
|
||||
files: { config: string; play: string | null; apple: string | null; contact: string; shotsCopy?: string | null };
|
||||
/** The screenshot captions (shots copy file): language -> scene -> eyebrow, title (two lines), sub. */
|
||||
shotsCopy?: Record<string, Record<string, { eyebrow?: string; title?: string[]; sub?: string } & Record<string, unknown>>> | null;
|
||||
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[] };
|
||||
|
|
@ -435,6 +437,8 @@ export interface EditableResult {
|
|||
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;
|
||||
/** Console form answers (Play content rating, target audience, App content declarations; the App Store record). */
|
||||
console?: 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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -58,12 +58,16 @@ export function editableValues(l: Loaded): EditableResult {
|
|||
apple.push({ lang: lang.apple, language: lang.id, status: s?.status ?? null, exists: !!s, values: s?.values ?? {} });
|
||||
}
|
||||
}
|
||||
const files = [path, ...(playFile ? [playFile] : []), ...(appleFile ? [appleFile] : [])];
|
||||
let shotsCopy: EditableResult['shotsCopy'] = null;
|
||||
const copyFile = config.shots?.copy ? resolve(config.shots.copy) : null;
|
||||
try { if (copyFile && existsSync(copyFile)) shotsCopy = JSON.parse(readFileSync(copyFile, 'utf8')) as EditableResult['shotsCopy']; } catch { /* broken JSON: shots check names it */ }
|
||||
const files = [path, ...(copyFile ? [copyFile] : []), ...(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) },
|
||||
files: { config: rel(path), play: playFile ? rel(playFile) : null, apple: appleFile ? rel(appleFile) : null, contact: rel(contactFile), shotsCopy: copyFile ? rel(copyFile) : null },
|
||||
shotsCopy,
|
||||
format: path.endsWith('.json') ? 'json' : path.endsWith('.js') ? 'js' : 'ts',
|
||||
git: gitState(root, files),
|
||||
appName: config.app.name,
|
||||
|
|
@ -74,6 +78,7 @@ export function editableValues(l: Loaded): EditableResult {
|
|||
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,
|
||||
console: config.console ?? null,
|
||||
shots: config.shots ? { scenes: config.shots.scenes, feature: config.shots.feature ?? null } : null,
|
||||
})) as EditableResult['config'],
|
||||
contact,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// 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)
|
||||
// - the shots copy file (shots.copy, JSON): screenshot captions per language and scene, as a text edit too
|
||||
// - .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';
|
||||
|
|
@ -14,7 +15,9 @@ import { appleLocaleIssues, emailIssue, type FieldIssue, playLangIssues, urlIssu
|
|||
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 };
|
||||
| { kind: 'contact'; field: ContactField; value: string }
|
||||
/** A screenshot caption in the shots copy file (shots.copy): eyebrow, title line 1 / 2 (title[0], title[1]) or sub. */
|
||||
| { kind: 'shotsCopy'; lang: string; scene: string; field: 'eyebrow' | 'title' | 'sub'; line?: 0 | 1; value: string };
|
||||
export type ContactField = 'firstName' | 'lastName' | 'phone' | 'email';
|
||||
export const CONTACT_FIELDS: readonly ContactField[] = ['firstName', 'lastName', 'phone', 'email'];
|
||||
|
||||
|
|
@ -29,6 +32,7 @@ export interface EditSources {
|
|||
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 };
|
||||
shotsCopy?: { file: string; rel: string; text: string | null } | null;
|
||||
appName: string;
|
||||
appleName: string;
|
||||
}
|
||||
|
|
@ -58,12 +62,20 @@ export function checkOp(o: unknown, i: number): EditOp {
|
|||
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 === 'shotsCopy') {
|
||||
const lang = str('lang', 40), scene = str('scene', 60);
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(lang) || !/^[A-Za-z0-9_-]+$/.test(scene)) throw new Error(`edit ${i + 1}: lang and scene are ids`);
|
||||
if (e.field !== 'eyebrow' && e.field !== 'title' && e.field !== 'sub') throw new Error(`edit ${i + 1}: caption field eyebrow, title or sub`);
|
||||
if (e.line !== undefined && e.line !== 0 && e.line !== 1) throw new Error(`edit ${i + 1}: line 0 or 1`);
|
||||
if (e.field === 'title' && e.line === undefined) throw new Error(`edit ${i + 1}: the title needs line 0 or 1`);
|
||||
return { kind: 'shotsCopy', lang, scene, field: e.field, ...(e.field === 'title' ? { line: e.line as 0 | 1 } : {}), value: str('value', 400) };
|
||||
}
|
||||
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`);
|
||||
throw new Error(`edit ${i + 1}: kind config, listing, shotsCopy or contact`);
|
||||
}
|
||||
|
||||
/** Plain JSON data only (no functions), not too deep or big. */
|
||||
|
|
@ -127,6 +139,23 @@ export function planEdits(ops: EditOp[], src: EditSources, getPath: (path: strin
|
|||
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 if (op.kind === 'shotsCopy') {
|
||||
if (!src.shotsCopy) throw new Error('this app has no shots copy file (shots.copy in the config)');
|
||||
const t = read(src.shotsCopy) || '{}\n';
|
||||
let cur: unknown;
|
||||
try { cur = JSON.parse(t); } catch (e) { throw new Error(`${src.shotsCopy.rel}: not JSON (${(e as Error).message})`); }
|
||||
const scene = ((cur as Record<string, Record<string, Record<string, unknown>>>)[op.lang] ?? {})[op.scene] ?? {};
|
||||
const title = Array.isArray(scene.title) ? [...(scene.title as string[])] : [];
|
||||
const from = op.field === 'title' ? title[op.line!] ?? null : (scene[op.field] as string | undefined) ?? null;
|
||||
let next: string;
|
||||
if (op.field === 'title') {
|
||||
// the title is two lines: set one, keep the other
|
||||
while (title.length < 2) title.push('');
|
||||
title[op.line!] = op.value;
|
||||
next = setByPath(t, src.shotsCopy.file, [{ key: op.lang }, { key: op.scene }, { key: 'title' }], title);
|
||||
} else next = setByPath(t, src.shotsCopy.file, [{ key: op.lang }, { key: op.scene }, { key: op.field }], op.value);
|
||||
texts.set(src.shotsCopy.file, next);
|
||||
changes.push({ what: `screenshot ${op.lang} ${op.scene} ${op.field}${op.field === 'title' ? ` line ${op.line! + 1}` : ''}`, file: src.shotsCopy.rel, from, to: op.value || null });
|
||||
} else {
|
||||
read(src.contact);
|
||||
contact ??= src.contact.text ? JSON.parse(src.contact.text) as Record<string, unknown> : {};
|
||||
|
|
|
|||
194
cli/listing-store.ts
Normal file
194
cli/listing-store.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// Listing text straight on the stores, without listing files: for store-only apps in the desktop (no app folder, so
|
||||
// nothing to save into) and for a quick fix of one language.
|
||||
// storebound listing live [--json]
|
||||
// the live texts per language: Google Play title / short / full (one throwaway edit, deleted), App Store name /
|
||||
// subtitle (the app info being prepared, else the live one) and description / keywords / promotional text / what's
|
||||
// new (the editable version, else the newest)
|
||||
// storebound listing set --store play|apple --lang <code> --fields '{"short":"..."}' [--dry] [--json]
|
||||
// changes only the given fields of one language. Every run reads the store first and prints old -> new per field;
|
||||
// --dry stops there; the real run asks in a terminal. Google Play: one edit, PATCH of that listing (other fields,
|
||||
// the promo video and the other languages stay), committed. App Store: PATCH of the localization (name, subtitle:
|
||||
// the app info being prepared; promotional text: the editable version, else the live one, which App Store
|
||||
// Connect allows; description, keywords, what's new: the editable version only). A missing language is created.
|
||||
// Checks before anything is sent: the store limits and the rules of `check` / `scan` (lib/listing-rules.ts); an error
|
||||
// stops the run. The URLs, contact and app details are not touched here (`play listing` / `apple listing` send them
|
||||
// from the config of an app folder).
|
||||
import { appleLocaleIssues, type FieldIssue, playLangIssues } from './lib/listing-rules';
|
||||
import type { AppleCopy } from './lib/copy';
|
||||
import type { Ctx } from './lib/ctx';
|
||||
import { confirm, need } from './lib/ctx';
|
||||
import { done, log, printJson, warn } from './lib/log';
|
||||
import { ascClient, EDITABLE, type J } from './apple/asc';
|
||||
import { playClient } from './play/client';
|
||||
|
||||
const TAG = 'listing';
|
||||
|
||||
export const PLAY_API_FIELD = { title: 'title', short: 'shortDescription', full: 'fullDescription' } as const;
|
||||
export type PlayTextField = keyof typeof PLAY_API_FIELD;
|
||||
export const APPLE_INFO_FIELDS = ['name', 'subtitle'] as const;
|
||||
export const APPLE_VERSION_FIELDS = ['description', 'keywords', 'promotionalText', 'whatsNew'] as const;
|
||||
|
||||
export interface LiveListings {
|
||||
play: { language: string; title: string; short: string; full: string }[] | null;
|
||||
apple: { version: string | null; state: string | null; editable: boolean; locales: { locale: string; values: AppleCopy }[] } | null;
|
||||
errors: { play?: string; apple?: string };
|
||||
}
|
||||
|
||||
export interface FieldPlan { field: string; from: string | null; to: string }
|
||||
export interface SetPlan { store: 'play' | 'apple'; lang: string; create: boolean; changes: FieldPlan[]; same: string[]; issues: FieldIssue[]; refused: string | null; target?: string }
|
||||
|
||||
/** Old -> new for the given fields of one language (an empty and a missing value are the same). Pure. */
|
||||
export function setPlan(store: 'play' | 'apple', lang: string, current: Record<string, string> | null, want: Record<string, string>, o: { appName: string; editable?: boolean }): SetPlan {
|
||||
const changes: FieldPlan[] = [];
|
||||
const same: string[] = [];
|
||||
for (const [f, v] of Object.entries(want)) {
|
||||
const from = current?.[f] ?? null;
|
||||
if ((from ?? '') === v.trim()) same.push(f); else changes.push({ field: f, from, to: v.trim() });
|
||||
}
|
||||
const merged = { ...(current ?? {}), ...Object.fromEntries(Object.entries(want).map(([k, v]) => [k, v.trim()])) };
|
||||
// only the fields this run sends are judged (a store value it leaves alone is not this run's problem)
|
||||
const sent = new Set(Object.keys(want));
|
||||
const all = store === 'play'
|
||||
? playLangIssues(lang, { title: merged.title || undefined, short: merged.short ?? '', full: merged.full ?? '' }, o.appName)
|
||||
: appleLocaleIssues(lang, merged as AppleCopy, o.appName);
|
||||
const issues = all.filter(i => sent.has(i.field) && !(i.level === 'error' && /is required$/.test(i.msg) && !(want[i.field] !== undefined && !want[i.field]!.trim())));
|
||||
let refused: string | null = null;
|
||||
if (store === 'apple' && o.editable === false && changes.some(c => c.field !== 'promotionalText' && (APPLE_VERSION_FIELDS as readonly string[]).includes(c.field)))
|
||||
refused = 'no editable App Store version: description, keywords and what\'s new change with a new version (Releases, or apple listing --version)';
|
||||
return { store, lang, create: !current, changes, same, issues, refused };
|
||||
}
|
||||
|
||||
async function playLive(ctx: Ctx): Promise<LiveListings['play']> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const play = await playClient(root, need(config.play?.packageName, 'play.packageName'));
|
||||
const now = await play.read(async id => (await play.call<{ listings?: { language: string; title?: string; shortDescription?: string; fullDescription?: string }[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? []);
|
||||
return now.map(l => ({ language: l.language, title: l.title ?? '', short: l.shortDescription ?? '', full: l.fullDescription ?? '' })).sort((a, b) => a.language.localeCompare(b.language));
|
||||
}
|
||||
|
||||
interface AppleRefs { c: Awaited<ReturnType<typeof ascClient>>; version: J | null; editable: boolean; info: J }
|
||||
|
||||
async function appleRefs(ctx: Ctx): Promise<AppleRefs> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
const ev = await c.editableVersion();
|
||||
const versions = ev ? [ev] : await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);
|
||||
const version = ev ?? [...versions].sort((x: J, y: J) => String(y.attributes.createdDate ?? '').localeCompare(String(x.attributes.createdDate ?? '')))[0] ?? null;
|
||||
const info = await c.editableAppInfo();
|
||||
return { c, version, editable: !!ev && EDITABLE.has(ev.attributes.appVersionState ?? ev.attributes.appStoreState), info };
|
||||
}
|
||||
|
||||
async function appleLive(ctx: Ctx): Promise<NonNullable<LiveListings['apple']>> {
|
||||
const r = await appleRefs(ctx);
|
||||
const by: Record<string, AppleCopy> = {};
|
||||
for (const l of await r.c.all(`/v1/appInfos/${r.info.id}/appInfoLocalizations?limit=200`)) by[l.attributes.locale] = { name: l.attributes.name ?? '', subtitle: l.attributes.subtitle ?? '' };
|
||||
if (r.version) for (const l of await r.c.all(`/v1/appStoreVersions/${r.version.id}/appStoreVersionLocalizations?limit=200`)) {
|
||||
const at = l.attributes;
|
||||
by[at.locale] = { ...by[at.locale], description: at.description ?? '', keywords: at.keywords ?? '', promotionalText: at.promotionalText ?? '', whatsNew: at.whatsNew ?? '' };
|
||||
}
|
||||
return {
|
||||
version: r.version?.attributes.versionString ?? null, state: r.version ? (r.version.attributes.appVersionState ?? r.version.attributes.appStoreState) : null, editable: r.editable,
|
||||
locales: Object.entries(by).sort(([a], [b]) => a.localeCompare(b)).map(([locale, values]) => ({ locale, values })),
|
||||
};
|
||||
}
|
||||
|
||||
async function live(ctx: Ctx): Promise<void> {
|
||||
const { config } = ctx.loaded;
|
||||
const out: LiveListings = { play: null, apple: null, errors: {} };
|
||||
await Promise.all([
|
||||
config.play ? playLive(ctx).then(v => { out.play = v; }, e => { out.errors.play = (e as Error).message.split('\n')[0]!; }) : null,
|
||||
config.apple ? appleLive(ctx).then(v => { out.apple = v; }, e => { out.errors.apple = (e as Error).message.split('\n')[0]!; }) : null,
|
||||
]);
|
||||
if (ctx.args.has('json')) { printJson(out); return; }
|
||||
for (const l of out.play ?? []) log(TAG, `Google Play ${l.language}: title ${l.title.length}, short ${l.short.length}, full ${l.full.length} characters`);
|
||||
for (const l of out.apple?.locales ?? []) log(TAG, `App Store ${l.locale}: ${Object.entries(l.values).filter(([, v]) => v).map(([k]) => k).join(', ')}`);
|
||||
for (const [s, e] of Object.entries(out.errors)) warn(TAG, `${s}: ${e}`);
|
||||
}
|
||||
|
||||
const cut = (s: string | null): string => (s === null ? '(none)' : `"${s.length > 60 ? `${s.slice(0, 57)}...` : s}"`.replace(/\n/g, ' '));
|
||||
|
||||
function show(ctx: Ctx, p: SetPlan): void {
|
||||
const where = `${p.store === 'play' ? 'Google Play' : 'App Store'} ${p.lang}${p.target ? ` (${p.target})` : ''}`;
|
||||
if (ctx.args.has('json') && process.env.STOREBOUND_MACHINE !== '1') return;
|
||||
log(TAG, `${where}: ${p.create ? 'new language' : 'update'}`);
|
||||
for (const c of p.changes) console.log(` ${c.field}: ${cut(c.from)} -> ${cut(c.to)}`);
|
||||
for (const s of p.same) console.log(` ${s}: same`);
|
||||
for (const i of p.issues) console.log(` ${i.level === 'error' ? 'ERROR' : i.level === 'warn' ? 'warn ' : 'hint '} ${i.field}: ${i.msg}`);
|
||||
if (p.refused) console.log(` refused: ${p.refused}`);
|
||||
}
|
||||
|
||||
async function set(ctx: Ctx): Promise<void> {
|
||||
const a = ctx.args;
|
||||
const { config } = ctx.loaded;
|
||||
const store = a.get('store');
|
||||
if (store !== 'play' && store !== 'apple') throw new Error('--store play or apple');
|
||||
const lang = a.get('lang') ?? '';
|
||||
if (!/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang)) throw new Error('--lang: a store language code (en-US, tr-TR, zh-Hans)');
|
||||
let want: Record<string, string>;
|
||||
try { want = JSON.parse(a.get('fields') ?? '') as Record<string, string>; } catch { throw new Error('--fields: JSON, e.g. {"short":"..."}'); }
|
||||
const allowed: readonly string[] = store === 'play' ? Object.keys(PLAY_API_FIELD) : [...APPLE_INFO_FIELDS, ...APPLE_VERSION_FIELDS];
|
||||
if (!want || typeof want !== 'object' || Array.isArray(want) || !Object.keys(want).length) throw new Error('--fields: at least one field');
|
||||
for (const [k, v] of Object.entries(want)) {
|
||||
if (!allowed.includes(k)) throw new Error(`--fields: ${store} has no text field ${k} here (${allowed.join(', ')})`);
|
||||
if (typeof v !== 'string' || v.length > 20_000) throw new Error(`--fields.${k}: text`);
|
||||
}
|
||||
const dry = a.has('dry');
|
||||
if (store === 'play') {
|
||||
const playName = config.play?.name ?? config.app.name;
|
||||
const now = await playLive(ctx);
|
||||
const cur = now?.find(l => l.language === lang) ?? null;
|
||||
const p = setPlan('play', lang, cur ? { title: cur.title, short: cur.short, full: cur.full } : null, want, { appName: playName });
|
||||
show(ctx, p);
|
||||
const errors = p.issues.filter(i => i.level === 'error').length;
|
||||
if (a.has('json')) printJson({ ...p, dry, written: false });
|
||||
if (errors) throw new Error(`nothing sent: ${errors} error(s)`);
|
||||
if (dry || !p.changes.length) { done(TAG, dry ? 'dry run: nothing sent' : 'already the same on Google Play'); return; }
|
||||
if (process.stdin.isTTY && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on Google Play (one edit, committed)?`))) { warn(TAG, 'nothing sent'); return; }
|
||||
const { root } = ctx.loaded;
|
||||
const play = await playClient(root, need(config.play?.packageName, 'play.packageName'));
|
||||
const body: Record<string, string> = { language: lang };
|
||||
for (const c of p.changes) body[PLAY_API_FIELD[c.field as PlayTextField]] = c.to;
|
||||
if (p.create && !body.title) body.title = playName;
|
||||
await play.edit(async id => {
|
||||
if (p.create) await play.call('PUT', `${play.API}/edits/${id}/listings/${lang}`, { title: playName, shortDescription: '', fullDescription: '', ...body });
|
||||
else await play.call('PATCH', `${play.API}/edits/${id}/listings/${lang}`, body);
|
||||
});
|
||||
done(TAG, `Google Play ${lang}: ${p.changes.map(c => c.field).join(', ')} changed (not public until Google reviews the change)`);
|
||||
return;
|
||||
}
|
||||
const r = await appleRefs(ctx);
|
||||
const infoLocs = await r.c.all(`/v1/appInfos/${r.info.id}/appInfoLocalizations?limit=200`);
|
||||
const verLocs = r.version ? await r.c.all(`/v1/appStoreVersions/${r.version.id}/appStoreVersionLocalizations?limit=200`) : [];
|
||||
const il = infoLocs.find((x: J) => x.attributes.locale === lang) ?? null;
|
||||
const vl = verLocs.find((x: J) => x.attributes.locale === lang) ?? null;
|
||||
const cur: Record<string, string> | null = il || vl ? {
|
||||
name: il?.attributes.name ?? '', subtitle: il?.attributes.subtitle ?? '', description: vl?.attributes.description ?? '', keywords: vl?.attributes.keywords ?? '',
|
||||
promotionalText: vl?.attributes.promotionalText ?? '', whatsNew: vl?.attributes.whatsNew ?? '',
|
||||
} : null;
|
||||
const appName = config.apple?.name ?? config.app.name;
|
||||
const p = { ...setPlan('apple', lang, cur, want, { appName, editable: r.editable }), target: r.version ? `version ${r.version.attributes.versionString}, ${r.version.attributes.appVersionState ?? r.version.attributes.appStoreState}` : 'no version' };
|
||||
show(ctx, p);
|
||||
const errors = p.issues.filter(i => i.level === 'error').length;
|
||||
if (a.has('json')) printJson({ ...p, dry, written: false });
|
||||
if (p.refused) throw new Error(p.refused);
|
||||
if (errors) throw new Error(`nothing sent: ${errors} error(s)`);
|
||||
if (dry || !p.changes.length) { done(TAG, dry ? 'dry run: nothing sent' : 'already the same on the App Store'); return; }
|
||||
if (process.stdin.isTTY && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on App Store Connect?`))) { warn(TAG, 'nothing sent'); return; }
|
||||
const info = Object.fromEntries(p.changes.filter(c => (APPLE_INFO_FIELDS as readonly string[]).includes(c.field)).map(c => [c.field, c.to]));
|
||||
const ver = Object.fromEntries(p.changes.filter(c => (APPLE_VERSION_FIELDS as readonly string[]).includes(c.field)).map(c => [c.field, c.to]));
|
||||
if (Object.keys(info).length) {
|
||||
if (il) await r.c.asc('PATCH', `/v1/appInfoLocalizations/${il.id}`, { data: { type: 'appInfoLocalizations', id: il.id, attributes: info } });
|
||||
else await r.c.asc('POST', '/v1/appInfoLocalizations', { data: { type: 'appInfoLocalizations', attributes: { locale: lang, name: info.name ?? appName, ...info }, relationships: { appInfo: { data: { type: 'appInfos', id: r.info.id } } } } });
|
||||
}
|
||||
if (Object.keys(ver).length) {
|
||||
if (!r.version) throw new Error('no App Store version to put the texts on');
|
||||
if (vl) await r.c.asc('PATCH', `/v1/appStoreVersionLocalizations/${vl.id}`, { data: { type: 'appStoreVersionLocalizations', id: vl.id, attributes: ver } });
|
||||
else await r.c.asc('POST', '/v1/appStoreVersionLocalizations', { data: { type: 'appStoreVersionLocalizations', attributes: { locale: lang, ...ver }, relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: r.version.id } } } } });
|
||||
}
|
||||
done(TAG, `App Store ${lang}: ${p.changes.map(c => c.field).join(', ')} changed (${p.target}; customers see it with the next approved version, promotional text at once)`);
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx, sub: string): Promise<void> {
|
||||
if (sub === 'live') return live(ctx);
|
||||
if (sub === 'set') return set(ctx);
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@
|
|||
// Reviewed sections (no marker) are kept unless --force; a section's What's new is kept.
|
||||
// storebound listing check [--json] limits, keyword rules, untranslated and draft sections; exit 1 on a problem
|
||||
// storebound listing reviewed --lang de-DE[,ja] | --all [--dry] a human read the draft: remove its marker
|
||||
// storebound listing live [--json] | set --store play|apple --lang <code> --fields '<json>' [--dry] the texts straight on
|
||||
// the stores, without listing files (store-only apps in the desktop): cli/listing-store.ts
|
||||
// --to / --from / --lang take config language ids, App Store locales or Play codes. Local files only: nothing is sent
|
||||
// to a store (apple listing / play listing push them after `storebound check`).
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
|
|
@ -211,5 +213,6 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (sub === 'translate') return translate(ctx);
|
||||
if (sub === 'check') return check(ctx);
|
||||
if (sub === 'reviewed') return reviewed(ctx);
|
||||
throw new Error('usage: storebound listing translate --to <languages> [--from <language>] [--translate "<command>"] [--dry] | check | reviewed --lang <ids>');
|
||||
if (sub === 'live' || sub === 'set') return (await import('./listing-store')).run(ctx, sub);
|
||||
throw new Error('usage: storebound listing translate --to <languages> [--from <language>] [--translate "<command>"] [--dry] | check | reviewed --lang <ids> | live [--json] | set --store play|apple --lang <code> --fields \'<json>\' [--dry]');
|
||||
}
|
||||
|
|
|
|||
20
cli/serve.ts
20
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_editable']);
|
||||
'project_icon', 'project_listings', 'reviews_drafts', 'project_declarations', 'project_listing_text', 'project_renders', 'project_render', 'prices_current', 'project_editable', 'listing_live']);
|
||||
|
||||
/** 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', 'project_edit']);
|
||||
export const JSON_WRITES = new Set(['console_fill', 'owner_apple_submit', 'owner_apple_resubmit', 'project_edit', 'listing_set']);
|
||||
/** 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']);
|
||||
|
||||
|
|
@ -104,6 +104,13 @@ export const DESKTOP_ONLY: ToolDef[] = [
|
|||
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: 'listing_live', title: 'Live listing texts', command: ['listing', 'live'], write: false, network: true,
|
||||
description: 'Read-only: the live listing texts per language on both stores (Google Play title, short and full description; App Store name, subtitle, description, keywords, promotional text, what\'s new of the editable version, else the newest).' },
|
||||
{ name: 'listing_set', title: 'Change listing texts on a store', command: ['listing', 'set'], write: true, network: true,
|
||||
description: 'Changes the given text fields of one language straight on one store (no listing files: store-only apps): Google Play one edit with a PATCH of that listing, committed; App Store the localization of the app info being prepared and of the editable version (promotional text also on the live one). The dry run reads the store and shows old -> new per field with the checks (store limits, keyword and metadata rules); an error stops it. Not public until the store reviews it.',
|
||||
props: { store: { type: 'string', enum: ['play', 'apple'] }, lang: { type: 'string', pattern: '^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$', description: 'Store language code (en-US, tr-TR, zh-Hans)' },
|
||||
fields: { type: 'string', pattern: '^\\{[\\s\\S]{1,60000}$', description: 'The fields as JSON, e.g. {"short":"..."}' } },
|
||||
required: ['store', 'lang', 'fields'], args: i => ['--store', String(i.store), '--lang', String(i.lang), '--fields', String(i.fields)] },
|
||||
{ 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' },
|
||||
|
|
@ -239,7 +246,10 @@ const SYSTEM: MethodDescriptor[] = [
|
|||
];
|
||||
|
||||
/** Read methods a store-only app (params.virtual, no app folder) may use: store reads that need only the store ids. */
|
||||
export const VIRTUAL_READS = new Set(['apple_status', 'play_status', 'health', 'health_issue', 'reviews_list', 'apple_testflight_status', 'apple_ppo_status']);
|
||||
export const VIRTUAL_READS = new Set(['apple_status', 'play_status', 'health', 'health_issue', 'reviews_list', 'apple_testflight_status', 'apple_ppo_status', 'listing_live']);
|
||||
/** Writes a store-only app may run (no app folder: they send only what the call carries, never the generated config's
|
||||
* placeholder links). The confirm token binds them to the store ids (`virtual`) instead of a folder. */
|
||||
export const VIRTUAL_WRITES = new Set(['listing_set']);
|
||||
|
||||
const PKG_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$/;
|
||||
const BUNDLE_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,154}$/;
|
||||
|
|
@ -413,7 +423,7 @@ export class Session {
|
|||
let project: string;
|
||||
if (params.virtual !== undefined) {
|
||||
if (params.project !== undefined) throw new RpcError(ERR.invalidParams, 'project or virtual, not both');
|
||||
if (t.write || !VIRTUAL_READS.has(t.name)) throw new RpcError(ERR.invalidParams, `${method}: needs an app folder (store-only apps have store reads only)`);
|
||||
if (t.write ? !VIRTUAL_WRITES.has(t.name) : !VIRTUAL_READS.has(t.name)) throw new RpcError(ERR.invalidParams, `${method}: needs an app folder (store-only apps have store reads only)`);
|
||||
project = virtualDir(checkVirtual(params.virtual));
|
||||
} else project = NEW_PROJECT.has(t.name) ? checkNewProject(params.project) : checkProject(params.project);
|
||||
const args = (params.args ?? {}) as Json;
|
||||
|
|
@ -428,7 +438,7 @@ export class Session {
|
|||
else {
|
||||
if (params.confirmToken === undefined) throw new RpcError(ERR.confirmRequired, `${method} writes: dryRun false needs a confirmToken from the desktop host`);
|
||||
// Bound to the project string exactly as sent (the host mints over what it sends) and the args object.
|
||||
const v = this.guard!.verify(params.confirmToken, method, { project: params.project, args });
|
||||
const v = this.guard!.verify(params.confirmToken, method, params.virtual !== undefined ? { virtual: params.virtual, args } : { project: params.project, args });
|
||||
if (!v.ok) throw new RpcError(ERR.confirmInvalid, `confirm token rejected: ${v.error}`, { reason: v.error });
|
||||
dryRun = false;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue