storebound/cli/lib/console-plan.ts

578 lines
55 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 { consoleLink, consoleUrl } from './console-links';
import { playCategoryOf, type ShipConfig } from './config';
import type { ProjectSignals } from './project-signals';
import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, isPlanOnly, type PlanFormId, type Store, valueText } from './console-answers';
import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, SEND_FOR_REVIEW, SEND_FOR_REVIEW_WHERE, startLabel } from './console-play-plans';
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, PLAY_NEVER_PRESS, 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;
}
/** One thing only the owner does (an answer no file proves, an agreement, a final button), in the order the agent needs it. */
export interface OwnerStep {
n: number;
/** before: the owner does it before the agent starts (so the run never waits mid-way); after: once the agent is done. */
when: 'before' | 'after';
kind: 'answers' | 'agreement' | 'credentials' | 'permission' | 'final';
/** What to do, in plain words. */
text: string;
/** Where exactly (the page, the control). */
where?: string;
}
export interface ConsolePlan {
form: PlanFormId;
store: Store;
title: string;
/** Every owner step of this plan in ONE batch, in the order the agent needs them (printed at the top). */
ownerBatch: OwnerStep[];
/** 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, draft saves and Play App content declaration saves (nothing public,
* nothing sent: a declaration goes to Google only with the owner's Send changes for review). */
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[];
/** A ready-made in-page helper (JavaScript as text): paste it once per page with one evaluate call, start a run, then
* poll window.__log in short calls (one evaluate call times out around 45 s on big console pages). */
inPageLoop: 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;
/** reviewDemoAccount exists in .storebound/credentials.json (only whether it exists: the values are never read here). */
demoAccount?: boolean;
/** What the project scan found (ad / attribution SDKs, the AD_ID permission, support and AR code); undefined: not scanned. */
signals?: ProjectSignals;
}
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`;
// "Crash Data" -> "crash data", not "crash data"
const dataOf = (t: string) => (/\bdata$/i.test(t) ? t.toLowerCase() : `${t.toLowerCase()} data`);
const trackingLabel = (t: string, yes: boolean) => (yes ? `Yes, we use ${dataOf(t)} for tracking purposes` : `No, we do not use ${dataOf(t)} 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.',
'Long flows run INSIDE the page: one Runtime.evaluate / javascript call times out at about 45 s on big Play pages (the IARC questionnaire, App content), so never loop over many controls in one call. Paste plan.inPageLoop once per page (one short call; it defines window.__sb), start the job with window.__sb.run([{ role: "radio", label: "No", within: "<question text>" }, { role: "checkbox", label: "<box label>", want: true }, ...]) (it returns at once), then poll window.__log.slice(-20) and window.__done in short calls every 2-5 s until the log says done. The helper only picks radios and ticks boxes (never agreements); press Next / Save yourself afterwards, by their visible text.',
'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.',
'Text fields: click the field and TYPE the text (key presses), then read the value back. Never set it programmatically (form_input, el.value = …): App Store Connect\'s New App kept its Name and SKU empty after a value set (verified 2026-10-13).',
'Owner steps: hand the owner plan.ownerBatch in ONE message before you start (the "before" items: answers, the IARC Terms of Use box) and run without stopping until the "after" items (the final button).',
'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 (never type the owner\'s own password), accept an agreement or tick a declaration: the owner does that in the browser. The one password you type is the App Review demo account from .storebound/credentials.json (reviewDemoAccount) on Play\'s Sign in details card, never printed.',
];
/**
* The in-page helper of every plan (plan.inPageLoop), as text: defines window.__sb with labelOf (aria-label, else the
* nearest non-empty innerText: Play's labels sit in sibling nodes), scopeOf (the smallest block holding a question text
* and its controls), set (a JS click on the inner [role=radio] / [role=checkbox] of a material control, read back) and
* run (a logged async job over a list of steps). It never clicks buttons and refuses agreements. No template literals,
* so it reads the same in JSON and in a terminal.
*/
export const IN_PAGE_LOOP = [
'(() => {',
' const norm = (t) => (t || "").replace(/\\s+/g, " ").trim();',
' const log = (m) => { window.__log.push(new Date().toISOString().slice(11, 19) + " " + m); };',
' const sleep = (ms) => new Promise((r) => setTimeout(r, ms));',
' const labelOf = (el) => {',
' const own = norm(el.getAttribute("aria-label"));',
' if (own) return own;',
' for (let a = el; a && a !== document.body; a = a.parentElement) { const t = norm(a.innerText); if (t) return t; }',
' return "";',
' };',
' const NEVER = /terms of use|terms of service|i agree|agree to|declaration|developer program polic|export laws/i;',
' const CONTROLS = "[role=radio], [role=checkbox], input[type=radio], input[type=checkbox]";',
' const scopeOf = (text) => {',
' const want = norm(text).toLowerCase(); let best = null;',
' for (const el of document.querySelectorAll("section, fieldset, div, [role=radiogroup], [role=group]"))',
' if (norm(el.innerText).toLowerCase().includes(want) && el.querySelector(CONTROLS) && (!best || best.contains(el))) best = el;',
' return best;',
' };',
' const find = (role, label, scope) => {',
' const want = norm(label).toLowerCase();',
' const hits = [...(scope || document).querySelectorAll("[role=" + role + "], input[type=" + role + "]")]',
' .filter((el) => el.getClientRects().length && labelOf(el).toLowerCase().startsWith(want));',
' return hits.length === 1 ? hits[0] : null;',
' };',
' const set = async (s) => {',
' const scope = s.within ? scopeOf(s.within) : null;',
' if (s.within && !scope) return "no block with \\"" + s.within + "\\"";',
' const el = find(s.role, s.label, scope);',
' if (!el) return "not found or ambiguous";',
' if (NEVER.test(labelOf(el))) return "refused: an agreement (the owner ticks it)";',
' const host = el.closest("material-radio, material-checkbox");',
' const inner = el.matches("[role=radio], [role=checkbox]") ? el : (host && host.querySelector("[role=radio], [role=checkbox]")) || el;',
' const state = () => (inner.hasAttribute("aria-checked") ? inner.getAttribute("aria-checked") === "true" : !!inner.checked);',
' const want = s.want !== false;',
' if (state() !== want) inner.click();',
' await sleep(150);',
' return state() === want ? "ok" : "did not change (read the control, try the <input> itself)";',
' };',
' window.__log = []; window.__done = false;',
' window.__sb = { log, sleep, labelOf, scopeOf, find, set };',
' window.__sb.run = (steps) => {',
' window.__done = false;',
' window.__run = (async () => {',
' for (let i = 0; i < steps.length; i++) {',
' const s = steps[i], tag = (i + 1) + "/" + steps.length + " " + s.role + " \\"" + s.label + "\\"" + (s.within ? " in \\"" + s.within + "\\"" : "");',
' try { log(tag + ": " + await set(s)); } catch (e) { log(tag + ": error " + e.message); }',
' }',
' log("done"); window.__done = true;',
' })();',
' return "started " + steps.length + " steps: poll window.__log";',
' };',
' return "storebound helper ready: window.__sb.run([...]), then poll window.__log.slice(-20) and window.__done";',
'})()',
].join('\n');
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): ${consoleUrl('asc.apps')}` });
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, then Publish again in the "Publish Your App Privacy Responses?" dialog (it puts the answers on the App Store product page). Afterwards the page shows "Published … ago by …".' });
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: it opens a <dialog> "Publish Your App Privacy Responses?" with Cancel / Publish (the owner\'s final button); after it the page shows "Published a few seconds ago by …" (verified 2026-10-12), which `console verify` reads as the published signal.',
],
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.',
'After the owner\'s Publish: a "Published … ago by …" line (the published signal).',
],
},
};
}
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'] };
}
// verified 2026-10-12: each App content card has its own page
if (dev && app) return { url: playAppPage(dev, app, form === 'play-content-rating' ? 'app-content/content-rating-overview' : 'app-content/target-audience-content'), deepLink: true, reach: [] };
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);
const planIds = { playDeveloperId: p.playDeveloperId ?? config.play?.developerId, playAppId: p.playAppId ?? config.play?.appId };
switch (form) {
case 'apple-app-record':
add({ text: `Open ${consoleLink('asc.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. Name and SKU: click the field and TYPE the text (your browser tool\'s type action, key by key); never set the value programmatically (form_input, el.value = …): the dialog then keeps an empty name / SKU (verified 2026-10-13). Read both fields back.', 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.',
'Verified 2026-10-13: type, don\'t set value. A programmatic value set (form_input, el.value = …) on the Name and SKU textboxes left the dialog\'s React state empty (""): Create then sees no name / SKU. Click the field and type (key presses); `console fill` types them the same way.',
'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 in the console 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. Read location.href and run `storebound console verify play-app-record --url "<that URL>"`: it reads the app id from the URL and shows the play.appId change as a dry diff; then the same command with `--write` saves it into storebound.config.ts (an upsert: the other Play forms deep-link from it). No need to hunt through the paginated app list (`storebound play app-id` is the fallback when the redirect was missed).' });
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). Take it from that redirect URL (`console verify play-app-record --url <url> --write`): the app list shows 10 apps per page and a new app is not on page 1 (verified 2026-10-13).',
`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.',
PLAY_MATERIAL_FACT,
],
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': {
const section = (sec: string) => all(a => a.id.startsWith(`d:${sec}:`));
add({ text: `Open ${consoleLink('play.content-rating', '', planIds)}, then "Start questionnaire": it opens /app-content/content-rating-iarc-questionnaire.`, click: ['Start questionnaire'] });
add({ text: 'Page 1: type the email address and pick the category (material radio: JS click on the inner [role=radio]). The IARC Terms of Use checkbox is an agreement: never tick it. The owner ticked it before the run (owner batch); read its aria-checked. Still unticked: Next does nothing (verified 2026-10-13), so do not press it again and again: say once that the box is waiting for the owner and poll aria-checked every 5 s (up to 15 min). Then Next.', click: ['Next'], answers: ['email', 'category', 'agree:iarcTerms'] });
add({ text: 'The questionnaire is ONE long page of sections (Violence, Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor, Digital Purchases, Miscellaneous). Answer each section\'s Yes / No as the sheet says; each Yes reveals follow-ups right below it, so answer top to bottom and re-read the page after every Yes. Run it as one in-page job (plan.inPageLoop) and poll window.__log.', answers: all(a => a.id.startsWith('q:')) });
add({ text: 'Violence follow-ups (after Yes): the humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds.', answers: section('violence') });
add({ text: 'Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor follow-ups (after Yes): answer the questions shown as the sheet says (details.<section>.other).', answers: ['fear', 'sexuality', 'gambling', 'language', 'controlledSubstances', 'crudeHumor'].flatMap(section) });
add({ text: 'Digital Purchases follow-ups (after "purchases of digital goods" Yes): the random items (loot box) question, then trading between players.', answers: section('digitalPurchases') });
add({ text: 'Miscellaneous: user interaction (after Yes: block, report, moderation, friends only), location sharing, Nazi symbols, Korea history, terrorism, crimes.', answers: [...section('userInteraction'), ...section('miscellaneous')] });
add({ text: 'Press Save (stores the draft; nothing goes to IARC yet), then Next: the Summary shows the rating per authority (PEGI, ESRB, USK, ClassInd, Google Play ...).', click: ['Save', 'Next'] });
add({ text: `Summary: read the ratings per authority, then press its Save yourself (a declaration save, "Save", not "Submit"; verified 2026-10-13: it stores the rating declaration, which Google gets only with the next "${SEND_FOR_REVIEW}" in Publishing overview, the owner's). Tell the owner the ratings in the final report.`, click: ['Save'] });
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: [
'Verified 2026-10-12 (an agent with a browser tool on a real game):',
`App content > Content ratings is /app-content/content-rating-overview (${consoleUrl('play.content-rating', planIds)}); "Start questionnaire" opens /app-content/content-rating-iarc-questionnaire.`,
'Page 1: email address, the category radio, and an IARC Terms of Use checkbox: an agreement, the owner ticks it, never the agent.',
'Verified 2026-10-13: page 1\'s Next does nothing until the IARC Terms of Use box is ticked. So the box is in the ONE owner batch before the run (the owner ticks it on page 1 first), never a stop in the middle.',
'The questionnaire is one long page of sections; each Yes reveals follow-ups (Violence: humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds; Digital Purchases: digital goods, then loot boxes, then player trading; Miscellaneous: user interaction then block / report / moderation / friends-only, location sharing, Nazi symbols, Korea history, terrorism, crimes). Follow-up wording here is storebound\'s: match by topic and the console\'s own labels.',
`Save on the questionnaire stores it; Next goes to the Summary with per-authority ratings. The Summary\'s button is "Save" (not "Submit"). Saving an App content declaration is not public (verified 2026-10-13): Google gets it only with the next "${SEND_FOR_REVIEW}" in Publishing overview (the owner\'s), so the agent presses this Save.`,
'Example result (the game of that run): PEGI 7, ESRB E10+, USK 12, ClassInd 18 (paid random items alone made it 18), Google Play 7+.',
'The email address field gets the full app.contactEmail from the config (printed masked here).',
PLAY_MATERIAL_FACT, 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, and a rating per authority.'] },
};
}
case 'play-target-audience': {
const teens = answers.filter(a => a.id.startsWith('age:')).every(a => a.status === 'certain' && (a.value !== true || ['age:13-15', 'age:16-17', 'age:18-over'].includes(a.id)));
add({ text: `Open ${consoleLink('play.target-audience', '', planIds)} > Start (or Manage).`, click: ['Start'] });
add({ text: 'Target age: tick exactly the age groups of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back). 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, then Next.${teens ? ' With 13+ age groups only the page skipped this question and went straight to the Summary (verified 2026-10-12): answer it only if it shows.' : ''}`, click: ['Next'], answers: ['appeal'] });
add({ text: `Ads (also ${consoleLink('play.ads', '', planIds)}): pick whether the app contains ads, if this section shows it.`, answers: ['ads'] });
add({ text: `Summary: press Save (the agent's button, a declaration save: Google gets it only with the next "${SEND_FOR_REVIEW}" in Publishing overview, the owner's). Read the summary back.`, click: ['Save'] });
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: [`Verified 2026-10-12: /app-content/target-audience-content; age groups are checkboxes; with 13+ age groups only it went straight to the Summary (no appeal-to-children question). Its Save stores the declaration (the agent's: Google gets it only with the owner's "${SEND_FOR_REVIEW}", verified 2026-10-13).`, 'Any age group under 13 brings the Families policy.', PLAY_MATERIAL_FACT, 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');
}
}
/** The owner's batch: every answer a plan cannot fill from the config, the owner checks and the agreements. */
function askOwnerOf(answers: PlanAnswer[], prefix = ''): string[] {
return answers.filter(a => a.status === 'ask-owner' || a.status === 'owner-check' || a.status === 'agreement')
.map(a => `${prefix}${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})` : ''}`}`);
}
const ruleText = (draftSaves: string[], final: string, declarationSaves: string[] = []) => `Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links), draft saves (${draftSaves.join(', ') || 'none in this form'})${declarationSaves.length ? ` and the App content declaration saves (${declarationSaves.join(', ')}: Google gets them only with the owner's ${SEND_FOR_REVIEW})` : ''}. Never press ${final} or anything that publishes, submits, creates, sends, restarts a review or accepts; stop and tell the owner.`;
const IARC_TERMS = 'Tick the IARC Terms of Use box';
/** Where the IARC Terms box is (verified 2026-10-12 / 2026-10-13). */
function iarcWhere(cfg: ShipConfig, p: PlanIds): string {
const dev = p.playDeveloperId ?? cfg.play?.developerId, app = p.playAppId ?? cfg.play?.appId;
const url = dev && app ? playAppPage(dev, app, 'app-content/content-rating-iarc-questionnaire') : `${consoleUrl('play.content-rating', { playDeveloperId: dev, playAppId: app })} (then /app-content/content-rating-iarc-questionnaire)`;
return `Play Console > the app > Policy and programs > App content > Content ratings > "Start questionnaire" (${url}), page 1: the IARC Terms of Use checkbox under the Email address and the Category. Tick it, leave the tab open; the agent fills the rest of page 1 and presses Next (Next does nothing until it is ticked).`;
}
/** The owner batch of a plan: open answers first, then what the owner does before the agent starts, then the final steps. */
function ownerBatchOf(form: PlanFormId, cfg: ShipConfig, p: PlanIds, answers: PlanAnswer[], final: string): OwnerStep[] {
const out: Omit<OwnerStep, 'n'>[] = [];
const open = answers.filter(a => (a.status === 'ask-owner' || a.status === 'owner-check') && !a.id.endsWith('credentials'));
if (open.length) out.push({ when: 'before', kind: 'answers', text: `Answer ${open.length} question(s) in ONE batch (askOwner), then write the answers into storebound.config.ts and rerun the plan: ${open.map(a => a.question).join('; ')}` });
if (answers.some(a => a.status === 'ask-owner' && a.id.endsWith('credentials'))) out.push({ when: 'before', kind: 'credentials', text: 'Put the App Review demo account into .storebound/credentials.json (reviewDemoAccount: username, password) so the agent types it without printing it; or type the username and password into the Sign in details card yourself', where: `${consoleLink('play.app-access', '> Add instructions')}` });
const iarc = answers.some(a => a.status === 'agreement' && /iarcTerms$/.test(a.id));
if (iarc) out.push({ when: 'before', kind: 'agreement', text: IARC_TERMS, where: iarcWhere(cfg, p) });
if (form === 'play-app-record') {
out.push({ when: 'after', kind: 'agreement', text: 'Tick the two declarations (Developer Program Policies, US export laws) and check Free or paid (a free app can never become paid)', where: 'the Create app form, bottom' });
out.push({ when: 'after', kind: 'final', text: 'Press Create app', where: 'the Create app form' });
out.push({ when: 'after', kind: 'permission', text: `Give the service account access to the new app: ${PLAY_SA_PATH}` , where: 'Users and permissions' });
} else if (final === SEND_FOR_REVIEW) out.push({ when: 'after', kind: 'final', text: `Press ${SEND_FOR_REVIEW} once every App content card and store setting is done`, where: SEND_FOR_REVIEW_WHERE });
else if (form === 'play-production-release') out.push({ when: 'after', kind: 'final', text: `Press "Submit N changes for review", then ${SEND_FOR_REVIEW}`, where: SEND_FOR_REVIEW_WHERE });
else if (final) out.push({ when: 'after', kind: 'final', text: `Press ${final}` });
return out.sort((a, b) => (a.when === b.when ? 0 : a.when === 'before' ? -1 : 1)).map((x, i) => ({ n: i + 1, ...x }));
}
/** The App content overview: every card in the page's order (the order the agent fixes them in), its page, its plan,
* and one owner batch. `all`: every card's steps inline, ending with the one owner list. */
function appContentPlan(cfg: ShipConfig, p: PlanIds, all = false): ConsolePlan {
const cards = APP_CONTENT_CARDS.map(card => ({ card, plan: card.form ? consolePlan(card.form, cfg, p) : null }));
const steps: PlanStep[] = [];
const add = (s: Omit<PlanStep, 'n'>) => steps.push({ n: steps.length + 1, ...s });
const answers: PlanAnswer[] = cards.flatMap(({ card, plan }) => (plan ? plan.answers.map(a => ({ ...a, id: `${plan.form}/${a.id}`, where: `${card.name}${a.where ? `: ${a.where}` : ''}` })) : []));
const ownerBatch = ownerBatchOf('play-app-content', cfg, p, answers, SEND_FOR_REVIEW);
add({ text: `Before the run: hand the owner the owner batch (ownerBatch, ${ownerBatch.length} item(s)) in ONE message: the open answers (write them into storebound.config.ts and rerun this plan: they turn certain) and what the owner does before you start (the IARC Terms of Use box). Then drive every card without stopping.` });
add({ text: 'Open App content (/app-content/overview). Each card has a button aria-label="Start <Name> declaration"; a card already done shows its answers and a Manage / Edit link instead (skip it unless its answers differ from the sheet).' });
for (const { card, plan } of cards) {
if (!plan) { add({ text: `${card.name}: ${card.via}. Dry run first, then the real run yourself; then read the card back.` }); continue; }
if (!all) { add({ text: `${card.name}: open /app-content/${card.slug} (the button "${startLabel(card.name)}") and follow \`storebound console plan ${plan.form}\`: ${plan.steps.length} steps; its Save is yours (a declaration save).`, click: [startLabel(card.name)], answers: answers.filter(a => a.id.startsWith(`${plan.form}/`)).map(a => a.id) }); continue; }
add({ text: `${card.name}: open /app-content/${card.slug} (the button "${startLabel(card.name)}").`, click: [startLabel(card.name)] });
for (const st of plan.steps.slice(1)) add({ text: `${card.name}: ${st.text}`, ...(st.click ? { click: st.click } : {}), ...(st.answers ? { answers: st.answers.map(id => `${plan.form}/${id}`) } : {}) });
}
add({ text: `Verify: run the capture snippet (verify.capture) on the overview and pipe its JSON to \`storebound console verify play-app-content --from-text -\`. Then stop: the owner list is ${ownerBatch.filter(o => o.kind !== 'answers').map(o => `[${o.when}: ${o.text}]`).join(', ')}; tell the owner the ${SEND_FOR_REVIEW} step (the before steps are done already).` });
const neverPress = [...new Set([SEND_FOR_REVIEW, ...cards.flatMap(c => c.plan?.buttons.neverPress ?? [])])];
const allowed = [{ button: 'Start <Name> declaration', class: 'navigation', where: 'App content overview: opens the card' },
...cards.flatMap(c => c.plan?.buttons.allowed ?? [])].filter((b, i, l) => l.findIndex(x => x.button === b.button) === i);
return {
form: 'play-app-content', store: 'play', title: `Play Console: App content (every declaration${all ? ', all cards inline' : ''})`, ownerBatch,
start: appContentStart(cfg, p), steps, answers,
askOwner: cards.flatMap(({ card, plan }) => (plan ? plan.askOwner.map(l => `${card.name}: ${l}`) : [])),
buttons: {
neverPress, final: SEND_FOR_REVIEW, allowed,
rule: ruleText(['Save draft / Save on the IARC questionnaire page'], SEND_FOR_REVIEW, ['each card\'s Save', 'the content rating Summary\'s Save']),
},
uiFacts: [
'Verified 2026-10-12 on /console/u/0/developers/<developerId>/app/<appId>/app-content/overview: each card has a button aria-label="Start <Name> declaration". The cards, in order:',
...APP_CONTENT_CARDS.map((c, i) => `${i + 1}. ${c.name}: /app-content/${c.slug}${c.verified ? '' : ' (slug modeled, not recorded)'}; ${c.via}.`),
`Verified 2026-10-13: saving a card is not public; every saved card goes to Google only with the next "${SEND_FOR_REVIEW}" in Publishing overview. So the agent presses each card's Save; the owner presses ${SEND_FOR_REVIEW} once at the end.`,
'The App category is not an App content card: Store settings (`storebound console plan play-store-settings`).',
PLAY_MATERIAL_FACT,
],
driving: DRIVING, inPageLoop: IN_PAGE_LOOP,
verify: { command: 'storebound console verify play-app-content --from-text -', source: 'page-text', capture: APP_CONTENT_CAPTURE, expect: ['No card still shows its "Start <Name> declaration" button.'] },
fillable: true,
guidance: ['Open: Policy and programs > App content', ...APP_CONTENT_CARDS.map(c => `${c.name}: ${c.via}`)],
};
}
/** Plan options: `all` (play-app-content): every card's steps inline. */
export interface PlanOpts { all?: boolean }
export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {}, o: PlanOpts = {}): ConsolePlan {
const cfg = p.appleAppId && config.apple && !config.apple.appId ? { ...config, apple: { ...config.apple, appId: p.appleAppId } } : config;
if (form === 'play-app-content') return appContentPlan(cfg, p, o.all);
if (isPlanOnly(form)) {
const part = playOnlyPart(form, cfg, p);
return {
form, store: 'play', title: part.title, ownerBatch: ownerBatchOf(form, cfg, p, part.answers, part.final), start: part.start, steps: part.steps, answers: part.answers, askOwner: askOwnerOf(part.answers),
buttons: { neverPress: part.neverPress, final: part.final, allowed: part.allowed, rule: ruleText(part.allowed.filter(b => b.class === 'draft-save').map(b => b.button), part.final, part.allowed.filter(b => b.class === 'declaration-save').map(b => b.button)) },
uiFacts: part.uiFacts, driving: DRIVING, inPageLoop: IN_PAGE_LOOP, verify: part.verify, fillable: true, guidance: part.guidance,
};
}
const meta = formMeta(form, cfg);
const raw = consoleAnswers(form, cfg, p.signals);
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, ...(meta.store === 'play' ? PLAY_NEVER_PRESS : [])])];
// the content rating's questionnaire Save is a draft save and its Summary's Save a declaration save (verified
// 2026-10-12 / 2026-10-13): the final button is the owner's Send changes for review in Publishing overview
const final = meta.stopBefore;
const teens = form === 'play-target-audience' && raw.filter(a => a.id.startsWith('age:')).every(a => a.certain && (a.value !== true || ['age:13-15', 'age:16-17', 'age:18-over'].includes(a.id)));
const askOwner = askOwnerOf(answers).map(l => (teens && l.startsWith('Could your app unintentionally appeal') ? `${l} (only if the page asks it: with 13+ age groups only it did not, 2026-10-12)` : l));
return {
form, store: meta.store, title: meta.title, ownerBatch: ownerBatchOf(form, cfg, p, answers, final), ...part, answers, askOwner,
buttons: {
neverPress, final,
allowed: rules.filter(r => r.auto).map(r => ({ button: r.button, class: r.class, where: r.where })),
rule: ruleText(rules.filter(r => r.class === 'draft-save').map(r => r.button), final, rules.filter(r => r.class === 'declaration-save').map(r => r.button)),
},
driving: DRIVING, inPageLoop: IN_PAGE_LOOP,
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)'}`];
if (pl.ownerBatch.length) L.push('', 'Owner batch (everything the owner does, in ONE message, in the order the agent needs it):',
...pl.ownerBatch.map(o => ` ${o.n}. [${o.when === 'before' ? 'before the run' : 'after the run'}] ${o.text}${o.where ? ` @ ${o.where}` : ''}`), '');
L.push(`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}`),
'', 'In-page helper (plan.inPageLoop: paste it once per page in one short call, then window.__sb.run([...]) and poll window.__log):', ...pl.inPageLoop.split('\n').map(l => ` ${l}`),
'', `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');
}
/**
* What `storebound check` says about the Play console steps that have no API (pure): the App category (a warning when
* the config has none: Play shows "Not selected" until an agent sets it in Store settings; a hint when it has one,
* since it is still a console step) and how many App content answers the owner has to give in the one batch.
*/
export function playConsoleReminders(config: ShipConfig): { warnings: string[]; hints: string[] } {
const warnings: string[] = [], hints: string[] = [];
if (!config.play) return { warnings, hints };
const cat = playCategoryOf(config);
if (!cat) warnings.push('play.category is not set: Play\'s App category stays "Not selected" (the Play API cannot set it, so `play listing` does not). Set play.category (a Play category name, e.g. "Strategy"), then set it in Store settings with `storebound console plan play-store-settings`');
else hints.push(`Play App category "${cat.category}" (${cat.source}) is a console step, not an API one: \`storebound console plan play-store-settings\``);
// only answers the config can hold: the IARC Terms box (an agreement the owner ticks) and the demo account's
// username / password (the owner types them from .storebound/credentials.json; check reports a missing one itself)
// stay owner steps however complete the config is, so they never count here
const ask = consolePlan('play-app-content', config).answers.filter(a => a.status === 'ask-owner' && !a.id.endsWith('/credentials'));
if (ask.length) warnings.push(`${ask.length} Play App content answer(s) are not in the config: ask the owner in one batch at the start (\`storebound console plan play-app-content\` lists them), then write them into the config`);
if (config.products?.some(p => p.randomRewards)) hints.push('products with randomRewards (loot boxes): ClassInd (Brazil) rated such a game 18 on a real IARC run; other authorities barely moved');
return { warnings, hints };
}