storebound/cli/lib/console-forms.ts

613 lines
45 KiB
TypeScript

// Where each answer of a console form lives on the page, as accessible names only (role + name / label), never CSS
// paths: `storebound console fill` locates every field of a section first and falls back to printed guidance when one
// is missing or ambiguous (the console UI changed). Several candidate names per field absorb small wording drift.
// Also the click gate (no Save / Submit / Publish / Next ... for field clicks), its scoped exceptions for moving
// through a form (navigation, App Privacy's draft Save: FORM_BUTTONS) and the sign-in page detector. Pure.
// The names are modeled on Apple's and Google's public help pages (sources in console-answers.ts), not recorded from a
// signed-in console: the exact App Privacy dialog titles, the IARC question wording and the Play category names could
// not be verified without an account, which is why every field has candidates and the engine falls back on a miss.
import { consoleLink } from './console-links';
import { AGE_GROUPS, appleName, IARC_DETAIL_SECTIONS, type ShipConfig } from './config';
import { AGE_LABELS, type Answer, APPLE_PURPOSES, type FormId, iarcSectionName } from './console-answers';
/** An accessible name: a string matches exactly, a RegExp matches the name. */
export type Name = string | RegExp;
export interface FieldDef {
/** Answer id. */
id: string;
kind: 'text' | 'select' | 'checkbox' | 'radio';
/** text / select / checkbox: candidate names of the control. radio: candidate names of the radiogroup (optional). */
names?: Name[];
/** radio: answer value (String(value)) -> candidate names of that radio button. */
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;
}
export interface SectionDef {
/** Shown to the owner ("page 2/3: Questionnaire"). */
title: string;
/** Candidate names of the dialog the section lives in; it is present when that dialog is visible. */
dialog?: Name[];
/** A later page of a multi-page dialog: not on the page yet (none of its fields seen) is not a UI change. */
page?: boolean;
/** App Privacy: the data type this section belongs to (its dialog opens from the Data Types section). */
type?: string;
/** Shown only after an answer reveals it (the IARC follow-ups): never needed to finish the form. */
optional?: boolean;
fields: FieldDef[];
}
/** Candidate group names of the IARC yes / no follow-ups (modeled from the section structure recorded 2026-10-12). */
const DETAIL_NAMES: Record<string, Name[]> = {
childlike: [/(cartoon|childlike|child-like)/i], historicalWar: [/historical|\bwar\b/i], innocents: [/innocent|defen[cs]eless/i], fierceSounds: [/sound/i],
playerTrading: [/trad(e|ing)/i], block: [/\bblock/i], report: [/\breport/i], moderation: [/moderat/i], friendsOnly: [/friend/i],
naziSymbols: [/nazi/i], koreaHistory: [/korea/i], terrorism: [/terror/i], crimes: [/crime/i], setting: [/setting/i], blood: [/blood/i],
};
const NEVER: Name[] = [/^\u0000$/];
/** The field of one IARC follow-up answer (d:<section>:<key>[:<label>] or d:<section>:other:<question>[:<label>]). */
function detailField(a: Answer): FieldDef {
const [, , key = '', ...rest] = a.id.split(':');
const tail = rest.join(':');
if (key === 'humans') return { id: a.id, kind: 'checkbox', names: [/^(violence )?(against )?humans?\b/i] };
if (key === 'nonHumans') return { id: a.id, kind: 'checkbox', names: [/non-?humans?/i] };
if (key === 'followUps' || ((key === 'reactions' || key === 'presentation') && !tail)) return { id: a.id, kind: 'checkbox', names: NEVER };
if (key === 'reactions' || key === 'presentation') return { id: a.id, kind: 'checkbox', names: labelNames(tail) };
if (key === 'other') {
const [q = '', ...l] = rest, label = l.join(':');
if (label) return { id: a.id, kind: 'checkbox', names: labelNames(label) };
const group: Name[] = [new RegExp(`^${esc(q)}`, 'i')];
return typeof a.value === 'string' ? { id: a.id, kind: 'radio', scoped: true, names: group, options: { [a.value]: labelNames(a.value) } } : { id: a.id, kind: 'radio', scoped: true, names: group, options: yesNo() };
}
if (key === 'setting' || key === 'blood') return { id: a.id, kind: 'radio', scoped: true, names: DETAIL_NAMES[key], options: typeof a.value === 'string' ? { [a.value]: labelNames(a.value) } : { unknown: NEVER } };
return { id: a.id, kind: 'radio', scoped: true, names: DETAIL_NAMES[key] ?? NEVER, options: yesNo() };
}
export interface FormDef { id: FormId; sections: SectionDef[] }
// Console labels use typographic quotes ("Developer’s Advertising", "the user’s identity"): every pattern built from a
// label accepts straight and curly quotes alike.
const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/['\u2018\u2019]/g, "['\u2018\u2019]").replace(/["\u201c\u201d]/g, '["\u201c\u201d]');
/** A label as an exact-match pattern that tolerates straight vs curly quotes (for names that contain a quote). */
export const quoteTolerant = (s: string): RegExp => new RegExp(`^${esc(s)}$`);
const yesNo = (yes: Name[] = ['Yes'], no: Name[] = ['No']): Record<string, Name[]> => ({ true: yes, false: no });
/** IARC topics -> candidate radiogroup names (modeled; see the header). */
const IARC_NAMES: Record<string, Name[]> = {
violence: [/^Does the app contain (any )?violen/i, /violen/i],
fear: [/^Does the app contain .*(scary|fear|horror)/i, /(fear|horror)/i],
sexuality: [/^Does the app contain .*(sexual|nudity)/i, /(sexual|nudity)/i],
language: [/^Does the app contain .*(profan|language)/i, /profanit/i],
controlledSubstances: [/^Does the app contain .*(drug|alcohol|tobacco|controlled substance)/i, /controlled substance/i],
crudeHumor: [/crude humou?r/i],
gambling: [/^Does the app (allow|let) .*gambl.*real (money|currency)/i, /real[- ]money gambling/i],
simulatedGambling: [/simulated gambling/i],
lootBoxes: [/(random|randomi[sz]ed) (paid )?items/i, /loot box/i],
userInteraction: [/(users|players) .*(interact|communicate|exchange)/i],
sharesLocation: [/share .*(physical )?location/i],
digitalPurchases: [/digital (goods|purchases)/i, /purchase digital/i],
unrestrictedInternet: [/unrestricted (internet|web)/i, /web browser/i],
};
/**
* App Store Connect's labels where they differ from the names of Apple's help page (App Privacy > Data Collection,
* read from a real console in October 2026): "Purchases" for Purchase History, "Other Data" for Other Data Types.
*/
export const TYPE_ALIASES: Record<string, string[]> = { 'Purchase History': ['Purchases'], 'Other Data Types': ['Other Data'] };
/** A data type's name and the console's other labels for it ("Purchase History", "Purchases"). */
export const typeLabels = (t: string): string[] => [t, ...(TYPE_ALIASES[t] ?? [])];
/**
* Candidate names of a checkbox labelled `label`: the exact name, then the name that STARTS with the label and goes on
* with its help text without a separator (the real Data Collection dialog: "NameIncluding first or last name",
* "Email AddressIncluding but not limited to ..."). The label must end where the help text starts (no lowercase
* letter follows), so "Contact" never matches "Contacts..."; two labels on one checkbox are told apart by find()
* (ambiguous) and by the exact names first.
*/
export function labelNames(label: string, aliases: string[] = []): Name[] {
const all = [label, ...aliases];
return [...all, ...all.map(l => new RegExp(`^${esc(l)}(?![a-z\u00e0-\u024f])`))];
}
/** Field definitions for the answers of one form (App Privacy has one section per collected data type). */
export function formDef(id: FormId, answers: Answer[]): FormDef {
const has = (aid: string) => answers.some(a => a.id === aid);
switch (id) {
case 'apple-app-record': return { id, sections: [{ title: 'New App', fields: [
{ id: 'platform:ios', kind: 'checkbox', names: ['iOS'] },
{ id: 'name', kind: 'text', names: ['Name', 'App Name'] },
{ id: 'language', kind: 'select', names: ['Primary Language', 'Primary language'] },
{ id: 'bundleId', kind: 'select', names: ['Bundle ID', 'Bundle Id'] },
{ id: 'sku', kind: 'text', names: ['SKU'] },
{ id: 'access', kind: 'radio', names: ['User Access'], options: { limited: ['Limited Access'], full: ['Full Access'] } },
] }] };
case 'apple-age-rating': return { id, sections: [] };
case 'play-app-record': return { id, sections: [{ title: 'Create app', fields: [
{ id: 'name', kind: 'text', names: ['App name', 'Uygulama adı'] },
{ id: 'packageName', kind: 'text', names: ['App package name', 'Package name', 'Uygulama paket adı', 'Paket adı'] },
{ id: 'language', kind: 'select', names: ['Default language', 'Varsayılan dil'] },
{ id: 'type', kind: 'radio', names: ['App or game', 'Uygulama veya oyun'], options: { App: ['App', 'Uygulama'], Game: ['Game', 'Oyun'] } },
{ id: 'price', kind: 'radio', names: ['Free or paid', 'Ücretsiz veya ücretli'], options: { Free: ['Free', 'Ücretsiz'], Paid: ['Paid', 'Ücretli'] } },
{ id: 'agree:policies', kind: 'checkbox', agreement: true, names: [/Developer Program Polic/i, /Geliştirici Program(ı)? Politika/iu] },
{ id: 'agree:exportLaws', kind: 'checkbox', agreement: true, names: [/export laws/i, /ihracat yasa/iu] },
] }] };
case 'apple-app-privacy': {
const sections: SectionDef[] = [{ title: 'Data collection', fields: [
{ id: 'collects', kind: 'radio', options: yesNo([/^Yes, we collect data/i], [/^No, we do not collect data/i]) },
] }];
const types = answers.filter(a => a.id.startsWith('type:'));
if (types.length) sections.push({ title: 'Data types', fields: types.map(a => ({ id: a.id, kind: 'checkbox', names: labelNames(a.id.slice(5), TYPE_ALIASES[a.id.slice(5)]) })) });
// one data type's dialog (opened by "Set Up <type>" in the Data Types section): purposes, linked to the user,
// tracking; one step each (Next between, Save at the end) or all on one page. Every part is a `page`: a dialog step
// with none of its fields is an unknown step (the watchdog dumps it and pauses), never a reason to stop the run
for (const a of types.filter(t => t.value === true && has(`linked:${t.id.slice(5)}`))) {
const n = a.id.slice(5);
const dialog = typeLabels(n).flatMap(l => [l, new RegExp(`^${esc(l)}\\b`, 'i')]);
sections.push({ title: `${n}: purposes`, dialog, type: n, page: true, fields: APPLE_PURPOSES.map(p => ({ id: `purpose:${n}:${p}`, kind: 'checkbox' as const, names: labelNames(p) })) });
sections.push({ title: `${n}: linked to the user`, dialog, type: n, page: true, fields: [{ id: `linked:${n}`, kind: 'radio', options: yesNo([/^Yes,.*\blinked to the user/i], [/^No,.*\blinked to the user/i]) }] });
sections.push({ title: `${n}: tracking`, dialog, type: n, page: true, fields: [{ id: `tracking:${n}`, kind: 'radio', options: yesNo([/^Yes,.*\btracking/i], [/^No,.*\btracking/i]) }] });
}
return { id, sections };
}
case 'play-content-rating': return { id, sections: [
{ 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, 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
...IARC_DETAIL_SECTIONS.flatMap(sec => {
const fields = answers.filter(a => a.id.startsWith(`d:${sec}:`)).map(a => detailField(a));
return fields.length ? [{ title: `Questionnaire: ${iarcSectionName(sec)} follow-ups`, page: true, optional: true, fields }] : [];
}),
] };
case 'play-target-audience': return { id, sections: [
{ title: 'Target age', fields: AGE_GROUPS.map(g => ({ id: `age:${g}`, kind: 'checkbox' as const, names: [AGE_LABELS[g], AGE_LABELS[g].replace(/^Ages /, '')] })) },
...(has('appeal') ? [{ title: 'Appeal to children', fields: [{ id: 'appeal', kind: 'radio' as const, names: [/appeal to children/i], options: yesNo() }] }] : []),
{ title: 'Ads', fields: [{ id: 'ads', kind: 'radio', options: yesNo([/^Yes, my app contains ads/i], [/^No, my app does not contain ads/i]) }] },
] };
}
}
/**
* The click gate: a control whose name says save, submit, publish, next... is never clicked by storebound (English and
* Turkish console wording). English words match whole words; Turkish stems match word starts (agglutinative suffixes).
*/
const EN = ['save', 'saved', 'submit', 'publish', 'create', 'send', 'apply', 'confirm', 'done', 'release', 'rollout', 'roll out', 'start', 'delete',
'remove', 'next', 'continue', 'finish', 'agree', 'accept', 'upload', 'deploy', 'discard', 'update', 'add for review', 'review', 'sign', 'log in', 'login',
// App Privacy's tracking step: a help panel, never needed (verified on the real dialog, October 2026)
'definitions and examples'];
const TR = ['kaydet', 'gönder', 'yayınla', 'yayımla', 'oluştur', 'onayla', 'tamam', 'bitti', 'bitir', 'sil', 'kaldır', 'başlat', 'sürüm',
'ileri', 'devam', 'sonraki', 'kabul', 'yükle', 'güncelle', 'incelemeye', 'giriş'];
const L = '\\p{L}\\p{N}';
// "uygula" (apply) is refused in every form, but not "uygulama" (app: Yeni Uygulama, Uygulama içeriği)
const FORBIDDEN = new RegExp(`(?<![${L}])(?:(?:${EN.map(esc).join('|')})(?![${L}])|(?:${TR.map(esc).join("|")}|uygula(?!ma)))`, 'iu');
/** 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 (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
* names carry help text: "Product InteractionSuch as ... saved place in a game"). What a tick must never do is accept an
* agreement: those are refused here.
*/
const AGREEMENT = /\b(?:i agree|agree to|accept(?:s|ed)?\b|terms of service|terms of use|developer program polic|export laws?|declaration|consent)|kabul|onaylıyorum|beyan|kullanım koşulları/iu;
export const forbiddenTick = (name: string): boolean => AGREEMENT.test(name.toLocaleLowerCase('tr').normalize('NFC'));
const SIGN_IN_HOSTS = ['appleid.apple.com', 'idmsa.apple.com', 'accounts.google.com'];
/**
* A sign-in page or frame (storebound never touches those; the owner signs in): Apple ID / Google accounts hosts, App
* Store Connect's /login, and Google sign-in paths (/signin, ServiceLogin) on any google.com host.
*/
export function isSignInUrl(url: string): boolean {
try {
const u = new URL(url), h = u.hostname;
if (SIGN_IN_HOSTS.includes(h)) return true;
if (h === 'appstoreconnect.apple.com' && /^\/login(\/|$)/.test(u.pathname)) return true;
return (h === 'google.com' || h.endsWith('.google.com')) && /\/(signin|ServiceLogin)(\/|$)/i.test(u.pathname);
} catch { return false; }
}
/** An Apple sign-in frame (idmsa.apple.com / appleid.apple.com): the only child frame whose form counts as a sign-in. */
export function isAppleSignInFrame(url: string): boolean {
try { const h = new URL(url).hostname; return h === 'idmsa.apple.com' || h === 'appleid.apple.com'; } catch { return false; }
}
/**
* Whole visible texts (or labels / placeholders) that only a sign-in form shows, English and Turkish. Apple's form
* lives in an idmsa.apple.com frame and starts with no password field (Email or Phone Number > Continue > passkey or
* password). `weak`: also used elsewhere (an account menu), so they count only inside a child frame.
*/
export const SIGN_IN_TEXTS = {
strong: ['Sign in with Passkey', 'Email or Phone Number', 'Sign in to App Store Connect', 'Keep me signed in', 'Email or phone',
'Geçiş Anahtarıyla Giriş Yap', 'Geçiş anahtarı ile giriş yap', 'Geçiş Anahtarı ile Giriş Yap', 'E-posta veya Telefon Numarası',
'App Store Connect’e giriş yap', "App Store Connect'e giriş yap", 'Oturumum açık kalsın', 'E-posta veya telefon'],
weak: ['Apple Account', 'Apple Hesabı', 'Apple ID'],
};
const textRe = (list: string[]) => new RegExp(`^\\s*(?:${list.map(esc).join('|')})\\s*$`, 'iu');
const STRONG = textRe(SIGN_IN_TEXTS.strong), WEAK = textRe(SIGN_IN_TEXTS.weak);
/** A text that marks a sign-in form (`inFrame`: it is inside a child frame, where the weak texts count too). */
export const isSignInText = (t: string, inFrame = false): boolean => {
const s = t.normalize('NFC').replace(/\s+/g, ' ');
return STRONG.test(s) || (inFrame && WEAK.test(s));
};
/** The console itself is open (signed in). `base` overrides the console origin (tests only, 127.0.0.1). */
export function consoleReached(store: 'apple' | 'play', url: string, base?: string): boolean {
try {
const u = new URL(url);
if (base) return u.origin === new URL(base).origin;
return store === 'apple' ? u.hostname === 'appstoreconnect.apple.com' && !u.pathname.startsWith('/login')
: u.hostname === 'play.google.com' && u.pathname.startsWith('/console') && !u.pathname.includes('signup');
} catch { return false; }
}
// ---- navigation to the form after sign-in (`storebound console fill` opens the form itself) ----
// Navigation clicks are allowed, saving / submitting / publishing never: every navigation click goes through navClick,
// which reads the element's real accessible name and checks it with navAllowed: the click gate above stays absolute.
// The one exception is FORM_OPENERS: a LINK whose whole name is exactly Start / Manage / Başla / Yönet (Play's App
// content entries), which only opens a form; a button named Start, or "Start rollout", stays refused.
// URLs (checked October 2026): App Store Connect app list /apps and an app's App Privacy page
// /apps/<appId>/distribution/privacy. Play Console has no deep link without the developer and app ids (the config has
// neither), so Play is navigated by role + name from the app list. A step not found in time -> the printed steps.
/** One navigation step. Steps are logged by `label`; a failed step is named in the fallback. */
export type NavStep =
| { kind: 'goto'; label: string; url: string }
/** Click the one visible control of one of `roles` named like one of `names`. `optional`: skipped when absent or when `unless` is already visible.
* `createApp`: the Play app list's Create app opener (createAppAllowed decides, never the plain gate). */
| { kind: 'click'; label: string; roles: string[]; names: Name[]; optional?: boolean; unless?: { roles: string[]; names: Name[] }; createApp?: boolean }
/** Pick the app from the list: rows / links whose name contains one of `tokens` (bundle id / package name) as a whole token, else whose name is `name`. */
| { kind: 'pickApp'; label: string; tokens: string[]; name?: string; search?: Name[] }
/** The Start / Manage link of the App content entry named like `entry`. */
| { kind: 'formLink'; label: string; entry: Name[] }
/** Wait for a visible element of one of `roles` named like `names` (the form is open); `level`: headings of that level only. */
| { kind: 'wait'; label: string; roles: string[]; names: Name[]; level?: number };
/** Link names that only open a form (whole name, links only): allowed although "start" is in the click gate. */
export const FORM_OPENERS = ['Start', 'Manage', 'Başla', 'Yönet'];
/**
* Play Console's page-level "Create app" button on Home (All apps) only OPENS the Create app form, but the same words
* are the form's final submit button. So it is let through only as the navigation step from the app list, never
* inside the form: the whole name is exactly one of these, the role is button or link, the page is the app list
* (an "All apps" heading or an /app-list URL), the Create app form is not open (no App name field on the page), and
* the control is not inside a form or dialog. Everything else named Create app is refused (forbiddenControl).
*/
export const CREATE_APP_OPENERS = ['Create app', 'Uygulama oluştur'];
export interface CreateAppWhere {
/** The page is the app list (All apps heading or /app-list URL). */
appList: boolean;
/** The Create app form is open on the page (its App name field is there). */
formOpen: boolean;
/** The control sits inside a <form>, role=form or a dialog. */
inForm: boolean;
}
export function createAppAllowed(name: string, role: string, where: CreateAppWhere): boolean {
const n = name.trim().replace(/\s+/g, ' ');
return CREATE_APP_OPENERS.some(o => o.toLocaleLowerCase('tr') === n.toLocaleLowerCase('tr')) && (role === 'button' || role === 'link')
&& where.appList && !where.formOpen && !where.inForm;
}
/** Headings of Play Console's app list (Home / All apps). */
export const ALL_APPS: Name[] = ['All apps', 'Tüm uygulamalar', 'Home', 'Ana sayfa'];
/** The Create app form's App name field (its presence means the form is open: Create app is the submit there). */
export const CREATE_FORM_FIELD: Name[] = ['App name', 'Uygulama adı'];
/** URL bases (tests point them at 127.0.0.1 fixtures). */
export interface NavBases { asc: string; play: string }
export const NAV_BASES: NavBases = { asc: 'https://appstoreconnect.apple.com', play: 'https://play.google.com/console' };
const APP_CONTENT: Name[] = ['App content', 'Uygulama içeriği', 'Uygulama İçeriği'];
const POLICY: Name[] = ['Policy and programs', 'Policy', 'Politika ve programlar', 'Politika'];
/**
* The steps that open a form after sign-in, built from the config only (apple.appId, apple.bundleId, play.packageName,
* apple.name / app.name). The last step waits for the form itself. Pure.
*/
export function navPlan(id: FormId, config: ShipConfig, bases: NavBases = NAV_BASES): NavStep[] {
const asc = bases.asc.replace(/\/$/, ''), play = bases.play.replace(/\/$/, '');
const name = config.app?.name;
const named = name ? { name } : {};
const appleNamed = name ? { name: appleName(config) } : {};
switch (id) {
case 'apple-app-record': return [
{ kind: 'goto', label: 'open Apps', url: `${asc}/apps` },
{ kind: 'click', label: 'the add button (+)', roles: ['button'], names: ['+', 'Add Apps', 'Add App', 'New App', 'Uygulama Ekle', 'Uygulama ekle', 'Yeni Uygulama', /^add apps?$/i] },
{ kind: 'click', label: 'New App in the menu', roles: ['menuitem', 'menuitemradio', 'link', 'button'], names: ['New App', 'Yeni Uygulama', /^new app$/i, /^yeni uygulama$/iu] },
{ kind: 'wait', label: 'the New App dialog', roles: ['dialog'], names: ['New App', 'Yeni Uygulama', /^new app\b/i, /^yeni uygulama\b/iu] },
];
case 'apple-age-rating': return [];
case 'play-app-record': return config.play?.developerId ? [
// verified 2026-10-12: the Create app form of one developer account has its own URL
{ kind: 'goto', label: 'open the Create app form of the developer account', url: playCreateApp(play, config.play.developerId) },
{ kind: 'wait', label: 'the Create app form', roles: ['textbox'], names: CREATE_FORM_FIELD },
] : [
{ kind: 'goto', label: 'open Play Console', url: play },
{ kind: 'click', label: 'Create app on the app list', roles: ['button', 'link'], names: CREATE_APP_OPENERS, createApp: true },
{ kind: 'wait', label: 'the Create app form', roles: ['textbox'], names: CREATE_FORM_FIELD },
];
case 'apple-app-privacy': {
const appId = config.apple?.appId;
const open: NavStep[] = appId ? [{ kind: 'goto', label: 'open App Privacy of the app (apple.appId)', url: `${asc}/apps/${encodeURIComponent(appId)}/distribution/privacy` }] : [
{ kind: 'goto', label: 'open Apps', url: `${asc}/apps` },
{ kind: 'pickApp', label: 'the app in the list', tokens: config.apple?.bundleId ? [config.apple.bundleId] : [], ...appleNamed },
{ kind: 'click', label: 'App Privacy in the sidebar', roles: ['link', 'tab'], names: ['App Privacy', 'Uygulama Gizliliği', /^app privacy$/i] },
];
return [...open, { kind: 'wait', label: 'the App Privacy page', roles: ['heading'], names: ['App Privacy', 'Uygulama Gizliliği', /^app privacy$/i] }];
}
case 'play-content-rating':
case 'play-target-audience': {
const [title, entry]: [string, Name[]] = id === 'play-content-rating'
? ['Content rating', ['Content rating', 'Content ratings', 'İçerik derecelendirmesi', /^content ratings?$/i]]
: ['Target audience and content', ['Target audience and content', 'Hedef kitle ve içerik', /^target audience\b/i, /^hedef kitle\b/iu]];
return [
config.play?.developerId
? { kind: 'goto', label: 'open the app list of the developer account', url: playAppList(play, config.play.developerId) }
: { kind: 'goto', label: 'open Play Console', url: play },
// by package name only when there is one: with several developer accounts, an app of the same name in another
// account must not count
{ kind: 'pickApp', label: 'the app in the list', tokens: config.play?.packageName ? [config.play.packageName] : [], ...(config.play?.packageName ? {} : named), search: [/^search/i, 'Uygulama ara', /^ara$/iu] },
{ kind: 'click', label: 'Policy and programs (opens the menu)', roles: ['button', 'link'], names: POLICY, optional: true, unless: { roles: ['link'], names: APP_CONTENT } },
{ kind: 'click', label: 'App content', roles: ['link'], names: APP_CONTENT },
{ kind: 'formLink', label: `${title}: Start or Manage`, entry },
{ kind: 'wait', label: `the ${title} form`, roles: ['heading'], names: entry, level: 1 },
];
}
}
}
/**
* The navigation click gate: true when storebound may click a control with this accessible name and role while it
* opens a form. forbiddenControl stays absolute; the only exception is a FORM_OPENERS link (whole name).
*/
export function navAllowed(name: string, role: string): boolean {
const n = name.trim().replace(/\s+/g, ' ');
if (FORM_OPENERS.includes(n)) return role === 'link'; // a Start / Başla BUTTON may start something: never
return n.length > 0 && !forbiddenControl(n);
}
// ---- the buttons of each form: navigation, draft save, final submit ----
// The owner should only click what only the owner may decide. Every button of the console forms is classified here
// (help pages below, checked October 2026); `auto` says whether storebound clicks it itself:
// navigation only moves between pages or opens a dialog: storebound clicks it (flowAllowed), after the page's
// certain answers are filled and only when no answer on the page is uncertain
// draft-save stores answers that are not public yet, clicked by storebound: App Privacy's in-dialog Save (Apple
// keeps the answers in App Store Connect and puts them on the product page only after Publish: "Your
// responses will be published on your product page on the App Store" once you click Publish) and the
// Play content rating's Save draft ("To finish later, click Save draft"; IARC gets nothing before
// Submit). The owner agreed to both (October 2026)
// final-submit publishes, submits, creates or sends something for review: always the owner's click
// Sources: Apple "Manage app privacy" https://developer.apple.com/help/app-store-connect/manage-app-information/manage-app-privacy ;
// Apple "Add a new app" https://developer.apple.com/help/app-store-connect/create-an-app-record/add-a-new-app ;
// Apple "Set an app age rating" https://developer.apple.com/help/app-store-connect/manage-app-information/set-an-app-age-rating ;
// Google "Content ratings" https://support.google.com/googleplay/android-developer/answer/9859655 ;
// 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
// 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'];
const BACK: Name[] = ['Back', 'Geri'];
const GET_STARTED: Name[] = ['Get Started', 'Başlayın', 'Başla'];
const EDIT: Name[] = ['Edit', 'Düzenle'];
const SAVE: Name[] = ['Save', 'Kaydet'];
const SAVE_DRAFT: Name[] = ['Save draft', 'Taslağı kaydet', 'Taslak olarak kaydet'];
const DONE: Name[] = ['Done', 'Bitti', 'Tamam'];
/** Play Create app: the read-only package name check next to App package name (verified 2026-10-12). */
const CHECK_AVAILABILITY: Name[] = ['Check availability', 'Kullanılabilirliği kontrol et', 'Uygunluğu kontrol et'];
/** The heading of App Privacy's Data Types section (Edit and the data types sit in it). */
export const DATA_TYPES_HEADING: Name[] = ['Data Types', 'Veri Türleri', 'Veri türleri'];
/** The App Privacy page heading. */
export const APP_PRIVACY_HEADING: Name[] = ['App Privacy', 'Uygulama Gizliliği', /^app privacy$/i];
/** Names the engine looks for. */
export const FLOW_NAMES = { next: NEXT, save: SAVE, saveDraft: SAVE_DRAFT, getStarted: GET_STARTED, edit: EDIT, checkAvailability: CHECK_AVAILABILITY };
/** Every button of every console form, classified (the console-autofill skill's table is written from this). */
export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
'apple-app-privacy': [
{ button: 'Get Started', class: 'navigation', where: 'App Privacy page, first time: opens the questionnaire', auto: true },
{ button: 'Edit (next to Data Types)', class: 'navigation', where: 'Data Types section: opens the data type list', auto: true },
{ button: 'Set Up <data type>', class: 'navigation', where: 'Data Types section, one per collected type (a plain text with a click handler, not a button): opens that type\'s questions', auto: true },
{ button: 'Edit (next to one data type)', class: 'navigation', where: 'Data Types section, a type set up before: reopens its questions, only when its purposes differ from the config', auto: true },
{ button: '? / More information', class: 'navigation', where: 'next to each heading and data type: help only (never needed)', auto: false },
{ button: 'Next / Continue', class: 'navigation', where: 'questionnaire dialogs', auto: true },
{ button: 'Back', class: 'navigation', where: 'questionnaire dialogs (never needed)', auto: false },
{ button: 'Definitions and Examples', class: 'navigation', where: 'a data type\'s tracking step: help only (never clicked)', auto: false },
{ button: 'Save', class: 'draft-save', where: 'questionnaire dialogs: stores the answers, not public until Publish', auto: true },
{ button: 'Publish (and its confirmation dialog)', class: 'final-submit', where: 'top right of the App Privacy page', auto: false },
],
'apple-app-record': [
{ button: '+ / New App', class: 'navigation', where: 'Apps: opens the New App dialog', auto: true },
{ button: 'Cancel', class: 'navigation', where: 'New App dialog: closes it, nothing saved (only to leave a team without the bundle id)', auto: true },
{ button: 'Create', class: 'final-submit', where: 'New App dialog: creates the app record', auto: false },
],
'apple-age-rating': [
{ button: 'Set Up Age Ratings / Edit', class: 'navigation', where: 'App Information (guide only: `storebound apple appinfo` sets the rating by API)', auto: false },
{ button: 'Next', class: 'navigation', where: 'age rating questionnaire (guide only)', auto: false },
{ button: 'Done / Save', class: 'final-submit', where: 'age rating questionnaire: stores the rating (guide only)', auto: false },
],
'play-app-record': [
{ button: 'Create app (app list)', class: 'navigation', where: 'Home / All apps: opens the Create app form', auto: true },
{ button: 'Check availability', class: 'navigation', where: 'the Create app form, next to App package name: read-only check ("Package name available")', auto: true },
{ button: 'Developer Program Policies / US export laws boxes', class: 'final-submit', where: 'the Create app form: agreements, never ticked', auto: false },
{ button: 'Create app (form)', class: 'final-submit', where: 'the Create app form: creates the app', auto: false },
],
'play-content-rating': [
{ button: 'Start / Start questionnaire', class: 'navigation', where: `${consoleLink('play.content-rating')}: 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 (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: '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 },
],
};
const norm = (n: string): string => n.trim().replace(/\s+/g, ' ');
const nameIs = (n: string, names: Name[]): boolean => names.some(x => (typeof x === 'string' ? x.toLocaleLowerCase('tr') === n.toLocaleLowerCase('tr') : x.test(n)));
/** Words that publish, submit or create: final whatever the context. */
const FINAL = /(?<![\p{L}])(publish|submit|create|send|release|rollout|roll out|yayınla|yayımla|gönder|oluştur|incelemeye)/iu;
/** Where a control sits, read from the page by the engine. */
export interface FlowWhere {
/** The App Privacy page is open (its heading). */
privacyPage: boolean;
/** Inside a dialog. */
inDialog: boolean;
/** Inside App Privacy's Data Types section. */
inDataTypes: boolean;
/** App Privacy: the data types storebound answers (their names open their questions). */
dataTypes: string[];
}
/** The class of a control of `form` with this accessible name and role where it sits, or null (unknown: never clicked). */
export function flowClass(form: FormId, name: string, role: string, where: FlowWhere): ButtonClass | null {
const n = norm(name);
if (!n || !['button', 'link'].includes(role)) return null;
if (FINAL.test(n.toLocaleLowerCase('tr')) || FINAL.test(n)) return 'final-submit';
if (nameIs(n, NEXT) || nameIs(n, BACK)) return 'navigation';
if (form === 'play-app-record' && nameIs(n, CHECK_AVAILABILITY)) return 'navigation';
if (form === 'apple-app-privacy') {
if (nameIs(n, GET_STARTED) && where.privacyPage && !where.inDialog) return 'navigation';
if (nameIs(n, EDIT) && where.inDataTypes && !where.inDialog) return 'navigation';
const low = n.toLocaleLowerCase('tr');
if (where.inDataTypes && !where.inDialog && where.dataTypes.some(t => { const tl = t.toLocaleLowerCase('tr'); return low === tl || low === `set up ${tl}` || low === `${tl} ayarla`; })) return 'navigation';
if (nameIs(n, SAVE)) return 'draft-save';
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`]);
/**
* The Set Up click gate: App Store Connect shows "Set Up <type>" as a plain text (a <p> in a div with a click handler,
* no button or link role), so the role-based gate cannot see it. It is clicked only when its whole text is exactly
* "Set Up " + one of the data types storebound answers (or their console labels), on the App Privacy page, in the Data
* Types section and outside any dialog. Nothing else is clicked by text (the "?" help buttons, Publish never).
*/
export function setUpAllowed(text: string, where: FlowWhere): boolean {
const n = norm(text).toLocaleLowerCase('tr');
if (!where.privacyPage || !where.inDataTypes || where.inDialog || forbiddenControl(n)) return false;
return where.dataTypes.some(t => setUpTexts(t).some(x => x.toLocaleLowerCase('tr') === n));
}
/** The forms whose draft save storebound clicks (the owner agreed): nothing there is public or sent before the final button. */
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, 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;
}
// ---- one login, several developer accounts / teams ----
// Switching the developer account (Play) or the team (App Store Connect) changes what the console shows, it writes
// nothing: such a click is let through when its target is one of the accounts storebound just read from the chooser or
// the team menu (exact name), is a link / menu item / option / row / button, and is never a sign-out or a deny-listed
// control (forbiddenControl stays absolute, so a studio named "Next Games" is not clicked: the owner picks it).
/** Play Console's Create app form of one developer account (verified 2026-10-12). */
export const playCreateApp = (playBase: string, developerId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/create-new-app`;
/** Play Console's app list of one developer account. */
export const playAppList = (playBase: string, developerId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/app-list`;
/** The Play Console app id in a URL (/developers/<id>/app/<digits>/...), or null. */
export function playAppIdOf(url: string): string | null {
try { return new URL(url).pathname.match(/\/developers\/\d{1,25}\/app\/(\d{1,25})(?:\/|$)/)?.[1] ?? null; } catch { return null; }
}
/** An app's Play Console dashboard (verified 2026-10-12: where Create app lands). */
export const playAppDashboard = (playBase: string, developerId: string, appId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/app/${encodeURIComponent(appId)}/app-dashboard`;
/** The developer account id in a Play Console URL (/developers/<digits>), or null. */
export function playDeveloperId(url: string): string | null {
try { return new URL(url).pathname.match(/\/developers\/(\d{1,25})(?:\/|$)/)?.[1] ?? null; } catch { return null; }
}
const SIGN_OUT = /\b(sign|log)\s*(out|off)\b|çıkış|oturumu kapat|hesap ekle|add account/iu;
/** Entries of an account menu that are not accounts (profile, settings, help, sign out). */
export const NOT_AN_ACCOUNT = /^(?:edit profile|profile|settings|account settings|help|users and access|switch team|teams?|profil(?:i düzenle)?|ayarlar|yardım|kullanıcılar ve erişim|ekip değiştir|ekipler?)$/iu;
/** The account-switch click gate: `name` must be one of `listed` (read from the chooser / team menu just before). */
export function switchAllowed(name: string, role: string, listed: string[]): boolean {
const n = name.trim().replace(/\s+/g, ' ');
return ['link', 'menuitem', 'menuitemradio', 'option', 'radio', 'row', 'button'].includes(role) && listed.includes(n)
&& !SIGN_OUT.test(n) && !NOT_AN_ACCOUNT.test(n) && navAllowed(n, role);
}
/** `id` as a whole token of `text` (com.example.game, not com.example.game2 or com.example.gamepro). */
export const hasToken = (text: string, id: string): boolean => new RegExp(`(?<![\\w.])${esc(id)}(?![\\w.])`).test(text);