storebound/cli/console/accounts.ts
CRT 59150c1186 console fill apple-app-privacy: fit the Data Collection dialog to the real console
A real run (sanitized debug dump) showed why the walk stopped after Next:
the data types step is the same "Data Collection" dialog (URL unchanged),
and each checkbox's accessible name is its label run together with its
help text ("NameIncluding first or last name"). Some labels also differ
from Apple's help page ("Purchases", "Other Data").

- labelNames: the exact name, then the label as a prefix that ends where
  the help text starts (no lowercase letter follows), so "Name" never
  matches "Phone Number...", "Head" never "Health...", "Contact" never
  "Contacts...". Used for the data types and the purposes.
  TYPE_ALIASES maps Purchase History / Other Data Types to the console's
  labels.
- Dialog per step: a section's dialog is also found as the one visible
  dialog with a heading of that name.
- Every click and check is bounded (ACTION_MS, 10 s; the scan test now
  also rejects clicks without a timeout).
- The flow fixture mirrors the real dialog's structure (dialog/h2 title,
  group h3s, input name = Apple's enum, <label> with label + help <p>s,
  Save disabled until a type is ticked); no data from the dump. A test
  checks every Apple type matches exactly one real-style checkbox.
- Pitfall 15.

Publish is still never clicked; unknown steps still get the watchdog dump.
2026-10-12 07:17:15 +08:00

401 lines
25 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, 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 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 } : {}), 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);
c.play[pkg] = { ...e, 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 { return await across(page, o); } 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 };
}