storebound/cli/lib/console-play-plans.ts

463 lines
39 KiB
TypeScript

// Console plans for the Play Console pages that have no API and no scripted fill (`storebound console plan <form>`):
// Store settings (the App category: the Play Developer API's edits.details has no category field, so `play listing`
// leaves it "Not selected") and the App content declarations next to Content rating and Target audience: Privacy
// policy, Ads, Sign in details (App access), Advertising ID, Government apps, Financial features, Health apps, plus the
// App content overview that lists every card in order. Pure: answers come from the config only (AGENTS.md rule 5:
// anything it does not prove is ask-owner, collected in the one batch at the start).
// Verified on a real Play Console by an agent with a browser tool (2026-10-12):
// /developers/<devId>/app/<appId>/store-settings: the first Edit opens App category; Category is a dropdown whose
// entries are [role=option] with the exact category name; Save stores it.
// /developers/<devId>/app/<appId>/app-content/overview: each card has a button aria-label="Start <Name> declaration".
// Card pages: app-content/privacy-policy, ads-declaration, testing-credentials (Sign in details), ad-id-declaration,
// government-apps, finance (2 steps; the last has "My app doesn't provide any financial features"), health (2 steps;
// the last step's "Other" box was the no-health-features answer on that run), target-audience-content,
// content-rating-overview > "Start questionnaire" > content-rating-iarc-questionnaire.
// The Data safety card's slug was not recorded (modeled); storebound answers Data safety through the API anyway.
import type { ShipConfig } from './config';
import { PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES, playCategoryOf, profileOf } from './config';
import type { PlanFormId, PlanOnlyFormId } from './console-answers';
import { NEVER_PRESS, PLAY_NEVER_PRESS, playAppList } from './console-forms';
import type { ConsolePlan, PlanAnswer, PlanIds, PlanStatus, PlanStep } from './console-plan';
import { AD_ID_PERMISSION, type ProjectSignals } from './project-signals';
const PLAY = 'https://play.google.com/console';
/** Play Console's material radios and checkboxes (verified 2026-10-12 on Create app, App content and the IARC
* questionnaire): what every Play plan tells the agent. */
export const PLAY_MATERIAL_FACT = 'Play Console radios and checkboxes are <material-radio> / <material-checkbox>: computer-tool clicks and setting the native input (form_input) can leave the Angular model unchanged. Click the inner [role=radio] / [role=checkbox] (or the <input> itself) with a JS el.click(), then read aria-checked (or input.checked). Their labels sit in sibling nodes, not in an aria-label: walk up from the control to the nearest parent whose innerText is not empty and match by "starts with" (verified 2026-10-12).';
/** One App content card on /app-content/overview, in the page's order. */
export interface AppContentCard {
/** The card's name; its button is aria-label="Start <name> declaration". */
name: string;
/** The page under /app-content/. */
slug: string;
/** The plan that fills it, or null when an API command covers it. */
form: PlanFormId | null;
/** How storebound covers it. */
via: string;
/** The slug was read on a real console (2026-10-12). */
verified: boolean;
}
export const APP_CONTENT_CARDS: AppContentCard[] = [
{ name: 'Privacy policy', slug: 'privacy-policy', form: 'play-privacy-policy', via: 'console plan play-privacy-policy', verified: true },
{ name: 'Ads', slug: 'ads-declaration', form: 'play-ads', via: 'console plan play-ads', verified: true },
{ name: 'Sign in details', slug: 'testing-credentials', form: 'play-sign-in-details', via: 'console plan play-sign-in-details', verified: true },
{ name: 'Content ratings', slug: 'content-rating-overview', form: 'play-content-rating', via: 'console plan play-content-rating (Start questionnaire > content-rating-iarc-questionnaire)', verified: true },
{ name: 'Target audience and content', slug: 'target-audience-content', form: 'play-target-audience', via: 'console plan play-target-audience', verified: true },
{ name: 'Data safety', slug: 'data-privacy-security', form: null, via: 'storebound play data-safety (API; no console form to fill; the page has Export to CSV / Import from CSV)', verified: true },
{ name: 'Advertising ID', slug: 'ad-id-declaration', form: 'play-advertising-id', via: 'console plan play-advertising-id', verified: true },
{ name: 'Government apps', slug: 'government-apps', form: 'play-government-apps', via: 'console plan play-government-apps', verified: true },
{ name: 'Financial features', slug: 'finance', form: 'play-financial-features', via: 'console plan play-financial-features', verified: true },
{ name: 'Health apps', slug: 'health', form: 'play-health-apps', via: 'console plan play-health-apps', verified: true },
];
/** The aria-label of a card's start button on the overview. */
export const startLabel = (card: string): string => `Start ${card} declaration`;
const devOf = (c: ShipConfig, p: PlanIds) => p.playDeveloperId ?? c.play?.developerId ?? null;
const appOf = (c: ShipConfig, p: PlanIds) => p.playAppId ?? c.play?.appId ?? null;
/** /console/u/0/developers/<dev>/app/<app>/<path>. */
export const playAppPage = (dev: string, app: string, path: string): string => `${PLAY}/u/0/developers/${encodeURIComponent(dev)}/app/${encodeURIComponent(app)}/${path}`;
function start(config: ShipConfig, p: PlanIds, path: string, reach: string[]): ConsolePlan['start'] {
const dev = devOf(config, p), app = appOf(config, p);
if (dev && app) return { url: playAppPage(dev, app, path), deepLink: true, reach: [] };
return {
url: dev ? playAppList(PLAY, dev) : PLAY, deepLink: false,
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 (set play.appId, the digits after /app/, for a deep link to /${path})`, ...reach],
};
}
const appContentReach = (card: string) => ['Policy and programs > App content (/app-content/overview)', `the "${card}" card: the button aria-label="${startLabel(card)}" (or its Manage / Edit link)`];
interface Part {
title: string;
start: ConsolePlan['start'];
steps: PlanStep[];
answers: PlanAnswer[];
final: string;
neverPress: string[];
allowed: ConsolePlan['buttons']['allowed'];
uiFacts: string[];
verify: ConsolePlan['verify'];
guidance: string[];
}
const PAGE_TEXT = 'document.querySelector("main")?.innerText ?? document.body.innerText';
const pageVerify = (form: PlanFormId, expect: string[]): ConsolePlan['verify'] => ({ command: `storebound console verify ${form} --from-text -`, source: 'page-text', capture: PAGE_TEXT, expect });
const ans = (id: string, question: string, value: string | boolean | null, status: PlanStatus, source: string, extra: Partial<PlanAnswer> = {}): PlanAnswer => ({
id, question, value: status === 'ask-owner' || status === 'agreement' ? null : value,
text: status === 'agreement' ? 'the owner ticks it (an agreement)' : value === null || status === 'ask-owner' ? '(you decide)' : value === true ? 'Yes' : value === false ? 'No' : value,
status, source, ...extra,
});
const yesNo = (v: boolean, yes: string, no: string) => (v ? yes : no);
function steps(list: (Omit<PlanStep, 'n'> | false | undefined)[]): PlanStep[] {
return list.filter((s): s is Omit<PlanStep, 'n'> => !!s).map((s, i) => ({ n: i + 1, ...s }));
}
/** Publishing overview's button that sends every saved change to Google: the owner's (verified 2026-10-12). */
export const SEND_FOR_REVIEW = 'Send changes for review';
/** Where the owner presses it. */
export const SEND_FOR_REVIEW_WHERE = 'Publishing overview (left menu): "Submit N changes for review", then "Send changes for review" in the "Send N changes for review?" dialog (a real click)';
/**
* A declaration's Save (verified 2026-10-13): it stores the card; nothing goes to Google before the owner presses Send
* changes for review in Publishing overview. So the agent presses it, reads the card back and goes on.
*/
const DECLARATION_SAVE = `Press Save yourself (the agent's button: it stores the declaration; Google gets it only with the next "${SEND_FOR_REVIEW}" in Publishing overview, which stays the owner's). Read the card back.`;
const declarationButtons = (where: string, extra: ConsolePlan['buttons']['allowed'] = []) => ({
final: SEND_FOR_REVIEW,
neverPress: [...new Set([...PLAY_NEVER_PRESS, ...NEVER_PRESS])],
allowed: [{ button: 'Start <card> declaration / Manage', class: 'navigation', where: 'App content overview: opens the card' }, ...extra, { button: 'Back', class: 'navigation', where },
{ button: 'Save', class: 'declaration-save', where: `${where}: stores the declaration; Google gets it only with the owner's ${SEND_FOR_REVIEW} (verified 2026-10-13)` }],
});
// ---- Store settings: the App category (no API) ----
function storeSettings(config: ShipConfig, p: PlanIds): Part {
const prof = config.app.profile, game = profileOf(config) === 'game';
const type = prof ? ans('appType', 'App or game', game ? 'Game' : 'App', 'certain', 'app.profile', { control: 'radio', action: 'pick', label: game ? 'Game' : 'App', where: 'App category (after the first Edit)' })
: ans('appType', 'App or game', null, 'ask-owner', 'app.profile', { note: 'Set app.profile (game | app | subscription-app).', control: 'radio', where: 'App category' });
const cat = playCategoryOf(config);
const list = (game ? PLAY_GAME_CATEGORIES : PLAY_APP_CATEGORIES).join(', ');
const category = cat
? ans('category', 'App category', cat.category, cat.source === 'play.category' ? 'certain' : 'owner-check', cat.source, {
control: 'select', action: 'choose', label: cat.category, where: 'App category > Category dropdown: the [role=option] with exactly this text',
...(cat.source === 'play.category' ? {} : { note: `Mapped from the App Store category; set play.category to make it explicit (one of ${list}).` }) })
: ans('category', 'App category', null, 'ask-owner', 'play.category', { control: 'select', action: 'choose', where: 'App category > Category dropdown', note: `Set play.category to one of: ${list}.` });
return {
title: 'Play Console: Store settings (App category)',
start: start(config, p, 'store-settings', ['Grow users > Store presence > Store settings']),
steps: steps([
{ text: 'Open Store settings of the app (/app/<appId>/store-settings; deep link when play.appId is known).' },
{ text: 'Press the FIRST "Edit" on the page (the App category section at the top; the other Edits are contact details and tags).', click: ['Edit'] },
{ text: 'App or game: pick the material radio (JS click on the inner [role=radio]); the category list depends on it.', answers: ['appType'] },
{ text: 'Category: open the dropdown, then click the [role=option] whose text is exactly the category of the sheet (e.g. "Strategy"). Read the dropdown back: it must show that name, not "Select a category".', answers: ['category'] },
{ text: 'Press Save (stores the setting; on an app that is not published nothing is public). Read the section back: App category shows the type and the category, no "Not selected".', click: ['Save'] },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-store-settings --from-text -`.' },
]),
answers: [type, category],
final: 'Send changes for review',
neverPress: [...PLAY_NEVER_PRESS, ...NEVER_PRESS],
allowed: [
{ button: 'Edit (the first one: App category)', class: 'navigation', where: 'Store settings: opens the App category section' },
{ button: 'Save', class: 'draft-save', where: 'Store settings: stores the category. On a published app the change goes out with Google\'s review (with managed publishing: Publishing overview > Send changes for review, the owner\'s)' },
],
uiFacts: [
'Verified 2026-10-12: `play listing` does not set the App category (the Play API\'s edits.details has no category field), so a new app shows "Not selected" until it is set here.',
'URL: /console/u/0/developers/<developerId>/app/<appId>/store-settings. The first Edit on the page opens App category; Category is a dropdown whose entries are [role=option] elements with the exact category name.',
'Contact details (email, website, phone) on the same page come from the API (`storebound play listing`): leave them.',
PLAY_MATERIAL_FACT,
],
verify: pageVerify('play-store-settings', ['The App category section shows the app type and the category of the sheet; no "Not selected".']),
guidance: ['Open: Grow users > Store presence > Store settings > App category > Edit'],
};
}
// ---- App content declarations ----
function privacyPolicy(config: ShipConfig, p: PlanIds): Part {
const url = config.app.privacyPolicyUrl;
return {
title: 'Play Console: Privacy policy (App content)',
start: start(config, p, 'app-content/privacy-policy', appContentReach('Privacy policy')),
steps: steps([
{ text: `Open App content > Privacy policy: ${start(config, p, 'app-content/privacy-policy', []).url}` },
{ text: 'Type the privacy policy URL into the Privacy policy URL field (replace what is there).', answers: ['url'] },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-privacy-policy --from-text -`.' },
]),
answers: [url ? ans('url', 'Privacy policy URL', url, 'certain', 'app.privacyPolicyUrl', { control: 'text', action: 'type', label: 'Privacy policy URL', where: 'Privacy policy' })
: ans('url', 'Privacy policy URL', null, 'ask-owner', 'app.privacyPolicyUrl', { note: 'Set app.privacyPolicyUrl (privacy-policy skill).' })],
...declarationButtons('Privacy policy'),
uiFacts: ['Verified 2026-10-12: the card\'s page is /app-content/privacy-policy (one URL field).', 'The URL must be public (no PDF, no sign-in): `storebound validate` checks it.', PLAY_MATERIAL_FACT],
verify: pageVerify('play-privacy-policy', ['The field shows app.privacyPolicyUrl.']),
guidance: [`Open: Policy and programs > App content > Privacy policy: ${start(config, p, 'app-content/privacy-policy', []).url}`],
};
}
/** The ads answer (the same one the target audience plan uses). */
function adsValue(config: ShipConfig): { v: boolean; src: string } | null {
const ta = config.console?.targetAudience;
if (ta?.containsAds !== undefined) return { v: ta.containsAds, src: 'console.targetAudience.containsAds' };
if (config.ads?.appAdsTxt?.length) return { v: true, src: 'ads.appAdsTxt' };
return null;
}
function adsDeclaration(config: ShipConfig, p: PlanIds): Part {
const a = adsValue(config);
const yes = 'Yes, my app contains ads', no = 'No, my app does not contain ads';
return {
title: 'Play Console: Ads (App content)',
start: start(config, p, 'app-content/ads-declaration', appContentReach('Ads')),
steps: steps([
{ text: `Open App content > Ads: ${start(config, p, 'app-content/ads-declaration', []).url}` },
{ text: 'Pick the radio (JS click on the inner [role=radio]); read aria-checked back.', answers: ['ads'] },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-ads --from-text -`.' },
]),
answers: [a ? ans('ads', 'Does your app contain ads?', a.v, 'certain', a.src, { control: 'radio', action: 'pick', label: yesNo(a.v, yes, no), where: 'Ads' })
: ans('ads', 'Does your app contain ads?', null, 'ask-owner', 'console.targetAudience.containsAds', { control: 'radio', where: 'Ads', note: 'Any ad SDK (banner, rewarded, house ads of other apps) is Yes; set console.targetAudience.containsAds.' })],
...declarationButtons('Ads'),
uiFacts: ['Verified 2026-10-12: /app-content/ads-declaration.', 'Rewarded ads count as ads.', PLAY_MATERIAL_FACT],
verify: pageVerify('play-ads', ['The selected radio matches the sheet.']),
guidance: [`Open: Policy and programs > App content > Ads: ${start(config, p, 'app-content/ads-declaration', []).url}`],
};
}
function signInDetails(config: ShipConfig, p: PlanIds): Part {
const ra = config.reviewAccess;
const all = 'All functionality in my app is available without any access restrictions', some = 'All or some functionality in my app is restricted';
const out: PlanAnswer[] = [ra ? ans('access', 'Is any functionality restricted (sign-in, membership, location)?', ra.signInRequired, 'certain', 'reviewAccess.signInRequired', { control: 'radio', action: 'pick', label: ra.signInRequired ? some : all, where: 'Sign in details (App access)' })
: ans('access', 'Is any functionality restricted (sign-in, membership, location)?', null, 'ask-owner', 'reviewAccess.signInRequired', { control: 'radio', where: 'Sign in details', note: 'Set reviewAccess { signInRequired } (review-access skill).' })];
if (ra?.signInRequired) {
out.push(ra.instructions ? ans('instructions', 'Instructions for the reviewer', ra.instructions, 'certain', 'reviewAccess.instructions', { control: 'text', action: 'type', label: 'Any other information required to access your app', where: 'Add instructions' })
: ans('instructions', 'Instructions for the reviewer', null, 'ask-owner', 'reviewAccess.instructions', { note: 'Set reviewAccess.instructions (how to sign in and reach the main features).' }));
// with reviewDemoAccount in .storebound/credentials.json the agent types it (never printed: not in chat, logs, the
// plan or a commit); without it the owner types the username and password (or adds it there first)
out.push(p.demoAccount
? ans('credentials', 'Username and password of the demo account', null, 'certain', '.storebound/credentials.json reviewDemoAccount', {
control: 'text', action: 'type', where: 'Add instructions: Username, Password',
text: 'from .storebound/credentials.json (never printed)',
note: 'reviewDemoAccount: present. The agent types reviewDemoAccount.username and .password from .storebound/credentials.json straight into the two fields (click, type), without echoing them in chat, logs, the plan or a commit.' })
: ans('credentials', 'Username and password of the demo account', null, 'ask-owner', '.storebound/credentials.json reviewDemoAccount', {
control: 'text', where: 'Add instructions: Username, Password',
note: `reviewDemoAccount: ${p.demoAccount === undefined ? 'not checked' : 'MISSING'}. The owner adds it to .storebound/credentials.json before the run (then the agent types it, never printed), or types the username and password into the card themselves. storebound never prints them.` }));
}
return {
title: 'Play Console: Sign in details (App access)',
start: start(config, p, 'app-content/testing-credentials', appContentReach('Sign in details')),
steps: steps([
{ text: `Open App content > Sign in details: ${start(config, p, 'app-content/testing-credentials', []).url}` },
{ text: 'Pick whether any functionality is restricted (material radio: JS click on the inner [role=radio]).', answers: ['access'] },
ra?.signInRequired && { text: p.demoAccount
? 'Restricted: press "Add instructions", type the instructions, then type the Username and Password from .storebound/credentials.json (reviewDemoAccount; click each field and type: never echo them in chat or logs).'
: 'Restricted: press "Add instructions", type the instructions; the Username and Password are the owner\'s (no reviewDemoAccount in .storebound/credentials.json: in the owner batch).', click: ['Add instructions'], answers: ['instructions', 'credentials'] },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-sign-in-details --from-text -`.' },
]),
answers: out,
...declarationButtons('Sign in details', [{ button: 'Add instructions', class: 'navigation', where: 'Sign in details: opens the instructions dialog' }]),
uiFacts: ['Verified 2026-10-12: the Sign in details card (App access) is /app-content/testing-credentials.', 'Credentials are never in the config, the plan or chat: the agent types them from .storebound/credentials.json (reviewDemoAccount) without printing them; without that entry the owner types them.', PLAY_MATERIAL_FACT],
verify: pageVerify('play-sign-in-details', ['The selected radio matches reviewAccess.signInRequired; with a sign-in, one set of instructions is listed.']),
guidance: [`Open: Policy and programs > App content > Sign in details (App access): ${start(config, p, 'app-content/testing-credentials', []).url}`],
};
}
/** Play's advertising ID purposes (the Data safety purpose names). */
const AD_ID_PURPOSES: Record<string, string> = {
functionality: 'App functionality', analytics: 'Analytics', communications: 'Developer communications', advertising: 'Advertising or marketing',
fraud: 'Fraud prevention, security, and compliance', personalization: 'Personalization', account: 'Account management',
};
/**
* "Does your app use advertising ID?" from real signals: console.appContent.advertisingId, ad / attribution SDKs (scan),
* ads.appAdsTxt, the AD_ID permission in the source or merged AndroidManifest (or its tools:node="remove"). A Data
* safety Device ID (PSL_DEVICE_ID) alone is not the advertising ID (a random install id is not). No signal: No
* (certain) when the project was scanned; signals that disagree: ask.
*/
export function advertisingIdUse(config: ShipConfig, s: ProjectSignals | undefined): { v: boolean | null; src: string; note?: string } {
const flag = config.console?.appContent?.advertisingId;
const yes: string[] = [], no: string[] = [];
if (flag === true) yes.push('console.appContent.advertisingId'); else if (flag === false) no.push('console.appContent.advertisingId');
if (config.ads?.appAdsTxt?.length) yes.push('ads.appAdsTxt (ad SDKs read the advertising ID)');
if (s) {
if (s.adSdks.length || s.attributionSdks.length) yes.push(`ad / attribution SDKs: ${[...s.adSdks, ...s.attributionSdks].join(', ')}`);
if (s.adIdRemoved) no.push(`AD_ID removed (tools:node="remove") at ${s.adIdRemoved}`);
else if (s.adIdPermission) yes.push(`${AD_ID_PERMISSION} at ${s.adIdPermission}`);
}
if (yes.length && no.length) return { v: null, src: [...yes, ...no].join('; '), note: `The signals disagree (yes: ${yes.join('; ')}; no: ${no.join('; ')}). Decide and set console.appContent.advertisingId (an SDK that reads it needs the AD_ID permission in the merged manifest).` };
if (yes.length) return { v: true, src: yes.join('; ') };
if (no.length) return { v: false, src: no.join('; ') };
if (s) return { v: false, src: `no ad / attribution SDK, no ${AD_ID_PERMISSION} in the manifests, no ads.appAdsTxt (a Device ID in dataSafety is not the advertising ID)` };
return { v: null, src: 'console.appContent.advertisingId' };
}
function advertisingId(config: ShipConfig, p: PlanIds): Part {
const dev = config.dataSafety?.data?.PSL_DEVICE_ID;
const uses = advertisingIdUse(config, p.signals);
const out: PlanAnswer[] = [uses.v !== null ? ans('uses', 'Does your app use advertising ID?', uses.v, 'certain', uses.src, { control: 'radio', action: 'pick', label: uses.v ? 'Yes' : 'No', where: 'Advertising ID' })
: ans('uses', 'Does your app use advertising ID?', null, 'ask-owner', uses.src, { control: 'radio', where: 'Advertising ID', note: uses.note ?? 'Yes when the app or any SDK (ads, analytics, attribution) reads it; set console.appContent.advertisingId.' })];
if (uses?.v) {
if (dev) {
const purposes = new Set([...dev.collect, ...(dev.shared ? dev.share ?? [] : [])]);
for (const [k, label] of Object.entries(AD_ID_PURPOSES)) out.push(ans(`purpose:${k}`, `Advertising ID purpose: ${label}`, purposes.has(k as never), 'certain', 'dataSafety.data.PSL_DEVICE_ID', { control: 'checkbox', action: purposes.has(k as never) ? 'tick' : 'untick', label, where: 'Advertising ID: why it is used' }));
} else out.push(ans('purposes', 'Advertising ID purposes', null, 'ask-owner', 'dataSafety.data.PSL_DEVICE_ID', { control: 'checkbox', where: 'Advertising ID', note: 'Add dataSafety.data.PSL_DEVICE_ID with its purposes (store-declarations skill): the same purposes answer this card.' }));
}
return {
title: 'Play Console: Advertising ID (App content)',
start: start(config, p, 'app-content/ad-id-declaration', appContentReach('Advertising ID')),
steps: steps([
{ text: `Open App content > Advertising ID: ${start(config, p, 'app-content/ad-id-declaration', []).url}` },
{ text: 'Pick Yes or No (material radio: JS click on the inner [role=radio]).', answers: ['uses'] },
uses.v === true && { text: 'Tick exactly the purposes of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back).', answers: out.filter(a => a.id.startsWith('purpose')).map(a => a.id) },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-advertising-id --from-text -`.' },
]),
answers: out,
...declarationButtons('Advertising ID'),
uiFacts: ['Verified 2026-10-12: /app-content/ad-id-declaration.', 'Yes needs the com.google.android.gms.permission.AD_ID permission in the merged manifest (ad SDKs usually add it); `storebound scan` reports the SDKs.', PLAY_MATERIAL_FACT],
verify: pageVerify('play-advertising-id', ['The selected radio and the ticked purposes match the sheet.']),
guidance: [`Open: Policy and programs > App content > Advertising ID: ${start(config, p, 'app-content/ad-id-declaration', []).url}`],
};
}
function government(config: ShipConfig, p: PlanIds): Part {
const v = config.console?.appContent?.government;
return {
title: 'Play Console: Government apps (App content)',
start: start(config, p, 'app-content/government-apps', appContentReach('Government apps')),
steps: steps([
{ text: `Open App content > Government apps: ${start(config, p, 'app-content/government-apps', []).url}` },
{ text: 'Pick whether the app is developed by or on behalf of a government (material radio).', answers: ['government'] },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-government-apps --from-text -`.' },
]),
answers: [v !== undefined ? ans('government', 'Is the app developed by or on behalf of a government?', v, 'certain', 'console.appContent.government', { control: 'radio', action: 'pick', label: v ? 'Yes' : 'No', where: 'Government apps' })
: ans('government', 'Is the app developed by or on behalf of a government?', null, 'ask-owner', 'console.appContent.government', { control: 'radio', where: 'Government apps', note: 'Set console.appContent.government (true only for a government body or its contractor).' })],
...declarationButtons('Government apps'),
uiFacts: ['Verified 2026-10-12: /app-content/government-apps.', PLAY_MATERIAL_FACT],
verify: pageVerify('play-government-apps', ['The selected answer matches the sheet.']),
guidance: [`Open: Policy and programs > App content > Government apps: ${start(config, p, 'app-content/government-apps', []).url}`],
};
}
/** Financial features / Health apps: two steps, the features on the last one (or the none box). */
function featureCard(config: ShipConfig, p: PlanIds, kind: 'finance' | 'health'): Part {
const fin = kind === 'finance';
const form: PlanOnlyFormId = fin ? 'play-financial-features' : 'play-health-apps';
const name = fin ? 'Financial features' : 'Health apps';
const key = fin ? 'financialFeatures' : 'healthFeatures';
const v = config.console?.appContent?.[key];
const none = fin ? 'My app doesn\'t provide any financial features' : 'Other';
const out: PlanAnswer[] = v === false
? [ans('none', fin ? 'No financial features' : 'No health features', true, 'certain', `console.appContent.${key}`, { control: 'checkbox', action: 'tick', label: none, curlyQuotes: false, where: `${name}, last step`,
...(fin ? {} : { note: 'On the verified run (2026-10-12) the no-health-features answer was the last step\'s "Other" box; when the page offers an explicit none option, tick that instead.' }) })]
: Array.isArray(v) ? v.map(l => ans(`feature:${l}`, `${name}: ${l}`, true, 'certain', `console.appContent.${key}`, { control: 'checkbox', action: 'tick', label: l, where: `${name}, last step` }))
: [ans('features', `${name}: which features does the app provide?`, null, 'ask-owner', `console.appContent.${key}`, { control: 'checkbox', where: `${name}, last step`, note: `Set console.appContent.${key}: false for none, or the console's labels to tick.` })];
return {
title: `Play Console: ${name} (App content)`,
start: start(config, p, `app-content/${kind}`, appContentReach(name)),
steps: steps([
{ text: `Open App content > ${name}: ${start(config, p, `app-content/${kind}`, []).url}` },
{ text: 'Step 1: read it and press Next.', click: ['Next'] },
{ text: `Last step: tick exactly the boxes of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back)${fin ? '; no financial features: "My app doesn\'t provide any financial features"' : '; no health features: "Other" (as on the verified run) or the none option the page offers'}. Then Next if the page shows one.`, click: ['Next'], answers: out.map(a => a.id) },
{ text: DECLARATION_SAVE },
{ text: `Verify: read the page text and pipe it to \`storebound console verify ${form} --from-text -\`.` },
]),
answers: out,
...declarationButtons(name, [{ button: 'Next', class: 'navigation', where: `${name}: between the two steps` }]),
uiFacts: [`Verified 2026-10-12: /app-content/${kind}, two steps; the last step holds ${fin ? '"My app doesn\'t provide any financial features"' : 'the health feature boxes, "Other" among them'}; then Next and Save.`, PLAY_MATERIAL_FACT],
verify: pageVerify(form, ['The ticked boxes match the sheet.']),
guidance: [`Open: Policy and programs > App content > ${name}: ${start(config, p, `app-content/${kind}`, []).url}`],
};
}
// ---- the first production release (after `play release --production draft`) ----
/** The managed publishing warning (Play's default is off: approved means live). */
export function managedPublishingNote(config: ShipConfig): string {
return config.play?.managedPublishing === true
? 'Managed publishing is on (play.managedPublishing): after Google approves, the owner publishes the changes in Publishing overview.'
: `Managed publishing is ${config.play?.managedPublishing === false ? 'off' : 'off by default (play.managedPublishing not set)'}: the release goes live automatically once Google approves it. To control the moment, the owner turns on Publishing overview > Managed publishing first (and sets play.managedPublishing: true).`;
}
const regionName = (code: string): string => { try { return new Intl.DisplayNames(['en'], { type: 'region' }).of(code) ?? code; } catch { return code; } };
function productionCountries(config: ShipConfig, p: PlanIds): Part {
const list = config.play?.countries;
const answers: PlanAnswer[] = list?.length
? list.map(c => ans(`country:${c}`, `Country / region: ${regionName(c)} (${c})`, true, 'certain', 'play.countries', { control: 'checkbox', action: 'tick', label: regionName(c), where: 'Edit countries / regions: the row of that country' }))
: [ans('all', 'All countries / regions', true, 'certain', 'default (play.countries not set)', { control: 'checkbox', action: 'tick', label: 'Select all rows', where: 'Edit countries / regions: the mat-checkbox aria-label="Select all rows"', note: 'Set play.countries (ISO codes) to limit them.' })];
return {
title: 'Play Console: production countries / regions',
start: start(config, p, 'tracks/production', ['Test and release > Production']),
steps: steps([
{ text: 'Open the production track (/app/<appId>/tracks/production) after `storebound play release --production draft` made the draft release.' },
{ text: 'Open the "Countries / regions" tab and press "Edit countries / regions".', click: ['Countries / regions', 'Edit countries / regions'] },
{ text: list?.length ? 'Tick exactly the rows of play.countries (material checkboxes: JS click on the inner [role=checkbox], read aria-checked back); untick the rest.' : 'Tick the mat-checkbox aria-label="Select all rows" (every country / region).', answers: answers.map(a => a.id) },
{ text: 'Press Save. A "Go to Publishing overview?" dialog follows: it only navigates; go on with `storebound console plan play-production-release`.', click: ['Save'] },
{ text: 'Verify: read the tab\'s text and pipe it to `storebound console verify play-production-countries --from-text -` (or run `storebound validate --play`: it reads the production track\'s countries through the API).' },
]),
answers,
final: 'Send changes for review',
neverPress: [...PLAY_NEVER_PRESS, ...NEVER_PRESS],
allowed: [
{ button: 'Countries / regions (tab), Edit countries / regions', class: 'navigation', where: 'production track: opens the country list' },
{ button: 'Save', class: 'draft-save', where: 'country list: stores the countries of the draft production release; nothing is public before the owner sends the changes for review' },
{ button: 'Go to Publishing overview / its dismiss button', class: 'navigation', where: 'the dialog after Save: navigation only' },
],
uiFacts: [
'Verified 2026-10-12: the API\'s edits.countryAvailability is read-only, so the countries are a console step. tracks/production > Countries / regions tab > Edit countries / regions > a mat-checkbox aria-label="Select all rows" (or one per row) > Save > a "Go to Publishing overview?" dialog.',
'A production track with 0 countries cannot reach anyone: `storebound validate` reports it.',
PLAY_MATERIAL_FACT,
],
verify: pageVerify('play-production-countries', ['The tab lists the countries / regions of the sheet (all of them by default); not 0.']),
guidance: ['Open: Test and release > Production > Countries / regions > Edit countries / regions'],
};
}
/** The dialog Publishing overview shows when changes are submitted while a review is running (seen 2026-10-12). */
export const RESTART_DIALOG = 'Do you want to restart your review?';
function productionRelease(config: ShipConfig, p: PlanIds): Part {
const mp = managedPublishingNote(config);
return {
title: 'Play Console: send the first production release for review',
start: start(config, p, 'tracks/production', ['Test and release > Production']),
steps: steps([
{ text: 'Before: the production countries are set (`storebound console plan play-production-countries`) and every App content card is done (`console plan play-app-content`).' },
{ text: 'Production track > Releases tab > "Edit release" on the draft that `play release --production draft` made.', click: ['Releases', 'Edit release'] },
{ text: 'Press Next: the review page lists errors and warnings. A missing deobfuscation file is only a warning. Any error: stop and fix it (it names the page).', click: ['Next'] },
{ text: 'Press Save (stores the release; nothing is sent yet).', click: ['Save'] },
{ text: 'Open Publishing overview (left menu). It lists "Changes not yet sent for review" and a "Submit N changes for review" button.' },
{ text: `Stop. The owner presses "Submit N changes for review" and then "Send changes for review" in the "Send N changes for review?" dialog (a real mouse click: a JS .click() did nothing on it). ${mp}`, answers: ['managedPublishing'] },
{ text: `While a review is already running, "Submit N changes for review" opens "${RESTART_DIALOG}" instead, with "Restart review": that sends the new changes and starts the running review over. It is the owner's button as well (tell the owner a review is running and what restarting means); never the agent's. Cancel closes the dialog and nothing changes.` },
{ text: 'Verify: read Publishing overview as text and pipe it to `storebound console verify play-production-release --from-text -`; `storebound play status` shows the production release.' },
]),
answers: [ans('managedPublishing', 'Managed publishing on?', config.play?.managedPublishing ?? false, config.play?.managedPublishing === undefined ? 'owner-check' : 'certain', config.play?.managedPublishing === undefined ? 'Play default (off)' : 'play.managedPublishing',
{ note: mp, where: 'Publishing overview > Managed publishing (only read; the owner switches it)' })],
final: 'Send changes for review',
neverPress: ['Submit N changes for review', 'Send changes for review', 'Restart review', ...NEVER_PRESS],
allowed: [
{ button: 'Releases (tab), Edit release, Next', class: 'navigation', where: 'production track: opens the draft release and its review page' },
{ button: 'Save', class: 'draft-save', where: 'the release review page: stores the draft release; nothing is sent' },
{ button: 'Cancel', class: 'navigation', where: `the "${RESTART_DIALOG}" dialog: closes it; the running review goes on unchanged` },
],
uiFacts: [
'Verified 2026-10-12 on a first production release: Releases tab > Edit release > Next (review page; a missing deobfuscation file is only a warning) > Save; then Publishing overview > "Submit N changes for review" > dialog "Send N changes for review?" > "Send changes for review" (the owner\'s final button; a JS .click() did nothing on it, it needs a real click).',
`Seen 2026-10-12: pressing "Submit N changes for review" while a review is running opens the dialog "${RESTART_DIALOG}" with "Restart review" (the owner\'s final button: the running review starts over with the new changes) and Cancel. Known dialog; agents never press Restart review.`,
mp,
PLAY_MATERIAL_FACT,
],
verify: pageVerify('play-production-release', ['Publishing overview no longer lists changes not yet sent for review (they are "In review").']),
guidance: ['Open: Test and release > Production > Releases > Edit release; then Publishing overview'],
};
}
export function playOnlyPart(form: Exclude<PlanOnlyFormId, 'play-app-content'>, config: ShipConfig, p: PlanIds): Part {
switch (form) {
case 'play-production-countries': return productionCountries(config, p);
case 'play-production-release': return productionRelease(config, p);
case 'play-store-settings': return storeSettings(config, p);
case 'play-privacy-policy': return privacyPolicy(config, p);
case 'play-ads': return adsDeclaration(config, p);
case 'play-sign-in-details': return signInDetails(config, p);
case 'play-advertising-id': return advertisingId(config, p);
case 'play-government-apps': return government(config, p);
case 'play-financial-features': return featureCard(config, p, 'finance');
case 'play-health-apps': return featureCard(config, p, 'health');
}
}
/** The overview's start (the App content page itself). */
export const appContentStart = (config: ShipConfig, p: PlanIds): ConsolePlan['start'] => start(config, p, 'app-content/overview', ['Policy and programs > App content']);
/** Capture for `console verify play-app-content`: the cards still to start (their aria-labels) and the page text. */
export const APP_CONTENT_CAPTURE = 'JSON.stringify({ start: [...document.querySelectorAll(\'[aria-label^="Start "][aria-label$=" declaration"]\')].map(b => b.getAttribute("aria-label")), text: (document.querySelector("main") ?? document.body).innerText })';
export type { Part as PlayPart };