storebound/cli/console/app-id.ts
CRT b39e44e6ab console fill: one app shown twice is one app; App Privacy opens by app id via the API
`console fill apple-app-privacy` without apple.appId stopped at "the app
in the list" with "more than one app matches: X | X" for an account with
one app: the Apps grid names each app twice (the icon link's aria-label
and the title link), both opening the same /apps/<id>/... page.

- pickApp tells hits apart by the app they open (/apps/<id>, else the
  link target), not by name. Two apps of one name with different targets
  still stop and are listed.
- Without apple.appId, but with App Store Connect API credentials, the
  app id is looked up from apple.bundleId (GET /v1/apps?filter[bundleId],
  as `apple status` does), cached in .storebound/console-app-ids.json, and
  App Privacy opens by deep link. Without credentials, or when the lookup
  fails, the Apps list by name is used, and the log says so.

Fixture asc-apps-card.html (a card with the name in the title and an
aria-label); tests for both paths.
2026-10-12 06:32:21 +08:00

69 lines
3.7 KiB
TypeScript

// 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/<id>/...). 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<string | null>;
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 <root>/.storebound/console-app-ids.json). */
cacheFile?: string;
}
const APP_ID = /^\d{1,20}$/;
function readCache(file: string): Record<string, string> {
try {
const j = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>;
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<AppIdLookup> {
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<string | null> {
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;