console: Play App content card Saves are the agent's (declaration-save; Send changes for review, Submit, Publish, Restart review stay forbidden), one owner batch per plan with the IARC Terms tick before the run, console plan play-app-content --all, New App fields typed not set, play.appId from the Create app redirect (console verify --url --write) and storebound play app-id across app list pages
This commit is contained in:
parent
1366e7e231
commit
f741c15829
29 changed files with 1029 additions and 165 deletions
|
|
@ -37,7 +37,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'ads apple': [() => import('./ads/apple'), 'Apple Ads (Search Ads): status | campaigns | report (spend, taps, installs, CPA per campaign / keyword, --csv); bid | budget | pause (dry run, asks first; never via MCP)'],
|
||||
'web': [() => import('./web'), 'init (apple-app-site-association, assetlinks.json, Smart App Banner tag, optional landing page) | check <domain> (live files: content type, no redirects, ids match)'],
|
||||
'revenuecat': [() => import('./revenuecat'), 'diff (catalog vs RevenueCat products, entitlements, default offering) | sync (dry run, asks first; never via MCP)'],
|
||||
'console plan': [() => import('./console/plan'), '<form> [--json]: agent-first console mode: start URL, steps, answer sheet (certain / ask owner, console labels), never-press list, UI facts and how to verify, for an AI agent that drives the browser itself; read-only'],
|
||||
'console plan': [() => import('./console/plan'), '<form> [--json] [--all]: agent-first console mode: the owner batch first, start URL, steps, answer sheet (certain / ask owner, console labels), never-press list, UI facts and how to verify, for an AI agent that drives the browser itself; read-only'],
|
||||
'console verify': [() => import('./console/verify'), '<form> [--from-text <file|->] [--json]: check a filled console form against the config (page text, input states or the App Store Connect API); read-only, exit 1 on a mismatch'],
|
||||
'console fill': [() => import('./console/fill'), '<form> [--store apple|play]: optional scripted fast path: autofill in a visible Chromium you sign in to yourself; stops before Save / Submit'],
|
||||
'reviews': [() => import('./reviews'), 'list | draft (local reply drafts in .storebound/replies) | send (approved drafts only, asks first)'],
|
||||
|
|
@ -55,6 +55,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'play status': [() => import('./play/status'), 'tracks, listings, products (read-only)'],
|
||||
'play rollout': [() => import('./play/rollout'), 'staged rollout: --fraction x | --halt | --resume | --complete (asks first)'],
|
||||
'play share': [() => import('./play/share'), 'internal app sharing: upload an AAB / APK, print the share link (asks first; --dry)'],
|
||||
'play app-id': [() => import('./play/app-id'), '[--from-text <file|->] [--write]: the Play Console app id (play.appId) by package name: known ids, else the agent steps (app list search / pages, capture) and the dry diff; --write saves it; read-only on Play'],
|
||||
'play next-version-code': [() => import('./play/next-version-code'), 'next free versionCode (prints the number)'],
|
||||
'apple bundle': [() => import('./apple/bundle'), 'register the bundle id; finds the app record'],
|
||||
'apple iap': [() => import('./apple/iap'), 'in-app purchases, localizations, price schedules, availability'],
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import type { Ctx } from '../lib/ctx';
|
|||
import { type Answer, type ConsoleResult, consoleResult, DISCLAIMER, FORM_IDS, type FormId, fillValue, formMeta, isForm, valueText } from '../lib/console-answers';
|
||||
import type { ShipConfig } from '../lib/config';
|
||||
import { APPLE_PRIVACY_TYPES } from '../lib/app-privacy';
|
||||
import { ALL_APPS, APP_PRIVACY_HEADING, consoleReached, CREATE_FORM_FIELD, createAppAllowed, DATA_TYPES_HEADING, type FieldDef, FLOW_NAMES, flowAllowed, flowClass, type FlowWhere, type FormDef, formDef, FORM_OPENERS, forbiddenControl, quoteTolerant, forbiddenTick, hasToken, isAppleSignInFrame, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan, type SectionDef, setUpAllowed, setUpTexts, typeLabels } from '../lib/console-forms';
|
||||
import { ALL_APPS, APP_PRIVACY_HEADING, consoleReached, CREATE_FORM_FIELD, createAppAllowed, DATA_TYPES_HEADING, DECLARATION_SAVE_FORMS, type FieldDef, FLOW_NAMES, flowAllowed, flowClass, type FlowWhere, type FormDef, formDef, FORM_OPENERS, forbiddenControl, quoteTolerant, forbiddenTick, hasToken, isAppleSignInFrame, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan, type SectionDef, setUpAllowed, setUpTexts, typeLabels } from '../lib/console-forms';
|
||||
import { log, printJson, warn } from '../lib/log';
|
||||
import { openAcross, withAccountIds } from './accounts';
|
||||
import { appleAppId, withAppleAppId } from './app-id';
|
||||
|
|
@ -180,9 +180,24 @@ export async function highlight(loc: Locator, text = CHECK): Promise<void> {
|
|||
}, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Types a text field like a person: click it, select what is there, delete it, type the value key by key, read it back.
|
||||
* App Store Connect's New App dialog (React; verified 2026-10-13) kept its state empty ("") after a programmatic value
|
||||
* set on Name and SKU (a form_input-style set of the value), while clicking and typing worked. true when the field
|
||||
* shows the value afterwards.
|
||||
*/
|
||||
export async function typeText(loc: Locator, v: string): Promise<boolean> {
|
||||
await loc.click({ timeout: ACTION_MS });
|
||||
await loc.press('ControlOrMeta+A', { timeout: ACTION_MS });
|
||||
await loc.press('Backspace', { timeout: ACTION_MS });
|
||||
await loc.pressSequentially(v, { delay: 15, timeout: ACTION_MS + v.length * 50 });
|
||||
const now: string = await loc.inputValue({ timeout: ACTION_MS }).catch(() => v);
|
||||
return now === v;
|
||||
}
|
||||
|
||||
async function setField(r: Resolved, v: string | boolean): Promise<boolean> {
|
||||
const f = r.field;
|
||||
if (f.kind === 'text') { await r.loc.fill(String(v)); return true; }
|
||||
if (f.kind === 'text') return typeText(r.loc, String(v));
|
||||
if (f.kind === 'checkbox') {
|
||||
const name = await accName(r.loc);
|
||||
if (await inMaterial(r.loc, 'material-checkbox')) return forbiddenTick(name) ? safeClick(r.loc, name, 'check') : materialToggle(r.loc, v === true);
|
||||
|
|
@ -266,8 +281,11 @@ export interface FillStep {
|
|||
fallback: boolean;
|
||||
/** A control of the filled section (the flow finds its dialog and its Next / Save from it). */
|
||||
anchor?: Locator;
|
||||
/** Per uncertain answer (agreements left out), its outlined controls: the flow waits until the owner set each one. */
|
||||
/** Per uncertain answer (agreements left out, except one that blocks Next), its outlined controls: the flow waits
|
||||
* until the owner set each one. */
|
||||
pending?: Locator[][];
|
||||
/** Agreements on the page that Next waits for and that are not ticked yet (the IARC Terms of Use). */
|
||||
blocked?: string[];
|
||||
}
|
||||
|
||||
/** The dialog of a section: by its names, else the one visible dialog with such a heading, else (the data type
|
||||
|
|
@ -321,7 +339,7 @@ export async function fillPage(page: Page, def: FormDef, answers: Answer[], done
|
|||
// missing fields to the owner (reported as "check")
|
||||
// (a page still loading can also miss a field for a moment: the caller retries before it passes partial = true)
|
||||
if (missing.length && (!partial || missing.length === rs.length)) return { section: i, title: sec.title, filled: [], highlighted: [], missing, fallback: true };
|
||||
const filled: string[] = [], highlighted: string[] = [...missing], pending: Locator[][] = [];
|
||||
const filled: string[] = [], highlighted: string[] = [...missing], pending: Locator[][] = [], blocked: string[] = [];
|
||||
let anchor: Locator | undefined;
|
||||
for (const r of rs) {
|
||||
if (!r.ok) continue;
|
||||
|
|
@ -329,7 +347,12 @@ export async function fillPage(page: Page, def: FormDef, answers: Answer[], done
|
|||
const a = byId.get(r.field.id);
|
||||
if (!a) continue;
|
||||
// an agreement is never ticked, whatever the answer says: highlighted for the owner
|
||||
if (r.field.agreement || a.agreement) { await mark(r, 'storebound: you agree to this, never ticked'); highlighted.push(a.id); continue; }
|
||||
if (r.field.agreement || a.agreement) {
|
||||
await mark(r, 'storebound: you agree to this, never ticked'); highlighted.push(a.id);
|
||||
// an agreement Next waits for (the IARC Terms of Use, verified 2026-10-13): the flow waits until the owner ticked it
|
||||
if (r.field.blocksNext && r.loc && !await r.loc.isChecked({ timeout: ACTION_MS }).catch(() => false)) { pending.push([r.loc]); blocked.push(a.id); }
|
||||
continue;
|
||||
}
|
||||
const set = a.certain && a.value !== null ? await setField(r, a.value) : false;
|
||||
if (set) { filled.push(a.id); if (a.ownerCheck) await mark(r, 'storebound: owner check'); } else {
|
||||
await mark(r); highlighted.push(a.id);
|
||||
|
|
@ -337,7 +360,7 @@ export async function fillPage(page: Page, def: FormDef, answers: Answer[], done
|
|||
}
|
||||
}
|
||||
done.add(i);
|
||||
return { section: i, title: sec.title, filled, highlighted, missing, fallback: false, ...(anchor ? { anchor } : {}), pending };
|
||||
return { section: i, title: sec.title, filled, highlighted, missing, fallback: false, ...(anchor ? { anchor } : {}), pending, ...(blocked.length ? { blocked } : {}) };
|
||||
}
|
||||
return { section: null, filled: [], highlighted: [], missing: [], fallback: false };
|
||||
}
|
||||
|
|
@ -1363,7 +1386,8 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
waitFor = open.length ? { title: s.title ?? '', pending: open } : null;
|
||||
if (waitFor) {
|
||||
const need = s.highlighted.filter(id => !pageAnswers.find(a => a.id === id)?.agreement);
|
||||
say(`answer needed on "${s.title}": ${need.map(id => labels.get(id) ?? id).join('; ')}. Answer ${need.length === 1 ? 'it' : 'each one'} in the browser (outlined in red); storebound goes on by itself once every outlined answer is set (a box that stays empty: click it twice)`);
|
||||
if (need.length) say(`answer needed on "${s.title}": ${need.map(id => labels.get(id) ?? id).join('; ')}. Answer ${need.length === 1 ? 'it' : 'each one'} in the browser (outlined in red); storebound goes on by itself once every outlined answer is set (a box that stays empty: click it twice)`);
|
||||
if (s.blocked?.length) say(`agreement needed on "${s.title}": ${s.blocked.map(id => `"${labels.get(id) ?? id}"`).join(' and ')} is yours to tick (outlined in red; storebound never ticks an agreement; the page waits for it). storebound goes on by itself once it is ticked`);
|
||||
}
|
||||
continue; // more of the form may be on this page
|
||||
}
|
||||
|
|
@ -1392,14 +1416,31 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
if (privacy) await privacyStep(p).catch(() => { /* navigating */ });
|
||||
await rt.sleep(tick);
|
||||
}
|
||||
/** The declaration's Save (the one visible button named exactly Save / Kaydet, no Next left on the page), polled at
|
||||
* most stepMs: the content rating Summary renders after the last Next. true when it was pressed. */
|
||||
async function declarationSave(p: Page): Promise<boolean> {
|
||||
const btn = await poll(stepMs, async () => {
|
||||
if (await nextControl(p).catch(() => null)) return null;
|
||||
return controlWhere(p, FLOW_NAMES.save, w => !w.inDialog, dataTypes).catch(() => null);
|
||||
}, undefined, p).catch(() => null);
|
||||
if (!btn) return false;
|
||||
return flowClick(btn, form, dataTypes, say, clicked).catch(() => false);
|
||||
}
|
||||
if (timedOut) {
|
||||
say(`nothing storebound knows happened for ${mins(idleMs)}: it stops here and closes the window${o.debugDir && dumps ? ` (debug: ${o.debugDir})` : ''}. Answers saved as drafts stay; run it again when the page is fixed.`);
|
||||
await b.close();
|
||||
return { filled, highlighted, fallback, timedOut: true, ...(o.debugDir && dumps ? { debugDir: o.debugDir } : {}) };
|
||||
}
|
||||
// a Play App content declaration (content rating Summary, target audience): its Save is the agent's (verified
|
||||
// 2026-10-13: nothing goes to Google before the owner's "Send changes for review" in Publishing overview)
|
||||
const declaration = DECLARATION_SAVE_FORMS.includes(form);
|
||||
const saved = declaration && !b.isClosed() && !fallback ? await declarationSave(active() ?? b.page) : false;
|
||||
if (!b.isClosed() && !fallback) say(privacy
|
||||
? 'review the summary, then press Publish yourself. storebound saved the answers as a draft (Save in each dialog); nothing is public until you press Publish. Close the window when done.'
|
||||
: `review every field and press ${meta.stopBefore} yourself. storebound never saves, submits or publishes. Close the window when done.`);
|
||||
: declaration ? (saved
|
||||
? `storebound pressed Save: the declaration is stored, nothing is sent yet. Once every App content card is done, press ${meta.stopBefore} in Publishing overview yourself. Close the window when done.`
|
||||
: `storebound did not find the declaration's Save on this page: press Save (it only stores the declaration), then ${meta.stopBefore} in Publishing overview once every App content card is done. Close the window when done.`)
|
||||
: `review every field and press ${meta.stopBefore} yourself. storebound never saves, submits or publishes. Close the window when done.`);
|
||||
else if (!b.isClosed()) say('close the browser window when done');
|
||||
// the owner reviews and presses the final button, then closes the window: a reminder every 10 heartbeats, at most finalMs
|
||||
const end = Date.now() + finalMs;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// The agent-first console mode: storebound supplies the knowledge, the calling AI agent drives the browser.
|
||||
// storebound console plan lists the forms
|
||||
// storebound console plan <form> [--json] [--lookup]
|
||||
// storebound console plan play-app-content --all [--json] every App content card's steps inline, one owner list
|
||||
// Read-only. Offline by default: the start URL, the ordered steps, the answer sheet (each answer certain or "ask
|
||||
// owner", with the console's own labels), the never-press list, the known UI facts and how to verify. Deep links use
|
||||
// apple.appId / play.developerId / play.appId, the ids cached by earlier runs (.storebound/console-app-ids.json,
|
||||
|
|
@ -44,7 +45,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const { projectSignals } = await import('../lib/project-signals');
|
||||
ids.signals = projectSignals(detectProject(root));
|
||||
} catch { /* not scanned */ }
|
||||
const plan = consolePlan(form, cfg, ids);
|
||||
if (args.has('all') && form !== 'play-app-content') throw new Error('console plan: --all is for play-app-content (every App content card inline)');
|
||||
const plan = consolePlan(form, cfg, ids, { all: args.has('all') });
|
||||
if (json) return printJson(plan);
|
||||
console.log(planText(plan));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,23 @@
|
|||
// Checks a console form against the config after the agent (or the owner) filled it. Read-only.
|
||||
// storebound console verify <form> [--from-text <file|->] [--json]
|
||||
// storebound console verify play-app-record --url <url after Create app> [--write]
|
||||
// apple-app-privacy: the page text of the App Privacy page (the Data Types section's per-type summaries; the App Store
|
||||
// Connect API has no App Privacy endpoint). play-app-record: the JSON of the plan's capture snippet (input states) or
|
||||
// the page text. play-content-rating, play-target-audience: the summary page text. play-app-content: the plan's
|
||||
// capture JSON (cards still showing "Start <Name> declaration"); play-store-settings, the other App content cards and
|
||||
// play-production-countries / -release: the page text after Save. apple-app-record: the App Store
|
||||
// Connect API (the app by bundle id; a GET) or, with --from-text, the page text. apple-age-rating: the API.
|
||||
// play-app-record also reads the Play Console app id from the URL Create app redirects to (/app/<appId>/app-dashboard;
|
||||
// --url, or the capture JSON's url) and shows the play.appId change as a dry diff; --write saves it into the config (an
|
||||
// upsert; the only write of this command, and only to the local config file).
|
||||
// Exit 1 on any mismatch, and when nothing could be matched. Pure logic in cli/lib/console-verify.ts. MCP: console_verify.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { isPlanForm, PLAN_FORM_IDS } from '../lib/console-answers';
|
||||
import { verifyAgeRatingApi, verifyAppRecordApi, type VerifyResult, verifyTable, verifyText } from '../lib/console-verify';
|
||||
import { printJson } from '../lib/log';
|
||||
import { log, printJson } from '../lib/log';
|
||||
import { appIdChange, appIdDiff, writePlayAppId } from '../lib/play-app-id';
|
||||
import { basename } from 'node:path';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
async function readText(src: string): Promise<string> {
|
||||
|
|
@ -24,7 +30,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const args = ctx.args, json = args.has('json');
|
||||
const form = args._[0];
|
||||
if (!isPlanForm(form)) throw new Error(`console verify <form> [--from-text <file|->]: one of ${PLAN_FORM_IDS.join(', ')}`);
|
||||
const from = args.get('from-text'), inline = args.get('text');
|
||||
const from = args.get('from-text'), url = args.get('url');
|
||||
const inline = args.get('text') ?? (url !== undefined ? url : undefined);
|
||||
if (url !== undefined && form !== 'play-app-record') throw new Error('console verify: --url is for play-app-record (the URL Create app redirects to)');
|
||||
let r: VerifyResult;
|
||||
if (from !== undefined || inline !== undefined) {
|
||||
const text = inline ?? await readText(from!);
|
||||
|
|
@ -45,6 +53,21 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
r = verifyAgeRatingApi(config, (age?.data?.attributes ?? null) as Record<string, unknown> | null);
|
||||
}
|
||||
} else throw new Error(`console verify ${form}: pass the page text with --from-text <file|-> (run \`storebound console plan ${form}\` for what to capture)`);
|
||||
if (json) printJson(r); else console.log(verifyTable(r));
|
||||
// play-app-record: the app id from the redirect URL -> play.appId (dry diff; --write saves it)
|
||||
let appId: { change: ReturnType<typeof appIdChange>; written: boolean } | null = null;
|
||||
if (r.playAppId && config.play) {
|
||||
const change = appIdChange(config, r.playAppId);
|
||||
const written = args.has('write') && !args.has('dry') && !!ctx.loaded.path ? writePlayAppId(ctx.loaded.path, config, r.playAppId) : false;
|
||||
appId = { change, written };
|
||||
}
|
||||
if (json) printJson(appId ? { ...r, appId } : r); else {
|
||||
console.log(verifyTable(r));
|
||||
if (appId) {
|
||||
const file = basename(ctx.loaded.path || 'storebound.config.ts');
|
||||
console.log(`\n${appIdDiff(appId.change, file)}`);
|
||||
if (appId.written) log('console', `wrote play.appId '${appId.change.to}' into ${file}`);
|
||||
else if (appId.change.changed) console.log('dry: run the same command with --write to save it');
|
||||
}
|
||||
}
|
||||
if (!r.ok) process.exitCode = 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -332,6 +332,7 @@
|
|||
"use-chrome-session"
|
||||
],
|
||||
"console plan": [
|
||||
"all",
|
||||
"capacitor",
|
||||
"cdp",
|
||||
"chrome-profile",
|
||||
|
|
@ -367,7 +368,9 @@
|
|||
"react-native",
|
||||
"sku",
|
||||
"text",
|
||||
"unity"
|
||||
"unity",
|
||||
"url",
|
||||
"write"
|
||||
],
|
||||
"desktop": [
|
||||
"channel",
|
||||
|
|
@ -430,6 +433,28 @@
|
|||
"against",
|
||||
"threshold"
|
||||
],
|
||||
"play app-id": [
|
||||
"capacitor",
|
||||
"cdp",
|
||||
"chrome-profile",
|
||||
"cordova",
|
||||
"cordova-android",
|
||||
"cordova-ios",
|
||||
"debug-dump",
|
||||
"expo",
|
||||
"flutter",
|
||||
"fresh-profile",
|
||||
"from-text",
|
||||
"godot",
|
||||
"profile",
|
||||
"react-native",
|
||||
"start-url",
|
||||
"step-timeout",
|
||||
"store",
|
||||
"unity",
|
||||
"use-chrome-session",
|
||||
"write"
|
||||
],
|
||||
"play build": [
|
||||
"allow-stale-web",
|
||||
"dir",
|
||||
|
|
@ -904,6 +929,7 @@
|
|||
"unity",
|
||||
"update-bigmac",
|
||||
"upload",
|
||||
"url",
|
||||
"use-chrome-session",
|
||||
"variant",
|
||||
"variants",
|
||||
|
|
@ -1082,10 +1108,12 @@
|
|||
],
|
||||
"console plan": [
|
||||
"storebound console plan lists the forms",
|
||||
"storebound console plan <form> [--json] [--lookup]"
|
||||
"storebound console plan <form> [--json] [--lookup]",
|
||||
"storebound console plan play-app-content --all [--json] every App content card's steps inline, one owner list"
|
||||
],
|
||||
"console verify": [
|
||||
"storebound console verify <form> [--from-text <file|->] [--json]"
|
||||
"storebound console verify <form> [--from-text <file|->] [--json]",
|
||||
"storebound console verify play-app-record --url <url after Create app> [--write]"
|
||||
],
|
||||
"desktop": [
|
||||
"storebound desktop open the app; in an app folder (storebound.config.*) it adds or selects that",
|
||||
|
|
@ -1132,6 +1160,10 @@
|
|||
"originality": [
|
||||
"storebound originality [--against <folder>[,<folder>]] [--threshold 0.7] [--json]"
|
||||
],
|
||||
"play app-id": [
|
||||
"storebound play app-id [--json] the id when known, else the agent's steps",
|
||||
"storebound play app-id --from-text <file|-> [--write] [--json] find it in captured app list pages"
|
||||
],
|
||||
"play build": [
|
||||
"storebound play build [--version-code N] [--version-name 1.2.0] [--flavor prod] [--dir android] [--module app]"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -90,11 +90,13 @@ export function formMeta(id: FormId, config: ShipConfig): FormMeta {
|
|||
'Free or paid is an owner check: a free app can never become paid later on Google Play.',
|
||||
'The Developer Program Policies and US export laws boxes are agreements: storebound never ticks them. Read and tick them yourself, then press Create app.',
|
||||
] };
|
||||
case 'play-content-rating': return { id, store: 'play', title: 'Play Console: Content rating (IARC)', url: PLAY, stopBefore: 'Save', fillable: true,
|
||||
case 'play-content-rating': return { id, store: 'play', title: 'Play Console: Content rating (IARC)', url: PLAY, stopBefore: 'Send changes for review', fillable: true,
|
||||
open: [pickApp, 'Policy > App content > Content ratings > Start (app-content/content-rating-overview > Start questionnaire)'],
|
||||
notes: ['The IARC Terms of Use box on page 1 is an agreement: storebound never ticks it.', 'The final button on the Summary (per-authority ratings) is "Save", not "Submit" (verified 2026-10-12): it is the owner\'s.'] };
|
||||
case 'play-target-audience': return { id, store: 'play', title: 'Play Console: Target audience and content', url: PLAY, stopBefore: 'Save', fillable: true,
|
||||
open: [pickApp, 'Policy > App content > Target audience and content > Start (or Manage); the Ads declaration is App content > Ads'] };
|
||||
notes: ['The IARC Terms of Use box on page 1 is an agreement: storebound never ticks it. Tick it before the run (page 1, under the email address and the category): Next does nothing until it is ticked (verified 2026-10-13).',
|
||||
'The Summary\'s button is "Save", not "Submit" (verified 2026-10-12). storebound presses it: it stores the rating declaration, which Google gets only with the next "Send changes for review" in Publishing overview (yours).'] };
|
||||
case 'play-target-audience': return { id, store: 'play', title: 'Play Console: Target audience and content', url: PLAY, stopBefore: 'Send changes for review', fillable: true,
|
||||
open: [pickApp, 'Policy > App content > Target audience and content > Start (or Manage); the Ads declaration is App content > Ads'],
|
||||
notes: ['storebound presses the last page\'s Save: it stores the declaration, which Google gets only with the next "Send changes for review" in Publishing overview (yours).'] };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -424,7 +426,10 @@ export function guidance(meta: FormMeta, answers: Answer[]): string[] {
|
|||
g.push(...meta.open.map(o => `Open: ${o}`));
|
||||
for (const a of answers) g.push(`${a.label}: ${a.agreement ? 'you tick it (an agreement; storebound never does)' : valueText(a)}${a.certain && !a.ownerCheck ? '' : ` (check: ${a.note ?? 'not in the config'})`}`);
|
||||
g.push(...(meta.notes ?? []).map(n => `Note: ${n}`));
|
||||
g.push(meta.fillable ? `Review every field, then press ${meta.stopBefore} yourself. ${meta.id === 'apple-app-privacy' ? 'storebound saves the answers only as a draft (Save in each dialog); nothing is public before Publish.' : 'storebound never saves, submits or publishes.'}` : 'Answers marked "check" are yours to confirm in storebound.config.ts (apple.info.ageRating).');
|
||||
const declaration = meta.id === 'play-content-rating' || meta.id === 'play-target-audience';
|
||||
g.push(!meta.fillable ? 'Answers marked "check" are yours to confirm in storebound.config.ts (apple.info.ageRating).'
|
||||
: declaration ? `storebound presses the declaration's Save itself (nothing goes to Google before it is sent for review). Once every App content card is done, press ${meta.stopBefore} in Publishing overview yourself.`
|
||||
: `Review every field, then press ${meta.stopBefore} yourself. ${meta.id === 'apple-app-privacy' ? 'storebound saves the answers only as a draft (Save in each dialog); nothing is public before Publish.' : 'storebound never saves, submits or publishes.'}`);
|
||||
return g;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ export interface FieldDef {
|
|||
options?: Record<string, Name[]>;
|
||||
/** An agreement checkbox: located and highlighted, NEVER ticked (whatever the answer says). */
|
||||
agreement?: boolean;
|
||||
/** An agreement the page's Next waits for (the IARC Terms of Use, verified 2026-10-13): the flow waits until the owner
|
||||
* ticked it instead of pressing a Next that does nothing. */
|
||||
blocksNext?: boolean;
|
||||
/** radio: the options count only inside the named group (no group on the page: the field is not there). The IARC
|
||||
* follow-ups, whose Yes / No options would otherwise match every question of the page. */
|
||||
scoped?: boolean;
|
||||
|
|
@ -157,7 +160,7 @@ export function formDef(id: FormId, answers: Answer[]): FormDef {
|
|||
{ title: 'Email address and category', fields: [
|
||||
{ id: 'email', kind: 'text', names: ['Email address', 'Email'] },
|
||||
{ id: 'category', kind: 'radio', options: { 'Game': [/^Game\b/i], 'Social or communication': [/^Social/i], 'All other app types': [/^All other app types/i, /^Utility/i] } },
|
||||
{ id: 'agree:iarcTerms', kind: 'checkbox', agreement: true, names: [/IARC Terms of Use/i, /terms of use/i] },
|
||||
{ id: 'agree:iarcTerms', kind: 'checkbox', agreement: true, blocksNext: true, names: [/IARC Terms of Use/i, /terms of use/i] },
|
||||
] },
|
||||
{ title: 'Questionnaire', fields: answers.filter(a => a.id.startsWith('q:')).map(a => ({ id: a.id, kind: 'radio' as const, names: IARC_NAMES[a.id.slice(2)], options: yesNo() })) },
|
||||
// follow-ups a Yes reveals on the same page (and Miscellaneous): optional, filled when they appear
|
||||
|
|
@ -191,9 +194,42 @@ const FORBIDDEN = new RegExp(`(?<![${L}])(?:(?:${EN.map(esc).join('|')})(?![${L}
|
|||
/** Controls never pressed by storebound or by an agent following `console plan`, whatever the form: help panels and
|
||||
* the final buttons (the agent-facing never-press list is built from this and FORM_BUTTONS). */
|
||||
export const NEVER_PRESS = ['Publish', 'Submit', 'Create', 'Create app', 'Send', 'Release', 'Definitions and Examples', '?', 'More information'];
|
||||
/** Play Console's publishing buttons (Publishing overview, verified 2026-10-12): in every Play plan's never-press list. */
|
||||
export const PLAY_NEVER_PRESS = ['Send changes for review', 'Submit N changes for review', 'Restart review'];
|
||||
|
||||
/** true when storebound must not click a control with this accessible name. */
|
||||
export const forbiddenControl = (name: string): boolean => FORBIDDEN.test(name.toLocaleLowerCase('tr').normalize('NFC')) || FORBIDDEN.test(name.toLowerCase());
|
||||
/** true when storebound must not click a control with this accessible name (field clicks: absolute; the scoped
|
||||
* exceptions are navAllowed, createAppAllowed, setUpAllowed, flowAllowed and declarationSaveAllowed). */
|
||||
export const forbiddenControl = (name: string): boolean => FORBIDDEN.test(name.toLocaleLowerCase('tr').normalize('NFC')) || FORBIDDEN.test(name.toLowerCase())
|
||||
|| publishingControl(name);
|
||||
|
||||
/**
|
||||
* Buttons that send something to Google or Apple, publish, or restart a review: never pressed by storebound or an agent,
|
||||
* in any form and by any exception (English and Turkish). Play's "Send changes for review", "Submit N changes for
|
||||
* review" and "Restart review" (Publishing overview; verified 2026-10-12), Submit, Publish, a release or rollout.
|
||||
*/
|
||||
const PUBLISHING = /(?<![\p{L}])(?:(?:send (?:\d+ )?changes? for review|submit|publish|restart (?:the |your )?review|send for review|release|roll ?out)(?![\p{L}])|yayınla|yayımla|gönder|incelemeye|incelemeyi yeniden)/iu;
|
||||
export const publishingControl = (name: string): boolean => PUBLISHING.test(name.normalize('NFC').replace(/\s+/g, ' '));
|
||||
|
||||
/** The exact names of a Play App content card's Save (English and Turkish). */
|
||||
const CARD_SAVE = ['save', 'kaydet'];
|
||||
/** Where a card Save sits: the page URL (an App content card is /app/<appId>/app-content/<card>, not the overview). */
|
||||
export interface CardWhere { url: string }
|
||||
/**
|
||||
* The App content declaration Save (verified 2026-10-13): saving a card (Privacy policy, Ads, Sign in details,
|
||||
* Advertising ID, Government apps, Financial features, Health apps, Target audience, the content rating questionnaire
|
||||
* and its Summary, Data safety) is not public: Play sends it to Google only with the next "Send changes for review" in
|
||||
* Publishing overview, which stays the owner's. So the agent (and the scripted engine) presses it: a button whose whole
|
||||
* name is exactly Save / Kaydet on a Play Console App content card page. Everything that publishes or sends
|
||||
* (publishingControl) stays refused here too.
|
||||
*/
|
||||
export function declarationSaveAllowed(name: string, role: string, where: CardWhere): boolean {
|
||||
const n = name.trim().replace(/\s+/g, ' ').toLocaleLowerCase('tr');
|
||||
if (role !== 'button' || !CARD_SAVE.includes(n) || publishingControl(name)) return false;
|
||||
try {
|
||||
const u = new URL(where.url);
|
||||
return /\/developers\/\d{1,25}\/app\/\d{1,25}\/app-content\/(?!overview(?:\/|$))[a-z-]+/.test(u.pathname);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticking a checkbox / radio never saves or sends anything, so the button word list above does not apply to them (their
|
||||
|
|
@ -395,7 +431,9 @@ export function navAllowed(name: string, role: string): boolean {
|
|||
// Google "Target audience and content" https://support.google.com/googleplay/android-developer/answer/9867159 ;
|
||||
// Google "Create and set up your app" https://support.google.com/googleplay/android-developer/answer/9859152
|
||||
|
||||
export type ButtonClass = 'navigation' | 'draft-save' | 'final-submit';
|
||||
// A fourth class (2026-10-13): declaration-save, an App content card's Save. It stores a declaration that Google gets
|
||||
// only with the next "Send changes for review" (the owner's, in Publishing overview), so the agent presses it.
|
||||
export type ButtonClass = 'navigation' | 'draft-save' | 'declaration-save' | 'final-submit';
|
||||
export interface ButtonRule { button: string; class: ButtonClass; where: string; auto: boolean }
|
||||
|
||||
const NEXT: Name[] = ['Next', 'Continue', 'İleri', 'Sonraki', 'Devam', 'Devam et'];
|
||||
|
|
@ -448,13 +486,15 @@ export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
|
|||
{ button: 'Start / Start questionnaire', class: 'navigation', where: 'App content > Content ratings (content-rating-overview): opens the questionnaire', auto: true },
|
||||
{ button: 'Next', class: 'navigation', where: 'after the category, and from the questions to the Summary', auto: true },
|
||||
{ button: 'Save draft / Save (questionnaire page)', class: 'draft-save', where: 'questionnaire: stores the answers as the one draft of the app; nothing goes to IARC before the Summary\'s Save', auto: true },
|
||||
{ button: 'IARC Terms of Use box', class: 'final-submit', where: 'page 1: an agreement, never ticked by the agent or storebound', auto: false },
|
||||
{ button: 'Save (Summary page)', class: 'final-submit', where: 'Summary (per-authority ratings): sends the questionnaire to IARC and applies the ratings (verified 2026-10-12: "Save", not "Submit")', auto: false },
|
||||
{ button: 'IARC Terms of Use box', class: 'final-submit', where: 'page 1: an agreement, never ticked by the agent or storebound (the owner ticks it before the run: Next does nothing until it is ticked, verified 2026-10-13)', auto: false },
|
||||
{ button: 'Save (Summary page)', class: 'declaration-save', where: 'Summary (per-authority ratings; "Save", not "Submit", verified 2026-10-12): stores the rating declaration; Google gets it only with the next Send changes for review', auto: true },
|
||||
{ button: 'Send changes for review', class: 'final-submit', where: 'Publishing overview, after every App content card: sends the changes to Google (the owner\'s)', auto: false },
|
||||
],
|
||||
'play-target-audience': [
|
||||
{ button: 'Start / Manage', class: 'navigation', where: 'App content: opens the section', auto: true },
|
||||
{ button: 'Next', class: 'navigation', where: 'between Target age, App details, Ads and Store presence', auto: true },
|
||||
{ button: 'Save', class: 'final-submit', where: 'the last page: stores the declaration, which Google then reviews', auto: false },
|
||||
{ button: 'Save', class: 'declaration-save', where: 'the last page: stores the declaration; Google gets it only with the next Send changes for review', auto: true },
|
||||
{ button: 'Send changes for review', class: 'final-submit', where: 'Publishing overview, after every App content card: sends the changes to Google (the owner\'s)', auto: false },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -491,10 +531,14 @@ export function flowClass(form: FormId, name: string, role: string, where: FlowW
|
|||
return null;
|
||||
}
|
||||
if (nameIs(n, SAVE_DRAFT)) return 'draft-save';
|
||||
if (nameIs(n, SAVE) && DECLARATION_SAVE_FORMS.includes(form)) return 'declaration-save';
|
||||
if (nameIs(n, SAVE) || nameIs(n, DONE)) return 'final-submit';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The scripted forms whose Save is an App content declaration save (the agent's: verified 2026-10-13). */
|
||||
export const DECLARATION_SAVE_FORMS: FormId[] = ['play-content-rating', 'play-target-audience'];
|
||||
|
||||
/** The texts that open one data type's questions ("Set Up Coarse Location"; Turkish "<type> ayarla"), for its labels. */
|
||||
export const setUpTexts = (t: string): string[] => typeLabels(t).flatMap(l => [`Set Up ${l}`, `${l} ayarla`]);
|
||||
|
||||
|
|
@ -514,13 +558,17 @@ export function setUpAllowed(text: string, where: FlowWhere): boolean {
|
|||
export const AUTO_DRAFT_SAVE: FormId[] = ['apple-app-privacy', 'play-content-rating'];
|
||||
|
||||
/**
|
||||
* The flow click gate: a navigation control of the form, App Privacy's Save inside a questionnaire dialog, or the
|
||||
* content rating's Save draft (drafts: nothing is public or sent before Publish / Submit). Everything else (Publish,
|
||||
* Submit, Create, a final Save, an unknown control) stays the owner's click.
|
||||
* The flow click gate: a navigation control of the form, App Privacy's Save inside a questionnaire dialog, the
|
||||
* content rating's Save draft (drafts: nothing is public or sent before Publish / Submit), or a Play App content
|
||||
* declaration's Save (content rating Summary, target audience: sent to Google only with the owner's Send changes for
|
||||
* review). Everything else (Publish, Submit, Create, Send changes for review, Restart review, an unknown control)
|
||||
* stays the owner's click.
|
||||
*/
|
||||
export function flowAllowed(form: FormId, name: string, role: string, where: FlowWhere): boolean {
|
||||
const c = flowClass(form, name, role, where);
|
||||
if (publishingControl(name)) return false;
|
||||
if (c === 'navigation') return true;
|
||||
if (c === 'declaration-save') return DECLARATION_SAVE_FORMS.includes(form) && !where.inDialog;
|
||||
if (c !== 'draft-save' || !AUTO_DRAFT_SAVE.includes(form)) return false;
|
||||
return form === 'apple-app-privacy' ? where.inDialog && where.privacyPage : true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
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, startLabel } from './console-play-plans';
|
||||
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms';
|
||||
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';
|
||||
|
||||
|
|
@ -50,10 +50,24 @@ export interface PlanStep {
|
|||
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[];
|
||||
|
|
@ -65,7 +79,8 @@ export interface ConsolePlan {
|
|||
neverPress: string[];
|
||||
/** The final button the owner presses; the agent stops before it and tells the owner. */
|
||||
final: string;
|
||||
/** Buttons the agent may press: navigation and draft saves (nothing public, nothing sent). */
|
||||
/** 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;
|
||||
|
|
@ -168,9 +183,11 @@ 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, type a password, accept an agreement or tick a declaration: the owner does that in the browser.',
|
||||
'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.',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -315,12 +332,14 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
|
|||
case 'apple-app-record':
|
||||
add({ text: 'Open App Store Connect > Apps.' });
|
||||
add({ text: 'Click the add button (+, labelled "Add Apps") at the top left, then "New App" in the menu. The New App dialog opens.', click: ['+', 'New App'] });
|
||||
add({ text: 'Fill the New App dialog: tick iOS, type the Name, choose the Primary Language and the Bundle ID, type the SKU, pick User Access.', answers: all(() => true) });
|
||||
add({ text: '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.', '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.'],
|
||||
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':
|
||||
|
|
@ -337,7 +356,7 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
|
|||
add({ text: 'Choose the Default language; pick App or game and Free or paid. These are <material-radio> elements: click the inner [role=radio] (a DOM click from a script works in a background window), then check that aria-checked is "true" and that the "Select app or game" / "Select free or paid" hint is gone.', answers: all(a => ['language', 'type', 'price'].includes(a.id)) });
|
||||
add({ text: 'Leave the two declarations (Developer Program Policies, US export laws) unticked: they are agreements the owner reads and ticks.', answers: all(a => a.status === 'agreement') });
|
||||
add({ text: 'Stop. Do NOT press Create app: tell the owner to tick the declarations, check Free or paid (a free app can never become paid) and press Create app.' });
|
||||
add({ text: 'After Create app the app opens at /console/u/0/developers/<developer id>/app/<app id>/app-dashboard: put that app id in play.appId (the other Play forms deep-link from it).' });
|
||||
add({ text: '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 {
|
||||
|
|
@ -348,7 +367,7 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
|
|||
'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).',
|
||||
'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,
|
||||
|
|
@ -359,14 +378,14 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
|
|||
case 'play-content-rating': {
|
||||
const section = (sec: string) => all(a => a.id.startsWith(`d:${sec}:`));
|
||||
add({ text: 'Open App content > Content ratings (/app-content/content-rating-overview), 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 ticks it (it is in the batch). Then Next.', click: ['Next'], answers: ['email', 'category', 'agree:iarcTerms'] });
|
||||
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: stop. Do NOT press its Save (the final button here is "Save", not "Submit"): tell the owner the ratings shown and let them press Save (it applies the IARC ratings).' });
|
||||
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,
|
||||
|
|
@ -374,8 +393,9 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
|
|||
'Verified 2026-10-12 (an agent with a browser tool on a real game):',
|
||||
'App content > Content ratings is /app-content/content-rating-overview; "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 final button is "Save" (not "Submit"): the owner\'s.',
|
||||
`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,
|
||||
|
|
@ -389,11 +409,11 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
|
|||
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 App content > Ads, /app-content/ads-declaration): pick whether the app contains ads, if this section shows it.', answers: ['ads'] });
|
||||
add({ text: 'Summary: stop. Do NOT press Save: tell the owner to review and press Save (Google reviews the declaration).' });
|
||||
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); Save there is the final button.', 'Any age group under 13 brings the Families policy.', PLAY_MATERIAL_FACT, PLAY_DASHBOARD_FACT],
|
||||
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.'] },
|
||||
};
|
||||
}
|
||||
|
|
@ -407,36 +427,66 @@ function askOwnerOf(answers: PlanAnswer[], prefix = ''): string[] {
|
|||
.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) => `Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links) and draft saves (${draftSaves.join(', ') || 'none in this form'}). Never press ${final} or anything that publishes, submits, creates, sends or accepts; stop and tell the owner.`;
|
||||
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.`;
|
||||
|
||||
/** The App content overview: every card in the page's order, its page, its plan, and one batch of owner questions. */
|
||||
function appContentPlan(cfg: ShipConfig, p: PlanIds): ConsolePlan {
|
||||
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') : '/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: 'App content > Sign in details > 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}` : ''}` })) : []));
|
||||
add({ text: 'Before the run: ask the owner every question of askOwner in ONE batch and write the answers into storebound.config.ts (rerun this plan: they turn certain).' });
|
||||
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.' });
|
||||
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; then read the card back.` }); continue; }
|
||||
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, final button ${plan.buttons.final}.`, click: [startLabel(card.name)], answers: answers.filter(a => a.id.startsWith(`${plan.form}/`)).map(a => a.id) });
|
||||
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: 'Stop before each card\'s final Save (the content rating Summary\'s Save too): list the filled cards for the owner, who presses them (or says yes in chat for the listed cards; then the agent presses them).' });
|
||||
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 -`.' });
|
||||
const neverPress = [...new Set(cards.flatMap(c => c.plan?.buttons.neverPress ?? []))];
|
||||
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)',
|
||||
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: 'Save (each card; the content rating Summary\'s Save)',
|
||||
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)],
|
||||
rule: ruleText(['Save draft / Save on the IARC questionnaire page', 'Save in Store settings'], 'a card\'s final Save'),
|
||||
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,
|
||||
],
|
||||
|
|
@ -447,14 +497,17 @@ function appContentPlan(cfg: ShipConfig, p: PlanIds): ConsolePlan {
|
|||
};
|
||||
}
|
||||
|
||||
export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {}): ConsolePlan {
|
||||
/** 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);
|
||||
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, 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) },
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -463,17 +516,18 @@ export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {
|
|||
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])];
|
||||
// the content rating's questionnaire Save is a draft save; only the Summary's Save is final (verified 2026-10-12)
|
||||
const final = form === 'play-content-rating' ? 'Save (Summary page)' : meta.stopBefore;
|
||||
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, ...part, answers, askOwner,
|
||||
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),
|
||||
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,
|
||||
|
|
@ -483,7 +537,10 @@ export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {
|
|||
|
||||
/** The plan as text for the terminal. */
|
||||
export function planText(pl: ConsolePlan): string {
|
||||
const L: string[] = [`${pl.title}${pl.fillable ? '' : ' (guide only)'}`, `start: ${pl.start.url}${pl.start.deepLink ? ' (deep link)' : ''}`, ...pl.start.reach.map(r => ` then: ${r}`), '', 'Steps:'];
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
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, playAppList } from './console-forms';
|
||||
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';
|
||||
|
||||
|
|
@ -100,12 +100,20 @@ 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 }));
|
||||
}
|
||||
|
||||
/** A declaration's Save: stores the answer, which Google reviews: the owner's (or the agent's after a clear yes in chat). */
|
||||
const DECLARATION_SAVE = 'Stop before Save: show the owner the filled card and let them press Save (or press it after the owner\'s clear yes in chat; one yes can cover the cards listed by `console plan play-app-content`). Google reviews the declaration with the next submission.';
|
||||
/** 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: 'Save',
|
||||
neverPress: ['Save', 'Send changes for review', ...NEVER_PRESS],
|
||||
allowed: [{ button: 'Start <card> declaration / Manage', class: 'navigation', where: 'App content overview: opens the card' }, ...extra, { button: 'Back', class: 'navigation', where }],
|
||||
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) ----
|
||||
|
|
@ -134,7 +142,7 @@ function storeSettings(config: ShipConfig, p: PlanIds): Part {
|
|||
]),
|
||||
answers: [type, category],
|
||||
final: 'Send changes for review',
|
||||
neverPress: ['Send changes for review', ...NEVER_PRESS],
|
||||
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)' },
|
||||
|
|
@ -209,9 +217,16 @@ function signInDetails(config: ShipConfig, p: PlanIds): Part {
|
|||
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).' }));
|
||||
out.push(ans('credentials', 'Username and password of the demo account', null, 'ask-owner', '.storebound/credentials.json reviewDemoAccount', {
|
||||
control: 'text', where: 'Add instructions: Username, Password',
|
||||
note: `The owner types them from .storebound/credentials.json (reviewDemoAccount: ${p.demoAccount === undefined ? 'not checked' : p.demoAccount ? 'present' : 'MISSING, add it first'}). storebound never prints them; an agent never types a password.` }));
|
||||
// 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)',
|
||||
|
|
@ -219,13 +234,15 @@ function signInDetails(config: ShipConfig, p: PlanIds): Part {
|
|||
steps: steps([
|
||||
{ text: 'Open App content > Sign in details (/app-content/testing-credentials).' },
|
||||
{ text: 'Pick whether any functionality is restricted (material radio: JS click on the inner [role=radio]).', answers: ['access'] },
|
||||
ra?.signInRequired && { text: 'Restricted: press "Add instructions", type the instructions; leave Username and Password to the owner (the demo account in .storebound/credentials.json).', click: ['Add instructions'], answers: ['instructions', 'credentials'] },
|
||||
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 or in chat: the owner types them from .storebound/credentials.json.', PLAY_MATERIAL_FACT],
|
||||
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)'],
|
||||
};
|
||||
|
|
@ -368,7 +385,7 @@ function productionCountries(config: ShipConfig, p: PlanIds): Part {
|
|||
]),
|
||||
answers,
|
||||
final: 'Send changes for review',
|
||||
neverPress: ['Send changes for review', ...NEVER_PRESS],
|
||||
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' },
|
||||
|
|
|
|||
|
|
@ -16,12 +16,15 @@ import { type Answer, consoleAnswers, type FormId, type PlanFormId, type PlanOnl
|
|||
import { APP_CONTENT_CARDS, playOnlyPart, startLabel } from './console-play-plans';
|
||||
import { APPLE_PRIVACY_TYPES } from './app-privacy';
|
||||
import { typeLabels } from './console-forms';
|
||||
import { playAppIdInText } from './play-app-id';
|
||||
import { APPLE_PURPOSES } from './console-answers';
|
||||
|
||||
export type RowStatus = 'match' | 'mismatch' | 'not-found' | 'ask-owner';
|
||||
export interface VerifyRow { item: string; expected: string; seen: string; status: RowStatus; note?: string }
|
||||
export interface VerifyResult {
|
||||
form: PlanFormId;
|
||||
/** play-app-record: the Play Console app id read from the URL after Create app (/app/<appId>/...). */
|
||||
playAppId?: string;
|
||||
source: 'page-text' | 'inputs' | 'api';
|
||||
rows: VerifyRow[];
|
||||
mismatches: number;
|
||||
|
|
@ -143,6 +146,8 @@ export interface InputStates {
|
|||
checked?: string[];
|
||||
/** The visible page text (for "Select …" hints and availability messages). */
|
||||
text?: string;
|
||||
/** location.href (after Create app: /app/<appId>/app-dashboard). */
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/** The capture snippet an agent runs in the page for `console verify play-app-record --from-text -` (prints JSON). */
|
||||
|
|
@ -150,7 +155,7 @@ export const PLAY_CREATE_CAPTURE = `(() => { const t = e => (e?.innerText ?? e?.
|
|||
const name = e => e.getAttribute('aria-label') || t(e.labels?.[0]) || (e.getAttribute('aria-labelledby') || '').split(/\\s+/).map(id => t(document.getElementById(id))).join(' ').trim() || t(e.closest('label')) || t(e.parentElement);
|
||||
const fields = {}; for (const i of document.querySelectorAll('input:not([type]), input[type=text], textarea')) if (i.offsetParent) fields[name(i)] = i.value;
|
||||
const checked = [...document.querySelectorAll('[role=radio][aria-checked=true], input[type=radio]:checked')].map(r => name(r) || t(r.closest('material-radio')));
|
||||
return JSON.stringify({ fields, checked, text: (document.querySelector('main') ?? document.body).innerText }); })()`;
|
||||
return JSON.stringify({ url: location.href, fields, checked, text: (document.querySelector('main') ?? document.body).innerText }); })()`;
|
||||
|
||||
export function parseInputStates(raw: string): InputStates | null {
|
||||
const s = raw.trim();
|
||||
|
|
@ -159,7 +164,7 @@ export function parseInputStates(raw: string): InputStates | null {
|
|||
const j = JSON.parse(s) as Record<string, unknown>;
|
||||
const fields = j.fields && typeof j.fields === 'object' ? Object.fromEntries(Object.entries(j.fields as Record<string, unknown>).filter(([, v]) => typeof v === 'string')) as Record<string, string> : undefined;
|
||||
const checked = Array.isArray(j.checked) ? j.checked.filter((x): x is string => typeof x === 'string') : undefined;
|
||||
return { ...(fields ? { fields } : {}), ...(checked ? { checked } : {}), ...(typeof j.text === 'string' ? { text: j.text } : {}) };
|
||||
return { ...(fields ? { fields } : {}), ...(checked ? { checked } : {}), ...(typeof j.text === 'string' ? { text: j.text } : {}), ...(typeof j.url === 'string' ? { url: j.url } : {}) };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +174,16 @@ export const SELECT_HINT = /^select (app or game|free or paid|an? .+)$/i;
|
|||
export function verifyPlayAppRecord(config: ShipConfig, raw: string): VerifyResult {
|
||||
const answers = consoleAnswers('play-app-record', config).filter(a => !a.agreement);
|
||||
const st = parseInputStates(raw);
|
||||
// after Create app (verified 2026-10-12 / 2026-10-13): the console redirects to /app/<appId>/app-dashboard; the form
|
||||
// is gone, so only the app id is checked (and handed to the play.appId upsert)
|
||||
const appId = playAppIdInText(st?.url ?? raw);
|
||||
const formShown = st ? Object.keys(st.fields ?? {}).length > 0 : /\b(App name|App package name|Uygulama adı)\b/.test(raw);
|
||||
if (appId && !formShown) {
|
||||
const want = config.play?.appId;
|
||||
const row: VerifyRow = { item: 'Play app id (the URL after Create app)', expected: want ?? '(not in the config yet: play.appId)', seen: appId,
|
||||
status: want && want !== appId ? 'mismatch' : 'match', ...(want && want !== appId ? { note: 'play.appId differs: --write replaces it' } : {}) };
|
||||
return { ...result('play-app-record', st ? 'inputs' : 'page-text', [row], ['The Create app form is gone: the app was created. --write saves the id into play.appId.']), playAppId: appId };
|
||||
}
|
||||
const text = st?.text ?? (st ? '' : raw);
|
||||
const ls = lines(text);
|
||||
const rows: VerifyRow[] = [];
|
||||
|
|
@ -332,7 +347,7 @@ export function verifyPlayDeclaration(form: Exclude<PlanOnlyFormId, 'play-app-co
|
|||
const blob = fold(text);
|
||||
const rows: VerifyRow[] = [];
|
||||
for (const a of part.answers) {
|
||||
if (a.status === 'agreement') continue;
|
||||
if (a.status === 'agreement' || a.id === 'credentials') continue; // the demo account is never read back or printed
|
||||
if (a.status === 'ask-owner' || a.value === null) { rows.push({ item: a.question, expected: '(owner decides)', seen: '-', status: 'ask-owner' }); continue; }
|
||||
const want = a.action === 'type' || a.action === 'choose' ? String(a.value) : a.label ?? '';
|
||||
const plain = !want || /^(yes|no)$/i.test(want) || a.action === 'untick';
|
||||
|
|
|
|||
122
cli/lib/play-app-id.ts
Normal file
122
cli/lib/play-app-id.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// The Play Console app id (play.appId: the digits in /console/u/0/developers/<developerId>/app/<appId>/...), which
|
||||
// deep-links every Play console plan. The Play Developer API does not return it, and the console's app list shows 10
|
||||
// apps per page with a new app not on page 1 (verified 2026-10-13), so finding it by hand took several steps. Two
|
||||
// ways, both agent steps: (1) right after Create app the console redirects to /app/<appId>/app-dashboard: `console
|
||||
// verify play-app-record --url <url>` reads it there; (2) `storebound play app-id`: the agent captures the app list
|
||||
// pages (the search box or ?page=) and storebound finds the row with the package name. Either way the change to
|
||||
// play.appId is shown as a dry diff first and written with --write (an upsert). Pure, except writePlayAppId.
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import type { ShipConfig } from './config';
|
||||
import { upsertNestedString } from './scan/config-edit';
|
||||
|
||||
const PLAY = 'https://play.google.com/console';
|
||||
const ID = /^\d{1,25}$/;
|
||||
|
||||
/** The app id in a Play Console URL or in any text that holds one (a capture JSON, page text): the first
|
||||
* /developers/<digits>/app/<digits>. null when there is none. */
|
||||
export function playAppIdInText(text: string): string | null {
|
||||
return /\/developers\/\d{1,25}\/app\/(\d{1,25})(?=[/?#"'\s]|$)/.exec(text)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** One captured page of the app list (APP_LIST_CAPTURE). */
|
||||
export interface AppListPage {
|
||||
url?: string;
|
||||
/** Each app row: its visible text (name, package name, status) and the href of its link. */
|
||||
rows: { text: string; href: string }[];
|
||||
/** The list has a next page (its "Next page" control is there and enabled). */
|
||||
hasNext?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run in the app list page (one evaluate call): the rows with an /app/<id>/ link and their row text, and whether a next
|
||||
* page exists. Modeled on the verified URL pattern and a paginated list of 10; the row markup was not recorded, so it
|
||||
* takes the nearest row-like ancestor of each app link.
|
||||
*/
|
||||
export const APP_LIST_CAPTURE = `JSON.stringify((() => {
|
||||
const t = e => (e?.innerText ?? e?.textContent ?? '').replace(/\\s+/g, ' ').trim();
|
||||
const rows = [], seen = new Set();
|
||||
for (const a of document.querySelectorAll('a[href*="/app/"]')) {
|
||||
const href = a.getAttribute('href') || '';
|
||||
if (!/\\/developers\\/\\d+\\/app\\/\\d+/.test(href) || seen.has(href)) continue;
|
||||
seen.add(href);
|
||||
const row = a.closest('[role=row], tr, li, [role=listitem]') || a.parentElement;
|
||||
rows.push({ text: t(row), href });
|
||||
}
|
||||
const next = [...document.querySelectorAll('button, a, [role=button]')].find(b => /^next( page)?$/i.test((b.getAttribute('aria-label') || t(b)).trim()));
|
||||
return { url: location.href, rows, hasNext: !!next && !next.disabled && next.getAttribute('aria-disabled') !== 'true' };
|
||||
})())`;
|
||||
|
||||
/** One capture, a JSON array of captures, or one capture per line. Throws on anything else. */
|
||||
export function parseAppListPages(raw: string): AppListPage[] {
|
||||
const s = raw.trim();
|
||||
const one = (j: unknown): AppListPage => {
|
||||
const o = j as Record<string, unknown>;
|
||||
if (!o || typeof o !== 'object' || !Array.isArray(o.rows)) throw new Error('play app-id: a capture needs "rows" (run the APP_LIST_CAPTURE snippet in the app list)');
|
||||
const rows = (o.rows as unknown[]).flatMap(r => {
|
||||
const x = r as Record<string, unknown>;
|
||||
return x && typeof x.text === 'string' && typeof x.href === 'string' ? [{ text: x.text, href: x.href }] : [];
|
||||
});
|
||||
return { rows, ...(typeof o.url === 'string' ? { url: o.url } : {}), ...(typeof o.hasNext === 'boolean' ? { hasNext: o.hasNext } : {}) };
|
||||
};
|
||||
try {
|
||||
const j = JSON.parse(s) as unknown;
|
||||
return Array.isArray(j) ? j.map(one) : [one(j)];
|
||||
} catch (e) {
|
||||
if (!(e instanceof SyntaxError)) throw e;
|
||||
return s.split(/\r?\n/).filter(l => l.trim()).map(l => one(JSON.parse(l)));
|
||||
}
|
||||
}
|
||||
|
||||
/** `id` as a whole token of `text` (com.example.game, not com.example.game2). */
|
||||
const hasToken = (text: string, id: string): boolean => new RegExp(`(?<![\\w.])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w.])`).test(text);
|
||||
|
||||
export type AppIdFind = { found: true; appId: string; page: number; row: string } | { found: false; pages: number; hasNext: boolean; ambiguous?: string[] };
|
||||
|
||||
/** The row whose text holds the package name as a whole token, across the captured pages (page numbers from 1). */
|
||||
export function findPlayAppId(pages: AppListPage[], packageName: string): AppIdFind {
|
||||
const hits: { appId: string; page: number; row: string }[] = [];
|
||||
pages.forEach((p, i) => {
|
||||
for (const r of p.rows) {
|
||||
const id = playAppIdInText(r.href.startsWith('/') ? r.href : (() => { try { return new URL(r.href).pathname; } catch { return r.href; } })());
|
||||
if (id && hasToken(r.text, packageName) && !hits.some(h => h.appId === id)) hits.push({ appId: id, page: i + 1, row: r.text });
|
||||
}
|
||||
});
|
||||
if (hits.length === 1) return { found: true, ...hits[0]! };
|
||||
return { found: false, pages: pages.length, hasNext: pages.at(-1)?.hasNext ?? false, ...(hits.length > 1 ? { ambiguous: hits.map(h => h.appId) } : {}) };
|
||||
}
|
||||
|
||||
/** The app list of a developer account, page `page` (1 = the first; the `page` query parameter is modeled: prefer the
|
||||
* list's own next-page control when it differs). */
|
||||
export const appListPage = (developerId: string, page = 1): string =>
|
||||
`${PLAY}/u/0/developers/${encodeURIComponent(developerId)}/app-list${page > 1 ? `?page=${page}` : ''}`;
|
||||
|
||||
/** The steps an agent follows to find the app id in the console (read-only). */
|
||||
export function appIdSteps(config: ShipConfig, developerId: string | null): string[] {
|
||||
const pkg = config.play?.packageName ?? '<play.packageName>';
|
||||
const list = developerId ? appListPage(developerId) : `${PLAY} (pick the developer account; set play.developerId for a deep link)`;
|
||||
return [
|
||||
`Open the app list: ${list}.`,
|
||||
`Fastest: type the package name (${pkg}) into the list's search box ("Search apps") and wait for the list to update; the one row left is the app.`,
|
||||
'Run the capture snippet (capture) in the page and pipe its JSON to `storebound play app-id --from-text -` (it finds the row with the package name and reads the id from its /app/<id>/ link).',
|
||||
`Not found and the capture says hasNext: press the list's next-page control (aria-label "Next page"; the URL pattern is /app-list?page=<n>, 10 apps per page, new apps are not on page 1), capture again, and pipe ALL captures (a JSON array or one per line) to the same command.`,
|
||||
'Found: the command shows the play.appId change as a dry diff; run it again with --write to save it into storebound.config.ts.',
|
||||
];
|
||||
}
|
||||
|
||||
/** What --write would change: play.appId from -> to. */
|
||||
export interface AppIdChange { from: string | null; to: string; changed: boolean }
|
||||
export const appIdChange = (config: ShipConfig, id: string): AppIdChange => ({ from: config.play?.appId ?? null, to: id, changed: config.play?.appId !== id });
|
||||
|
||||
/** The dry diff line. */
|
||||
export const appIdDiff = (c: AppIdChange, file: string): string => c.changed
|
||||
? `${file}: play.appId ${c.from ? `'${c.from}'` : '(not set)'} -> '${c.to}'`
|
||||
: `${file}: play.appId is already '${c.to}' (nothing to change)`;
|
||||
|
||||
/** Writes play.appId into the config file (an upsert); false when it was already that id. */
|
||||
export function writePlayAppId(configPath: string, config: ShipConfig, id: string): boolean {
|
||||
if (!ID.test(id)) throw new Error(`play.appId: "${id}" is not a Play Console app id (digits)`);
|
||||
if (!config.play) throw new Error('the config has no play block: add play.packageName first');
|
||||
if (!appIdChange(config, id).changed) return false;
|
||||
writeFileSync(configPath, upsertNestedString(readFileSync(configPath, 'utf8'), configPath, 'play', 'appId', id));
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// Writes one top-level block (dataSafety, appPrivacy) into storebound.config.ts / .js / .json text: replaces the
|
||||
// existing `key: { ... }` property, or inserts it after the `app: { ... }` block. Pure text in, text out; the rest of
|
||||
// the file (comments, order, formatting) stays as it is. Used by `storebound scan --write` only.
|
||||
// the file (comments, order, formatting) stays as it is. Used by `storebound scan --write`; upsertNestedString sets one
|
||||
// nested string (play.appId: `console verify play-app-record --write`, `play app-id --write`).
|
||||
|
||||
/** Index of the brace that closes the one at `open`, skipping strings, template literals and comments. -1 if none. */
|
||||
export function matchBrace(text: string, open: number): number {
|
||||
|
|
@ -69,3 +70,63 @@ export function upsertBlock(text: string, file: string, key: string, value: unkn
|
|||
const block = `\n${comment ? `${ind}// ${comment}\n` : ''}${ind}${key}: ${toTs(value, ind)},`;
|
||||
return text.slice(0, at) + block + text.slice(at);
|
||||
}
|
||||
|
||||
/**
|
||||
* The config text with `parent.key` set to a string `value` (an upsert: `play.appId`, used by `console verify
|
||||
* play-app-record --write` and `play app-id --write`): JSON is parsed and written back; in .ts / .js the `key: '…'`
|
||||
* inside the top-level `parent: { … }` block is replaced, or `key: '<value>',` is added right after its opening brace.
|
||||
* Throws when the block is missing or the key holds something other than a string literal. The rest of the file
|
||||
* (comments, order, formatting) stays as it is.
|
||||
*/
|
||||
export function upsertNestedString(text: string, file: string, parent: string, key: string, value: string): string {
|
||||
if (file.endsWith('.json')) {
|
||||
const j = JSON.parse(text) as Record<string, unknown>;
|
||||
const p = j[parent];
|
||||
if (!p || typeof p !== 'object' || Array.isArray(p)) throw new Error(`${file}: no ${parent} block to put ${parent}.${key} into`);
|
||||
(p as Record<string, unknown>)[key] = value;
|
||||
return JSON.stringify(j, null, 2) + '\n';
|
||||
}
|
||||
// the top-level block: the `parent: {` with the smallest indent
|
||||
const re = new RegExp(`^([ \\t]*)${parent}\\s*:\\s*\\{`, 'gm');
|
||||
let best: RegExpExecArray | null = null;
|
||||
for (let m = re.exec(text); m; m = re.exec(text)) if (!best || m[1]!.length < best[1]!.length) best = m;
|
||||
// a one-line config (`export default { play: { … } }`): the first `parent: {` anywhere
|
||||
best ??= new RegExp(`()\\b${parent}\\s*:\\s*\\{`).exec(text);
|
||||
if (!best) throw new Error(`${file}: no ${parent} block to put ${parent}.${key} into; add ${key}: '${value}' to ${parent} in the config file`);
|
||||
const open = best.index + best[0].length - 1;
|
||||
const close = matchBrace(text, open);
|
||||
if (close < 0) throw new Error(`${file}: could not find the end of the ${parent} block`);
|
||||
const body = text.slice(open + 1, close);
|
||||
const lit = toTs(value);
|
||||
const keyRe = new RegExp(`(^|[{,\\s])${key}\\s*:\\s*`, 'g');
|
||||
for (let k = keyRe.exec(body); k; k = keyRe.exec(body)) {
|
||||
if (depthAt(body, k.index + k[1]!.length) !== 0) continue; // a key of a nested object
|
||||
const valStart = k.index + k[0].length;
|
||||
const q = body[valStart];
|
||||
if (q !== '\'' && q !== '"' && q !== '`') throw new Error(`${file}: ${parent}.${key} is not a plain string; set it to '${value}' in the config file`);
|
||||
let end = valStart + 1;
|
||||
for (; end < body.length && body[end] !== q; end++) if (body[end] === '\\') end++;
|
||||
const abs = open + 1;
|
||||
return text.slice(0, abs + valStart) + lit + text.slice(abs + end + 1);
|
||||
}
|
||||
if (!body.includes('\n')) {
|
||||
const inner = body.trim().replace(/,$/, '');
|
||||
return `${text.slice(0, open + 1)} ${inner ? `${inner}, ` : ''}${key}: ${lit} ${text.slice(close)}`;
|
||||
}
|
||||
const ind = /\n([ \t]*)\S/.exec(body)?.[1] ?? `${best[1]} `;
|
||||
return `${text.slice(0, open + 1)}\n${ind}${key}: ${lit},${body}${text.slice(close)}`;
|
||||
}
|
||||
|
||||
/** Bracket depth at `at` in `s` (strings and comments skipped). */
|
||||
function depthAt(s: string, at: number): number {
|
||||
let d = 0;
|
||||
for (let i = 0; i < at; i++) {
|
||||
const ch = s[i]!;
|
||||
if (ch === '/' && s[i + 1] === '/') { const n = s.indexOf('\n', i); if (n < 0 || n >= at) return d; i = n; continue; }
|
||||
if (ch === '/' && s[i + 1] === '*') { const n = s.indexOf('*/', i + 2); if (n < 0 || n >= at) return d; i = n + 1; continue; }
|
||||
if (ch === '"' || ch === '\'' || ch === '`') { for (i++; i < at && s[i] !== ch; i++) if (s[i] === '\\') i++; continue; }
|
||||
if (ch === '{' || ch === '[' || ch === '(') d++;
|
||||
else if (ch === '}' || ch === ']' || ch === ')') d--;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -396,9 +396,10 @@ export const TOOLS: ToolDef[] = [
|
|||
description: 'Upload the event card (16:9, 1920x1080 to 3840x2160) and details page (9:16) images of one draft in-app event per locale, size-checked first.',
|
||||
props: { name: { type: 'string', description: 'Reference name from apple.events' } }, required: ['name'], args: i => opt('name', i.name) },
|
||||
{ name: 'console_plan', title: 'Console form plan (agent-first)', command: ['console', 'plan'], write: false, network: false,
|
||||
description: 'Read-only plan for filling a console form that has no API with YOUR OWN browser tool (Claude in Chrome, a built-in browser, Playwright): start URL (deep link when the ids are known), ordered steps (what to click by visible text, what to answer), the answer sheet from the config (each answer certain or ask-owner, with the console\'s exact labels, curly quotes noted), the buttons never to press (Publish, Submit, Create, agreements, help panels), known UI facts per form (Play: material radios and checkboxes take a JS click on the inner [role=radio] / [role=checkbox]), a ready-made in-page helper (inPageLoop: one evaluate call times out around 45 s, so long flows run in the page and you poll window.__log) and how to verify. Forms: the App Store and Play forms of `console fill`, plus agent-only Play pages: play-store-settings (the App category, which the Play API cannot set), play-app-content (every App content card in order with its page and one batch of owner questions) and its cards play-privacy-policy, play-ads, play-sign-in-details, play-advertising-id, play-government-apps, play-financial-features, play-health-apps; and the first production release: play-production-countries (the API cannot set them) and play-production-release (its final Send changes for review is the owner\'s). Drive the page yourself, stop before the final button and tell the owner what to press, then call console_verify.',
|
||||
props: { form: { type: 'string', enum: CONSOLE_PLAN_FORMS } },
|
||||
required: ['form'], args: i => [String(i.form), '--json'] },
|
||||
description: 'Read-only plan for filling a console form that has no API with YOUR OWN browser tool (Claude in Chrome, a built-in browser, Playwright): start URL (deep link when the ids are known), ordered steps (what to click by visible text, what to answer), the answer sheet from the config (each answer certain or ask-owner, with the console\'s exact labels, curly quotes noted), the buttons never to press (Publish, Submit, Create, agreements, help panels), known UI facts per form (Play: material radios and checkboxes take a JS click on the inner [role=radio] / [role=checkbox]), a ready-made in-page helper (inPageLoop: one evaluate call times out around 45 s, so long flows run in the page and you poll window.__log) and how to verify. Forms: the App Store and Play forms of `console fill`, plus agent-only Play pages: play-store-settings (the App category, which the Play API cannot set), play-app-content (every App content card in order with its page and one owner batch; all: true inlines every card) and its cards play-privacy-policy, play-ads, play-sign-in-details, play-advertising-id, play-government-apps, play-financial-features, play-health-apps; and the first production release: play-production-countries (the API cannot set them) and play-production-release (its final Send changes for review is the owner\'s). Drive the page yourself, stop before the final button and tell the owner what to press, then call console_verify.',
|
||||
props: { form: { type: 'string', enum: CONSOLE_PLAN_FORMS },
|
||||
all: { type: 'boolean', description: 'play-app-content only: every card\'s steps inline in fix order, ending with the one owner list (tick the IARC Terms before, Send changes for review after)' } },
|
||||
required: ['form'], args: i => [String(i.form), ...(i.all === true ? ['--all'] : []), '--json'] },
|
||||
{ name: 'console_verify', title: 'Verify a console form', command: ['console', 'verify'], write: false, network: true,
|
||||
description: 'Read-only check of a filled console form against the config. Pass the page text you read (App Privacy: the App Privacy page\'s innerText, whose Data Types section shows per type "Used for …", linked and tracking; Play Create app: the JSON of the plan\'s capture snippet; Play content rating / target audience: the summary page text; play-app-content: the JSON of the plan\'s capture snippet; Play store settings and the other App content cards: the page text after Save). apple-app-record and apple-age-rating without text read the App Store Connect API. Returns a per-item match / mismatch table; exit code 1 on a mismatch.',
|
||||
props: { form: { type: 'string', enum: CONSOLE_PLAN_FORMS },
|
||||
|
|
|
|||
64
cli/play/app-id.ts
Normal file
64
cli/play/app-id.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// The Play Console app id of this app (play.appId), found by package name. Read-only on Play Console.
|
||||
// storebound play app-id [--json] the id when known, else the agent's steps
|
||||
// storebound play app-id --from-text <file|-> [--write] [--json] find it in captured app list pages
|
||||
// Known: play.appId, or the id storebound remembered when a Play form was open (~/.storebound/console-accounts.json).
|
||||
// Otherwise it is an agent step: the agent opens the developer account's app list (10 apps per page, a new app is not
|
||||
// on page 1; verified 2026-10-13), searches the package name or walks the pages, runs the capture snippet and pipes
|
||||
// the captures here; storebound finds the row with the package name and reads the id from its /app/<id>/ link. The
|
||||
// play.appId change is shown as a dry diff; --write saves it into storebound.config.ts (an upsert). Right after
|
||||
// Create app, `storebound console verify play-app-record --url <url> --write` takes it from the redirect instead.
|
||||
// Pure logic in cli/lib/play-app-id.ts.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { log, printJson } from '../lib/log';
|
||||
import { APP_LIST_CAPTURE, appIdChange, appIdDiff, appIdSteps, findPlayAppId, parseAppListPages, writePlayAppId } from '../lib/play-app-id';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, path } = ctx.loaded;
|
||||
const args = ctx.args, json = args.has('json');
|
||||
const pkg = config.play?.packageName;
|
||||
if (!pkg) throw new Error('play app-id: play.packageName is not set');
|
||||
const from = args.get('from-text');
|
||||
const { readAccounts, withAccountIds } = await import('../console/accounts');
|
||||
const cfg = withAccountIds(config);
|
||||
const cached = readAccounts().play[pkg];
|
||||
const dev = cfg.play?.developerId ?? cached?.developerId ?? null;
|
||||
const file = basename(path || 'storebound.config.ts');
|
||||
if (from === undefined) {
|
||||
const known = config.play?.appId ?? cached?.appId ?? null;
|
||||
const source = config.play?.appId ? 'play.appId' : cached?.appId ? 'remembered from an open Play form (~/.storebound/console-accounts.json)' : null;
|
||||
const change = known && !config.play?.appId ? appIdChange(config, known) : null;
|
||||
if (json) return printJson({ packageName: pkg, appId: known, source, steps: known ? [] : appIdSteps(cfg, dev), capture: APP_LIST_CAPTURE, ...(change ? { change } : {}) });
|
||||
if (known) {
|
||||
console.log(`${pkg}: Play Console app id ${known} (${source})`);
|
||||
if (change) console.log(`${appIdDiff(change, file)} (dry: add it with \`storebound play app-id --from-text\` and --write, or put appId: '${known}' into play)`);
|
||||
return;
|
||||
}
|
||||
console.log(`${pkg}: the Play Console app id is not known yet. Find it in the console (read-only), then pipe the captures here:\n`);
|
||||
appIdSteps(cfg, dev).forEach((s, i) => console.log(` ${i + 1}. ${s}`));
|
||||
console.log(`\nCapture snippet (one evaluate call in the app list page):\n${APP_LIST_CAPTURE}`);
|
||||
return;
|
||||
}
|
||||
const raw = from === '-' || from === 'true' ? await rt.stdinText() : readFileSync(from, 'utf8');
|
||||
const pages = parseAppListPages(raw);
|
||||
const r = findPlayAppId(pages, pkg);
|
||||
if (!r.found) {
|
||||
const why = r.ambiguous ? `several rows hold ${pkg} (app ids ${r.ambiguous.join(', ')}): search the exact package name and capture again`
|
||||
: r.hasNext ? `not on the ${r.pages} captured page(s) and the list has a next page: press "Next page", capture again and pipe every capture (a JSON array or one per line)`
|
||||
: `not on the ${r.pages} captured page(s), and the list has no next page: is this the developer account that has the app? (search ${pkg} in the list's search box)`;
|
||||
if (json) printJson({ packageName: pkg, appId: null, ...r, next: why }); else console.log(`${pkg}: ${why}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const change = appIdChange(config, r.appId);
|
||||
const write = args.has('write') && !args.has('dry');
|
||||
const wrote = write && path ? writePlayAppId(path, config, r.appId) : false;
|
||||
if (json) return printJson({ packageName: pkg, appId: r.appId, page: r.page, change, written: wrote });
|
||||
console.log(`${pkg}: Play Console app id ${r.appId} (page ${r.page} of the captures)`);
|
||||
console.log(appIdDiff(change, file));
|
||||
if (!change.changed) return;
|
||||
if (wrote) log('play', `wrote play.appId '${r.appId}' into ${file}`);
|
||||
else console.log('dry: run the same command with --write to save it');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue