- `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.
414 lines
26 KiB
TypeScript
414 lines
26 KiB
TypeScript
// One sign-in, many developer accounts and teams (`storebound console fill`): the owner's ONE Google login can be an
|
|
// admin of several Play developer accounts, and ONE Apple ID a member of several App Store Connect teams. storebound
|
|
// never asks for a second sign-in: it finds the account that has the app and remembers it.
|
|
// Play: the app list of one developer account is play.google.com/console/u/0/developers/<id>/app-list. The id comes
|
|
// from play.developerId (config), STOREBOUND_PLAY_DEVELOPER_ID (a workspace account's developerId, set by
|
|
// `ws`), or the cache; else storebound reads every developer account link on the chooser
|
|
// (/console/u/0/developers) and looks for play.packageName in each app list.
|
|
// play-app-record (Create app) is a FIRST create: no account has the package yet, so the account is picked, not
|
|
// searched: play.developerId / the workspace's id / the cache, else the one account the login sees, else the
|
|
// chooser entry named exactly play.developerAccount. Ambiguous or not set -> storebound stops and lists them.
|
|
// Apple: the team is switched in the account menu at the top right (the team list; Apple's help "Switch between
|
|
// teams"). The order tried: the cached team, the menu entry that names apple.teamId, then every other team; the
|
|
// app is found by its list entry (App Privacy) or, for a new app, by the bundle id in the New App dialog's Bundle ID
|
|
// list (the dialog is closed again with Cancel when the bundle id is not there). App Store Connect has no
|
|
// documented URL parameter that selects a team, so storebound does not use one.
|
|
// The id mappings (no cookies, no names of people) live in ~/.storebound/console-accounts.json, shared with the
|
|
// desktop app: { "version": 1, "play": { "<package>": { "developerId", "account", "at" } }, "apple": { "<bundle id>":
|
|
// { "team", "teamId"?, "at" } } }. Switching only changes what the console shows: every switch click goes through the
|
|
// account-switch gate (switchAllowed: the exact name just read from the list, never sign out or a deny-listed name).
|
|
// The chooser and the team menu are MODELED from the public help pages, not recorded: a miss falls back to the account
|
|
// that is open, and the log says to set play.developerId / apple.teamId.
|
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
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, 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; /** 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> }
|
|
|
|
export const accountsFile = (): string => process.env.STOREBOUND_CONSOLE_ACCOUNTS || join(homedir(), '.storebound', 'console-accounts.json');
|
|
|
|
const KEY = /^[A-Za-z0-9._-]{1,200}$/;
|
|
const str = (v: unknown, max = 200): v is string => typeof v === 'string' && v.length > 0 && v.length <= max;
|
|
|
|
/** The cache, cleaned (anything malformed is dropped); empty when there is none. */
|
|
export function readAccounts(file = accountsFile()): AccountsCache {
|
|
const out: AccountsCache = { version: 1, play: {}, apple: {} };
|
|
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 } : {}), ...(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 : '' };
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 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);
|
|
// 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 {
|
|
const c = readAccounts(file);
|
|
c.apple[bundleId] = { ...e, at: new Date().toISOString() };
|
|
write(c, file);
|
|
}
|
|
function write(c: AccountsCache, file: string): void {
|
|
mkdirSync(dirname(file), { recursive: true });
|
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
writeFileSync(tmp, JSON.stringify(c, null, 2) + '\n');
|
|
renameSync(tmp, file);
|
|
}
|
|
|
|
// ---- Play: developer accounts ----
|
|
|
|
/** One entry of the account chooser: its visible name, its developer id when the page carries it (link target or a
|
|
* data attribute; '' when only a click reveals it) and `key`, the data-storebound-account mark to click it by. */
|
|
export interface PlayAccount { id: string; name: string; key?: string }
|
|
|
|
/** Chooser entries that are not accounts (Create a new developer account ...). */
|
|
const NOT_ACCOUNT = /^(create|new developer account|yeni geliştirici hesabı)|developer account oluştur|hesabı oluştur/iu;
|
|
|
|
/**
|
|
* Reads the account chooser robustly (a real chooser's rows may be links, role=button / option / row elements or plain
|
|
* list items with nested spans, an avatar image or an icon font): every visible clickable row in the page's content
|
|
* (header and navigation left out). Name: the row's first visible line of text, icon fonts, images and aria-hidden
|
|
* parts skipped. Id: /developers/<id> in a link target or any attribute of the row (or its link), else a data
|
|
* attribute holding the number; '' when there is none (then the row is clicked to open it). Rows are marked with
|
|
* data-storebound-account so they can be clicked. Read-only otherwise.
|
|
*/
|
|
export async function readChooser(page: Page): Promise<PlayAccount[]> {
|
|
const rows: { name: string; id: string; key: string }[] = await page.evaluate(() => {
|
|
const CLICK = 'a[href], button, [role="link"], [role="button"], [role="option"], [role="row"], [role="menuitem"], [role="radio"]';
|
|
const ROWS = `${CLICK}, [role="listitem"], li, [data-developer-id], [data-account-id], [data-id]`;
|
|
const SKIP = '[aria-hidden="true"], img, svg, .material-icons, .material-icons-outlined, .material-symbols-outlined, .google-material-icons, mat-icon';
|
|
const shown = (el: Element) => !!(el as HTMLElement).getClientRects().length && getComputedStyle(el).visibility !== 'hidden';
|
|
const idIn = (el: Element): string => {
|
|
const scan = [el, ...el.querySelectorAll('*'), el.parentElement, el.parentElement?.parentElement].filter(Boolean) as Element[];
|
|
for (const e of scan) for (const a of e.attributes) { const m = a.value.match(/\/developers\/(\d{1,25})(?:[/?#]|$)/); if (m) return m[1]!; }
|
|
for (const e of scan) for (const a of e.attributes) if (/^data-/.test(a.name) && /id$/i.test(a.name) && /^\d{1,25}$/.test(a.value.trim())) return a.value.trim();
|
|
return '';
|
|
};
|
|
const firstLine = (el: Element): string => {
|
|
// text pieces grouped by their nearest block-level box inside the row; the first group is the name
|
|
const groups = new Map<Element, string>();
|
|
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
for (let n = walk.nextNode(); n; n = walk.nextNode()) {
|
|
const p = n.parentElement;
|
|
if (!p || p.closest(SKIP) || !shown(p)) continue; // spaces between inline parts count ("Example" "AI")
|
|
let box: Element = p;
|
|
for (let up = 0; up < 50 && box !== el && getComputedStyle(box).display.startsWith('inline'); up++) box = box.parentElement ?? el;
|
|
groups.set(box, `${groups.get(box) ?? ''}${n.textContent}`);
|
|
}
|
|
for (const t of groups.values()) { const s = t.trim().replace(/\s+/g, ' '); if (s) return s; }
|
|
return '';
|
|
};
|
|
const out: { name: string; id: string; key: string }[] = [];
|
|
let k = 0;
|
|
for (const el of document.querySelectorAll(ROWS)) {
|
|
if (!shown(el) || el.closest('header, nav, footer, [role="banner"], [role="navigation"], [role="dialog"]')) continue;
|
|
if (el.querySelector(CLICK)) continue; // the innermost clickable element is the row
|
|
const id = idIn(el);
|
|
// a row without an id counts only inside a list (not a stray button of the page)
|
|
if (!id && !el.closest('[role="list"], [role="listbox"], [role="grid"], [role="table"], [role="radiogroup"], ul, ol, table')) continue;
|
|
const name = firstLine(el);
|
|
if (!name) continue;
|
|
if (out.some(o => (id && o.id === id) || (!id && !o.id && o.name === name))) continue;
|
|
const key = String(k++);
|
|
el.setAttribute('data-storebound-account', key);
|
|
out.push({ name, id, key });
|
|
}
|
|
return out;
|
|
});
|
|
return rows.filter(r => !NOT_ACCOUNT.test(r.name));
|
|
}
|
|
|
|
/**
|
|
* The developer accounts the login can see, read from the account chooser (/console/u/0/developers). A login with one
|
|
* account lands in that account: its id only.
|
|
*/
|
|
export async function playAccounts(page: Page, playBase: string, ms: number, stop?: Stop): Promise<PlayAccount[]> {
|
|
await page.goto(`${playBase.replace(/\/$/, '')}/u/0/developers`, { waitUntil: 'domcontentloaded' }).catch(() => { /* read what is there */ });
|
|
const found = await poll(ms, async () => { const r = await readChooser(page); return r.length ? r : null; }, stop);
|
|
if (found) return found;
|
|
const here = playDeveloperId(page.url());
|
|
return here ? [{ id: here, name: '' }] : [];
|
|
}
|
|
|
|
const norm = (s: string): string => s.trim().replace(/\s+/g, ' ');
|
|
/** Case folded for comparing names, English and Turkish alike (I / ı / İ / i all fold to i). */
|
|
const fold = (s: string): string => s.normalize('NFD').replace(/\u0307/g, '').toLowerCase().replace(/ı/g, 'i');
|
|
|
|
/**
|
|
* The chooser entry named `want`: the exact name (case-sensitive) first, then the one case-insensitive exact match,
|
|
* then the one case-insensitive prefix match. "ACME" and "Acme" differ only in case: the case-sensitive match
|
|
* picks between them; a name that matches both only case-insensitively is ambiguous. Pure.
|
|
*/
|
|
export function matchAccount<A extends { name: string }>(want: string, all: A[]): { hit?: A; ambiguous?: A[] } {
|
|
const w = norm(want), wl = fold(w);
|
|
const named = all.filter(a => a.name);
|
|
for (const pick of [
|
|
(a: A) => norm(a.name) === w,
|
|
(a: A) => fold(norm(a.name)) === wl,
|
|
(a: A) => fold(norm(a.name)).startsWith(wl),
|
|
]) {
|
|
const hits = named.filter(pick);
|
|
if (hits.length === 1) return { hit: hits[0]! };
|
|
if (hits.length > 1) return { ambiguous: hits };
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/** "Name (id)" for the log, every visible entry (an entry whose id only a click shows: "Name (id not shown)"). */
|
|
export const accountList = (all: PlayAccount[]): string => all.map(a => (a.name ? `${a.name} (${a.id || 'id not shown'})` : a.id)).join(', ');
|
|
|
|
/**
|
|
* The developer id of a chooser entry: its own when the page shows it, else it is opened (the account-switch gate:
|
|
* its exact name was just read from the chooser) and the id is read from the URL it lands on. null when that fails.
|
|
*/
|
|
async function accountId(page: Page, a: PlayAccount, listed: string[], ms: number, stop?: Stop): Promise<string | null> {
|
|
if (a.id) return a.id;
|
|
if (!a.key) return null;
|
|
const row = page.locator(`[data-storebound-account="${a.key}"]`);
|
|
const role = await realRole(row).catch(() => '');
|
|
if (!switchAllowed(a.name, ['link', 'menuitem', 'menuitemradio', 'option', 'radio', 'row', 'button'].includes(role) ? role : 'row', listed)) return null;
|
|
await row.click({ timeout: ACTION_MS });
|
|
return poll(ms, async () => playDeveloperId(page.url()), stop);
|
|
}
|
|
|
|
// ---- App Store Connect: teams ----
|
|
|
|
/** The account menu button at the top right (it lists the teams). */
|
|
export const ACCOUNT_MENU: Name[] = ['Account', 'Account menu', 'Your account', 'Hesap', 'Hesabım', 'Hesap menüsü', /^account\b/i, /^hesap\b/iu];
|
|
const CANCEL: Name[] = ['Cancel', 'İptal', 'Vazgeç'];
|
|
|
|
interface TeamItem { name: string; loc: Locator; role: string; current: boolean }
|
|
|
|
async function teamMenu(page: Page, ms: number, stop?: Stop): Promise<TeamItem[] | null> {
|
|
const t = await poll(ms, async () => { const f = await findAny(page, ['button'], ACCOUNT_MENU); return f.state === 'ok' ? f.loc : null; }, stop);
|
|
if (!t || !(await navClick(t)).clicked) return null;
|
|
return poll(Math.min(ms, 3000), async () => {
|
|
const menus = vis(page.getByRole('menu'));
|
|
if (await menus.count() !== 1) return null;
|
|
const out: TeamItem[] = [];
|
|
for (const role of ['menuitemradio', 'menuitem', 'option', 'link', 'button']) {
|
|
const l = vis(menus.getByRole(role));
|
|
const n: number = await l.count();
|
|
for (let i = 0; i < n; i++) {
|
|
const loc = l.nth(i), name = await accName(loc);
|
|
if (out.some(o => o.name === name) || !switchAllowed(name, role, [name])) continue;
|
|
out.push({ name, loc, role, current: (await loc.getAttribute('aria-checked')) === 'true' || (await loc.getAttribute('aria-current')) !== null });
|
|
}
|
|
}
|
|
return out.length ? out : null;
|
|
}, stop);
|
|
}
|
|
|
|
const closeMenu = async (page: Page) => { await page.keyboard.press('Escape').catch(() => { /* gone */ }); };
|
|
|
|
/** The teams in the account menu (read, then the menu is closed again), or null when there is no team menu. */
|
|
export async function appleTeams(page: Page, ms: number, stop?: Stop): Promise<string[] | null> {
|
|
const items = await teamMenu(page, ms, stop);
|
|
if (items) await closeMenu(page);
|
|
return items ? items.map(i => i.name) : null;
|
|
}
|
|
|
|
/** Switches to the team `name` (one of `listed`) through the account menu. true when switched (or already current). */
|
|
export async function switchTeam(page: Page, name: string, listed: string[], ms: number, stop?: Stop): Promise<boolean> {
|
|
const items = await teamMenu(page, ms, stop);
|
|
const it = items?.find(i => i.name === name);
|
|
if (!it) { await closeMenu(page); return false; }
|
|
if (it.current) { await closeMenu(page); return true; }
|
|
const real = await accName(it.loc), role = await realRole(it.loc);
|
|
if (!switchAllowed(real, role, listed)) { await closeMenu(page); return false; }
|
|
await it.loc.click({ timeout: ACTION_MS });
|
|
await page.waitForLoadState('domcontentloaded', { timeout: ms }).catch(() => { /* same page, or slow: read what is there */ });
|
|
await Bun.sleep(300);
|
|
return true;
|
|
}
|
|
|
|
/** In the open New App dialog: does the Bundle ID list offer `bundleId`? null when it cannot tell (no native list). */
|
|
export async function bundleOffered(page: Page, bundleId: string): Promise<boolean | null> {
|
|
const box = vis(page.getByRole('dialog').getByRole('combobox', { name: /bundle id|paket kimliği|bundle kimliği/iu }));
|
|
if (await box.count() !== 1) return null;
|
|
const opts: string[] | null = await box.evaluate((el: any) => (el.tagName === 'SELECT' ? [...el.options].map((o: any) => o.textContent || '') : null));
|
|
return opts ? opts.some(o => hasToken(o, bundleId)) : null;
|
|
}
|
|
|
|
// ---- opening the form in the right account ----
|
|
|
|
export interface AcrossOpts {
|
|
form: FormId;
|
|
config: ShipConfig;
|
|
say: (m: string) => void;
|
|
bases?: NavBases;
|
|
navMs: number;
|
|
signIn?: (page: Page) => Promise<boolean>;
|
|
file?: string;
|
|
env?: Record<string, string | undefined>;
|
|
}
|
|
|
|
/** The config with the ids a workspace account passes (env) when the config itself has none. */
|
|
export function withAccountIds(config: ShipConfig, env: Record<string, string | undefined> = process.env): ShipConfig {
|
|
const dev = env.STOREBOUND_PLAY_DEVELOPER_ID, team = env.STOREBOUND_APPLE_TEAM_ID;
|
|
let c = config;
|
|
if (c.play && !c.play.developerId && dev && /^\d{1,25}$/.test(dev)) c = { ...c, play: { ...c.play, developerId: dev } };
|
|
if (c.apple && !c.apple.teamId && team && /^[A-Z0-9]{10}$/.test(team)) c = { ...c, apple: { ...c.apple, teamId: team } };
|
|
return c;
|
|
}
|
|
|
|
/** The navigation stopped because the app is not in this account (not because the page changed). */
|
|
function notHere(r: NavResult, plan: NavStep[]): boolean {
|
|
const s = plan.find(p => p.label === r.failed);
|
|
if (!s || r.signIn) return false;
|
|
if (s.kind === 'pickApp') return !r.candidates && /^no app matching/.test(r.why ?? '');
|
|
return s.kind === 'wait' && plan.length === 2 && plan[0]!.kind === 'goto'; // a deep link (apple.appId) of another team
|
|
}
|
|
|
|
/**
|
|
* Opens the form in the account that has the app: the remembered / configured one first, else every account the one
|
|
* login can see. Returns the navigation result and the plan used (resumeAt continues with it). A sign-in page ->
|
|
* `signIn` (the caller waits and calls again).
|
|
*/
|
|
export async function openAcross(page: Page, o: AcrossOpts): Promise<{ nav: NavResult; plan: NavStep[] }> {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* play-app-record: the developer account to create the app in. Never guessed among several: play.developerId (or the
|
|
* workspace's STOREBOUND_PLAY_DEVELOPER_ID, or the remembered one for this package), else the only account the login
|
|
* sees, else the chooser entry named play.developerAccount. Remembered under play.packageName once the form is open.
|
|
*/
|
|
async function pickPlayAccount(page: Page, config: ShipConfig, o: AcrossOpts, cache: AccountsCache, file: string,
|
|
nav: (plan: NavStep[], ms?: number) => Promise<NavResult>, quick: number, stop: Stop): Promise<{ nav: NavResult; plan: NavStep[] }> {
|
|
const say = o.say, bases = o.bases ?? NAV_BASES, pkg = config.play?.packageName, want = config.play?.developerAccount;
|
|
const planFor = (id?: string) => navPlan(o.form, id ? { ...config, play: { ...config.play!, developerId: id } } : config, bases);
|
|
const go = async (id: string, account?: string) => {
|
|
const plan = planFor(id), r = await nav(plan);
|
|
if (r.ok && pkg) rememberPlay(pkg, { developerId: id, ...(account ? { account } : {}) }, file);
|
|
return { nav: r, plan };
|
|
};
|
|
const known = config.play?.developerId ?? (pkg ? cache.play[pkg]?.developerId : undefined);
|
|
if (known) { say(`creating the app in developer account ${known}${config.play?.developerId ? ' (play.developerId)' : ` (remembered in ${file})`}`); return go(known, pkg ? cache.play[pkg]?.account : undefined); }
|
|
const all = await playAccounts(page, bases.play, quick, stop);
|
|
const list = accountList(all);
|
|
const stopHere = (why: string) => ({ nav: { ok: false, steps: [], failed: 'the developer account', why, ...(all.length ? { candidates: all.map(a => (a.name ? `${a.name} (${a.id || 'id not shown'})` : a.id)) } : {}) } as NavResult, plan: planFor() });
|
|
/** Opens the chosen entry's account (its id read from the page, or from where its click lands). */
|
|
const use = async (a: PlayAccount, why: string) => {
|
|
const id = await accountId(page, a, all.map(x => x.name).filter(Boolean), quick, stop);
|
|
if (!id) return stopHere(`could not read the developer id of "${a.name}" on the chooser (${list}): set play.developerId`);
|
|
say(`creating the app in developer account "${a.name || id}" (${id}, ${why})`);
|
|
return go(id, a.name || undefined);
|
|
};
|
|
// one account: nothing to choose (a different play.developerAccount is said, not obeyed: there is no other)
|
|
if (all.length === 1) {
|
|
const only = all[0]!;
|
|
if (want && only.name && !matchAccount(want, all).hit) say(`play.developerAccount is "${want}", but this Google login has only the developer account "${only.name}": using it`);
|
|
return use(only, 'the only developer account of this login');
|
|
}
|
|
if (want) {
|
|
const m = matchAccount(want, all);
|
|
if (m.hit) return use(m.hit, 'play.developerAccount');
|
|
return stopHere(m.ambiguous ? `"${want}" (play.developerAccount) matches more than one developer account: ${accountList(m.ambiguous)}. Write the name exactly as the chooser shows it (case counts when two differ only in case), or set play.developerId`
|
|
: `no developer account named "${want}" (play.developerAccount) on the chooser${all.length ? `: ${list}` : ''}. Set play.developerAccount to one of them, or play.developerId`);
|
|
}
|
|
if (all.length > 1) return stopHere(`this Google login sees ${all.length} developer accounts (${list}): set play.developerAccount (the name) or play.developerId to pick the one for the new app`);
|
|
return stopHere('could not read the developer accounts on the "Choose a developer account" page: set play.developerId (or play.developerAccount)');
|
|
}
|
|
|
|
async function across(page: Page, o: AcrossOpts): Promise<{ nav: NavResult; plan: NavStep[] }> {
|
|
const config = withAccountIds(o.config, o.env), file = o.file ?? accountsFile(), cache = readAccounts(file);
|
|
const bases = o.bases ?? NAV_BASES, say = o.say;
|
|
const isSignIn = o.signIn ?? signInPage;
|
|
const stop: Stop = () => isSignIn(page).catch(() => false);
|
|
const nav = (plan: NavStep[], ms = o.navMs) => navigate(page, plan, { say, timeoutMs: ms, signIn: isSignIn });
|
|
const store = o.form.startsWith('play-') ? 'play' : 'apple';
|
|
const quick = Math.min(o.navMs, 8000);
|
|
|
|
if (store === 'play' && o.form === 'play-app-record') return pickPlayAccount(page, config, o, cache, file, nav, quick, stop);
|
|
if (store === 'play') {
|
|
const pkg = config.play?.packageName;
|
|
const planFor = (id?: string) => navPlan(o.form, id ? { ...config, play: { ...config.play!, developerId: id } } : config, bases);
|
|
const known = config.play?.developerId ?? (pkg ? cache.play[pkg]?.developerId : undefined);
|
|
if (known) {
|
|
const plan = planFor(known), r = await nav(plan);
|
|
if (r.ok && pkg) rememberPlay(pkg, { developerId: known, ...(cache.play[pkg]?.account ? { account: cache.play[pkg]!.account! } : {}) }, file);
|
|
if (r.ok || r.signIn || !notHere(r, plan)) return { nav: r, plan };
|
|
say(`${pkg} is not in developer account ${known}: looking in every developer account this Google login can see`);
|
|
}
|
|
const all = (await playAccounts(page, bases.play, quick, stop)).filter(a => a.id && a.id !== known);
|
|
if (!pkg || all.length <= 1) {
|
|
const plan = planFor(all[0]?.id);
|
|
const r = await nav(plan);
|
|
if (r.ok && pkg && all[0]) rememberPlay(pkg, { developerId: all[0].id, ...(all[0].name ? { account: all[0].name } : {}) }, file);
|
|
return { nav: r, plan };
|
|
}
|
|
say(`this Google login sees ${all.length} developer accounts: looking for ${pkg} in each`);
|
|
for (const [i, a] of all.entries()) {
|
|
say(`developer account ${i + 1}/${all.length}: ${a.name || a.id}`);
|
|
const plan = planFor(a.id), r = await nav(plan, quick);
|
|
if (r.ok) { rememberPlay(pkg, { developerId: a.id, ...(a.name ? { account: a.name } : {}) }, file); say(`${pkg} is in ${a.name || a.id} (remembered in ${file})`); }
|
|
if (r.ok || r.signIn || !notHere(r, plan)) return { nav: r, plan };
|
|
}
|
|
const plan = planFor();
|
|
return { nav: { ok: false, steps: [], failed: 'the developer account', why: `${pkg} is in none of the ${all.length} developer accounts this Google login can see (${all.map(a => a.name || a.id).join(', ')}). Create the app first, or ask the account owner to invite you` }, plan };
|
|
}
|
|
|
|
// Apple
|
|
const plan = navPlan(o.form, config, bases), bundle = config.apple?.bundleId;
|
|
if (!plan.length) return { nav: { ok: true, steps: [] }, plan };
|
|
const found = async (r: NavResult): Promise<boolean | null> => {
|
|
if (!r.ok) return notHere(r, plan) ? false : null;
|
|
if (o.form !== 'apple-app-record' || !bundle) return true;
|
|
const has = await bundleOffered(page, bundle);
|
|
if (has !== false) return true;
|
|
const c = await findAny(vis(page.getByRole('dialog')), ['button'], CANCEL);
|
|
if (c.state === 'ok') await safeClick(c.loc, await accName(c.loc));
|
|
return false;
|
|
};
|
|
const teams = bundle ? await appleTeams(page, Math.min(quick, 3000), stop).catch(() => null) : null;
|
|
if (!teams || teams.length < 2) return { nav: await nav(plan), plan };
|
|
const cached = cache.apple[bundle!]?.team, teamId = config.apple?.teamId;
|
|
const order = [...new Set([
|
|
...(cached && teams.includes(cached) ? [cached] : []),
|
|
...(teamId ? teams.filter(t => hasToken(t, teamId)) : []),
|
|
...teams,
|
|
])];
|
|
say(`this Apple Account is in ${teams.length} teams: looking for ${bundle} in each`);
|
|
for (const [i, t] of order.entries()) {
|
|
say(`team ${i + 1}/${order.length}: ${t}`);
|
|
if (!await switchTeam(page, t, teams, quick, stop)) { say(` could not switch to ${t} in the account menu`); continue; }
|
|
const r = await nav(plan, quick);
|
|
if (r.signIn) return { nav: r, plan };
|
|
const f = await found(r);
|
|
if (f) { rememberApple(bundle!, { team: t, ...(teamId ? { teamId } : {}) }, file); say(`${bundle} is in team ${t} (remembered in ${file})`); return { nav: r, plan }; }
|
|
if (f === null) return { nav: r, plan };
|
|
}
|
|
return { nav: { ok: false, steps: [], failed: 'the team', why: `${bundle} is in none of the ${teams.length} teams of this Apple Account (${teams.join(', ')}): register the bundle id in the right team (Certificates, Identifiers & Profiles), or set apple.teamId` }, plan };
|
|
}
|