// 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//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": { "": { "developerId", "account", "at" } }, "apple": { "": // { "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; apple: Record } 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)) { 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)) { 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, 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, 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/ 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 { 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(); 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 { 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(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 { 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 { 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 { 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 { 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 { 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; file?: string; env?: Record; } /** The config with the ids a workspace account passes (env) when the config itself has none. */ export function withAccountIds(config: ShipConfig, env: Record = 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, 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 => { 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 }; }