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:
CRTkafa 2026-10-13 05:43:56 +08:00
parent 1366e7e231
commit f741c15829
29 changed files with 1029 additions and 165 deletions

View file

@ -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;

View file

@ -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));
}

View file

@ -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;
}