// The Play Console app id (play.appId: the digits in /console/u/0/developers//app//...), which // deep-links every Play console plan. The Play Developer API does not return it, and the console's app list shows 10 // apps per page with a new app not on page 1 (verified 2026-10-13), so finding it by hand took several steps. Two // ways, both agent steps: (1) right after Create app the console redirects to /app//app-dashboard: `console // verify play-app-record --url ` reads it there; (2) `storebound play app-id`: the agent captures the app list // pages (the search box or ?page=) and storebound finds the row with the package name. Either way the change to // play.appId is shown as a dry diff first and written with --write (an upsert). Pure, except writePlayAppId. import { readFileSync, writeFileSync } from 'node:fs'; import type { ShipConfig } from './config'; import { upsertNestedString } from './scan/config-edit'; const PLAY = 'https://play.google.com/console'; const ID = /^\d{1,25}$/; /** The app id in a Play Console URL or in any text that holds one (a capture JSON, page text): the first * /developers//app/. null when there is none. */ export function playAppIdInText(text: string): string | null { return /\/developers\/\d{1,25}\/app\/(\d{1,25})(?=[/?#"'\s]|$)/.exec(text)?.[1] ?? null; } /** One captured page of the app list (APP_LIST_CAPTURE). */ export interface AppListPage { url?: string; /** Each app row: its visible text (name, package name, status) and the href of its link. */ rows: { text: string; href: string }[]; /** The list has a next page (its "Next page" control is there and enabled). */ hasNext?: boolean; } /** * Run in the app list page (one evaluate call): the rows with an /app// link and their row text, and whether a next * page exists. Modeled on the verified URL pattern and a paginated list of 10; the row markup was not recorded, so it * takes the nearest row-like ancestor of each app link. */ export const APP_LIST_CAPTURE = `JSON.stringify((() => { const t = e => (e?.innerText ?? e?.textContent ?? '').replace(/\\s+/g, ' ').trim(); const rows = [], seen = new Set(); for (const a of document.querySelectorAll('a[href*="/app/"]')) { const href = a.getAttribute('href') || ''; if (!/\\/developers\\/\\d+\\/app\\/\\d+/.test(href) || seen.has(href)) continue; seen.add(href); const row = a.closest('[role=row], tr, li, [role=listitem]') || a.parentElement; rows.push({ text: t(row), href }); } const next = [...document.querySelectorAll('button, a, [role=button]')].find(b => /^next( page)?$/i.test((b.getAttribute('aria-label') || t(b)).trim())); return { url: location.href, rows, hasNext: !!next && !next.disabled && next.getAttribute('aria-disabled') !== 'true' }; })())`; /** One capture, a JSON array of captures, or one capture per line. Throws on anything else. */ export function parseAppListPages(raw: string): AppListPage[] { const s = raw.trim(); const one = (j: unknown): AppListPage => { const o = j as Record; if (!o || typeof o !== 'object' || !Array.isArray(o.rows)) throw new Error('play app-id: a capture needs "rows" (run the APP_LIST_CAPTURE snippet in the app list)'); const rows = (o.rows as unknown[]).flatMap(r => { const x = r as Record; return x && typeof x.text === 'string' && typeof x.href === 'string' ? [{ text: x.text, href: x.href }] : []; }); return { rows, ...(typeof o.url === 'string' ? { url: o.url } : {}), ...(typeof o.hasNext === 'boolean' ? { hasNext: o.hasNext } : {}) }; }; try { const j = JSON.parse(s) as unknown; return Array.isArray(j) ? j.map(one) : [one(j)]; } catch (e) { if (!(e instanceof SyntaxError)) throw e; return s.split(/\r?\n/).filter(l => l.trim()).map(l => one(JSON.parse(l))); } } /** `id` as a whole token of `text` (com.example.game, not com.example.game2). */ const hasToken = (text: string, id: string): boolean => new RegExp(`(? { for (const r of p.rows) { const id = playAppIdInText(r.href.startsWith('/') ? r.href : (() => { try { return new URL(r.href).pathname; } catch { return r.href; } })()); if (id && hasToken(r.text, packageName) && !hits.some(h => h.appId === id)) hits.push({ appId: id, page: i + 1, row: r.text }); } }); if (hits.length === 1) return { found: true, ...hits[0]! }; return { found: false, pages: pages.length, hasNext: pages.at(-1)?.hasNext ?? false, ...(hits.length > 1 ? { ambiguous: hits.map(h => h.appId) } : {}) }; } /** The app list of a developer account, page `page` (1 = the first; the `page` query parameter is modeled: prefer the * list's own next-page control when it differs). */ export const appListPage = (developerId: string, page = 1): string => `${PLAY}/u/0/developers/${encodeURIComponent(developerId)}/app-list${page > 1 ? `?page=${page}` : ''}`; /** The steps an agent follows to find the app id in the console (read-only). */ export function appIdSteps(config: ShipConfig, developerId: string | null): string[] { const pkg = config.play?.packageName ?? ''; const list = developerId ? appListPage(developerId) : `${PLAY} (pick the developer account; set play.developerId for a deep link)`; return [ `Open the app list: ${list}.`, `Fastest: type the package name (${pkg}) into the list's search box ("Search apps") and wait for the list to update; the one row left is the app.`, 'Run the capture snippet (capture) in the page and pipe its JSON to `storebound play app-id --from-text -` (it finds the row with the package name and reads the id from its /app// link).', `Not found and the capture says hasNext: press the list's next-page control (aria-label "Next page"; the URL pattern is /app-list?page=, 10 apps per page, new apps are not on page 1), capture again, and pipe ALL captures (a JSON array or one per line) to the same command.`, 'Found: the command shows the play.appId change as a dry diff; run it again with --write to save it into storebound.config.ts.', ]; } /** What --write would change: play.appId from -> to. */ export interface AppIdChange { from: string | null; to: string; changed: boolean } export const appIdChange = (config: ShipConfig, id: string): AppIdChange => ({ from: config.play?.appId ?? null, to: id, changed: config.play?.appId !== id }); /** The dry diff line. */ export const appIdDiff = (c: AppIdChange, file: string): string => c.changed ? `${file}: play.appId ${c.from ? `'${c.from}'` : '(not set)'} -> '${c.to}'` : `${file}: play.appId is already '${c.to}' (nothing to change)`; /** Writes play.appId into the config file (an upsert); false when it was already that id. */ export function writePlayAppId(configPath: string, config: ShipConfig, id: string): boolean { if (!ID.test(id)) throw new Error(`play.appId: "${id}" is not a Play Console app id (digits)`); if (!config.play) throw new Error('the config has no play block: add play.packageName first'); if (!appIdChange(config, id).changed) return false; writeFileSync(configPath, upsertNestedString(readFileSync(configPath, 'utf8'), configPath, 'play', 'appId', id)); return true; }