storebound/cli/lib/console-plan.ts
CRT c9a7f095c4 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.
2026-10-12 08:39:12 +08:00

343 lines
30 KiB
TypeScript

// `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');
}