storebound/cli/lib/adhoc.ts

177 lines
11 KiB
TypeScript

// Ad-hoc / release-testing distribution: device list parsing, UDID checks and masking, the register plan, the
// provisioning profile request, ExportOptions.plist and the xcodebuild argv. Pure (no IO), used by cli/apple/adhoc.ts.
// Sources (checked October 2026):
// App Store Connect API, Devices: https://developer.apple.com/documentation/appstoreconnectapi/devices
// Device.Attributes: deviceClass APPLE_VISION_PRO | APPLE_WATCH | IPAD | IPHONE | IPOD | APPLE_TV | MAC,
// status ENABLED | DISABLED, platform BundleIdPlatform (IOS | MAC_OS | UNIVERSAL), name, model, udid, addedDate.
// DeviceCreateRequest.Data.Attributes: name, platform, udid (all required). Devices can only be removed on the
// Apple Developer website.
// Profiles: https://developer.apple.com/documentation/appstoreconnectapi/profiles
// ProfileCreateRequest: attributes name + profileType (IOS_APP_ADHOC, ...), relationships bundleId + certificates
// (required), devices.
// Devices overview (Developer Account Help): up to 100 devices per product family per membership year; disabling a
// device does not give the slot back. https://developer.apple.com/help/account/devices/devices-overview
// `xcodebuild -help` (Xcode 27): -exportOptionsPlist keys method (app-store-connect, release-testing, enterprise,
// debugging, developer-id, mac-application, validation; ad-hoc / development / app-store are the deprecated names),
// teamID, signingStyle (manual | automatic), provisioningProfiles (bundle id -> profile name or UUID), destination
// (export | upload), thinning (<none> ...). compileBitcode is no longer listed (bitcode was removed in Xcode 14),
// so it is not written.
// UDID shapes: Apple documents no format. Accepted here, from what devices report: the legacy 40 hex digits, the
// 8-16 hex form of newer iPhones / iPads and Apple silicon Macs (the "Provisioning UDID"), and for MAC_OS also the
// 8-4-4-4-12 hardware UUID of Intel Macs. This shape check is not from an Apple document.
import { parseCsvRecords } from './csv';
export type DevicePlatform = 'IOS' | 'MAC_OS';
export const DEVICE_PLATFORMS: DevicePlatform[] = ['IOS', 'MAC_OS'];
export const DEVICE_CLASSES = ['IPHONE', 'IPAD', 'IPOD', 'APPLE_WATCH', 'APPLE_TV', 'APPLE_VISION_PRO', 'MAC'] as const;
/** Devices per product family per membership year (Devices overview). */
export const YEARLY_LIMIT = 100;
export type ExportMethod = 'release-testing' | 'ad-hoc';
/** Deprecated ExportOptions method names -> the current ones (`xcodebuild -help`, Xcode 15.3+). */
export const MODERN_METHOD: Record<string, string> = { 'ad-hoc': 'release-testing', development: 'debugging', 'app-store': 'app-store-connect' };
export interface WantedDevice { name: string; udid: string; platform: DevicePlatform }
export interface RegisteredDevice { id: string; name: string; udid: string; platform: string; deviceClass?: string; model?: string | null; status: string }
const LEGACY = /^[0-9a-f]{40}$/i;
const MODERN = /^[0-9a-f]{8}-[0-9a-f]{16}$/i;
const MAC_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function validUdid(udid: string, platform: DevicePlatform = 'IOS'): boolean {
const u = udid.trim();
return LEGACY.test(u) || MODERN.test(u) || (platform === 'MAC_OS' && MAC_UUID.test(u));
}
/** First 4 + "…" + last 4. Never print a full UDID anywhere (logs, JSON, errors). */
export function maskUdid(udid: string): string {
const u = udid.trim();
return u.length <= 8 ? '…' : `${u.slice(0, 4)}…${u.slice(-4)}`;
}
/** Replaces every UDID-shaped string (and every given UDID) in a message with its masked form: API errors can echo them. */
export function scrubUdids(text: string, known: string[] = []): string {
let s = text;
for (const u of known) if (u.length > 8) s = s.split(u).join(maskUdid(u));
return s.replace(/\b[0-9a-fA-F]{40}\b|\b[0-9a-fA-F]{8}-[0-9a-fA-F]{16}\b|\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g, m => maskUdid(m));
}
export function parsePlatform(p: string | undefined): DevicePlatform | null {
const v = (p ?? '').trim().toUpperCase().replace(/[\s-]/g, '_');
if (!v || v === 'IOS' || v === 'IPADOS') return 'IOS';
if (v === 'MAC_OS' || v === 'MACOS' || v === 'MAC') return 'MAC_OS';
return null;
}
/** devices.csv (name, udid, platform; platform optional, default IOS). Errors name the row and the masked UDID. */
export function parseDevicesCsv(text: string): { devices: WantedDevice[]; errors: string[] } {
const devices: WantedDevice[] = [];
const errors: string[] = [];
const seen = new Set<string>();
parseCsvRecords(text).forEach((r, i) => {
const row = i + 2;
const name = (r.name ?? r.device_name ?? '').trim();
const udid = (r.udid ?? r.device_id ?? r.identifier ?? '').trim();
const platform = parsePlatform(r.platform);
if (!udid && !name) return;
if (!name) errors.push(`row ${row}: name is empty`);
if (!platform) errors.push(`row ${row}: platform must be IOS or MAC_OS`);
if (!validUdid(udid, platform ?? 'IOS')) errors.push(`row ${row}: "${maskUdid(udid)}" is not a device UDID (40 hex digits, or 8 hex - 16 hex)`);
const key = udid.toLowerCase();
if (seen.has(key)) errors.push(`row ${row}: ${maskUdid(udid)} is listed twice`);
seen.add(key);
if (name && platform && validUdid(udid, platform)) devices.push({ name, udid, platform });
});
return { devices, errors: [...new Set(errors)] };
}
export interface RegisterPlan {
add: WantedDevice[];
/** Already registered and enabled. */
present: WantedDevice[];
/** Registered but disabled: storebound never enables devices; the owner does it on the developer website. */
disabled: WantedDevice[];
}
/** What a register run would POST: the wanted devices whose UDID is not registered yet (case-insensitive). */
export function planRegister(wanted: WantedDevice[], registered: RegisteredDevice[]): RegisterPlan {
const by = new Map(registered.map(d => [d.udid.toLowerCase(), d] as const));
const plan: RegisterPlan = { add: [], present: [], disabled: [] };
for (const w of wanted) {
const r = by.get(w.udid.toLowerCase());
if (!r) plan.add.push(w);
else if (r.status === 'DISABLED') plan.disabled.push(w);
else plan.present.push(w);
}
return plan;
}
/** Registered devices per device class (enabled and disabled both use a slot of the yearly 100). */
export function slotUsage(registered: RegisteredDevice[]): { deviceClass: string; registered: number; limit: number }[] {
const n = new Map<string, number>();
for (const d of registered) n.set(d.deviceClass ?? 'UNKNOWN', (n.get(d.deviceClass ?? 'UNKNOWN') ?? 0) + 1);
return [...n].sort((a, b) => a[0].localeCompare(b[0])).map(([deviceClass, registered]) => ({ deviceClass, registered, limit: YEARLY_LIMIT }));
}
export function deviceFromApi(d: { id: string; attributes: Record<string, unknown> }): RegisteredDevice {
const a = d.attributes;
return { id: d.id, name: String(a.name ?? ''), udid: String(a.udid ?? ''), platform: String(a.platform ?? ''), deviceClass: a.deviceClass ? String(a.deviceClass) : undefined, model: (a.model as string | null) ?? null, status: String(a.status ?? '') };
}
/** One device as printed (table and --json): the UDID masked. */
export function maskedDevice(d: RegisteredDevice): { id: string; name: string; platform: string; deviceClass: string | null; model: string | null; status: string; udid: string } {
return { id: d.id, name: d.name, platform: d.platform, deviceClass: d.deviceClass ?? null, model: d.model ?? null, status: d.status, udid: maskUdid(d.udid) };
}
export const deviceCreateReq = (d: WantedDevice) => ({ data: { type: 'devices', attributes: { name: d.name, udid: d.udid, platform: d.platform } } });
/** Devices an iOS ad-hoc profile takes: enabled, platform IOS, not Apple TV (tvOS has its own TVOS_APP_ADHOC profiles). */
export const profileDevices = (registered: RegisteredDevice[]): RegisteredDevice[] =>
registered.filter(d => d.status === 'ENABLED' && d.platform === 'IOS' && d.deviceClass !== 'APPLE_TV');
export function profileCreateReq(name: string, bundleIdId: string, certificateIds: string[], deviceIds: string[]) {
return { data: { type: 'profiles', attributes: { name, profileType: 'IOS_APP_ADHOC' },
relationships: {
bundleId: { data: { type: 'bundleIds', id: bundleIdId } },
certificates: { data: certificateIds.map(id => ({ type: 'certificates', id })) },
devices: { data: deviceIds.map(id => ({ type: 'devices', id })) },
} } };
}
/** Distribution certificates usable for an ad-hoc profile: Apple Distribution (DISTRIBUTION) or the older
* iOS Distribution (IOS_DISTRIBUTION), not expired. */
export function usableCertificates(certs: { id: string; attributes: Record<string, unknown> }[], now = new Date()): string[] {
return certs.filter(c => ['DISTRIBUTION', 'IOS_DISTRIBUTION'].includes(String(c.attributes.certificateType))
&& (!c.attributes.expirationDate || new Date(String(c.attributes.expirationDate)) > now)).map(c => c.id);
}
/** An existing profile is current when it is ACTIVE and holds every wanted device. */
export function profileCurrent(state: string | undefined, has: string[], want: string[]): { current: boolean; missing: number } {
const s = new Set(has);
const missing = want.filter(id => !s.has(id)).length;
return { current: state === 'ACTIVE' && missing === 0, missing };
}
export const escapeXml = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
export interface ExportOptions { method: string; teamId: string; bundleId?: string; profile?: string }
/** ExportOptions.plist (XML plist, keys sorted like Xcode writes them). Manual signing only with a profile name. */
export function exportOptionsPlist(o: ExportOptions): string {
const str = (k: string, v: string) => `\t<key>${escapeXml(k)}</key>\n\t<string>${escapeXml(v)}</string>\n`;
let body = str('destination', 'export') + str('method', o.method);
if (o.profile) {
if (!o.bundleId) throw new Error('a manual-signing profile needs apple.bundleId');
body += `\t<key>provisioningProfiles</key>\n\t<dict>\n\t\t<key>${escapeXml(o.bundleId)}</key>\n\t\t<string>${escapeXml(o.profile)}</string>\n\t</dict>\n`;
}
body += str('signingStyle', o.profile ? 'manual' : 'automatic') + str('teamID', o.teamId) + str('thinning', '<none>');
return '<?xml version="1.0" encoding="UTF-8"?>\n'
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
+ `<plist version="1.0">\n<dict>\n${body}</dict>\n</plist>\n`;
}
export const exportArgv = (archive: string, plist: string, out: string): string[] =>
['xcodebuild', '-exportArchive', '-archivePath', archive, '-exportOptionsPlist', plist, '-exportPath', out, '-allowProvisioningUpdates'];
/** A command line to print: arguments with shell-special characters single-quoted. */
export const shellLine = (argv: string[]): string => argv.map(a => (/^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`)).join(' ');