`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.
69 lines
3.7 KiB
TypeScript
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;
|