storebound/cli/apple/adhoc.ts

209 lines
15 KiB
TypeScript

// Ad-hoc / release-testing distribution: register test devices, an ad-hoc provisioning profile, ExportOptions.plist
// and the IPA export.
// storebound apple adhoc devices [--platform IOS|MAC_OS] [--json] registered devices, UDIDs masked
// storebound apple adhoc register [--csv .storebound/devices.csv] [--name n --udid u --platform IOS] [--dry] [--yes]
// storebound apple adhoc profile [--name "<profile name>"] [--dry] [--yes] IOS_APP_ADHOC profile with every enabled iOS device
// storebound apple adhoc options [--method release-testing|ad-hoc] [--out build/ExportOptions.plist] [--dry]
// storebound apple adhoc export --archive <App.xcarchive> [--plist build/ExportOptions.plist] [--out build/adhoc] [--dry]
// The devices CSV (name, udid, platform) is personal data: keep it in .storebound/ (gitignored). Full UDIDs are never
// printed, also not in --json or in API errors. register only adds devices: it never disables, enables or deletes one
// (that is the owner's call, on the Apple Developer website). Each registration uses one of the 100 yearly slots of
// its product family, and disabling a device does not give the slot back, so register asks first.
// --json of devices: { devices: [{ id, name, platform, deviceClass, model, status, udid (masked) }], usage: [{ deviceClass, registered, limit }] }.
// Sources (checked October 2026):
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-devices (filter[platform] IOS|MAC_OS|UNIVERSAL, limit <= 200)
// https://developer.apple.com/documentation/appstoreconnectapi/post-v1-devices (name, platform, udid)
// https://developer.apple.com/documentation/appstoreconnectapi/post-v1-profiles (profileType IOS_APP_ADHOC)
// .../get-v1-bundleids (filter[identifier]), .../get-v1-certificates (filter[certificateType] DISTRIBUTION | IOS_DISTRIBUTION),
// .../get-v1-bundleids-_id_-profiles, .../get-v1-profiles-_id_-devices
// https://developer.apple.com/help/account/devices/devices-overview (100 per product family per membership year)
// `xcodebuild -help` (Xcode 27) for the ExportOptions keys and -exportArchive / -allowProvisioningUpdates.
// Not verified from a document: the UDID shapes (see cli/lib/adhoc.ts) and whether App Store Connect accepts a second
// profile with the same name; `profile` therefore gives a changed profile a new, dated name.
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import {
deviceCreateReq, deviceFromApi, type DevicePlatform, exportArgv, type ExportMethod, exportOptionsPlist, maskedDevice, maskUdid,
parseDevicesCsv, parsePlatform, planRegister, profileCreateReq, profileCurrent, profileDevices, type RegisteredDevice, scrubUdids,
shellLine, slotUsage, usableCertificates, validUdid, type WantedDevice, YEARLY_LIMIT,
} from '../lib/adhoc';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { textTable } from '../lib/table';
import { createOnce } from '../lib/upsert';
import { type Asc, ascClient, type J } from './asc';
const TAG = 'adhoc';
const USAGE = 'usage: storebound apple adhoc devices | register [--csv f | --name n --udid u] | profile | options | export --archive <App.xcarchive>';
const METHODS: ExportMethod[] = ['release-testing', 'ad-hoc'];
async function registered(c: Asc, platform?: DevicePlatform): Promise<RegisteredDevice[]> {
return (await c.all(`/v1/devices?limit=200${platform ? `&filter[platform]=${platform}` : ''}`)).map(deviceFromApi);
}
function usageLines(devs: RegisteredDevice[]): void {
for (const u of slotUsage(devs)) log(TAG, `${u.deviceClass}: ${u.registered} registered of ${u.limit} per membership year`);
}
function wantedDevices(ctx: Ctx): WantedDevice[] {
const a = ctx.args;
const udid = a.get('udid');
if (udid !== undefined) {
const name = a.get('name');
const platform = parsePlatform(a.get('platform'));
if (!name || name === 'true') throw new Error('--udid needs --name "<device name>"');
if (!platform) throw new Error('--platform: IOS or MAC_OS');
if (!validUdid(udid, platform)) throw new Error(`--udid ${maskUdid(udid)}: not a device UDID (40 hex digits, or 8 hex - 16 hex)`);
return [{ name, udid: udid.trim(), platform }];
}
const rel = a.get('csv') ?? ctx.loaded.config.apple?.adhoc?.devices ?? '.storebound/devices.csv';
const file = ctx.loaded.resolve(rel);
if (!existsSync(file)) throw new Error(`${rel}: no devices file. Create it with the header line "name,udid,platform" and one device per line (platform IOS or MAC_OS); see skills/adhoc-distribution`);
if (!relative(ctx.loaded.root, file).startsWith('.storebound')) warn(TAG, `${rel} is outside .storebound/: device UDIDs are personal data, keep the file gitignored`);
const { devices, errors } = parseDevicesCsv(readFileSync(file, 'utf8'));
if (errors.length) throw new Error(`${rel}:\n ${errors.join('\n ')}`);
if (!devices.length) throw new Error(`${rel}: no devices`);
return devices;
}
async function register(ctx: Ctx, c: () => Promise<Asc>): Promise<void> {
const wanted = wantedDevices(ctx);
const api = await c();
const all = await registered(api);
const plan = planRegister(wanted, all);
log(TAG, `${wanted.length} in the list: ${plan.add.length} new, ${plan.present.length} already registered, ${plan.disabled.length} disabled`);
for (const d of plan.disabled) warn(TAG, `${d.name} (${maskUdid(d.udid)}) is registered but disabled; enable it on the Apple Developer website if it should test (storebound does not)`);
usageLines(all);
if (!plan.add.length) { done(TAG, 'nothing to register'); return; }
console.log(textTable(['name', 'platform', 'udid'], plan.add.map(d => [d.name, d.platform, maskUdid(d.udid)])));
if (ctx.args.has('dry')) { log(TAG, `dry run: would register ${plan.add.length} device(s); nothing sent`); return; }
if (!(await confirm(ctx, `Register ${plan.add.length} device(s)? Each uses one of the ${YEARLY_LIMIT} yearly slots of its product family, also if it is disabled later.`))) {
log(TAG, 'not confirmed, nothing registered');
return;
}
const udids = plan.add.map(d => d.udid);
let failed = 0;
for (const d of plan.add) {
try {
// a UDID is registered once per team: a 409 or an unclear answer is checked by a direct lookup, never re-sent blindly
const r = await createOnce({ what: `device ${d.name} (${maskUdid(d.udid)})`, tag: TAG, unique: true,
find: async () => (await api.all(`/v1/devices?filter[udid]=${encodeURIComponent(d.udid)}&limit=10`)).find((x: J) => String(x.attributes?.udid ?? '').toLowerCase() === d.udid.toLowerCase()) ?? null,
create: async () => (await api.asc('POST', '/v1/devices', deviceCreateReq(d))).data });
log(TAG, `${r.created ? 'registered' : 'already registered:'} ${d.name} (${maskUdid(d.udid)})`);
} catch (e) {
failed++;
warn(TAG, `${d.name} (${maskUdid(d.udid)}) not registered: ${scrubUdids((e as Error).message, udids)}`);
}
}
if (failed) throw new Error(`${failed} of ${plan.add.length} device(s) not registered`);
done(TAG, `${plan.add.length} device(s) registered; run \`storebound apple adhoc profile\` (or export with automatic signing) so the builds include them`);
}
async function profile(ctx: Ctx, c: () => Promise<Asc>): Promise<void> {
const { config, resolve } = ctx.loaded;
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
const name = ctx.args.get('name') ?? config.apple?.adhoc?.profile ?? `${bundleId} AdHoc`;
const api = await c();
const bids = await api.asc('GET', `/v1/bundleIds?filter[identifier]=${encodeURIComponent(bundleId)}&limit=200`);
const bid = (bids.data as J[]).find(b => b.attributes?.identifier === bundleId && ['IOS', 'UNIVERSAL'].includes(b.attributes?.platform));
if (!bid) throw new Error(`no iOS bundle id ${bundleId} on the Apple Developer account (Certificates, Identifiers & Profiles > Identifiers)`);
const certs = usableCertificates(await api.all('/v1/certificates?filter[certificateType]=DISTRIBUTION,IOS_DISTRIBUTION&limit=200'));
if (!certs.length) throw new Error('no valid Apple Distribution certificate: create one in Xcode (Settings > Accounts > Manage Certificates) or on the developer website');
const devices = profileDevices(await registered(api, 'IOS'));
if (!devices.length) throw new Error('no enabled iOS devices: register them first (storebound apple adhoc register)');
const want = devices.map(d => d.id);
// the profile and the dated ones an earlier run made when it had to replace it ("<name> YYYY-MM-DD"), newest first
const dated = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}( \\d{4}-\\d{2}-\\d{2}( \\d{2}-\\d{2})?)?$`);
const existing = (await api.all(`/v1/bundleIds/${bid.id}/profiles?limit=200`)).filter((p: J) => p.attributes?.profileType === 'IOS_APP_ADHOC' && dated.test(p.attributes?.name ?? ''))
.sort((x: J, y: J) => String(y.attributes.name).localeCompare(String(x.attributes.name)));
let newName = name;
for (const p of existing) {
const has = (await api.all(`/v1/profiles/${p.id}/devices?limit=200`)).map((d: J) => d.id as string);
const st = profileCurrent(p.attributes.profileState, has, want);
if (st.current) { done(TAG, `profile "${p.attributes.name}" is current (${has.length} devices, expires ${String(p.attributes.expirationDate ?? '').slice(0, 10)})`); return; }
log(TAG, `profile "${p.attributes.name}" is ${p.attributes.profileState === 'ACTIVE' ? `missing ${st.missing} device(s)` : 'invalid'}; profiles cannot be changed, so a new one is created`);
newName = `${name} ${new Date().toISOString().slice(0, 10)}`;
}
const classes = new Map<string, number>();
for (const d of devices) classes.set(d.deviceClass ?? 'UNKNOWN', (classes.get(d.deviceClass ?? 'UNKNOWN') ?? 0) + 1);
log(TAG, `plan: create IOS_APP_ADHOC "${newName}" for ${bundleId}: ${devices.length} devices (${[...classes].map(([k, v]) => `${v} ${k}`).join(', ')}), ${certs.length} distribution certificate(s)`);
if (ctx.args.has('dry')) { log(TAG, 'dry run: nothing created'); return; }
if (!(await confirm(ctx, `Create the ad-hoc provisioning profile "${newName}"?`))) { log(TAG, 'not confirmed, nothing created'); return; }
if (existing.some((p: J) => p.attributes.name === newName)) newName = `${name} ${new Date().toISOString().slice(0, 16).replace('T', ' ').replace(':', '-')}`;
const res = (await createOnce<J>({ what: `profile "${newName}"`, tag: TAG,
find: async () => (await api.all(`/v1/bundleIds/${bid.id}/profiles?limit=200`)).find((p: J) => p.attributes?.name === newName) ?? null,
create: async () => (await api.asc('POST', '/v1/profiles', profileCreateReq(newName, bid.id, certs, want))).data })).value;
const content = res?.attributes?.profileContent as string | undefined;
if (content) {
const out = resolve(`build/adhoc/${newName.replace(/[^\w.-]+/g, '_')}.mobileprovision`);
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, Buffer.from(content, 'base64'));
log(TAG, `saved ${relative(ctx.loaded.root, out)} (double-click to install it for manual signing)`);
}
if (newName !== name) warn(TAG, `the new profile is "${newName}": set apple.adhoc.profile to it for manual signing; the old one can be deleted on the Apple Developer website (owner)`);
done(TAG, `profile "${newName}" created (uuid ${res?.attributes?.uuid ?? '?'})`);
}
function plistFor(ctx: Ctx): string {
const { config } = ctx.loaded;
const method = (ctx.args.get('method') ?? config.apple?.adhoc?.method ?? 'release-testing') as ExportMethod;
if (!METHODS.includes(method)) throw new Error(`--method: ${METHODS.join(' | ')} (ad-hoc is the name before Xcode 15.3)`);
return exportOptionsPlist({ method, teamId: need(config.apple?.teamId, 'apple.teamId'), bundleId: config.apple?.bundleId, profile: config.apple?.adhoc?.profile });
}
function options(ctx: Ctx): void {
const rel = ctx.args.str('out', 'build/ExportOptions.plist');
const xml = plistFor(ctx);
if (ctx.args.has('dry')) { log(TAG, `dry run: would write ${rel}`); console.log(xml); return; }
const out = ctx.loaded.resolve(rel);
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, xml);
done(TAG, `wrote ${rel}`);
}
function exportIpa(ctx: Ctx): void {
const a = ctx.args;
const archive = a.required('archive', 'App.xcarchive', 'Xcode: Product > Archive, or storebound apple build');
if (!existsSync(archive)) throw new Error(`${archive}: no such archive (Xcode: Product > Archive, or xcodebuild archive)`);
const plistRel = a.str('plist', 'build/ExportOptions.plist');
const plist = ctx.loaded.resolve(plistRel);
const out = ctx.loaded.resolve(a.str('out', 'build/adhoc'));
const dry = a.has('dry');
if (!existsSync(plist)) {
const xml = plistFor(ctx);
if (dry) { log(TAG, `dry run: would write ${plistRel}`); console.log(xml); }
else { mkdirSync(dirname(plist), { recursive: true }); writeFileSync(plist, xml); log(TAG, `wrote ${plistRel}`); }
}
const argv = exportArgv(archive, plist, out);
if (dry) { log(TAG, 'dry run: would run'); console.log(shellLine(argv)); return; }
log(TAG, 'running xcodebuild -exportArchive; -allowProvisioningUpdates lets Xcode contact Apple to create or update signing assets');
const r = spawnSync(argv[0]!, argv.slice(1), { stdio: 'inherit' });
if (r.error) throw new Error(`xcodebuild: ${r.error.message} (Xcode installed? xcode-select -p)`);
if (r.status !== 0) throw new Error(`xcodebuild -exportArchive failed (exit ${r.status})`);
const ipas = existsSync(out) ? readdirSync(out).filter(f => f.endsWith('.ipa')) : [];
done(TAG, ipas.length ? `exported ${ipas.map(f => join(relative(ctx.loaded.root, out), f)).join(', ')}` : `export finished in ${relative(ctx.loaded.root, out)}`);
}
export async function run(ctx: Ctx): Promise<void> {
const a = ctx.args;
const sub = a._[0];
let client: Asc | null = null;
const c = async (): Promise<Asc> => (client ??= await ascClient(ctx.loaded.root, ctx.loaded.config.apple?.bundleId ?? '', ctx.loaded.config.apple?.appId));
if (sub === 'devices') {
const p = a.get('platform');
const platform = p ? parsePlatform(p) : undefined;
if (p && !platform) throw new Error('--platform: IOS or MAC_OS');
const devs = await registered(await c(), platform ?? undefined);
if (a.has('json')) { printJson({ devices: devs.map(maskedDevice), usage: slotUsage(devs) }); return; }
console.log(textTable(['name', 'platform', 'class', 'model', 'status', 'udid'], devs.map(maskedDevice).map(d => [d.name, d.platform, d.deviceClass ?? '', d.model ?? '', d.status, d.udid])));
usageLines(devs);
return;
}
if (sub === 'register') return register(ctx, c);
if (sub === 'profile') return profile(ctx, c);
if (sub === 'options') return options(ctx);
if (sub === 'export') return exportIpa(ctx);
throw new Error(USAGE);
}