// AI-assisted autofill of console forms that have no API, in a visible Chromium the owner signs in to: // storebound console fill lists the forms // storebound console fill
--dry [--json] the answers and the step-by-step guidance; opens no browser // storebound console fill [--store apple|play] [--json] [--profile ] // Forms: apple-app-record, apple-age-rating (guide only: `storebound apple appinfo` sets it), apple-app-privacy, // play-app-record (Play Console's Create app form: the Play Developer API cannot create apps), play-content-rating, // play-target-audience. Answers come from the config only (cli/lib/console-answers.ts); anything // it does not prove stays EMPTY and highlighted (AGENTS.md rule 5). // Safety: storebound never types passwords, never touches sign-in pages (it waits while the owner signs in), never reads // cookies or storage, and never clicks Save / Submit / Publish / Create / Next: every click goes through safeClick and // its gate. After sign-in it opens the form itself (navPlan in cli/lib/console-forms.ts: Apps > + > New App, the app's // App Privacy page, Play app list > the app > App content > Start / Manage); those clicks go through navClick, which // applies the same gate (only a link named exactly Start / Manage / Başla / Yönet is let through). A sign-in page at // any point (signInReason: the top-level URL, the main frame's form, an Apple sign-in frame; never a Google widget // frame) -> storebound waits for the owner, then starts again at step 1. A step not // found in time -> the printed steps, and storebound keeps watching (fillSession). With several Play developer // accounts or App Store Connect teams behind one login, the account that has the app is found and remembered // (cli/console/accounts.ts). The owner reviews each page, clicks Next, and presses the // final button; the command ends when the window is closed. Locators are accessible names only // (cli/lib/console-forms.ts); a missing or ambiguous field means the UI changed: filling stops and the guidance is // printed instead. Not exposed over MCP. // Browser (cli/console/session.ts): the owner's running Chrome (--cdp / remote debugging), else a temporary copy of only // their Chrome sign-in cookies (macOS; deleted at the end), else storebound's own profile // (~/.storebound/console-profile/, or --profile / STOREBOUND_CONSOLE_PROFILE / --fresh-profile). // Sources (checked October 2026): see cli/lib/console-answers.ts; Playwright launchPersistentContext, getByRole, // getByLabel: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context , // https://playwright.dev/docs/locators import { homedir } from 'node:os'; import { join } from 'node:path'; 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 { 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, forbiddenTick, hasToken, isAppleSignInFrame, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan } from '../lib/console-forms'; import { log, printJson, warn } from '../lib/log'; import { openAcross, withAccountIds } from './accounts'; import { appleAppId, withAppleAppId } from './app-id'; import { bareUrl } from './debug'; import { type ConsoleBrowser, openConsoleBrowser } from './session'; /* eslint-disable @typescript-eslint/no-explicit-any */ export type Page = any; export type Locator = any; const TAG = 'console'; /** The longest any single click or check may take (Playwright waits for the control to be enabled and stable). */ export const ACTION_MS = 10_000; const CHECK = 'storebound: check this'; /** Locators with only the visible matches (hidden earlier pages stay in many consoles' DOM). */ export const vis = (l: Locator): Locator => { try { return l.filter({ visible: true }); } catch { return l; } }; const nameOpts = (n: Name) => (typeof n === 'string' ? { name: n, exact: true } : { name: n }); type Found = { state: 'ok'; loc: Locator } | { state: 'none' | 'ambiguous' }; async function first(tries: Locator[]): Promise { for (const t of tries) { const l = vis(t); const n = await l.count(); if (n === 1) return { state: 'ok', loc: l }; if (n > 1) return { state: 'ambiguous' }; } return { state: 'none' }; } async function find(root: Locator, role: string, names: Name[], label = false): Promise { for (const n of names) { const tries = [root.getByRole(role, nameOpts(n))]; if (label) tries.push(root.getByLabel(n, typeof n === 'string' ? { exact: true } : {})); const f = await first(tries); if (f.state !== 'none') return f; } return { state: 'none' }; } interface Resolved { field: FieldDef; ok: boolean; seen: boolean; loc?: Locator; options?: Record } async function resolve(root: Locator, f: FieldDef): Promise { if (f.kind !== 'radio') { const role = f.kind === 'text' ? 'textbox' : f.kind === 'select' ? 'combobox' : 'checkbox'; const r = await find(root, role, f.names ?? [], f.kind !== 'checkbox'); return { field: f, ok: r.state === 'ok', seen: r.state !== 'none', ...(r.state === 'ok' ? { loc: r.loc } : {}) }; } let scope = root, seen = false; if (f.names?.length) { let g = await find(root, 'radiogroup', f.names); if (g.state === 'none') g = await find(root, 'group', f.names); if (g.state === 'ambiguous') return { field: f, ok: false, seen: true }; // App Store Connect labels "User Access" with a plain heading, not an ARIA group: then the options are looked up // in the whole dialog (their own names, e.g. "Full Access", are unique there) if (g.state === 'ok') { scope = g.loc; seen = true; } } const options: Record = {}; let ok = true; for (const [v, names] of Object.entries(f.options ?? {})) { const r = await find(scope, 'radio', names); if (r.state === 'ok') { options[v] = r.loc; seen = true; } else { ok = false; if (r.state === 'ambiguous') seen = true; } } // loc = the group when the page has one; without it (plain heading) each option is highlighted on its own return { field: f, ok, seen, ...(scope !== root ? { loc: scope } : {}), options }; } /** The accessible name of a control as the page labels it (what the click gate checks). */ export async function accName(loc: Locator): Promise { return (await loc.evaluate((el: any) => { const by = el.getAttribute('aria-labelledby'); 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, ' '); } /** * The single click gate: refuses any control whose name says save, submit, publish, create, next ... (English and * Turkish). Every click storebound makes, checkboxes and radio buttons included, goes through here. */ export async function safeClick(loc: Locator, name: string, action: 'click' | 'check' | 'uncheck' = 'click'): Promise { // buttons and links: the full word gate; ticks (checkbox / radio): only agreements are refused, help text is not a button label if (action === 'click' ? forbiddenControl(name) : forbiddenTick(name)) { warn(TAG, action === 'click' ? `not clicking "${name}": storebound never presses save, submit, publish, next or similar` : `not ticking "${name}": agreements are yours to accept`); return false; } if (action === 'click') await loc.click({ timeout: ACTION_MS }); else await loc.setChecked(action === 'check', { timeout: ACTION_MS }); return true; } /** Outline + a small "storebound: check this" label next to the element (no SVG). */ export async function highlight(loc: Locator, text = CHECK): Promise { await loc.evaluate((el: any, t: string) => { const box = (el.tagName === 'INPUT' && el.closest('label')) || el; if (box.getAttribute('data-storebound-check')) return; box.setAttribute('data-storebound-check', '1'); // the owner answering it (a click, a choice, typing) is what the flow waits for before it moves on const answered = () => { box.setAttribute('data-storebound-answered', '1'); el.setAttribute('data-storebound-answered', '1'); }; el.addEventListener('change', answered); el.addEventListener('input', answered); box.style.outline = '3px solid #d93025'; box.style.outlineOffset = '2px'; const s = el.ownerDocument.createElement('span'); s.textContent = t; s.setAttribute('data-storebound-label', '1'); s.style.cssText = 'display:inline-block;margin:2px 6px;padding:1px 6px;border-radius:4px;background:#d93025;color:#fff;font:600 12px/18px system-ui,sans-serif'; box.insertAdjacentElement('afterend', s); }, text); } async function setField(r: Resolved, v: string | boolean): Promise { const f = r.field; if (f.kind === 'text') { await r.loc.fill(String(v)); return true; } 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; } // select: only a native