// The App Store Connect app id (Apple ID) for `console fill` when the config has no apple.appId: with App Store Connect // API credentials it is looked up from apple.bundleId (GET /v1/apps?filter[bundleId], the lookup `apple status` uses), // cached in .storebound/console-app-ids.json (gitignored, never the config) and the form opens by deep link // (/apps//...). Without credentials (or when the lookup fails) navigation falls back to the Apps list by name. import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import type { ShipConfig } from '../lib/config'; /** Looks the app id up by bundle id: the id, null when no app has that bundle id; throws without credentials. */ export type AppIdLookup = (bundleId: string) => Promise; export interface AppIdOpts { say: (m: string) => void; /** Tests: the API lookup (default: ascClient with the project's credentials). */ lookup?: AppIdLookup; /** Tests: the cache file (default /.storebound/console-app-ids.json). */ cacheFile?: string; } const APP_ID = /^\d{1,20}$/; function readCache(file: string): Record { try { const j = JSON.parse(readFileSync(file, 'utf8')) as Record; return Object.fromEntries(Object.entries(j).filter((e): e is [string, string] => typeof e[1] === 'string' && APP_ID.test(e[1]))); } catch { return {}; } } async function apiLookup(root: string): Promise { const { ascClient } = await import('../apple/asc'); return async bundleId => { const c = await ascClient(root, bundleId); // throws without ASC_KEY_ID / ASC_ISSUER_ID / key const found = await c.asc('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}`); const hit = (found.data as { id: string; attributes: { bundleId: string } }[]).find(a => a.attributes.bundleId === bundleId); return hit?.id ?? null; }; } /** * apple.appId, else the cached one for apple.bundleId, else the API lookup (then cached). null: the Apps list by name is * used (no bundle id, no credentials, no such app, or the API failed: said in the log). */ export async function appleAppId(root: string, config: ShipConfig, o: AppIdOpts): Promise { if (config.apple?.appId) return config.apple.appId; const bundleId = config.apple?.bundleId; if (!bundleId) return null; const file = o.cacheFile ?? join(root, '.storebound', 'console-app-ids.json'); const cache = readCache(file); if (cache[bundleId]) { o.say(`app: ${bundleId} is Apple ID ${cache[bundleId]} (looked up earlier, ${file}); opening it by link`); return cache[bundleId]!; } let id: string | null; try { id = await (o.lookup ?? await apiLookup(root))(bundleId); } catch (e) { o.say(`app: no apple.appId in the config and the App Store Connect API lookup did not work (${(e as Error).message.split('\n')[0]}); finding the app in the Apps list by name`); return null; } if (!id || !APP_ID.test(id)) { o.say(`app: the App Store Connect API has no app with bundle id ${bundleId}; finding it in the Apps list by name`); return null; } try { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, `${JSON.stringify({ ...cache, [bundleId]: id }, null, 1)}\n`); } catch { /* cache only */ } o.say(`app: ${bundleId} is Apple ID ${id} (App Store Connect API); opening it by link. Put "appId": "${id}" in apple of the config to skip the lookup`); return id; } /** The config with apple.appId filled in (unchanged when there is none). */ export const withAppleAppId = (config: ShipConfig, id: string | null): ShipConfig => id && config.apple && !config.apple.appId ? { ...config, apple: { ...config.apple, appId: id } } : config;