console: agent-first mode (console plan / console verify), verified App Privacy and Play Create app facts
- `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.
This commit is contained in:
parent
a9261a8052
commit
c9a7f095c4
34 changed files with 1430 additions and 113 deletions
|
|
@ -28,7 +28,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'appEvents_createInstance': 'apple/events',
|
||||
'appEvents_getInstance': 'apple/events',
|
||||
'appEvents_localizations_getToManyRelated': 'apple/events',
|
||||
'appInfos_ageRatingDeclaration_getToOneRelated': 'apple/appinfo validate',
|
||||
'appInfos_ageRatingDeclaration_getToOneRelated': 'apple/appinfo console/verify validate',
|
||||
'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing validate',
|
||||
'appInfos_getInstance': 'apple/appinfo',
|
||||
'appInfos_updateInstance': 'apple/appinfo',
|
||||
|
|
@ -77,7 +77,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'apps_betaGroups_getToManyRelated': 'apple/testflight',
|
||||
'apps_buildUploads_getToManyRelated': 'apple/upload',
|
||||
'apps_customerReviews_getToManyRelated': 'reviews',
|
||||
'apps_getCollection': 'apple/asc apple/bundle apps console/app-id lib/store-apps',
|
||||
'apps_getCollection': 'apple/asc apple/bundle apps console/app-id console/verify lib/store-apps',
|
||||
'apps_getInstance': 'apple/appinfo apple/status',
|
||||
'apps_inAppPurchasesV2_getToManyRelated': 'apple/iap apple/iap-review apple/iap-submit apple/offer-codes apple/promoted apple/status catalog prices validate',
|
||||
'apps_perfPowerMetrics_getToManyRelated': 'health',
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'ads apple': [() => import('./ads/apple'), 'Apple Ads (Search Ads): status | campaigns | report (spend, taps, installs, CPA per campaign / keyword, --csv); bid | budget | pause (dry run, asks first; never via MCP)'],
|
||||
'web': [() => import('./web'), 'init (apple-app-site-association, assetlinks.json, Smart App Banner tag, optional landing page) | check <domain> (live files: content type, no redirects, ids match)'],
|
||||
'revenuecat': [() => import('./revenuecat'), 'diff (catalog vs RevenueCat products, entitlements, default offering) | sync (dry run, asks first; never via MCP)'],
|
||||
'console fill': [() => import('./console/fill'), '<form> [--store apple|play]: AI-assisted autofill of console forms without an API in a visible Chromium you sign in to yourself; stops before Save / Submit'],
|
||||
'console plan': [() => import('./console/plan'), '<form> [--json]: agent-first console mode: start URL, steps, answer sheet (certain / ask owner, console labels), never-press list, UI facts and how to verify, for an AI agent that drives the browser itself; read-only'],
|
||||
'console verify': [() => import('./console/verify'), '<form> [--from-text <file|->] [--json]: check a filled console form against the config (page text, input states or the App Store Connect API); read-only, exit 1 on a mismatch'],
|
||||
'console fill': [() => import('./console/fill'), '<form> [--store apple|play]: optional scripted fast path: autofill in a visible Chromium you sign in to yourself; stops before Save / Submit'],
|
||||
'reviews': [() => import('./reviews'), 'list | draft (local reply drafts in .storebound/replies) | send (approved drafts only, asks first)'],
|
||||
'originality': [() => import('./originality'), 'Guideline 4.3 check vs your other apps (--against <folders>): listing text, name, icon and screenshot perceptual hashes, project structure; scores, flags, suggestions; offline, exit 1 on a near-duplicate'],
|
||||
'listing': [() => import('./listing'), 'translate --to de-DE,ja [--from en-US] [--translate "<command>"]: listing translation drafts per language (App Store name, subtitle, promo text, keywords, description; Play title, short, full) with store limits and keyword rules, marked until reviewed | check | reviewed --lang <ids>; local files only'],
|
||||
|
|
|
|||
|
|
@ -24,12 +24,12 @@ import { homedir } from 'node:os';
|
|||
import { dirname, join } from 'node:path';
|
||||
import type { ShipConfig } from '../lib/config';
|
||||
import type { FormId } from '../lib/console-answers';
|
||||
import { hasToken, type Name, NAV_BASES, type NavBases, type NavStep, navPlan, playDeveloperId, switchAllowed } from '../lib/console-forms';
|
||||
import { hasToken, type Name, NAV_BASES, type NavBases, type NavStep, navPlan, playAppIdOf, playDeveloperId, switchAllowed } from '../lib/console-forms';
|
||||
import { ACTION_MS, accName, findAny, type Locator, navClick, navigate, type NavResult, type Page, poll, realRole, safeClick, signInPage, SignInSeen, type Stop, vis } from './fill';
|
||||
|
||||
// ---- the cache ----
|
||||
|
||||
export interface PlayEntry { developerId: string; account?: string; at: string }
|
||||
export interface PlayEntry { developerId: string; account?: string; /** The Play Console app id (/app/<appId>/), read from the URL once the app is open. */ appId?: string; at: string }
|
||||
export interface AppleEntry { team: string; teamId?: string; at: string }
|
||||
export interface AccountsCache { version: 1; play: Record<string, PlayEntry>; apple: Record<string, AppleEntry> }
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ export function readAccounts(file = accountsFile()): AccountsCache {
|
|||
let j: any;
|
||||
try { j = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null; } catch { return out; }
|
||||
for (const [k, v] of Object.entries((j?.play ?? {}) as Record<string, any>)) {
|
||||
if (KEY.test(k) && typeof v?.developerId === 'string' && /^\d{1,25}$/.test(v.developerId)) out.play[k] = { developerId: v.developerId, ...(str(v.account) ? { account: v.account } : {}), at: str(v.at, 40) ? v.at : '' };
|
||||
if (KEY.test(k) && typeof v?.developerId === 'string' && /^\d{1,25}$/.test(v.developerId)) out.play[k] = { developerId: v.developerId, ...(str(v.account) ? { account: v.account } : {}), ...(typeof v.appId === 'string' && /^\d{1,25}$/.test(v.appId) ? { appId: v.appId } : {}), at: str(v.at, 40) ? v.at : '' };
|
||||
}
|
||||
for (const [k, v] of Object.entries((j?.apple ?? {}) as Record<string, any>)) {
|
||||
if (KEY.test(k) && str(v?.team)) out.apple[k] = { team: v.team, ...(typeof v.teamId === 'string' && /^[A-Z0-9]{10}$/.test(v.teamId) ? { teamId: v.teamId } : {}), at: str(v.at, 40) ? v.at : '' };
|
||||
|
|
@ -55,7 +55,9 @@ export function readAccounts(file = accountsFile()): AccountsCache {
|
|||
/** Remembers which account has the app (written atomically: the desktop app may read it at the same time). */
|
||||
export function rememberPlay(pkg: string, e: Omit<PlayEntry, 'at'>, file = accountsFile()): void {
|
||||
const c = readAccounts(file);
|
||||
c.play[pkg] = { ...e, at: new Date().toISOString() };
|
||||
// the app id stays when the same account is remembered again without one
|
||||
const keep = c.play[pkg]?.developerId === e.developerId && c.play[pkg]?.appId && !e.appId ? { appId: c.play[pkg]!.appId! } : {};
|
||||
c.play[pkg] = { ...e, ...keep, at: new Date().toISOString() };
|
||||
write(c, file);
|
||||
}
|
||||
export function rememberApple(bundleId: string, e: Omit<AppleEntry, 'at'>, file = accountsFile()): void {
|
||||
|
|
@ -281,7 +283,18 @@ function notHere(r: NavResult, plan: NavStep[]): boolean {
|
|||
* `signIn` (the caller waits and calls again).
|
||||
*/
|
||||
export async function openAcross(page: Page, o: AcrossOpts): Promise<{ nav: NavResult; plan: NavStep[] }> {
|
||||
try { return await across(page, o); } catch (e) {
|
||||
try {
|
||||
const r = await across(page, o);
|
||||
// a Play form of an existing app is open: its URL carries the Play Console app id (/app/<appId>/), remembered
|
||||
// next to the developer id so `console plan` can deep-link the App content forms
|
||||
const pkg = o.config.play?.packageName;
|
||||
if (r.nav.ok && pkg && o.form.startsWith('play-') && o.form !== 'play-app-record') {
|
||||
const dev = playDeveloperId(page.url()), app = playAppIdOf(page.url());
|
||||
const f = o.file ?? accountsFile(), account = readAccounts(f).play[pkg]?.account;
|
||||
if (dev && app) rememberPlay(pkg, { developerId: dev, ...(account ? { account } : {}), appId: app }, f);
|
||||
}
|
||||
return r;
|
||||
} catch (e) {
|
||||
if (e instanceof SignInSeen) return { nav: { ok: false, steps: [], failed: 'the account list', why: 'a sign-in page appeared', signIn: true }, plan: [] };
|
||||
throw e;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,37 @@ async function find(root: Locator, role: string, names: Name[], label = false):
|
|||
const f = await first(tries);
|
||||
if (f.state !== 'none') return f;
|
||||
}
|
||||
return role === 'checkbox' || role === 'radio' ? byParentText(root, role, names) : { state: 'none' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearest non-empty texts around controls that has no accessible name of its own (no aria-label, aria-labelledby
|
||||
* or associated <label>): its parents are walked up until one has text. App Privacy's purpose checkboxes are like
|
||||
* that (verified October 2026): the label and its help text sit in a sibling, run together ("Third-Party
|
||||
* AdvertisingSuch as displaying ..."). '' when the control has a name (getByRole already saw it). Read-only.
|
||||
*/
|
||||
export const PARENT_TEXTS = (els: any[]): string[] => els.map((el: any) => {
|
||||
const norm = (t: string | null | undefined) => (t ?? '').trim().replace(/\s+/g, ' ');
|
||||
if (norm(el.getAttribute('aria-label'))) return '';
|
||||
const by = el.getAttribute('aria-labelledby');
|
||||
if (by && norm(by.split(/\s+/).map((id: string) => el.ownerDocument.getElementById(id)?.textContent ?? '').join(' '))) return '';
|
||||
if (el.labels && [...el.labels].some((l: any) => norm(l.textContent))) return '';
|
||||
for (let a = el.parentElement; a && a !== el.ownerDocument.body; a = a.parentElement) { const t = norm(a.textContent); if (t) return t; }
|
||||
return '';
|
||||
});
|
||||
|
||||
/** A checkbox / radio without an accessible name, matched by its nearest non-empty parent text (PARENT_TEXTS; one
|
||||
* page call for all of them). */
|
||||
async function byParentText(root: Locator, role: string, names: Name[]): Promise<Found> {
|
||||
const all = vis(root.locator(role === 'checkbox' ? 'input[type="checkbox"], [role="checkbox"]' : 'input[type="radio"], [role="radio"]'));
|
||||
const texts: string[] = await all.evaluateAll(PARENT_TEXTS).catch(() => []);
|
||||
if (!texts.some(Boolean)) return { state: 'none' };
|
||||
for (const name of names) {
|
||||
const re = typeof name === 'string' ? quoteTolerant(name) : name;
|
||||
const hits = texts.flatMap((t, i) => (t && re.test(t) ? [i] : []));
|
||||
if (hits.length === 1) return { state: 'ok', loc: all.nth(hits[0]!) };
|
||||
if (hits.length > 1) return { state: 'ambiguous' };
|
||||
}
|
||||
return { state: 'none' };
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +140,7 @@ export async function accName(loc: Locator): Promise<string> {
|
|||
const ref = by ? by.split(/\s+/).map((id: string) => el.ownerDocument.getElementById(id)?.textContent ?? '').join(' ') : '';
|
||||
const labels = el.labels ? [...el.labels].map((l: any) => l.textContent).join(' ') : '';
|
||||
return el.getAttribute('aria-label') || ref || labels || el.textContent || '';
|
||||
})).trim().replace(/\s+/g, ' ');
|
||||
})).trim().replace(/\s+/g, ' ') || ((await loc.evaluateAll(PARENT_TEXTS).catch(() => [])) as string[])[0] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -153,7 +184,11 @@ async function setField(r: Resolved, v: string | boolean): Promise<boolean> {
|
|||
if (f.kind === 'checkbox') return safeClick(r.loc, await accName(r.loc), v ? 'check' : 'uncheck');
|
||||
if (f.kind === 'radio') {
|
||||
const o = r.options?.[String(v)];
|
||||
return o ? safeClick(o, await accName(o), 'check') : false;
|
||||
if (!o) return false;
|
||||
const name = await accName(o);
|
||||
if (forbiddenTick(name)) return safeClick(o, name, 'check');
|
||||
try { if (!await safeClick(o, name, 'check')) return false; } catch (e) { if (!await inMaterialRadio(o)) throw e; }
|
||||
return materialRadio(o);
|
||||
}
|
||||
// select: only a native <select>; a custom dropdown is left to the owner (highlighted)
|
||||
const idx: number = await r.loc.evaluate((el: any, want: string) => {
|
||||
|
|
@ -174,6 +209,25 @@ async function setField(r: Resolved, v: string | boolean): Promise<boolean> {
|
|||
return true;
|
||||
}
|
||||
|
||||
/** The radio sits in Play Console's <material-radio> (or is a [role=radio] itself). */
|
||||
const inMaterialRadio = (o: Locator): Promise<boolean> => o.evaluate((el: any) => !!el.closest('material-radio')).catch(() => false);
|
||||
|
||||
/**
|
||||
* Play Console's <material-radio> (verified 2026-10-12 on Create app): setting the native input, or a click that does
|
||||
* not reach the right element (a background window), leaves the Angular model empty and the form keeps showing
|
||||
* "Select app or game". Only a click on the inner [role=radio] updates the model, so it gets a DOM click when it is not
|
||||
* aria-checked yet. Other radios are left as they are. true when the radio is checked afterwards.
|
||||
*/
|
||||
export async function materialRadio(o: Locator): Promise<boolean> {
|
||||
return o.evaluate((el: any) => {
|
||||
const host = el.closest('material-radio');
|
||||
const inner = el.getAttribute('role') === 'radio' ? el : host?.querySelector('[role="radio"]');
|
||||
if (!inner) return el.checked !== false;
|
||||
if (inner.getAttribute('aria-checked') !== 'true') inner.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
||||
return inner.getAttribute('aria-checked') === 'true' || el.checked === true;
|
||||
}).catch(() => false);
|
||||
}
|
||||
|
||||
async function mark(r: Resolved, text = CHECK): Promise<void> {
|
||||
if (r.loc) return highlight(r.loc, text);
|
||||
for (const o of Object.values(r.options ?? {})) await highlight(o, text);
|
||||
|
|
@ -1211,6 +1265,14 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
progress(`the next part after "${s.title}"`);
|
||||
if (o.debugEveryStep) await dump(p, `filled ${s.title}`);
|
||||
say(`${s.title}: filled ${s.filled.length}, check ${s.highlighted.length}${s.highlighted.length ? `: ${s.highlighted.map(id => labels.get(id) ?? id).join('; ')}` : ''}`);
|
||||
// Play Create app: the package name's read-only "Check availability" (verified 2026-10-12)
|
||||
if (form === 'play-app-record' && s.filled.includes('packageName')) {
|
||||
const check = await controlWhere(p, FLOW_NAMES.checkAvailability, () => true, dataTypes).catch(() => null);
|
||||
if (check && await flowClick(check, form, dataTypes, say).catch(() => false)) {
|
||||
const ok = await poll(5000, async () => ((await vis(p.getByText(/package name (is )?available/i)).count()) > 0 ? true : null)).catch(() => null);
|
||||
say(ok ? 'App package name: "Package name available"' : 'App package name: Play Console did not say "Package name available": check the message next to the field');
|
||||
}
|
||||
}
|
||||
anchor = s.anchor; filledAt = s.section;
|
||||
// the last page of a Play / App Store form without a Next ends the run: its button is the owner's (Save, Submit, Create)
|
||||
const ends = !privacy && s.section === last && !await nextControl(p).catch(() => null);
|
||||
|
|
|
|||
39
cli/console/plan.ts
Normal file
39
cli/console/plan.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// The agent-first console mode: storebound supplies the knowledge, the calling AI agent drives the browser.
|
||||
// storebound console plan lists the forms
|
||||
// storebound console plan <form> [--json] [--lookup]
|
||||
// Read-only. Offline by default: the start URL, the ordered steps, the answer sheet (each answer certain or "ask
|
||||
// owner", with the console's own labels), the never-press list, the known UI facts and how to verify. Deep links use
|
||||
// apple.appId / play.developerId / play.appId, the ids cached by earlier runs (.storebound/console-app-ids.json,
|
||||
// ~/.storebound/console-accounts.json) and the workspace's account ids; --lookup also asks the App Store Connect API
|
||||
// for the app id by bundle id (a GET; needs the API key). Pure logic in cli/lib/console-plan.ts. MCP: console_plan.
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { FORM_IDS, formMeta, isForm } from '../lib/console-answers';
|
||||
import { consolePlan, type PlanIds, planText } from '../lib/console-plan';
|
||||
import { printJson } from '../lib/log';
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const args = ctx.args, json = args.has('json');
|
||||
const form = args._[0];
|
||||
if (!form) {
|
||||
const forms = FORM_IDS.map(id => { const m = formMeta(id, config); return { id, store: m.store, title: m.title, fillable: m.fillable }; });
|
||||
if (json) return printJson({ forms });
|
||||
console.log('storebound console plan <form> [--json] [--lookup] (then drive the page yourself; `storebound console verify <form>` checks it)\n');
|
||||
for (const f of forms) console.log(` ${f.id.padEnd(22)} ${f.store.padEnd(6)} ${f.title}${f.fillable ? '' : ' (guide only)'}`);
|
||||
return;
|
||||
}
|
||||
if (!isForm(form)) throw new Error(`console plan: unknown form "${form}" (one of ${FORM_IDS.join(', ')})`);
|
||||
const { withAccountIds, readAccounts } = await import('./accounts');
|
||||
const cfg = withAccountIds(config);
|
||||
const pkg = cfg.play?.packageName;
|
||||
const cached = pkg ? readAccounts().play[pkg] : undefined;
|
||||
const ids: PlanIds = { playDeveloperId: cfg.play?.developerId ?? cached?.developerId ?? null, playAppId: cfg.play?.appId ?? cached?.appId ?? null };
|
||||
if (form.startsWith('apple-') && !cfg.apple?.appId && cfg.apple?.bundleId) {
|
||||
const { appleAppId } = await import('./app-id');
|
||||
// without --lookup only the cache is read: the lookup refuses, so no request is made
|
||||
ids.appleAppId = await appleAppId(root, cfg, { say: () => { /* the plan's start says what it found */ }, ...(args.has('lookup') ? {} : { lookup: async () => { throw new Error('offline (add --lookup to ask the API)'); } }) });
|
||||
}
|
||||
const plan = consolePlan(form, cfg, ids);
|
||||
if (json) return printJson(plan);
|
||||
console.log(planText(plan));
|
||||
}
|
||||
47
cli/console/verify.ts
Normal file
47
cli/console/verify.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Checks a console form against the config after the agent (or the owner) filled it. Read-only.
|
||||
// storebound console verify <form> [--from-text <file|->] [--json]
|
||||
// apple-app-privacy: the page text of the App Privacy page (the Data Types section's per-type summaries; the App Store
|
||||
// Connect API has no App Privacy endpoint). play-app-record: the JSON of the plan's capture snippet (input states) or
|
||||
// the page text. play-content-rating, play-target-audience: the summary page text. apple-app-record: the App Store
|
||||
// Connect API (the app by bundle id; a GET) or, with --from-text, the page text. apple-age-rating: the API.
|
||||
// Exit 1 on any mismatch, and when nothing could be matched. Pure logic in cli/lib/console-verify.ts. MCP: console_verify.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { FORM_IDS, isForm } from '../lib/console-answers';
|
||||
import { verifyAgeRatingApi, verifyAppRecordApi, type VerifyResult, verifyTable, verifyText } from '../lib/console-verify';
|
||||
import { printJson } from '../lib/log';
|
||||
|
||||
async function readText(src: string): Promise<string> {
|
||||
if (src === '-' || src === 'true') return Bun.stdin.text();
|
||||
return readFileSync(src, 'utf8');
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const args = ctx.args, json = args.has('json');
|
||||
const form = args._[0];
|
||||
if (!isForm(form)) throw new Error(`console verify <form> [--from-text <file|->]: one of ${FORM_IDS.join(', ')}`);
|
||||
const from = args.get('from-text'), inline = args.get('text');
|
||||
let r: VerifyResult;
|
||||
if (from !== undefined || inline !== undefined) {
|
||||
const text = inline ?? await readText(from!);
|
||||
if (!text.trim()) throw new Error('console verify: the page text is empty');
|
||||
r = verifyText(form, config, text);
|
||||
} else if (form === 'apple-app-record' || form === 'apple-age-rating') {
|
||||
const bundleId = config.apple?.bundleId;
|
||||
if (!bundleId) throw new Error('console verify: apple.bundleId is not set');
|
||||
const { ascClient } = await import('../apple/asc');
|
||||
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||
if (form === 'apple-app-record') {
|
||||
const found = await c.asc('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}`);
|
||||
const hit = (found.data as { attributes: { bundleId: string; name?: string; sku?: string; primaryLocale?: string } }[]).find(a => a.attributes.bundleId === bundleId);
|
||||
r = verifyAppRecordApi(config, hit?.attributes ?? null);
|
||||
} else {
|
||||
const info = await c.editableAppInfo();
|
||||
const age = await c.maybe(`/v1/appInfos/${info.id}/ageRatingDeclaration`);
|
||||
r = verifyAgeRatingApi(config, (age?.data?.attributes ?? null) as Record<string, unknown> | null);
|
||||
}
|
||||
} else throw new Error(`console verify ${form}: pass the page text with --from-text <file|-> (run \`storebound console plan ${form}\` for what to capture)`);
|
||||
if (json) printJson(r); else console.log(verifyTable(r));
|
||||
if (!r.ok) process.exitCode = 1;
|
||||
}
|
||||
|
|
@ -297,6 +297,26 @@
|
|||
"store",
|
||||
"use-chrome-session"
|
||||
],
|
||||
"console plan": [
|
||||
"cdp",
|
||||
"chrome-profile",
|
||||
"debug-dump",
|
||||
"fresh-profile",
|
||||
"lookup",
|
||||
"name",
|
||||
"profile",
|
||||
"sku",
|
||||
"start-url",
|
||||
"step-timeout",
|
||||
"store",
|
||||
"use-chrome-session"
|
||||
],
|
||||
"console verify": [
|
||||
"from-text",
|
||||
"name",
|
||||
"sku",
|
||||
"text"
|
||||
],
|
||||
"desktop": [
|
||||
"channel",
|
||||
"dir",
|
||||
|
|
@ -696,6 +716,7 @@
|
|||
"fresh",
|
||||
"fresh-profile",
|
||||
"from",
|
||||
"from-text",
|
||||
"godot",
|
||||
"group",
|
||||
"halt",
|
||||
|
|
@ -722,6 +743,7 @@
|
|||
"listing-text",
|
||||
"listings",
|
||||
"locale",
|
||||
"lookup",
|
||||
"manual-release",
|
||||
"md",
|
||||
"merge",
|
||||
|
|
@ -782,6 +804,7 @@
|
|||
"seconds",
|
||||
"since",
|
||||
"size",
|
||||
"sku",
|
||||
"source",
|
||||
"src",
|
||||
"start",
|
||||
|
|
@ -981,6 +1004,13 @@
|
|||
"storebound console fill <form> --dry [--json] the answers and the step-by-step guidance; opens no browser",
|
||||
"storebound console fill <form> [--store apple|play] [--json] [--profile <dir>] [--use-chrome-session google|apple]"
|
||||
],
|
||||
"console plan": [
|
||||
"storebound console plan lists the forms",
|
||||
"storebound console plan <form> [--json] [--lookup]"
|
||||
],
|
||||
"console verify": [
|
||||
"storebound console verify <form> [--from-text <file|->] [--json]"
|
||||
],
|
||||
"desktop": [
|
||||
"storebound desktop open the app; in an app folder (storebound.config.*) it adds or selects that",
|
||||
"storebound desktop --dir <folder> the same for another folder; --no-folder: just open the app",
|
||||
|
|
|
|||
|
|
@ -501,6 +501,9 @@ export interface ShipConfig {
|
|||
* one Google login in several developer accounts, `storebound console fill` finds the app by package name and
|
||||
* remembers the account (~/.storebound/console-accounts.json); this skips that search. */
|
||||
developerId?: string;
|
||||
/** The Play Console app id (the digits in /console/u/0/developers/<developerId>/app/<appId>/app-dashboard, shown
|
||||
* after Create app). Optional: `storebound console plan` deep-links the App content forms with it. */
|
||||
appId?: string;
|
||||
/** The developer account's display name on Play Console's "Choose a developer account" page (e.g. an
|
||||
* organisation account). `storebound console fill play-app-record` picks the account by it when there is no
|
||||
* package in any account yet and no developerId; ambiguous or unknown -> it stops and lists the accounts. */
|
||||
|
|
@ -689,6 +692,7 @@ export function validateExtras(cfg: Partial<ShipConfig>): string[] {
|
|||
const e: string[] = [];
|
||||
if (cfg.apple?.teamId !== undefined && !/^[A-Z0-9]{10}$/.test(cfg.apple.teamId)) e.push('apple.teamId: the 10-character Team ID (Membership details)');
|
||||
if (cfg.play?.developerId !== undefined && !/^\d{1,25}$/.test(cfg.play.developerId)) e.push('play.developerId: the digits of play.google.com/console/u/0/developers/<id>');
|
||||
if (cfg.play?.appId !== undefined && !/^\d{1,25}$/.test(cfg.play.appId)) e.push('play.appId: the digits of play.google.com/console/u/0/developers/<developerId>/app/<appId>/app-dashboard');
|
||||
if (cfg.play?.developerAccount !== undefined && !(typeof cfg.play.developerAccount === 'string' && cfg.play.developerAccount.trim() && cfg.play.developerAccount.length <= 100))
|
||||
e.push('play.developerAccount: the developer account name as Play Console shows it (1-100 characters)');
|
||||
if (cfg.play?.name !== undefined && !(typeof cfg.play.name === 'string' && cfg.play.name.trim() && [...cfg.play.name].length <= 30)) e.push('play.name: 1-30 characters (Play app name)');
|
||||
|
|
|
|||
|
|
@ -72,10 +72,10 @@ export function formMeta(id: FormId, config: ShipConfig): FormMeta {
|
|||
open: ['General > App Information > Age Ratings > Set Up Age Ratings (only to look: `storebound apple appinfo` sets it)'] };
|
||||
case 'apple-app-privacy': return { id, store: 'apple', title: 'App Store Connect: App Privacy', url: privacy, stopBefore: 'Publish', fillable: true,
|
||||
open: [...(config.apple?.appId ? [] : ['Apps > your app (set apple.appId so storebound opens it directly)']), 'App Privacy in the sidebar (storebound clicks Get Started, or Edit next to Data Types, and walks the questionnaire itself)'] };
|
||||
case 'play-app-record': return { id, store: 'play', title: 'Play Console: Create app', url: PLAY, stopBefore: 'Create app', fillable: true,
|
||||
open: ['Pick the developer account if Play Console asks (play.developerId or play.developerAccount picks it)', 'Home (All apps) > Create app'],
|
||||
case 'play-app-record': return { id, store: 'play', title: 'Play Console: Create app', url: config.play?.developerId ? `${PLAY}/u/0/developers/${encodeURIComponent(config.play.developerId)}/create-new-app` : PLAY, stopBefore: 'Create app', fillable: true,
|
||||
open: config.play?.developerId ? ['the Create app form of the developer account opens by link (/developers/<play.developerId>/create-new-app)'] : ['Pick the developer account if Play Console asks (play.developerId or play.developerAccount picks it)', 'Home (All apps) > Create app (or /console/u/0/developers/<developer id>/create-new-app)'],
|
||||
notes: [
|
||||
'The package name is not part of this form: the first AAB upload sets it for good (storebound play upload --track internal, or storebound flow run release).',
|
||||
'The form has an App package name field (play.packageName, at most 150 characters): storebound fills it and presses "Check availability" (read-only), which should say "Package name available". The package name is fixed for good once the app is created.',
|
||||
'Free or paid is an owner check: a free app can never become paid later on Google Play.',
|
||||
'The Developer Program Policies and US export laws boxes are agreements: storebound never ticks them. Read and tick them yourself, then press Create app.',
|
||||
] };
|
||||
|
|
@ -138,6 +138,8 @@ export const playLanguageLabel = (code: string): string | null => (PLAY_LANGUAGE
|
|||
|
||||
/** The Play app name limit (Create app form and store listing title). */
|
||||
export const PLAY_NAME_MAX = 30;
|
||||
/** The Create app form's App package name limit (verified 2026-10-12). */
|
||||
export const PLAY_PACKAGE_MAX = 150;
|
||||
|
||||
function playAppRecord(config: ShipConfig): Answer[] {
|
||||
const p = config.play;
|
||||
|
|
@ -150,9 +152,12 @@ function playAppRecord(config: ShipConfig): Answer[] {
|
|||
const profile = config.app.profile;
|
||||
const sells = (config.products?.length ?? 0) > 0 || (config.subscriptions?.length ?? 0) > 0, ads = (config.ads?.appAdsTxt?.length ?? 0) > 0;
|
||||
const once = 'A free app can never become paid later on Google Play (a paid app can become free).';
|
||||
const pkg = p?.packageName;
|
||||
const out: Answer[] = [
|
||||
len <= PLAY_NAME_MAX ? ok('name', 'App name', name, nameSrc, 'Shown on Google Play; the store listing can change it later.')
|
||||
: ask('name', 'App name', nameSrc, `"${name}" is ${len} characters; Play allows ${PLAY_NAME_MAX}. Set play.name to a shorter name.`),
|
||||
pkg && pkg.length <= PLAY_PACKAGE_MAX ? ok('packageName', 'App package name', pkg, 'play.packageName', 'Fixed for good once the app is created. Press "Check availability" next to it (read-only): "Package name available".')
|
||||
: ask('packageName', 'App package name', 'play.packageName', pkg ? `"${pkg}" is longer than ${PLAY_PACKAGE_MAX} characters.` : 'Set play.packageName (the applicationId of the Android build): it is fixed for good once the app is created.'),
|
||||
lang ? ok('language', 'Default language', lang, langSrc)
|
||||
: ask('language', 'Default language', 'play.defaultLanguage', code ? `Pick the language of code "${code}" (storebound does not know its Play Console name).` : 'Set play.defaultLanguage (e.g. en-US) or a play code on the first language.'),
|
||||
profile ? ok('type', 'App or game', profile === 'game' ? 'Game' : 'App', 'app.profile', 'Changeable later in the store settings.')
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ export function formDef(id: FormId, answers: Answer[]): FormDef {
|
|||
case 'apple-age-rating': return { id, sections: [] };
|
||||
case 'play-app-record': return { id, sections: [{ title: 'Create app', fields: [
|
||||
{ id: 'name', kind: 'text', names: ['App name', 'Uygulama adı'] },
|
||||
{ id: 'packageName', kind: 'text', names: ['App package name', 'Package name', 'Uygulama paket adı', 'Paket adı'] },
|
||||
{ id: 'language', kind: 'select', names: ['Default language', 'Varsayılan dil'] },
|
||||
{ id: 'type', kind: 'radio', names: ['App or game', 'Uygulama veya oyun'], options: { App: ['App', 'Uygulama'], Game: ['Game', 'Oyun'] } },
|
||||
{ id: 'price', kind: 'radio', names: ['Free or paid', 'Ücretsiz veya ücretli'], options: { Free: ['Free', 'Ücretsiz'], Paid: ['Paid', 'Ücretli'] } },
|
||||
|
|
@ -141,13 +142,19 @@ export function formDef(id: FormId, answers: Answer[]): FormDef {
|
|||
* Turkish console wording). English words match whole words; Turkish stems match word starts (agglutinative suffixes).
|
||||
*/
|
||||
const EN = ['save', 'saved', 'submit', 'publish', 'create', 'send', 'apply', 'confirm', 'done', 'release', 'rollout', 'roll out', 'start', 'delete',
|
||||
'remove', 'next', 'continue', 'finish', 'agree', 'accept', 'upload', 'deploy', 'discard', 'update', 'add for review', 'review', 'sign', 'log in', 'login'];
|
||||
'remove', 'next', 'continue', 'finish', 'agree', 'accept', 'upload', 'deploy', 'discard', 'update', 'add for review', 'review', 'sign', 'log in', 'login',
|
||||
// App Privacy's tracking step: a help panel, never needed (verified on the real dialog, October 2026)
|
||||
'definitions and examples'];
|
||||
const TR = ['kaydet', 'gönder', 'yayınla', 'yayımla', 'oluştur', 'onayla', 'tamam', 'bitti', 'bitir', 'sil', 'kaldır', 'başlat', 'sürüm',
|
||||
'ileri', 'devam', 'sonraki', 'kabul', 'yükle', 'güncelle', 'incelemeye', 'giriş'];
|
||||
const L = '\\p{L}\\p{N}';
|
||||
// "uygula" (apply) is refused in every form, but not "uygulama" (app: Yeni Uygulama, Uygulama içeriği)
|
||||
const FORBIDDEN = new RegExp(`(?<![${L}])(?:(?:${EN.map(esc).join('|')})(?![${L}])|(?:${TR.map(esc).join("|")}|uygula(?!ma)))`, 'iu');
|
||||
|
||||
/** Controls never pressed by storebound or by an agent following `console plan`, whatever the form: help panels and
|
||||
* the final buttons (the agent-facing never-press list is built from this and FORM_BUTTONS). */
|
||||
export const NEVER_PRESS = ['Publish', 'Submit', 'Create', 'Create app', 'Send', 'Release', 'Definitions and Examples', '?', 'More information'];
|
||||
|
||||
/** true when storebound must not click a control with this accessible name. */
|
||||
export const forbiddenControl = (name: string): boolean => FORBIDDEN.test(name.toLocaleLowerCase('tr').normalize('NFC')) || FORBIDDEN.test(name.toLowerCase());
|
||||
|
||||
|
|
@ -284,10 +291,12 @@ export function navPlan(id: FormId, config: ShipConfig, bases: NavBases = NAV_BA
|
|||
{ kind: 'wait', label: 'the New App dialog', roles: ['dialog'], names: ['New App', 'Yeni Uygulama', /^new app\b/i, /^yeni uygulama\b/iu] },
|
||||
];
|
||||
case 'apple-age-rating': return [];
|
||||
case 'play-app-record': return [
|
||||
config.play?.developerId
|
||||
? { kind: 'goto', label: 'open the app list of the developer account', url: playAppList(play, config.play.developerId) }
|
||||
: { kind: 'goto', label: 'open Play Console', url: play },
|
||||
case 'play-app-record': return config.play?.developerId ? [
|
||||
// verified 2026-10-12: the Create app form of one developer account has its own URL
|
||||
{ kind: 'goto', label: 'open the Create app form of the developer account', url: playCreateApp(play, config.play.developerId) },
|
||||
{ kind: 'wait', label: 'the Create app form', roles: ['textbox'], names: CREATE_FORM_FIELD },
|
||||
] : [
|
||||
{ kind: 'goto', label: 'open Play Console', url: play },
|
||||
{ kind: 'click', label: 'Create app on the app list', roles: ['button', 'link'], names: CREATE_APP_OPENERS, createApp: true },
|
||||
{ kind: 'wait', label: 'the Create app form', roles: ['textbox'], names: CREATE_FORM_FIELD },
|
||||
];
|
||||
|
|
@ -359,12 +368,14 @@ const EDIT: Name[] = ['Edit', 'Düzenle'];
|
|||
const SAVE: Name[] = ['Save', 'Kaydet'];
|
||||
const SAVE_DRAFT: Name[] = ['Save draft', 'Taslağı kaydet', 'Taslak olarak kaydet'];
|
||||
const DONE: Name[] = ['Done', 'Bitti', 'Tamam'];
|
||||
/** Play Create app: the read-only package name check next to App package name (verified 2026-10-12). */
|
||||
const CHECK_AVAILABILITY: Name[] = ['Check availability', 'Kullanılabilirliği kontrol et', 'Uygunluğu kontrol et'];
|
||||
/** The heading of App Privacy's Data Types section (Edit and the data types sit in it). */
|
||||
export const DATA_TYPES_HEADING: Name[] = ['Data Types', 'Veri Türleri', 'Veri türleri'];
|
||||
/** The App Privacy page heading. */
|
||||
export const APP_PRIVACY_HEADING: Name[] = ['App Privacy', 'Uygulama Gizliliği', /^app privacy$/i];
|
||||
/** Names the engine looks for. */
|
||||
export const FLOW_NAMES = { next: NEXT, save: SAVE, saveDraft: SAVE_DRAFT, getStarted: GET_STARTED, edit: EDIT };
|
||||
export const FLOW_NAMES = { next: NEXT, save: SAVE, saveDraft: SAVE_DRAFT, getStarted: GET_STARTED, edit: EDIT, checkAvailability: CHECK_AVAILABILITY };
|
||||
|
||||
/** Every button of every console form, classified (the console-autofill skill's table is written from this). */
|
||||
export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
|
||||
|
|
@ -376,6 +387,7 @@ export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
|
|||
{ button: '? / More information', class: 'navigation', where: 'next to each heading and data type: help only (never needed)', auto: false },
|
||||
{ button: 'Next / Continue', class: 'navigation', where: 'questionnaire dialogs', auto: true },
|
||||
{ button: 'Back', class: 'navigation', where: 'questionnaire dialogs (never needed)', auto: false },
|
||||
{ button: 'Definitions and Examples', class: 'navigation', where: 'a data type\'s tracking step: help only (never clicked)', auto: false },
|
||||
{ button: 'Save', class: 'draft-save', where: 'questionnaire dialogs: stores the answers, not public until Publish', auto: true },
|
||||
{ button: 'Publish (and its confirmation dialog)', class: 'final-submit', where: 'top right of the App Privacy page', auto: false },
|
||||
],
|
||||
|
|
@ -391,6 +403,7 @@ export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
|
|||
],
|
||||
'play-app-record': [
|
||||
{ button: 'Create app (app list)', class: 'navigation', where: 'Home / All apps: opens the Create app form', auto: true },
|
||||
{ button: 'Check availability', class: 'navigation', where: 'the Create app form, next to App package name: read-only check ("Package name available")', auto: true },
|
||||
{ button: 'Developer Program Policies / US export laws boxes', class: 'final-submit', where: 'the Create app form: agreements, never ticked', auto: false },
|
||||
{ button: 'Create app (form)', class: 'final-submit', where: 'the Create app form: creates the app', auto: false },
|
||||
],
|
||||
|
|
@ -430,6 +443,7 @@ export function flowClass(form: FormId, name: string, role: string, where: FlowW
|
|||
if (!n || !['button', 'link'].includes(role)) return null;
|
||||
if (FINAL.test(n.toLocaleLowerCase('tr')) || FINAL.test(n)) return 'final-submit';
|
||||
if (nameIs(n, NEXT) || nameIs(n, BACK)) return 'navigation';
|
||||
if (form === 'play-app-record' && nameIs(n, CHECK_AVAILABILITY)) return 'navigation';
|
||||
if (form === 'apple-app-privacy') {
|
||||
if (nameIs(n, GET_STARTED) && where.privacyPage && !where.inDialog) return 'navigation';
|
||||
if (nameIs(n, EDIT) && where.inDataTypes && !where.inDialog) return 'navigation';
|
||||
|
|
@ -479,9 +493,19 @@ export function flowAllowed(form: FormId, name: string, role: string, where: Flo
|
|||
// the team menu (exact name), is a link / menu item / option / row / button, and is never a sign-out or a deny-listed
|
||||
// control (forbiddenControl stays absolute, so a studio named "Next Games" is not clicked: the owner picks it).
|
||||
|
||||
/** Play Console's Create app form of one developer account (verified 2026-10-12). */
|
||||
export const playCreateApp = (playBase: string, developerId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/create-new-app`;
|
||||
|
||||
/** Play Console's app list of one developer account. */
|
||||
export const playAppList = (playBase: string, developerId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/app-list`;
|
||||
|
||||
/** The Play Console app id in a URL (/developers/<id>/app/<digits>/...), or null. */
|
||||
export function playAppIdOf(url: string): string | null {
|
||||
try { return new URL(url).pathname.match(/\/developers\/\d{1,25}\/app\/(\d{1,25})(?:\/|$)/)?.[1] ?? null; } catch { return null; }
|
||||
}
|
||||
/** An app's Play Console dashboard (verified 2026-10-12: where Create app lands). */
|
||||
export const playAppDashboard = (playBase: string, developerId: string, appId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/app/${encodeURIComponent(appId)}/app-dashboard`;
|
||||
|
||||
/** The developer account id in a Play Console URL (/developers/<digits>), or null. */
|
||||
export function playDeveloperId(url: string): string | null {
|
||||
try { return new URL(url).pathname.match(/\/developers\/(\d{1,25})(?:\/|$)/)?.[1] ?? null; } catch { return null; }
|
||||
|
|
|
|||
343
cli/lib/console-plan.ts
Normal file
343
cli/lib/console-plan.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
// `storebound console plan <form>`: everything an AI agent needs to fill a console form that has no API with its OWN
|
||||
// browser tool (Claude in Chrome, a built-in browser, Playwright), adapting to whatever the page shows. storebound
|
||||
// supplies the knowledge (where the form is, the steps, the answer sheet from the config, the buttons never to press,
|
||||
// the UI facts verified on the real consoles) and the check afterwards (`console verify`); the agent drives the page.
|
||||
// The scripted engine (`console fill`) uses the same answers and click rules and stays an optional fast path. Pure.
|
||||
// UI facts marked "verified" were read on a real console (App Privacy, 2026-10-12, /apps/<id>/distribution/privacy);
|
||||
// everything else is modeled on Apple's and Google's help pages (sources in console-answers.ts): the agent adapts.
|
||||
import type { ShipConfig } from './config';
|
||||
import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, type Store, valueText } from './console-answers';
|
||||
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, playAppDashboard, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms';
|
||||
import { PLAY_CREATE_CAPTURE } from './console-verify';
|
||||
import { APPLE_PURPOSES } from './console-answers';
|
||||
|
||||
export type PlanStatus = 'certain' | 'ask-owner' | 'owner-check' | 'agreement';
|
||||
|
||||
export interface PlanAnswer {
|
||||
id: string;
|
||||
/** The question in storebound's words. */
|
||||
question: string;
|
||||
/** The answer (null: the owner decides; never filled by the agent). */
|
||||
value: AnswerValue;
|
||||
/** Yes / No / the text, as said to the owner. */
|
||||
text: string;
|
||||
status: PlanStatus;
|
||||
source: string;
|
||||
note?: string;
|
||||
control?: 'text' | 'select' | 'checkbox' | 'radio';
|
||||
/** The control's label as the console shows it (curly quotes kept: "Developer’s Advertising or Marketing"). For a
|
||||
* radio, the label of the option to pick; for a checkbox, the box to tick or untick. "…" stands for any text. */
|
||||
label?: string;
|
||||
/** checkbox: tick | untick; radio: pick; text / select: type / choose. */
|
||||
action?: 'tick' | 'untick' | 'pick' | 'type' | 'choose';
|
||||
/** Where on the page (the dialog and its step). */
|
||||
where?: string;
|
||||
/** The label has a typographic quote (’ “ ”): match straight and curly quotes alike. */
|
||||
curlyQuotes?: boolean;
|
||||
}
|
||||
|
||||
export interface PlanStep {
|
||||
n: number;
|
||||
/** Plain language. */
|
||||
text: string;
|
||||
/** Visible texts to click, in order (never anything from neverPress). */
|
||||
click?: string[];
|
||||
/** Answer ids set on this step (see answers). */
|
||||
answers?: string[];
|
||||
/** App Privacy: the data type this step belongs to. */
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface ConsolePlan {
|
||||
form: FormId;
|
||||
store: Store;
|
||||
title: string;
|
||||
/** Where to start: a deep link when the ids are known, else the console and how to reach the form. */
|
||||
start: { url: string; deepLink: boolean; reach: string[] };
|
||||
steps: PlanStep[];
|
||||
answers: PlanAnswer[];
|
||||
/** Answers the agent must ask the owner before filling (status ask-owner), and owner checks to point out. */
|
||||
askOwner: string[];
|
||||
buttons: {
|
||||
/** Never press these (whole visible text, any case): final buttons, help panels, agreements. */
|
||||
neverPress: string[];
|
||||
/** The final button the owner presses; the agent stops before it and tells the owner. */
|
||||
final: string;
|
||||
/** Buttons the agent may press: navigation and draft saves (nothing public, nothing sent). */
|
||||
allowed: { button: string; class: string; where: string }[];
|
||||
/** The rule in one line. */
|
||||
rule: string;
|
||||
};
|
||||
/** Known UI facts of this form (verified ones say so). */
|
||||
uiFacts: string[];
|
||||
/** How to drive long flows from a browser tool. */
|
||||
driving: string[];
|
||||
verify: { command: string; source: 'page-text' | 'api' | 'api-or-page-text' | 'none'; capture: string; expect: string[] };
|
||||
/** False: guide only (the API sets it; `storebound apple appinfo`). */
|
||||
fillable: boolean;
|
||||
guidance: string[];
|
||||
}
|
||||
|
||||
/** Ids known outside the config (an API lookup or the account cache): they make deep links. */
|
||||
export interface PlanIds { appleAppId?: string | null; playDeveloperId?: string | null; playAppId?: string | null }
|
||||
|
||||
const ASC = 'https://appstoreconnect.apple.com';
|
||||
const PLAY = 'https://play.google.com/console';
|
||||
const CURLY = /[‘’“”]/;
|
||||
|
||||
/** A Name (string or the engine's RegExp) as a readable label: "Yes, … linked to the user". */
|
||||
export function nameText(n: Name): string {
|
||||
if (typeof n === 'string') return n;
|
||||
return n.source
|
||||
.replace(/^\^/, '').replace(/\$$/, '')
|
||||
.replace(/\(\?![^)]*\)/g, '')
|
||||
.replace(/\['\\u2018\\u2019\]|\['‘’\]/g, '’')
|
||||
.replace(/\\b/g, ' ').replace(/\.\*/g, '…').replace(/\\s\*/g, ' ').replace(/\\s\+/g, ' ')
|
||||
.replace(/\(([^()|]*)\|[^()]*\)\??/g, '$1').replace(/\?/g, '')
|
||||
.replace(/\\(.)/g, '$1').replace(/\s+/g, ' ').replace(/ ,/g, ',').trim();
|
||||
}
|
||||
|
||||
/** App Privacy labels verified on the real dialog (2026-10-12). */
|
||||
const linkedLabel = (t: string, yes: boolean) => `${yes ? 'Yes' : 'No'}, ${t.toLowerCase()} data collected from this app ${yes ? 'is' : 'is not'} linked to the user’s identity`;
|
||||
const trackingLabel = (t: string, yes: boolean) => (yes ? `Yes, we use ${t.toLowerCase()} data for tracking purposes` : `No, we do not use ${t.toLowerCase()} data for tracking purposes`);
|
||||
/** Purpose labels as the console shows them (curly ’ in Developer’s). */
|
||||
const purposeLabel = (p: string) => p.replace(/'/g, '’');
|
||||
|
||||
function status(a: Answer): PlanStatus {
|
||||
if (a.agreement) return 'agreement';
|
||||
if (!a.certain) return 'ask-owner';
|
||||
return a.ownerCheck ? 'owner-check' : 'certain';
|
||||
}
|
||||
|
||||
function base(a: Answer): PlanAnswer {
|
||||
return { id: a.id, question: a.label, value: a.agreement ? null : a.value, text: a.agreement ? 'the owner ticks it (an agreement)' : valueText(a), status: status(a), source: a.source, ...(a.note ? { note: a.note } : {}) };
|
||||
}
|
||||
|
||||
function withLabel(p: PlanAnswer, label: string, extra: Partial<PlanAnswer>): PlanAnswer {
|
||||
return { ...p, label, ...(CURLY.test(label) ? { curlyQuotes: true } : {}), ...extra };
|
||||
}
|
||||
|
||||
/** The answer sheet: every answer with the console label of its control and what to do with it. */
|
||||
function sheet(form: FormId, answers: Answer[]): PlanAnswer[] {
|
||||
const def = formDef(form, answers);
|
||||
const fieldOf = new Map(def.sections.flatMap(s => s.fields.map(f => [f.id, { f, s }] as const)));
|
||||
return answers.map(a => {
|
||||
const p = base(a);
|
||||
if (form === 'apple-app-privacy') {
|
||||
const [kind, t, purpose] = a.id.split(':') as [string, string, string | undefined];
|
||||
const v = a.value;
|
||||
if (a.id === 'collects') return withLabel(p, v === false ? 'No, we do not collect data from this app' : 'Yes, we collect data from this app', { control: 'radio', action: 'pick', where: 'the Data Collection question (Get Started)' });
|
||||
if (kind === 'type') return withLabel(p, typeLabels(t)[typeLabels(t).length - 1]!, { control: 'checkbox', action: v === true ? 'tick' : 'untick', where: 'the Data Collection dialog (Edit next to Data Types): the data type list' });
|
||||
if (kind === 'purpose') return withLabel(p, purposeLabel(purpose!), { control: 'checkbox', action: v === true ? 'tick' : 'untick', where: `the "${t}" dialog, step 1 (purposes)` });
|
||||
if (kind === 'linked') return withLabel(p, linkedLabel(t, v !== false), { control: 'radio', action: 'pick', where: `the "${t}" dialog, step 2 (linked to the user)` });
|
||||
if (kind === 'tracking') return withLabel(p, trackingLabel(t, v !== false), { control: 'radio', action: 'pick', where: `the "${t}" dialog, step 5 (tracking)` });
|
||||
return p;
|
||||
}
|
||||
const hit = fieldOf.get(a.id);
|
||||
if (!hit) return p;
|
||||
const { f, s } = hit;
|
||||
const first = (ns: Name[] | undefined) => (ns?.length ? nameText(ns.find(n => typeof n === 'string') ?? ns[0]!) : undefined);
|
||||
if (f.kind === 'radio') {
|
||||
const opt = a.value === null ? undefined : f.options?.[String(a.value)] ?? f.options?.[a.value === true ? 'true' : a.value === false ? 'false' : String(a.value).toLowerCase()];
|
||||
const group = first(f.names);
|
||||
const label = opt ? first(opt)! : group ?? a.label;
|
||||
return withLabel(p, label, { control: 'radio', action: 'pick', where: `${s.title}${group ? ` (${group})` : ''}` });
|
||||
}
|
||||
const label = first(f.names) ?? a.label;
|
||||
if (f.kind === 'checkbox') return withLabel(p, label, { control: 'checkbox', action: a.agreement || f.agreement ? 'untick' : a.value === true ? 'tick' : 'untick', where: s.title });
|
||||
return withLabel(p, label, { control: f.kind, action: f.kind === 'text' ? 'type' : 'choose', where: s.title });
|
||||
});
|
||||
}
|
||||
|
||||
const ids = (answers: PlanAnswer[], pred: (a: PlanAnswer) => boolean) => answers.filter(pred).map(a => a.id);
|
||||
|
||||
const DRIVING = [
|
||||
'Use your own browser tool (Claude in Chrome, a built-in browser, Playwright). Read the page as text or an accessibility tree first; click by visible text; adapt when the page differs from these steps (the console changes without notice), but never press anything in buttons.neverPress.',
|
||||
'For a long flow, start an in-page async loop and poll it: one Runtime.evaluate / javascript call times out around 45 s. Pattern: window.__log = []; window.__run = (async () => { ... window.__log.push("step ..."); ... })(); then read window.__log every few seconds until it says done or stopped.',
|
||||
'Wait for each step to render (poll for the next heading or input, up to ~10 s) before acting; never act on a step you have not read.',
|
||||
'An answer with status ask-owner: ask the owner in chat before filling it (AGENTS.md rule 5), or leave it empty and tell the owner. Never guess.',
|
||||
'Stop before the final button (buttons.final) and tell the owner exactly what to press; then run the verify command.',
|
||||
'Never sign in, type a password, accept an agreement or tick a declaration: the owner does that in the browser.',
|
||||
];
|
||||
|
||||
function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined): Pick<ConsolePlan, 'steps' | 'uiFacts' | 'verify' | 'start'> {
|
||||
const types = answers.filter(a => a.id.startsWith('type:'));
|
||||
const collected = types.filter(a => a.value === true).map(a => a.id.slice(5));
|
||||
const steps: PlanStep[] = [];
|
||||
const add = (s: Omit<PlanStep, 'n'>) => steps.push({ n: steps.length + 1, ...s });
|
||||
add({ text: appId ? 'Open the App Privacy page of the app (deep link).' : 'Open App Store Connect > Apps > the app > App Privacy (sidebar).' });
|
||||
add({ text: 'First time: click "Get Started" and answer the Data Collection question; then Next.', click: ['Get Started'], answers: ['collects'] });
|
||||
add({ text: 'Data types: when the Data Types section does not list exactly the collected types below, click the "Edit" next to the "Data Types" heading (never the Privacy Policy Edit), tick exactly these types and untick the rest, then Save (a draft: nothing is public before Publish). Console labels: Purchase History shows as "Purchases", Other Data Types as "Other Data".', click: ['Edit', 'Save'], answers: ids(answers, a => a.id.startsWith('type:')) });
|
||||
for (const t of collected) {
|
||||
const set = setUpTexts(t).filter(x => !/ ayarla$/.test(x));
|
||||
add({ type: t, text: `"${t}": click the text "${set.join('" or "')}" in the Data Types section (a plain <p> with a click handler, not a button). A dialog opens whose h2 is "${typeLabels(t).join('" or "')}". (Set up before: its row shows Edit and a summary; open it with that row's Edit only when the summary differs from the answers.)`, click: set });
|
||||
add({ type: t, text: `"${t}" step 1, purposes: tick and untick the 6 checkboxes as answered, then Next.`, click: ['Next'], answers: ids(answers, a => a.id.startsWith(`purpose:${t}:`)) });
|
||||
add({ type: t, text: `"${t}" step 2, linked to the user: pick the radio, then Next.`, click: ['Next'], answers: [`linked:${t}`] });
|
||||
add({ type: t, text: `"${t}" steps 3 and 4: two information pages ("Let’s define two important terms…", then "To help put tracking into context, here are a few examples:"), no inputs: press Next on each.`, click: ['Next', 'Next'] });
|
||||
add({ type: t, text: `"${t}" step 5, tracking: pick the radio, then Save (a draft). Never "Definitions and Examples". The dialog closes; the row shows Edit and the summary.`, click: ['Save'], answers: [`tracking:${t}`] });
|
||||
}
|
||||
add({ text: 'Stop. Do NOT press Publish: tell the owner to review the summary on the App Privacy page and press Publish (it puts the answers on the App Store product page).' });
|
||||
add({ text: 'Verify: read the Data Types section as text (document.querySelector("main").innerText) and pipe it to `storebound console verify apple-app-privacy --from-text -`.' });
|
||||
return {
|
||||
start: appId ? { url: `${ASC}/apps/${appId}/distribution/privacy`, deepLink: true, reach: [] }
|
||||
: { url: `${ASC}/apps`, deepLink: false, reach: ['Apps > the app (by name or bundle id) > App Privacy in the sidebar (set apple.appId, or give storebound App Store Connect API keys, for a deep link)'] },
|
||||
steps,
|
||||
uiFacts: [
|
||||
'Verified 2026-10-12 on /apps/<id>/distribution/privacy:',
|
||||
'Data Types section: each collected type has "Set Up <type>", a plain <p> inside a div with the click handler (no button or link role). Click it by its exact text. It opens a <dialog> whose h2 is the type name.',
|
||||
`Step 1, purposes: 6 checkboxes in this order: ${APPLE_PURPOSES.map(purposeLabel).join(', ')} (Developer’s with a curly ’). An input has no aria label; its label is the nearest non-empty parent text (the label and its help text run together, e.g. "AnalyticsSuch as ..."): match by "starts with the label". Buttons: Cancel, Next.`,
|
||||
'Step 2, linked: 2 radios, "Yes, <type in lower case> data collected from this app is linked to the user’s identity" / "No, …" (curly ’). Buttons: Back, Cancel, Next.',
|
||||
'Step 3: an information page "Let’s define two important terms…" (Tracking / Third-Party Data). No inputs: press Next.',
|
||||
'Step 4: an information page "To help put tracking into context, here are a few examples:". No inputs: press Next. (Two information steps in a row: keep pressing Next while the dialog shows no inputs.)',
|
||||
'Step 5, tracking: 2 radios, "Yes, we use <type> data for tracking purposes" / "No, we do not …". Buttons: "Definitions and Examples" (help: never press), Back, Cancel, Save.',
|
||||
'Save closes the dialog. The type then shows "Edit" and a summary: "Used for <purposes>", "Linked to the user’s identity" (or not), "Used for tracking purposes" (or not). That summary is what `console verify` reads.',
|
||||
'Labels use typographic quotes (’): match straight and curly quotes alike.',
|
||||
'The "?" buttons ("More information") are help only. Publish (top right) is the owner\'s.',
|
||||
],
|
||||
verify: {
|
||||
command: 'storebound console verify apple-app-privacy --from-text -',
|
||||
source: 'page-text',
|
||||
capture: 'document.querySelector("main").innerText (the App Privacy page with every dialog closed; the Data Types section is enough)',
|
||||
expect: [
|
||||
`Every collected type (${collected.join(', ') || 'none'}) shows Edit and a summary, no "Set Up <type>" left.`,
|
||||
'Each summary lists the purposes of the answer sheet ("Used for …"), linked and tracking as answered.',
|
||||
'No other data type is listed. The App Store Connect API has no App Privacy endpoint, so the page text is the only source.',
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function playStart(form: FormId, config: ShipConfig, p: PlanIds): ConsolePlan['start'] {
|
||||
const dev = p.playDeveloperId ?? config.play?.developerId ?? null;
|
||||
const app = p.playAppId ?? null;
|
||||
const entry = form === 'play-content-rating' ? 'Content rating' : 'Target audience and content';
|
||||
if (form === 'play-app-record') {
|
||||
return dev ? { url: playCreateApp(PLAY, dev), deepLink: true, reach: [] }
|
||||
: { url: PLAY, deepLink: false, reach: ['Pick the developer account on the chooser (set play.developerId for a deep link)', 'then /console/u/0/developers/<developer id>/create-new-app, or Home (All apps) > Create app'] };
|
||||
}
|
||||
if (dev && app) return { url: playAppDashboard(PLAY, dev, app), deepLink: true, reach: ['Policy and programs > App content', `${entry} > Start (or Manage)`] };
|
||||
return {
|
||||
url: dev ? playAppList(PLAY, dev) : PLAY, deepLink: !!dev,
|
||||
reach: [...(dev ? [] : ['Pick the developer account if asked (set play.developerId for a deep link)']),
|
||||
`the app (${config.play?.packageName ?? config.app.name}) in the app list`, 'Policy and programs > App content', `${entry} > Start (or Manage)`],
|
||||
};
|
||||
}
|
||||
|
||||
/** The service account permission path (verified 2026-10-12): an owner step after Create app. */
|
||||
export const PLAY_SA_PERMISSIONS = ['View app information and download bulk reports (read-only)', 'Manage store presence', 'Manage production releases', 'Release to testing tracks', 'Manage testing tracks and edit tester lists', 'Manage policy declarations (Data safety)', 'Manage orders and subscriptions', 'View financial data, orders, and cancellation survey responses (sales / earnings reports only)'];
|
||||
const PLAY_SA_PATH = `Users and permissions > <the service account> > Add app > the new app > Admin (all permissions), or at least: ${PLAY_SA_PERMISSIONS.join('; ')} > Apply > Save changes`;
|
||||
const PLAY_DASHBOARD_FACT = 'An app\'s Play Console home is /console/u/0/developers/<developerId>/app/<appId>/app-dashboard (verified 2026-10-12); with play.appId (or the id storebound remembered) the plan starts there.';
|
||||
const PAGE_TEXT = 'document.querySelector("main")?.innerText ?? document.body.innerText';
|
||||
|
||||
function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: PlanIds): Pick<ConsolePlan, 'steps' | 'uiFacts' | 'verify' | 'start'> {
|
||||
const steps: PlanStep[] = [];
|
||||
const add = (s: Omit<PlanStep, 'n'>) => steps.push({ n: steps.length + 1, ...s });
|
||||
const all = (pred: (a: PlanAnswer) => boolean) => ids(answers, pred);
|
||||
switch (form) {
|
||||
case 'apple-app-record':
|
||||
add({ text: 'Open App Store Connect > Apps.' });
|
||||
add({ text: 'Click the add button (+, labelled "Add Apps") at the top left, then "New App" in the menu. The New App dialog opens.', click: ['+', 'New App'] });
|
||||
add({ text: 'Fill the New App dialog: tick iOS, type the Name, choose the Primary Language and the Bundle ID, type the SKU, pick User Access.', answers: all(() => true) });
|
||||
add({ text: 'Stop. Do NOT press Create: tell the owner to review and press Create (it creates the app record for good; the bundle id and SKU cannot change).' });
|
||||
add({ text: 'Verify: `storebound console verify apple-app-record` reads the new record from the App Store Connect API (or pass the App Information page text with --from-text -).' });
|
||||
return {
|
||||
start: { url: `${ASC}/apps`, deepLink: false, reach: ['the + button > New App'] }, steps,
|
||||
uiFacts: ['Modeled on Apple\'s "Add a new app" help page, not recorded: adapt to the page.', 'User Access is a plain heading with two radios (Full Access, Limited Access), not an ARIA group.', 'The Bundle ID list only has ids registered in Certificates, Identifiers & Profiles (`storebound apple bundle` registers it).', 'The name must be unique on the App Store; "already being used": ask the owner for a variant and set apple.name.'],
|
||||
verify: { command: 'storebound console verify apple-app-record', source: 'api-or-page-text', capture: PAGE_TEXT, expect: ['An app with the bundle id exists, with the name, primary language and SKU of the answer sheet.'] },
|
||||
};
|
||||
case 'apple-age-rating':
|
||||
add({ text: 'Nothing to fill in the browser: `storebound apple appinfo --dry`, then (after the owner agrees) `storebound apple appinfo` sets every age rating answer through the API.' });
|
||||
add({ text: 'Verify: `storebound console verify apple-age-rating` reads the age rating declaration from the API.' });
|
||||
return {
|
||||
start: { url: config.apple?.appId ? `${ASC}/apps/${config.apple.appId}` : `${ASC}/apps`, deepLink: !!config.apple?.appId, reach: ['General > App Information > Age Ratings (only to look)'] }, steps,
|
||||
uiFacts: ['The age rating has an API (ageRatingDeclarations): never fill it by hand when storebound can set it.'],
|
||||
verify: { command: 'storebound console verify apple-age-rating', source: 'api', capture: '', expect: ['Every question of the API declaration equals the answer sheet.'] },
|
||||
};
|
||||
case 'play-app-record':
|
||||
add({ text: 'Open the Create app form: /console/u/0/developers/<developer id>/create-new-app (deep link), or the developer account\'s app list (Home / All apps) and its page-level "Create app" button (it only opens the form; inside the form the same words are the final submit).', click: p.playDeveloperId ?? config.play?.developerId ? [] : ['Create app'] });
|
||||
add({ text: 'Fill App name (max 30) and App package name (max 150), then press "Check availability" (read-only): it should say "Package name available".', click: ['Check availability'], answers: all(a => a.id === 'name' || a.id === 'packageName') });
|
||||
add({ text: 'Choose the Default language; pick App or game and Free or paid. These are <material-radio> elements: click the inner [role=radio] (a DOM click from a script works in a background window), then check that aria-checked is "true" and that the "Select app or game" / "Select free or paid" hint is gone.', answers: all(a => ['language', 'type', 'price'].includes(a.id)) });
|
||||
add({ text: 'Leave the two declarations (Developer Program Policies, US export laws) unticked: they are agreements the owner reads and ticks.', answers: all(a => a.status === 'agreement') });
|
||||
add({ text: 'Stop. Do NOT press Create app: tell the owner to tick the declarations, check Free or paid (a free app can never become paid) and press Create app.' });
|
||||
add({ text: 'After Create app the app opens at /console/u/0/developers/<developer id>/app/<app id>/app-dashboard: put that app id in play.appId (the other Play forms deep-link from it).' });
|
||||
add({ text: `Owner step, never done by an agent or storebound (a permission grant): app-level permissions of the service account do NOT cover apps created later. The owner opens ${PLAY_SA_PATH}, then runs \`storebound apps check --store play\` to confirm the API sees the app.` });
|
||||
add({ text: 'Verify before the owner presses Create app: run the capture snippet (verify.capture) in the page and pipe its JSON to `storebound console verify play-app-record --from-text -`.' });
|
||||
return {
|
||||
start: playStart(form, config, p), steps,
|
||||
uiFacts: [
|
||||
'Verified 2026-10-12 (Claude in Chrome on the real form):',
|
||||
'URL: /console/u/0/developers/<developerId>/create-new-app (after the account chooser).',
|
||||
'Fields: App name (textbox, max 30); App package name (textbox, max 150) with a "Check availability" button (read-only, safe to press) that reports "Package name available"; Default language (dropdown, default "English (United States) – en-US"); App or game (material-radio App / Game); Free or paid (material-radio Free / Paid); Declarations: two checkboxes (Developer Program Policies, US export laws), owner only.',
|
||||
'Gotcha: in a background window, computer-tool clicks on the material radios did nothing, and setting the native inputs (form_input) did not update the Angular model: the form kept showing "Select app or game". Only a click on the inner [role=radio] of each material-radio (via JS: el.click()) updated it. Then verify aria-checked="true" and that no "Select …" hint is left.',
|
||||
'Text inputs set with form_input update the character counters correctly.',
|
||||
'After Create app the app lives at /console/u/0/developers/<developerId>/app/<appId>/app-dashboard (verified): the app id deep-links the other Play forms (play.appId; storebound also remembers it).',
|
||||
`A service account's app-level permissions do NOT cover apps created later: the owner adds the new app (${PLAY_SA_PATH}). Agents never do this (a permission grant; auto mode blocks it too).`,
|
||||
'The package name is fixed for good once the app is created. Free can never become Paid.',
|
||||
],
|
||||
verify: { command: 'storebound console verify play-app-record --from-text -', source: 'page-text', capture: PLAY_CREATE_CAPTURE,
|
||||
expect: ['App name and App package name equal the answer sheet.', 'Game / App and Free / Paid are aria-checked="true" as answered.', 'No "Select …" hint text is left on the form.', '"Package name available" after Check availability.'] },
|
||||
};
|
||||
case 'play-content-rating':
|
||||
add({ text: 'Open the app > Policy and programs > App content > Content rating > Start (or Start new questionnaire).', click: ['Start'] });
|
||||
add({ text: 'Page 1: type the email address and pick the category. Save draft, then Next.', click: ['Save draft', 'Next'], answers: ['email', 'category'] });
|
||||
add({ text: 'Questionnaire: answer each question Yes or No as the sheet says; leave ask-owner questions to the owner. Save draft, then Next.', click: ['Save draft', 'Next'], answers: all(a => a.id.startsWith('q:')) });
|
||||
add({ text: 'Summary: stop. Do NOT press Submit: tell the owner to review the summary and press Submit (it sends the questionnaire to IARC).' });
|
||||
add({ text: 'Verify: read the Summary page as text and pipe it to `storebound console verify play-content-rating --from-text -`.' });
|
||||
return {
|
||||
start: playStart(form, config, p), steps,
|
||||
uiFacts: ['Modeled on Google\'s "Content ratings" help page, not recorded: the IARC question wording differs per category; match questions by their topic (violence, fear, gambling …) and adapt.', '"Save draft" stores the one draft of the app: nothing goes to IARC before Submit.', 'The email address field gets the full app.contactEmail from the config (printed masked here).', PLAY_DASHBOARD_FACT],
|
||||
verify: { command: 'storebound console verify play-content-rating --from-text -', source: 'page-text', capture: PAGE_TEXT, expect: ['The Summary shows each question with the Yes / No of the answer sheet.'] },
|
||||
};
|
||||
case 'play-target-audience':
|
||||
add({ text: 'Open the app > Policy and programs > App content > Target audience and content > Start (or Manage).', click: ['Start'] });
|
||||
add({ text: 'Target age: tick exactly the age groups of the sheet. Next.', click: ['Next'], answers: all(a => a.id.startsWith('age:')) });
|
||||
if (answers.some(a => a.id === 'appeal')) add({ text: 'Appeal to children: pick the answer. Next.', click: ['Next'], answers: ['appeal'] });
|
||||
add({ text: 'Ads (also App content > Ads): pick whether the app contains ads.', answers: ['ads'] });
|
||||
add({ text: 'Stop. Do NOT press the last Save: tell the owner to review and press Save (Google reviews the declaration).' });
|
||||
add({ text: 'Verify: read the section\'s summary as text and pipe it to `storebound console verify play-target-audience --from-text -`.' });
|
||||
return {
|
||||
start: playStart(form, config, p), steps,
|
||||
uiFacts: ['Modeled on Google\'s "Target audience and content" help page, not recorded: adapt to the page.', 'Any age group under 13 brings the Families policy.', 'Save on the last page is final (Google reviews it).', PLAY_DASHBOARD_FACT],
|
||||
verify: { command: 'storebound console verify play-target-audience --from-text -', source: 'page-text', capture: PAGE_TEXT, expect: ['The target age groups and the ads answer equal the answer sheet.'] },
|
||||
};
|
||||
case 'apple-app-privacy': throw new Error('unreachable');
|
||||
}
|
||||
}
|
||||
|
||||
export function consolePlan(form: FormId, config: ShipConfig, p: PlanIds = {}): ConsolePlan {
|
||||
const cfg = p.appleAppId && config.apple && !config.apple.appId ? { ...config, apple: { ...config.apple, appId: p.appleAppId } } : config;
|
||||
const meta = formMeta(form, cfg);
|
||||
const raw = consoleAnswers(form, cfg);
|
||||
const answers = sheet(form, raw);
|
||||
const part = form === 'apple-app-privacy' ? appPrivacyPlan(answers, cfg.apple?.appId) : otherPlan(form, cfg, answers, p);
|
||||
const rules = FORM_BUTTONS[form];
|
||||
const neverPress = [...new Set([...rules.filter(r => !r.auto && r.class === 'final-submit').flatMap(r => r.button.replace(/ \((?:form|and its confirmation dialog)\)$/, '').split(' / ')), ...NEVER_PRESS])];
|
||||
const askOwner = answers.filter(a => a.status === 'ask-owner' || a.status === 'owner-check' || a.status === 'agreement')
|
||||
.map(a => `${a.question}: ${a.status === 'agreement' ? 'an agreement, the owner ticks it' : a.status === 'owner-check' ? `${a.text} (owner check: ${a.note ?? ''})` : `ask the owner${a.note ? ` (${a.note})` : ''}`}`);
|
||||
return {
|
||||
form, store: meta.store, title: meta.title, ...part, answers, askOwner,
|
||||
buttons: {
|
||||
neverPress, final: meta.stopBefore,
|
||||
allowed: rules.filter(r => r.auto).map(r => ({ button: r.button, class: r.class, where: r.where })),
|
||||
rule: `Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links) and draft saves (${rules.filter(r => r.class === 'draft-save').map(r => r.button).join(', ') || 'none in this form'}). Never press ${meta.stopBefore} or anything that publishes, submits, creates, sends or accepts; stop and tell the owner.`,
|
||||
},
|
||||
driving: DRIVING,
|
||||
fillable: meta.fillable,
|
||||
guidance: [...meta.open.map(o => `Open: ${o}`), ...(meta.notes ?? []).map(n => `Note: ${n}`)],
|
||||
};
|
||||
}
|
||||
|
||||
/** The plan as text for the terminal. */
|
||||
export function planText(pl: ConsolePlan): string {
|
||||
const L: string[] = [`${pl.title}${pl.fillable ? '' : ' (guide only)'}`, `start: ${pl.start.url}${pl.start.deepLink ? ' (deep link)' : ''}`, ...pl.start.reach.map(r => ` then: ${r}`), '', 'Steps:'];
|
||||
for (const s of pl.steps) L.push(` ${s.n}. ${s.text}${s.click?.length ? ` [click: ${s.click.map(c => `"${c}"`).join(', ')}]` : ''}`);
|
||||
L.push('', 'Answer sheet:');
|
||||
for (const a of pl.answers) {
|
||||
const tag = a.status === 'certain' ? '[ok] ' : a.status === 'owner-check' ? '[owner]' : a.status === 'agreement' ? '[you] ' : '[ask] ';
|
||||
L.push(` ${tag} ${a.question}: ${a.text}${a.label ? ` -> ${a.action ?? ''} "${a.label}"${a.curlyQuotes ? ' (curly quotes)' : ''}` : ''}${a.where ? ` @ ${a.where}` : ''}`);
|
||||
if (a.status !== 'certain' && a.note) L.push(` ${a.note}`);
|
||||
}
|
||||
L.push('', `Never press: ${pl.buttons.neverPress.map(b => `"${b}"`).join(', ')}`, `Final button (the owner's): ${pl.buttons.final}`, `Rule: ${pl.buttons.rule}`, '', 'UI facts:', ...pl.uiFacts.map(f => ` - ${f}`), '', 'Driving:', ...pl.driving.map(d => ` - ${d}`), '', `Verify: ${pl.verify.command}`, ...pl.verify.expect.map(e => ` - ${e}`));
|
||||
if (pl.askOwner.length) L.push('', 'Ask the owner first:', ...pl.askOwner.map(a => ` - ${a}`));
|
||||
return L.join('\n');
|
||||
}
|
||||
321
cli/lib/console-verify.ts
Normal file
321
cli/lib/console-verify.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
// `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');
|
||||
}
|
||||
|
|
@ -390,6 +390,15 @@ export const TOOLS: ToolDef[] = [
|
|||
{ name: 'apple_events_upload', title: 'Upload in-app event images', command: ['apple', 'events', 'upload'], write: true, destructive: true, network: true, idempotent: true,
|
||||
description: 'Upload the event card (16:9, 1920x1080 to 3840x2160) and details page (9:16) images of one draft in-app event per locale, size-checked first.',
|
||||
props: { name: { type: 'string', description: 'Reference name from apple.events' } }, required: ['name'], args: i => opt('name', i.name) },
|
||||
{ name: 'console_plan', title: 'Console form plan (agent-first)', command: ['console', 'plan'], write: false, network: false,
|
||||
description: 'Read-only plan for filling a console form that has no API with YOUR OWN browser tool (Claude in Chrome, a built-in browser, Playwright): start URL (deep link when the ids are known), ordered steps (what to click by visible text, what to answer), the answer sheet from the config (each answer certain or ask-owner, with the console\'s exact labels, curly quotes noted), the buttons never to press (Publish, Submit, Create, agreements, help panels), known UI facts per form, how to drive long flows (an in-page async loop writing window.__log) and how to verify. Drive the page yourself, stop before the final button and tell the owner what to press, then call console_verify.',
|
||||
props: { form: { type: 'string', enum: ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'] } },
|
||||
required: ['form'], args: i => [String(i.form), '--json'] },
|
||||
{ name: 'console_verify', title: 'Verify a console form', command: ['console', 'verify'], write: false, network: true,
|
||||
description: 'Read-only check of a filled console form against the config. Pass the page text you read (App Privacy: the App Privacy page\'s innerText, whose Data Types section shows per type "Used for …", linked and tracking; Play Create app: the JSON of the plan\'s capture snippet; Play content rating / target audience: the summary page text). apple-app-record and apple-age-rating without text read the App Store Connect API. Returns a per-item match / mismatch table; exit code 1 on a mismatch.',
|
||||
props: { form: { type: 'string', enum: ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'] },
|
||||
text: { type: 'string', description: 'The page text (or the capture JSON) of the finished form' } },
|
||||
required: ['form'], args: i => [String(i.form), ...(typeof i.text === 'string' && i.text ? [`--text=${i.text}`] : []), '--json'] },
|
||||
{ name: 'apple_preorder_status', title: 'App Store pre-order status', command: ['apple', 'preorder', 'status'], write: false, network: true,
|
||||
description: 'Read-only: per territory whether the app is available, its release date, pre-order on / off and its content status. Enabling or ending a pre-order is an owner-only terminal command.', args: () => ['--json'] },
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue