- `storebound console plan <form> [--json] [--lookup]`: read-only plan for an AI agent that drives the browser itself: start URL (deep links), ordered steps, answer sheet with the console's exact labels (certain / ask owner, curly quotes noted), never-press list, verified UI facts, driving guidance (in-page async loop + window.__log), verify instructions. All six forms. - `storebound console verify <form> [--from-text <file|->]`: App Privacy per-type summaries from page text, Play Create app from input states, content rating / target audience summaries, App Store record and age rating from the API; exit 1 on a mismatch. - MCP: console_plan, console_verify (read-only); console fill stays off MCP. - App Privacy (verified 2026-10-12): two information steps, unlabeled purpose checkboxes (nearest parent text), "Definitions and Examples" never pressed; fixture and engine follow. - Play Create app (verified 2026-10-12): /developers/<id>/create-new-app, App package name + read-only Check availability, material-radio inner [role=radio] click, app dashboard URL and play.appId, service account "Add app" as an owner step. - console-autofill skill rewritten agent-first; agents-core rule 10; README; pitfalls 12, 18 (confirmed sign-out), 19; CHANGELOG; play-first-release.
321 lines
22 KiB
TypeScript
321 lines
22 KiB
TypeScript
// `storebound console verify <form>`: checks what a console form shows against the config's answers, read-only. Pure
|
|
// (the command in cli/console/verify.ts feeds it page text, an input-state JSON or API attributes).
|
|
// Sources per form:
|
|
// apple-app-privacy page text only (the App Store Connect API has no App Privacy endpoint): the Data Types
|
|
// section after each type was saved shows "Edit" and a summary per type: "Used for <purposes>",
|
|
// "Linked to the user’s identity" / "Not linked …", "Used for tracking purposes" / "Not used …"
|
|
// (verified 2026-10-12). A summary without a linked / tracking line is read as No (marked).
|
|
// play-app-record the Create app form's input states (JSON from the plan's capture snippet: text fields, the
|
|
// checked [role=radio] labels, the page text for "Select …" hints) or plain page text
|
|
// apple-app-record the API (the app by bundle id: name, primary locale, SKU) or page text
|
|
// apple-age-rating the API (ageRatingDeclaration)
|
|
// play-content-rating, play-target-audience page text (summary pages: a label line, its answer on the same or the
|
|
// next line). Modeled, not recorded: rows that cannot be found are "not found", never a match.
|
|
import type { ShipConfig } from './config';
|
|
import { type Answer, consoleAnswers, type FormId, valueText } from './console-answers';
|
|
import { APPLE_PRIVACY_TYPES } from './app-privacy';
|
|
import { typeLabels } from './console-forms';
|
|
import { APPLE_PURPOSES } from './console-answers';
|
|
|
|
export type RowStatus = 'match' | 'mismatch' | 'not-found' | 'ask-owner';
|
|
export interface VerifyRow { item: string; expected: string; seen: string; status: RowStatus; note?: string }
|
|
export interface VerifyResult {
|
|
form: FormId;
|
|
source: 'page-text' | 'inputs' | 'api';
|
|
rows: VerifyRow[];
|
|
mismatches: number;
|
|
matched: number;
|
|
/** true when nothing mismatches and at least one row matched. */
|
|
ok: boolean;
|
|
notes: string[];
|
|
}
|
|
|
|
const lines = (text: string): string[] => text.normalize('NFC').split(/\r?\n/).map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
/** Straight and curly quotes as one, case and spaces aside: for comparing labels. */
|
|
export const fold = (s: string): string => s.normalize('NFC').replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
|
|
function result(form: FormId, source: VerifyResult['source'], rows: VerifyRow[], notes: string[] = []): VerifyResult {
|
|
const mismatches = rows.filter(r => r.status === 'mismatch').length, matched = rows.filter(r => r.status === 'match').length;
|
|
return { form, source, rows, mismatches, matched, ok: mismatches === 0 && matched > 0, notes };
|
|
}
|
|
|
|
const yn = (v: boolean | null | undefined): string => (v === true ? 'Yes' : v === false ? 'No' : '?');
|
|
|
|
// ---- App Privacy ----
|
|
|
|
export interface PrivacyTypeSeen {
|
|
/** "Set Up <type>" is still shown: not answered yet. */
|
|
setUp: boolean;
|
|
/** Purposes from "Used for …" (Apple's names), null when no such line. */
|
|
purposes: string[] | null;
|
|
linked: boolean | null;
|
|
tracking: boolean | null;
|
|
/** linked / tracking were not shown and are read as No. */
|
|
inferred: string[];
|
|
}
|
|
|
|
/** Every console label of a data type -> Apple's name (Purchases -> Purchase History, Other Data -> Other Data Types). */
|
|
const TYPE_OF = new Map<string, string>(Object.values(APPLE_PRIVACY_TYPES).flatMap(([, n]) => typeLabels(n).map(l => [fold(l), n] as [string, string])));
|
|
const PURPOSES_FOLDED = APPLE_PURPOSES.map(p => [fold(p), p] as const);
|
|
|
|
/** The per-type summaries of App Privacy's Data Types section (innerText of the page or of the section). */
|
|
export function parseAppPrivacy(text: string): Map<string, PrivacyTypeSeen> {
|
|
let ls = lines(text);
|
|
const at = ls.findIndex(l => fold(l) === 'data types');
|
|
if (at >= 0) ls = ls.slice(at + 1);
|
|
const out = new Map<string, PrivacyTypeSeen>();
|
|
const entry = (t: string) => { let e = out.get(t); if (!e) { e = { setUp: false, purposes: null, linked: null, tracking: null, inferred: [] }; out.set(t, e); } return e; };
|
|
let cur: string | null = null;
|
|
for (const l of ls) {
|
|
const f = fold(l);
|
|
const set = /^set up (.+)$/.exec(f);
|
|
if (set && TYPE_OF.has(set[1]!)) { cur = TYPE_OF.get(set[1]!)!; entry(cur).setUp = true; continue; }
|
|
if (TYPE_OF.has(f)) { cur = TYPE_OF.get(f)!; continue; }
|
|
if (!cur) continue;
|
|
if (/^used for (?!tracking)/.test(f)) {
|
|
const rest = f.slice('used for '.length);
|
|
entry(cur).purposes = PURPOSES_FOLDED.filter(([k]) => rest.includes(k)).map(([, p]) => p);
|
|
} else if (/^not linked to the user/.test(f)) entry(cur).linked = false;
|
|
else if (/^linked to the user/.test(f)) entry(cur).linked = true;
|
|
else if (/^not used for tracking/.test(f)) entry(cur).tracking = false;
|
|
else if (/^used for tracking/.test(f)) entry(cur).tracking = true;
|
|
}
|
|
// a type with a summary but without a linked / tracking line: the console shows only what applies
|
|
for (const e of out.values()) {
|
|
if (e.purposes === null || e.setUp) continue;
|
|
if (e.linked === null) { e.linked = false; e.inferred.push('linked'); }
|
|
if (e.tracking === null) { e.tracking = false; e.inferred.push('tracking'); }
|
|
}
|
|
// a label followed by nothing (a category line equal to a type name, "Purchases" above "Set Up Purchase History")
|
|
for (const [t, e] of [...out]) if (!e.setUp && e.purposes === null && e.linked === null && e.tracking === null) out.delete(t);
|
|
return out;
|
|
}
|
|
|
|
export function verifyAppPrivacy(config: ShipConfig, text: string): VerifyResult {
|
|
const answers = consoleAnswers('apple-app-privacy', config);
|
|
const by = new Map(answers.map(a => [a.id, a]));
|
|
const seen = parseAppPrivacy(text);
|
|
const rows: VerifyRow[] = [];
|
|
const notes = ['The App Store Connect API has no App Privacy endpoint: checked from the page text.'];
|
|
const types = answers.filter(a => a.id.startsWith('type:'));
|
|
for (const a of types) {
|
|
const t = a.id.slice(5), s = seen.get(t);
|
|
if (a.value === false) {
|
|
if (s) rows.push({ item: t, expected: 'not collected', seen: s.setUp ? 'listed (Set Up)' : 'listed with a summary', status: a.certain ? 'mismatch' : 'ask-owner' });
|
|
continue;
|
|
}
|
|
if (a.value === null) { if (s) rows.push({ item: t, expected: '(owner decides)', seen: 'listed', status: 'ask-owner', ...(a.note ? { note: a.note } : {}) }); continue; }
|
|
if (!s) { rows.push({ item: t, expected: 'collected', seen: 'not on the page', status: a.certain ? 'mismatch' : 'ask-owner' }); continue; }
|
|
if (s.setUp) { rows.push({ item: t, expected: 'set up', seen: `"Set Up ${t}" still shown`, status: 'mismatch' }); continue; }
|
|
const purposes = answers.filter(x => x.id.startsWith(`purpose:${t}:`));
|
|
const want = purposes.filter(x => x.value === true).map(x => x.id.split(':')[2]!);
|
|
const unsure = purposes.some(x => !x.certain);
|
|
const got = s.purposes ?? [];
|
|
const same = want.length === got.length && want.every(p => got.includes(p));
|
|
rows.push({ item: `${t}: purposes`, expected: want.join(', ') || '(none)', seen: s.purposes ? got.join(', ') || '(none)' : 'no "Used for" line', status: s.purposes === null ? 'not-found' : unsure && !same ? 'ask-owner' : same ? 'match' : 'mismatch' });
|
|
for (const k of ['linked', 'tracking'] as const) {
|
|
const x = by.get(`${k}:${t}`);
|
|
const v = s[k];
|
|
const label = k === 'linked' ? 'linked to the user' : 'used for tracking';
|
|
const inferred = s.inferred.includes(k) ? 'not shown, read as No' : undefined;
|
|
if (!x || x.value === null) rows.push({ item: `${t}: ${label}`, expected: '(owner decides)', seen: yn(v), status: 'ask-owner', ...(inferred ? { note: inferred } : {}) });
|
|
else rows.push({ item: `${t}: ${label}`, expected: yn(x.value as boolean), seen: yn(v), status: v === null ? 'not-found' : v === x.value ? 'match' : 'mismatch', ...(inferred ? { note: inferred } : {}) });
|
|
}
|
|
}
|
|
return result('apple-app-privacy', 'page-text', rows, notes);
|
|
}
|
|
|
|
// ---- Play Create app ----
|
|
|
|
/** The input states the plan's capture snippet returns. */
|
|
export interface InputStates {
|
|
/** Text inputs by their label (or aria-label). */
|
|
fields?: Record<string, string>;
|
|
/** Labels of the [role=radio] elements with aria-checked=true (and checked native radios). */
|
|
checked?: string[];
|
|
/** The visible page text (for "Select …" hints and availability messages). */
|
|
text?: string;
|
|
}
|
|
|
|
/** The capture snippet an agent runs in the page for `console verify play-app-record --from-text -` (prints JSON). */
|
|
export const PLAY_CREATE_CAPTURE = `(() => { const t = e => (e?.innerText ?? e?.textContent ?? '').trim().replace(/\\s+/g, ' ');
|
|
const name = e => e.getAttribute('aria-label') || t(e.labels?.[0]) || (e.getAttribute('aria-labelledby') || '').split(/\\s+/).map(id => t(document.getElementById(id))).join(' ').trim() || t(e.closest('label')) || t(e.parentElement);
|
|
const fields = {}; for (const i of document.querySelectorAll('input:not([type]), input[type=text], textarea')) if (i.offsetParent) fields[name(i)] = i.value;
|
|
const checked = [...document.querySelectorAll('[role=radio][aria-checked=true], input[type=radio]:checked')].map(r => name(r) || t(r.closest('material-radio')));
|
|
return JSON.stringify({ fields, checked, text: (document.querySelector('main') ?? document.body).innerText }); })()`;
|
|
|
|
export function parseInputStates(raw: string): InputStates | null {
|
|
const s = raw.trim();
|
|
if (!s.startsWith('{')) return null;
|
|
try {
|
|
const j = JSON.parse(s) as Record<string, unknown>;
|
|
const fields = j.fields && typeof j.fields === 'object' ? Object.fromEntries(Object.entries(j.fields as Record<string, unknown>).filter(([, v]) => typeof v === 'string')) as Record<string, string> : undefined;
|
|
const checked = Array.isArray(j.checked) ? j.checked.filter((x): x is string => typeof x === 'string') : undefined;
|
|
return { ...(fields ? { fields } : {}), ...(checked ? { checked } : {}), ...(typeof j.text === 'string' ? { text: j.text } : {}) };
|
|
} catch { return null; }
|
|
}
|
|
|
|
/** A "Select …" hint the Create app form shows while a radio group has no value in its model ("Select app or game"). */
|
|
export const SELECT_HINT = /^select (app or game|free or paid|an? .+)$/i;
|
|
|
|
export function verifyPlayAppRecord(config: ShipConfig, raw: string): VerifyResult {
|
|
const answers = consoleAnswers('play-app-record', config).filter(a => !a.agreement);
|
|
const st = parseInputStates(raw);
|
|
const text = st?.text ?? (st ? '' : raw);
|
|
const ls = lines(text);
|
|
const rows: VerifyRow[] = [];
|
|
const fieldValue = (labels: string[]): string | null => {
|
|
if (st?.fields) for (const [k, v] of Object.entries(st.fields)) if (labels.some(l => fold(k).startsWith(fold(l)))) return v;
|
|
return null;
|
|
};
|
|
const checked = (st?.checked ?? []).map(fold);
|
|
for (const a of answers) {
|
|
const exp = a.value === null ? null : String(a.value);
|
|
if (a.id === 'name' || a.id === 'packageName') {
|
|
const labels = a.id === 'name' ? ['App name', 'Uygulama adı'] : ['App package name', 'Package name', 'Uygulama paket adı'];
|
|
const v = fieldValue(labels);
|
|
const inText = v === null && exp !== null && ls.some(l => l === exp || l.endsWith(` ${exp}`));
|
|
const seen = v ?? (inText ? exp! : null);
|
|
rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: seen ?? 'not found', status: exp === null ? 'ask-owner' : seen === null ? 'not-found' : seen === exp ? 'match' : 'mismatch' });
|
|
} else if (a.id === 'type' || a.id === 'price') {
|
|
const opts = a.id === 'type' ? ['App', 'Game'] : ['Free', 'Paid'];
|
|
const got = st?.checked ? opts.filter(o => checked.some(c => c === fold(o) || c.startsWith(`${fold(o)} `))) : [];
|
|
const seen = got.length === 1 ? got[0]! : got.length ? got.join(' + ') : null;
|
|
rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: seen ?? (st?.checked ? 'nothing selected' : 'not readable from page text: pass the input states (plan capture)'), status: exp === null ? 'ask-owner' : seen === null ? (st?.checked ? 'mismatch' : 'not-found') : seen === exp ? 'match' : 'mismatch' });
|
|
} else if (a.id === 'language') {
|
|
const v = fieldValue(['Default language', 'Varsayılan dil']);
|
|
const inText = exp !== null && ls.some(l => l.includes(exp));
|
|
rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: v ?? (inText ? exp! : 'not found'), status: exp === null ? 'ask-owner' : v !== null ? (v.includes(exp) ? 'match' : 'mismatch') : inText ? 'match' : 'not-found' });
|
|
}
|
|
}
|
|
const hints = ls.filter(l => SELECT_HINT.test(l));
|
|
rows.push({ item: '"Select …" hints (a radio set on the input but not in the form\'s model)', expected: 'none', seen: hints.length ? hints.join('; ') : 'none', status: hints.length ? 'mismatch' : text ? 'match' : 'not-found',
|
|
...(hints.length ? { note: 'click the inner [role=radio] of that material-radio (a JS click), then check again' } : {}) });
|
|
if (ls.some(l => /package name (is )?available/i.test(l))) rows.push({ item: 'Check availability', expected: 'Package name available', seen: 'Package name available', status: 'match' });
|
|
return result('play-app-record', st ? 'inputs' : 'page-text', rows, st ? [] : ['Page text only: radios cannot be read from text. For a full check pass the JSON of the plan\'s capture snippet.']);
|
|
}
|
|
|
|
// ---- label / answer pairs from page text (summaries) ----
|
|
|
|
/** The answer next to a label: on the same line after the label, or the next line. */
|
|
function valueAfter(ls: string[], label: RegExp): string | null {
|
|
for (let i = 0; i < ls.length; i++) {
|
|
const l = ls[i]!;
|
|
const m = label.exec(l);
|
|
if (!m) continue;
|
|
const rest = l.slice(m.index + m[0].length).replace(/^[\s:–-]+/, '').trim();
|
|
return rest || ls[i + 1] || null;
|
|
}
|
|
return null;
|
|
}
|
|
const yesNoOf = (s: string | null): boolean | null => (s === null ? null : /^(yes|evet)\b/i.test(s) ? true : /^(no|hayır)\b/i.test(s) ? false : null);
|
|
|
|
function labelRows(answers: Answer[], ls: string[], labelOf: (a: Answer) => RegExp | null): VerifyRow[] {
|
|
const rows: VerifyRow[] = [];
|
|
for (const a of answers) {
|
|
if (a.agreement) continue;
|
|
const re = labelOf(a);
|
|
if (!re) continue;
|
|
const raw = valueAfter(ls, re);
|
|
if (typeof a.value === 'boolean' || a.value === null && raw !== null && yesNoOf(raw) !== null) {
|
|
const v = yesNoOf(raw);
|
|
rows.push({ item: a.label, expected: a.value === null ? '(owner decides)' : yn(a.value as boolean), seen: v === null ? (raw ?? 'not found') : yn(v), status: a.value === null ? 'ask-owner' : v === null ? 'not-found' : v === a.value ? 'match' : 'mismatch' });
|
|
} else {
|
|
const exp = a.value === null ? null : String(a.value);
|
|
rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: raw ?? 'not found', status: exp === null ? 'ask-owner' : raw === null ? 'not-found' : fold(raw).includes(fold(exp)) ? 'match' : 'mismatch' });
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
const escRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
export function verifyContentRating(config: ShipConfig, text: string): VerifyResult {
|
|
const answers = consoleAnswers('play-content-rating', config).filter(a => a.id !== 'email');
|
|
const TOPIC: Record<string, RegExp> = {
|
|
violence: /violen/i, fear: /(fear|horror|scary)/i, sexuality: /(sexual|nudity)/i, language: /(profan|crude language)/i, controlledSubstances: /(drug|alcohol|tobacco|controlled substance)/i,
|
|
crudeHumor: /crude humou?r/i, gambling: /real[- ](money|currency)/i, simulatedGambling: /simulated gambling/i, lootBoxes: /(loot box|random(ized)? (paid )?items)/i,
|
|
userInteraction: /(interact|communicate|exchange)/i, sharesLocation: /location/i, digitalPurchases: /digital (goods|purchases)|purchase digital/i, unrestrictedInternet: /(unrestricted (internet|web)|web browser)/i,
|
|
};
|
|
const rows = labelRows(answers, lines(text), a => (a.id === 'category' ? /^(category|app category)\b/i : a.id.startsWith('q:') ? TOPIC[a.id.slice(2)] ?? null : null));
|
|
return result('play-content-rating', 'page-text', rows, ['Modeled summary wording: a question that is not found is reported, never counted as a match.']);
|
|
}
|
|
|
|
export function verifyTargetAudience(config: ShipConfig, text: string): VerifyResult {
|
|
const answers = consoleAnswers('play-target-audience', config);
|
|
const ls = lines(text);
|
|
const rows: VerifyRow[] = [];
|
|
const ages = answers.filter(a => a.id.startsWith('age:'));
|
|
const blob = fold(ls.join('\n'));
|
|
if (ages.length) {
|
|
const want = ages.filter(a => a.value === true).map(a => a.label.replace(/^Target age: /, ''));
|
|
const shown = ages.map(a => a.label.replace(/^Target age: /, '')).filter(l => blob.includes(fold(l)) || blob.includes(fold(l.replace(/^Ages /, ''))));
|
|
const unsure = ages.some(a => !a.certain);
|
|
const same = want.length === shown.length && want.every(w => shown.includes(w));
|
|
rows.push({ item: 'Target age groups', expected: unsure ? '(owner decides)' : want.join(', '), seen: shown.join(', ') || 'not found', status: unsure ? 'ask-owner' : !shown.length ? 'not-found' : same ? 'match' : 'mismatch' });
|
|
}
|
|
rows.push(...labelRows(answers.filter(a => a.id === 'appeal'), ls, () => /appeal to children/i));
|
|
const ads = answers.find(a => a.id === 'ads');
|
|
if (ads) {
|
|
const no = ls.some(l => /(does not|doesn't) contain ads|no,? (my app )?(does not|doesn't) contain ads|^no ads$/i.test(l));
|
|
const yes = !no && ls.some(l => /contains ads|^ads$|yes,? my app contains ads/i.test(l));
|
|
const seen = no ? false : yes ? true : null;
|
|
rows.push({ item: ads.label, expected: ads.value === null ? '(owner decides)' : yn(ads.value as boolean), seen: yn(seen), status: ads.value === null ? 'ask-owner' : seen === null ? 'not-found' : seen === ads.value ? 'match' : 'mismatch' });
|
|
}
|
|
return result('play-target-audience', 'page-text', rows, ['Modeled summary wording: rows that are not found are reported, never counted as a match.']);
|
|
}
|
|
|
|
export function verifyAppRecordText(config: ShipConfig, text: string): VerifyResult {
|
|
const answers = consoleAnswers('apple-app-record', config).filter(a => ['name', 'language', 'bundleId', 'sku'].includes(a.id));
|
|
const rows = labelRows(answers, lines(text), a => new RegExp(`^${escRe(a.label)}\\b`, 'i'));
|
|
return result('apple-app-record', 'page-text', rows);
|
|
}
|
|
|
|
/** apple-app-record against the API's app attributes (GET /v1/apps?filter[bundleId]). null: no such app. */
|
|
export function verifyAppRecordApi(config: ShipConfig, app: { name?: string; bundleId?: string; sku?: string; primaryLocale?: string } | null): VerifyResult {
|
|
const answers = consoleAnswers('apple-app-record', config);
|
|
const by = new Map(answers.map(a => [a.id, a]));
|
|
if (!app) return result('apple-app-record', 'api', [{ item: 'App record', expected: `an app with bundle id ${config.apple?.bundleId ?? '?'}`, seen: 'none', status: 'mismatch' }]);
|
|
const locale = config.apple?.primaryLocale ?? config.languages.find(l => l.apple)?.apple ?? null;
|
|
const row = (item: string, exp: string | null, seen: string | undefined, certain = true): VerifyRow => ({ item, expected: exp ?? '(owner decides)', seen: seen ?? 'not set', status: exp === null || !certain ? 'ask-owner' : seen === undefined ? 'not-found' : seen === exp ? 'match' : 'mismatch' });
|
|
const name = by.get('name'), sku = by.get('sku');
|
|
return result('apple-app-record', 'api', [
|
|
row('Bundle ID', config.apple?.bundleId ?? null, app.bundleId),
|
|
row('Name', name?.value === null || name === undefined ? null : String(name.value), app.name),
|
|
row('Primary Language (locale)', locale, app.primaryLocale),
|
|
row('SKU', sku?.certain ? String(sku.value) : null, app.sku),
|
|
]);
|
|
}
|
|
|
|
/** apple-age-rating against the API's ageRatingDeclaration attributes. */
|
|
export function verifyAgeRatingApi(config: ShipConfig, attrs: Record<string, unknown> | null): VerifyResult {
|
|
const answers = consoleAnswers('apple-age-rating', config);
|
|
if (!attrs) return result('apple-age-rating', 'api', [{ item: 'Age rating declaration', expected: 'set', seen: 'none', status: 'mismatch' }]);
|
|
const rows = answers.map(a => {
|
|
const seen = attrs[a.id];
|
|
const s = seen === undefined || seen === null ? undefined : String(seen);
|
|
return { item: a.id, expected: a.certain ? valueText(a) : '(owner decides)', seen: s ?? 'not set', status: (!a.certain ? 'ask-owner' : s === undefined ? 'not-found' : s === String(a.value) ? 'match' : 'mismatch') as RowStatus };
|
|
});
|
|
return result('apple-age-rating', 'api', rows);
|
|
}
|
|
|
|
/** Page-text verification of any form that has one. */
|
|
export function verifyText(form: FormId, config: ShipConfig, text: string): VerifyResult {
|
|
switch (form) {
|
|
case 'apple-app-privacy': return verifyAppPrivacy(config, text);
|
|
case 'play-app-record': return verifyPlayAppRecord(config, text);
|
|
case 'play-content-rating': return verifyContentRating(config, text);
|
|
case 'play-target-audience': return verifyTargetAudience(config, text);
|
|
case 'apple-app-record': return verifyAppRecordText(config, text);
|
|
case 'apple-age-rating': throw new Error('console verify apple-age-rating reads the App Store Connect API (no --from-text)');
|
|
}
|
|
}
|
|
|
|
export function verifyTable(r: VerifyResult): string {
|
|
const w = Math.min(48, Math.max(4, ...r.rows.map(x => x.item.length)));
|
|
const L = [`${r.form} (${r.source})`, ''];
|
|
for (const x of r.rows) L.push(` ${x.status.padEnd(9)} ${x.item.padEnd(w)} expected ${x.expected} | page ${x.seen}${x.note ? ` (${x.note})` : ''}`);
|
|
L.push('', `${r.matched} match, ${r.mismatches} mismatch, ${r.rows.filter(x => x.status === 'not-found').length} not found, ${r.rows.filter(x => x.status === 'ask-owner').length} for the owner`, ...r.notes.map(n => `note: ${n}`));
|
|
if (!r.ok) L.push(r.mismatches ? 'MISMATCH: fix the page (or the config), then verify again.' : 'Nothing could be matched: pass the page text of the finished form.');
|
|
return L.join('\n');
|
|
}
|