console: agent-first mode (console plan / console verify), verified App Privacy and Play Create app facts
- `storebound console plan <form> [--json] [--lookup]`: read-only plan for an AI agent that drives the browser itself: start URL (deep links), ordered steps, answer sheet with the console's exact labels (certain / ask owner, curly quotes noted), never-press list, verified UI facts, driving guidance (in-page async loop + window.__log), verify instructions. All six forms. - `storebound console verify <form> [--from-text <file|->]`: App Privacy per-type summaries from page text, Play Create app from input states, content rating / target audience summaries, App Store record and age rating from the API; exit 1 on a mismatch. - MCP: console_plan, console_verify (read-only); console fill stays off MCP. - App Privacy (verified 2026-10-12): two information steps, unlabeled purpose checkboxes (nearest parent text), "Definitions and Examples" never pressed; fixture and engine follow. - Play Create app (verified 2026-10-12): /developers/<id>/create-new-app, App package name + read-only Check availability, material-radio inner [role=radio] click, app dashboard URL and play.appId, service account "Add app" as an owner step. - console-autofill skill rewritten agent-first; agents-core rule 10; README; pitfalls 12, 18 (confirmed sign-out), 19; CHANGELOG; play-first-release.
This commit is contained in:
parent
a9261a8052
commit
c9a7f095c4
34 changed files with 1430 additions and 113 deletions
|
|
@ -24,12 +24,12 @@ import { homedir } from 'node:os';
|
|||
import { dirname, join } from 'node:path';
|
||||
import type { ShipConfig } from '../lib/config';
|
||||
import type { FormId } from '../lib/console-answers';
|
||||
import { hasToken, type Name, NAV_BASES, type NavBases, type NavStep, navPlan, playDeveloperId, switchAllowed } from '../lib/console-forms';
|
||||
import { hasToken, type Name, NAV_BASES, type NavBases, type NavStep, navPlan, playAppIdOf, playDeveloperId, switchAllowed } from '../lib/console-forms';
|
||||
import { ACTION_MS, accName, findAny, type Locator, navClick, navigate, type NavResult, type Page, poll, realRole, safeClick, signInPage, SignInSeen, type Stop, vis } from './fill';
|
||||
|
||||
// ---- the cache ----
|
||||
|
||||
export interface PlayEntry { developerId: string; account?: string; at: string }
|
||||
export interface PlayEntry { developerId: string; account?: string; /** The Play Console app id (/app/<appId>/), read from the URL once the app is open. */ appId?: string; at: string }
|
||||
export interface AppleEntry { team: string; teamId?: string; at: string }
|
||||
export interface AccountsCache { version: 1; play: Record<string, PlayEntry>; apple: Record<string, AppleEntry> }
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ export function readAccounts(file = accountsFile()): AccountsCache {
|
|||
let j: any;
|
||||
try { j = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null; } catch { return out; }
|
||||
for (const [k, v] of Object.entries((j?.play ?? {}) as Record<string, any>)) {
|
||||
if (KEY.test(k) && typeof v?.developerId === 'string' && /^\d{1,25}$/.test(v.developerId)) out.play[k] = { developerId: v.developerId, ...(str(v.account) ? { account: v.account } : {}), at: str(v.at, 40) ? v.at : '' };
|
||||
if (KEY.test(k) && typeof v?.developerId === 'string' && /^\d{1,25}$/.test(v.developerId)) out.play[k] = { developerId: v.developerId, ...(str(v.account) ? { account: v.account } : {}), ...(typeof v.appId === 'string' && /^\d{1,25}$/.test(v.appId) ? { appId: v.appId } : {}), at: str(v.at, 40) ? v.at : '' };
|
||||
}
|
||||
for (const [k, v] of Object.entries((j?.apple ?? {}) as Record<string, any>)) {
|
||||
if (KEY.test(k) && str(v?.team)) out.apple[k] = { team: v.team, ...(typeof v.teamId === 'string' && /^[A-Z0-9]{10}$/.test(v.teamId) ? { teamId: v.teamId } : {}), at: str(v.at, 40) ? v.at : '' };
|
||||
|
|
@ -55,7 +55,9 @@ export function readAccounts(file = accountsFile()): AccountsCache {
|
|||
/** Remembers which account has the app (written atomically: the desktop app may read it at the same time). */
|
||||
export function rememberPlay(pkg: string, e: Omit<PlayEntry, 'at'>, file = accountsFile()): void {
|
||||
const c = readAccounts(file);
|
||||
c.play[pkg] = { ...e, at: new Date().toISOString() };
|
||||
// the app id stays when the same account is remembered again without one
|
||||
const keep = c.play[pkg]?.developerId === e.developerId && c.play[pkg]?.appId && !e.appId ? { appId: c.play[pkg]!.appId! } : {};
|
||||
c.play[pkg] = { ...e, ...keep, at: new Date().toISOString() };
|
||||
write(c, file);
|
||||
}
|
||||
export function rememberApple(bundleId: string, e: Omit<AppleEntry, 'at'>, file = accountsFile()): void {
|
||||
|
|
@ -281,7 +283,18 @@ function notHere(r: NavResult, plan: NavStep[]): boolean {
|
|||
* `signIn` (the caller waits and calls again).
|
||||
*/
|
||||
export async function openAcross(page: Page, o: AcrossOpts): Promise<{ nav: NavResult; plan: NavStep[] }> {
|
||||
try { return await across(page, o); } catch (e) {
|
||||
try {
|
||||
const r = await across(page, o);
|
||||
// a Play form of an existing app is open: its URL carries the Play Console app id (/app/<appId>/), remembered
|
||||
// next to the developer id so `console plan` can deep-link the App content forms
|
||||
const pkg = o.config.play?.packageName;
|
||||
if (r.nav.ok && pkg && o.form.startsWith('play-') && o.form !== 'play-app-record') {
|
||||
const dev = playDeveloperId(page.url()), app = playAppIdOf(page.url());
|
||||
const f = o.file ?? accountsFile(), account = readAccounts(f).play[pkg]?.account;
|
||||
if (dev && app) rememberPlay(pkg, { developerId: dev, ...(account ? { account } : {}), appId: app }, f);
|
||||
}
|
||||
return r;
|
||||
} catch (e) {
|
||||
if (e instanceof SignInSeen) return { nav: { ok: false, steps: [], failed: 'the account list', why: 'a sign-in page appeared', signIn: true }, plan: [] };
|
||||
throw e;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,37 @@ async function find(root: Locator, role: string, names: Name[], label = false):
|
|||
const f = await first(tries);
|
||||
if (f.state !== 'none') return f;
|
||||
}
|
||||
return role === 'checkbox' || role === 'radio' ? byParentText(root, role, names) : { state: 'none' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearest non-empty texts around controls that has no accessible name of its own (no aria-label, aria-labelledby
|
||||
* or associated <label>): its parents are walked up until one has text. App Privacy's purpose checkboxes are like
|
||||
* that (verified October 2026): the label and its help text sit in a sibling, run together ("Third-Party
|
||||
* AdvertisingSuch as displaying ..."). '' when the control has a name (getByRole already saw it). Read-only.
|
||||
*/
|
||||
export const PARENT_TEXTS = (els: any[]): string[] => els.map((el: any) => {
|
||||
const norm = (t: string | null | undefined) => (t ?? '').trim().replace(/\s+/g, ' ');
|
||||
if (norm(el.getAttribute('aria-label'))) return '';
|
||||
const by = el.getAttribute('aria-labelledby');
|
||||
if (by && norm(by.split(/\s+/).map((id: string) => el.ownerDocument.getElementById(id)?.textContent ?? '').join(' '))) return '';
|
||||
if (el.labels && [...el.labels].some((l: any) => norm(l.textContent))) return '';
|
||||
for (let a = el.parentElement; a && a !== el.ownerDocument.body; a = a.parentElement) { const t = norm(a.textContent); if (t) return t; }
|
||||
return '';
|
||||
});
|
||||
|
||||
/** A checkbox / radio without an accessible name, matched by its nearest non-empty parent text (PARENT_TEXTS; one
|
||||
* page call for all of them). */
|
||||
async function byParentText(root: Locator, role: string, names: Name[]): Promise<Found> {
|
||||
const all = vis(root.locator(role === 'checkbox' ? 'input[type="checkbox"], [role="checkbox"]' : 'input[type="radio"], [role="radio"]'));
|
||||
const texts: string[] = await all.evaluateAll(PARENT_TEXTS).catch(() => []);
|
||||
if (!texts.some(Boolean)) return { state: 'none' };
|
||||
for (const name of names) {
|
||||
const re = typeof name === 'string' ? quoteTolerant(name) : name;
|
||||
const hits = texts.flatMap((t, i) => (t && re.test(t) ? [i] : []));
|
||||
if (hits.length === 1) return { state: 'ok', loc: all.nth(hits[0]!) };
|
||||
if (hits.length > 1) return { state: 'ambiguous' };
|
||||
}
|
||||
return { state: 'none' };
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +140,7 @@ export async function accName(loc: Locator): Promise<string> {
|
|||
const ref = by ? by.split(/\s+/).map((id: string) => el.ownerDocument.getElementById(id)?.textContent ?? '').join(' ') : '';
|
||||
const labels = el.labels ? [...el.labels].map((l: any) => l.textContent).join(' ') : '';
|
||||
return el.getAttribute('aria-label') || ref || labels || el.textContent || '';
|
||||
})).trim().replace(/\s+/g, ' ');
|
||||
})).trim().replace(/\s+/g, ' ') || ((await loc.evaluateAll(PARENT_TEXTS).catch(() => [])) as string[])[0] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -153,7 +184,11 @@ async function setField(r: Resolved, v: string | boolean): Promise<boolean> {
|
|||
if (f.kind === 'checkbox') return safeClick(r.loc, await accName(r.loc), v ? 'check' : 'uncheck');
|
||||
if (f.kind === 'radio') {
|
||||
const o = r.options?.[String(v)];
|
||||
return o ? safeClick(o, await accName(o), 'check') : false;
|
||||
if (!o) return false;
|
||||
const name = await accName(o);
|
||||
if (forbiddenTick(name)) return safeClick(o, name, 'check');
|
||||
try { if (!await safeClick(o, name, 'check')) return false; } catch (e) { if (!await inMaterialRadio(o)) throw e; }
|
||||
return materialRadio(o);
|
||||
}
|
||||
// select: only a native <select>; a custom dropdown is left to the owner (highlighted)
|
||||
const idx: number = await r.loc.evaluate((el: any, want: string) => {
|
||||
|
|
@ -174,6 +209,25 @@ async function setField(r: Resolved, v: string | boolean): Promise<boolean> {
|
|||
return true;
|
||||
}
|
||||
|
||||
/** The radio sits in Play Console's <material-radio> (or is a [role=radio] itself). */
|
||||
const inMaterialRadio = (o: Locator): Promise<boolean> => o.evaluate((el: any) => !!el.closest('material-radio')).catch(() => false);
|
||||
|
||||
/**
|
||||
* Play Console's <material-radio> (verified 2026-10-12 on Create app): setting the native input, or a click that does
|
||||
* not reach the right element (a background window), leaves the Angular model empty and the form keeps showing
|
||||
* "Select app or game". Only a click on the inner [role=radio] updates the model, so it gets a DOM click when it is not
|
||||
* aria-checked yet. Other radios are left as they are. true when the radio is checked afterwards.
|
||||
*/
|
||||
export async function materialRadio(o: Locator): Promise<boolean> {
|
||||
return o.evaluate((el: any) => {
|
||||
const host = el.closest('material-radio');
|
||||
const inner = el.getAttribute('role') === 'radio' ? el : host?.querySelector('[role="radio"]');
|
||||
if (!inner) return el.checked !== false;
|
||||
if (inner.getAttribute('aria-checked') !== 'true') inner.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
||||
return inner.getAttribute('aria-checked') === 'true' || el.checked === true;
|
||||
}).catch(() => false);
|
||||
}
|
||||
|
||||
async function mark(r: Resolved, text = CHECK): Promise<void> {
|
||||
if (r.loc) return highlight(r.loc, text);
|
||||
for (const o of Object.values(r.options ?? {})) await highlight(o, text);
|
||||
|
|
@ -1211,6 +1265,14 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
progress(`the next part after "${s.title}"`);
|
||||
if (o.debugEveryStep) await dump(p, `filled ${s.title}`);
|
||||
say(`${s.title}: filled ${s.filled.length}, check ${s.highlighted.length}${s.highlighted.length ? `: ${s.highlighted.map(id => labels.get(id) ?? id).join('; ')}` : ''}`);
|
||||
// Play Create app: the package name's read-only "Check availability" (verified 2026-10-12)
|
||||
if (form === 'play-app-record' && s.filled.includes('packageName')) {
|
||||
const check = await controlWhere(p, FLOW_NAMES.checkAvailability, () => true, dataTypes).catch(() => null);
|
||||
if (check && await flowClick(check, form, dataTypes, say).catch(() => false)) {
|
||||
const ok = await poll(5000, async () => ((await vis(p.getByText(/package name (is )?available/i)).count()) > 0 ? true : null)).catch(() => null);
|
||||
say(ok ? 'App package name: "Package name available"' : 'App package name: Play Console did not say "Package name available": check the message next to the field');
|
||||
}
|
||||
}
|
||||
anchor = s.anchor; filledAt = s.section;
|
||||
// the last page of a Play / App Store form without a Next ends the run: its button is the owner's (Save, Submit, Create)
|
||||
const ends = !privacy && s.section === last && !await nextControl(p).catch(() => null);
|
||||
|
|
|
|||
39
cli/console/plan.ts
Normal file
39
cli/console/plan.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// 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]
|
||||
// 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,
|
||||
// ~/.storebound/console-accounts.json) and the workspace's account ids; --lookup also asks the App Store Connect API
|
||||
// for the app id by bundle id (a GET; needs the API key). Pure logic in cli/lib/console-plan.ts. MCP: console_plan.
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { FORM_IDS, formMeta, isForm } from '../lib/console-answers';
|
||||
import { consolePlan, type PlanIds, planText } from '../lib/console-plan';
|
||||
import { printJson } from '../lib/log';
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const args = ctx.args, json = args.has('json');
|
||||
const form = args._[0];
|
||||
if (!form) {
|
||||
const forms = FORM_IDS.map(id => { const m = formMeta(id, config); return { id, store: m.store, title: m.title, fillable: m.fillable }; });
|
||||
if (json) return printJson({ forms });
|
||||
console.log('storebound console plan <form> [--json] [--lookup] (then drive the page yourself; `storebound console verify <form>` checks it)\n');
|
||||
for (const f of forms) console.log(` ${f.id.padEnd(22)} ${f.store.padEnd(6)} ${f.title}${f.fillable ? '' : ' (guide only)'}`);
|
||||
return;
|
||||
}
|
||||
if (!isForm(form)) throw new Error(`console plan: unknown form "${form}" (one of ${FORM_IDS.join(', ')})`);
|
||||
const { withAccountIds, readAccounts } = await import('./accounts');
|
||||
const cfg = withAccountIds(config);
|
||||
const pkg = cfg.play?.packageName;
|
||||
const cached = pkg ? readAccounts().play[pkg] : undefined;
|
||||
const ids: PlanIds = { playDeveloperId: cfg.play?.developerId ?? cached?.developerId ?? null, playAppId: cfg.play?.appId ?? cached?.appId ?? null };
|
||||
if (form.startsWith('apple-') && !cfg.apple?.appId && cfg.apple?.bundleId) {
|
||||
const { appleAppId } = await import('./app-id');
|
||||
// without --lookup only the cache is read: the lookup refuses, so no request is made
|
||||
ids.appleAppId = await appleAppId(root, cfg, { say: () => { /* the plan's start says what it found */ }, ...(args.has('lookup') ? {} : { lookup: async () => { throw new Error('offline (add --lookup to ask the API)'); } }) });
|
||||
}
|
||||
const plan = consolePlan(form, cfg, ids);
|
||||
if (json) return printJson(plan);
|
||||
console.log(planText(plan));
|
||||
}
|
||||
47
cli/console/verify.ts
Normal file
47
cli/console/verify.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// 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]
|
||||
// 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. 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.
|
||||
// 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 { FORM_IDS, isForm } from '../lib/console-answers';
|
||||
import { verifyAgeRatingApi, verifyAppRecordApi, type VerifyResult, verifyTable, verifyText } from '../lib/console-verify';
|
||||
import { printJson } from '../lib/log';
|
||||
|
||||
async function readText(src: string): Promise<string> {
|
||||
if (src === '-' || src === 'true') return Bun.stdin.text();
|
||||
return readFileSync(src, 'utf8');
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const args = ctx.args, json = args.has('json');
|
||||
const form = args._[0];
|
||||
if (!isForm(form)) throw new Error(`console verify <form> [--from-text <file|->]: one of ${FORM_IDS.join(', ')}`);
|
||||
const from = args.get('from-text'), inline = args.get('text');
|
||||
let r: VerifyResult;
|
||||
if (from !== undefined || inline !== undefined) {
|
||||
const text = inline ?? await readText(from!);
|
||||
if (!text.trim()) throw new Error('console verify: the page text is empty');
|
||||
r = verifyText(form, config, text);
|
||||
} else if (form === 'apple-app-record' || form === 'apple-age-rating') {
|
||||
const bundleId = config.apple?.bundleId;
|
||||
if (!bundleId) throw new Error('console verify: apple.bundleId is not set');
|
||||
const { ascClient } = await import('../apple/asc');
|
||||
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||
if (form === 'apple-app-record') {
|
||||
const found = await c.asc('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}`);
|
||||
const hit = (found.data as { attributes: { bundleId: string; name?: string; sku?: string; primaryLocale?: string } }[]).find(a => a.attributes.bundleId === bundleId);
|
||||
r = verifyAppRecordApi(config, hit?.attributes ?? null);
|
||||
} else {
|
||||
const info = await c.editableAppInfo();
|
||||
const age = await c.maybe(`/v1/appInfos/${info.id}/ageRatingDeclaration`);
|
||||
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));
|
||||
if (!r.ok) process.exitCode = 1;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue