// 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 ""] [--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 [--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 '; const METHODS: ExportMethod[] = ['release-testing', 'ad-hoc']; async function registered(c: Asc, platform?: DevicePlatform): Promise { 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 ""'); 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): Promise { 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): Promise { 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 (" 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(); 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({ 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.get('archive'); if (!archive || archive === 'true') throw new Error('usage: storebound apple adhoc export --archive [--out build/adhoc] [--dry]'); 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 { const a = ctx.args; const sub = a._[0]; let client: Asc | null = null; const c = async (): Promise => (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); }