// TestFlight through the App Store Connect API. // storebound apple testflight status [--json] groups, testers per group, recent builds + beta states // storebound apple testflight groups list the groups and what the config would create (read-only) // storebound apple testflight groups sync [--dry] | groups --yes create the groups of apple.testflight.groups that are missing // storebound apple testflight testers [--csv f] [--group a,b] [--remove] [--invite] [--dry] [--yes] // storebound apple testflight what-to-test --build N [--version x.y.z] [--locale en-US,tr] [--text "..."] [--dry] // storebound apple testflight info [--dry] Test Information + Beta App Review contact / notes // storebound apple testflight distribute --build N --group a,b [--version x.y.z] [--internal-only] [--submit-review] [--dry] [--yes] // storebound apple testflight wait --build N [--version x.y.z] [--timeout 60m] [--dry] [--json] until processing ends // exit codes: 0 VALID, 1 FAILED / INVALID, 7 timeout; --dry checks once and does not wait // storebound apple testflight expire (--keep N | --older-than D) [--version x.y.z] [--dry] [--yes] [--json] // expires old builds (permanent; asks first); builds attached to an App Store version and processing builds stay // Testers come from a CSV in .storebound/ (gitignored; never commit tester emails): email, first_name, last_name, // groups (";"-separated). Emails are masked in every log line. Adding testers makes Apple email TestFlight invitations // (once the group has a build), so `testers` asks first; `--remove` takes testers out of the managed groups. // `distribute --submit-review` sends the build to Beta App Review (external groups) after a confirmation; neither // testers nor external distribution nor the review submission are MCP tools. Pure planning: cli/lib/testflight.ts. import { elapsed, heartbeat, type HeartbeatOptions } from '../lib/progress'; import { existsSync, readFileSync } from 'node:fs'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { reviewContact } from '../lib/credentials'; import { done, log, printJson, warn } from '../lib/log'; import { textTable } from '../lib/table'; import { BUILD_WAIT_EXIT, type BuildSeen, expireCandidates, planExpire, validHint, waitDelay, type WaitOutcome, waitOutcome } from '../lib/build-ops'; import { parseDuration } from '../lib/release'; import { type ApiBuild, betaDescriptions, betaReadiness, betaReviewReq, buildsWithVersion, getsAllBuilds, type Group, groupFromApi, inviteReq, maskEmail, installableBuild, noInstallableBuildHint, noInstallableBuilds409, parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, unknownGroupMessage, whatToTestFromListing, } from '../lib/testflight'; import { type Asc, ascClient, type J } from './asc'; import { describeReq, execute, type Req } from './jsonapi'; import { findOrCreate, getById } from '../lib/upsert'; import * as rt from '../lib/rt'; const TAG = 'testflight'; const USAGE = 'usage: storebound apple testflight status | groups [sync] | testers | what-to-test --build N | info | distribute --build N --group | wait --build N | expire --keep N | --older-than D'; /** The state of build `number` (of `marketing` version) right now; found: false while App Store Connect does not list it. */ async function seeBuild(c: Asc, appId: string, number: string, marketing?: string): Promise { const q = new URLSearchParams({ 'filter[app]': appId, 'filter[version]': number, 'filter[preReleaseVersion.platform]': 'IOS', include: 'preReleaseVersion,buildBetaDetail', limit: '50' }); if (marketing) q.set('filter[preReleaseVersion.version]', marketing); const resp = await c.asc('GET', `/v1/builds?${q}`); const hits = buildsWithVersion(resp).filter(b => b.attributes.version === number && (!marketing || b.marketing === marketing)); if (!hits.length) return { found: false }; const b = pickBuild(hits, number, marketing); const d = (resp.included ?? []).find((x: J) => x.type === 'buildBetaDetails' && x.id === (b as J).relationships?.buildBetaDetail?.data?.id)?.attributes; return { found: true, processingState: b.attributes.processingState, internal: d?.internalBuildState ?? null, external: d?.externalBuildState ?? null, version: b.marketing }; } export interface BuildWait { outcome: WaitOutcome; timedOut: boolean; seen: BuildSeen & { version?: string }; waitedSeconds: number } /** * Polls build `number` until processing ends (VALID, or FAILED / INVALID) or `timeoutMs` passes; logs each state change. * `once` reads the state a single time. Shared by `testflight wait` and `apple upload` (which waits after the upload). */ export async function waitForBuild(c: Asc, appId: string, number: string, o: { marketing?: string; timeoutMs: number; once?: boolean; tag?: string } & HeartbeatOptions): Promise { const started = Date.now(); let delay: number | null = null, last = ''; // polls are up to 2 minutes apart: a heartbeat in between says the wait is alive const beat = o.once ? null : heartbeat(ms => log(o.tag ?? TAG, `still waiting for build ${number}: ${last || 'first check'}, ${elapsed(ms)} of ${elapsed(o.timeoutMs)}`), o); try { for (;;) { const seen = await seeBuild(c, appId, number, o.marketing); const outcome = waitOutcome(seen); const line = outcome === 'missing' ? 'not listed yet (App Store Connect lists it once processing starts)' : `${seen.processingState}${seen.internal ? `, internal ${seen.internal}` : ''}`; if (line !== last) { log(o.tag ?? TAG, `build ${number}${seen.version ? ` (${seen.version})` : ''}: ${line} [${Math.round((Date.now() - started) / 60_000)} min]`); last = line; } const result = (timedOut: boolean): BuildWait => ({ outcome, timedOut, seen, waitedSeconds: Math.round((Date.now() - started) / 1000) }); if (outcome === 'valid' || outcome === 'failed') return result(false); if (o.once) return result(false); delay = waitDelay(delay); if (Date.now() + delay - started > o.timeoutMs) return result(true); await rt.sleep(delay); } } finally { beat?.stop(); } } /** The closing line for a finished wait (VALID with the next step, or FAILED / INVALID with what to do). */ export function waitMessage(number: string, r: BuildWait): string { return r.outcome === 'valid' ? `build ${number} is VALID; ${validHint(r.seen).replace('N', number)}` : `build ${number} is ${r.seen.processingState}: read the email from App Store Connect (missing icons, wrong SDK, invalid signature), fix and upload a new build number`; } /** Every page of a GET, with `included` kept (c.all keeps only data). */ async function pagesOf(c: Asc, path: string): Promise { const out: J[] = []; for (let url: string | null = path; url;) { const page: J = await c.asc('GET', url); out.push(page); url = page.links?.next ?? null; } return out; } /** Creates the planned groups (fresh lookup, then the journal for the list lag, then the create; a 409 finds it). */ async function createGroups(c: Asc, appId: string, root: string, create: Req[], dry: boolean): Promise { if (dry) { await execute(c.asc, create, { dry, tag: TAG }); return; } for (const r of create) { const name = (r.body as J).data.attributes.name as string; const made = await findOrCreate({ what: `beta group "${name}"`, tag: TAG, unique: true, find: async () => (await c.all(`/v1/apps/${appId}/betaGroups?limit=200`)).find((g: J) => g.attributes.name === name) ?? null, journal: { root, key: `apple:${appId}:betaGroup:${name}`, byId: id => getById(c.asc, `/v1/betaGroups/${id}`) as Promise, idOf: v => v.id }, create: async () => (await c.asc('POST', r.path, r.body)).data }); log(TAG, `${describeReq(r)}: ${made.created ? 'done' : 'exists already'}`); } } async function groups(c: Asc, appId: string): Promise { return (await c.all(`/v1/apps/${appId}/betaGroups?limit=200`)).map(groupFromApi); } async function findBuild(c: Asc, appId: string, number: string, marketing?: string): Promise { const q = new URLSearchParams({ 'filter[app]': appId, 'filter[version]': number, 'filter[preReleaseVersion.platform]': 'IOS', include: 'preReleaseVersion,buildBetaDetail', limit: '50' }); if (marketing) q.set('filter[preReleaseVersion.version]', marketing); const resp = await c.asc('GET', `/v1/builds?${q}`); const b = pickBuild(buildsWithVersion(resp), number, marketing); const detail = (resp.included ?? []).find((x: J) => x.type === 'buildBetaDetails' && x.id === (b as J).relationships?.buildBetaDetail?.data?.id)?.attributes ?? null; return { ...b, detail }; } /** Whether the group has a build its testers can install now (past Beta App Review for an external group). An internal * all-builds group has every build of the app. */ async function groupHasInstallableBuild(c: Asc, appId: string, g: Group): Promise { const builds = getsAllBuilds(g) ? await c.all(`/v1/builds?filter[app]=${appId}&limit=200`) : await c.all(`/v1/betaGroups/${g.id}/builds?limit=200`); for (const b of builds) { const detail = (await c.maybe(`/v1/builds/${b.id}/buildBetaDetail`))?.data?.attributes ?? null; if (installableBuild(b, detail, g.internal)) return true; } return false; } export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const a = ctx.args; const sub = a._[0]; if (!sub) throw new Error(USAGE); const dry = a.has('dry'); const tf = config.apple?.testflight; const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const appId = await c.app(); if (sub === 'status') { const gs = await groups(c, appId); const rows: (string | number)[][] = []; const jsonGroups: Record[] = []; for (const g of gs) { const testers = await c.all(`/v1/betaGroups/${g.id}/betaTesters?limit=200`); const builds = await c.all(`/v1/betaGroups/${g.id}/builds?limit=200`); jsonGroups.push({ id: g.id, name: g.name, internal: g.internal, testers: testers.length, builds: g.internal && g.hasAccessToAllBuilds ? 'all' : builds.length, publicLink: !!g.publicLinkEnabled }); rows.push([g.name, g.internal ? 'internal' : 'external', testers.length, g.internal && g.hasAccessToAllBuilds ? 'all' : builds.length, g.publicLinkEnabled ? 'public link on' : '']); } const q = new URLSearchParams({ 'filter[app]': appId, include: 'preReleaseVersion,buildBetaDetail', sort: '-uploadedDate', limit: '8' }); const resp = await c.asc('GET', `/v1/builds?${q}`); const details = new Map((resp.included ?? []).filter((x: J) => x.type === 'buildBetaDetails').map((x: J) => [x.id, x.attributes] as const)); const missingGroups = (tf?.groups ?? []).filter(w => !gs.some(g => g.name === w.name)).map(w => w.name); if (a.has('json')) { printJson({ groups: jsonGroups, builds: buildsWithVersion(resp).map(b => { const d = details.get((b as J).relationships?.buildBetaDetail?.data?.id) as J; return { version: b.marketing ?? null, build: b.attributes.version ?? null, processing: b.attributes.processingState ?? null, internal: d?.internalBuildState ?? null, external: d?.externalBuildState ?? null, uploaded: b.attributes.uploadedDate ?? null }; }), missingGroups }); return; } console.log(textTable(['group', 'kind', 'testers', 'builds', ''], rows)); console.log('\n' + textTable(['version', 'build', 'processing', 'internal', 'external', 'uploaded'], buildsWithVersion(resp).map(b => { const d = details.get((b as J).relationships?.buildBetaDetail?.data?.id) as J; return [b.marketing ?? '', b.attributes.version ?? '', b.attributes.processingState ?? '', d?.internalBuildState ?? '', d?.externalBuildState ?? '', (b.attributes.uploadedDate ?? '').slice(0, 10)]; }))); if (missingGroups.length) warn(TAG, `configured but not on App Store Connect: ${missingGroups.join(', ')} (storebound apple testflight groups sync)`); return; } if (sub === 'groups') { const gs = await groups(c, appId); const plan = planGroups(appId, gs, tf?.groups ?? []); // bare `groups` only lists: creating is `groups sync` (or `groups --yes`); a listing-sounding command never writes const write = a._[1] === 'sync' || a.has('yes'); if (!write) { for (const g of gs) log(TAG, `group "${g.name}": ${g.internal ? 'internal' : 'external'}${tf?.groups?.some(w => w.name === g.name) ? '' : ' (not in apple.testflight.groups)'}`); if (!gs.length) log(TAG, 'no beta groups on App Store Connect yet'); const names = plan.create.map(r => `"${(r.body as J).data.attributes.name}"`); done(TAG, names.length ? `would create ${names.join(', ')} (from apple.testflight.groups): storebound apple testflight groups sync` : 'every configured group exists'); return; } if (!tf?.groups?.length) warn(TAG, 'apple.testflight.groups is empty: nothing to create'); for (const n of plan.existing) log(TAG, `group "${n}": exists`); await createGroups(c, appId, root, plan.create, dry); done(TAG, dry ? `groups checked (dry run): ${plan.create.length} to create` : `groups done (${plan.create.length} created)`); return; } if (sub === 'testers') { const csvPath = resolve(a.get('csv') ?? tf?.testers ?? '.storebound/testers.csv'); if (!existsSync(csvPath)) throw new Error(`no tester CSV at ${csvPath} (columns: email, first_name, last_name, groups; keep it in .storebound/)`); const only = a.list('group'); const want = parseTesters(readFileSync(csvPath, 'utf8'), only.length === 1 ? only[0] : undefined); const gs = await groups(c, appId); const existing = new Map(); const resp = await c.all(`/v1/betaTesters?filter[apps]=${appId}&include=betaGroups&limit=200`); for (const t of resp) existing.set(String(t.attributes.email ?? '').toLowerCase(), { id: t.id, groupIds: (t.relationships?.betaGroups?.data ?? []).map((g: J) => g.id), state: t.attributes.state }); const plan = planTesters(gs, existing, want, { remove: a.has('remove'), only }); if (plan.unknownGroups.length) throw new Error(unknownGroupMessage(plan.unknownGroups, gs, tf?.groups ?? [])); const internal = gs.filter(g => g.internal && want.some(t => t.groups.includes(g.name))); if (internal.length) warn(TAG, `internal groups (${internal.map(g => g.name).join(', ')}) only take App Store Connect users of your team (Users and Access)`); log(TAG, `${want.length} testers in the CSV: ${plan.summary.join(', ')}`); const reqs = [...plan.create, ...plan.add, ...plan.remove]; if (!reqs.length && !a.has('invite')) { done(TAG, 'testers up to date'); return; } if (dry) { await execute(c.asc, reqs, { dry, tag: TAG }); done(TAG, 'testers checked (dry run)'); return; } if (reqs.length && !(await confirm(ctx, `Apply ${plan.summary.join(', ')}? New testers get a TestFlight invitation email from Apple.`))) return; await execute(c.asc, reqs, { dry, tag: TAG }); if (a.has('invite')) { const fresh = new Map((await c.all(`/v1/betaTesters?filter[apps]=${appId}&limit=200`)).map((t: J) => [String(t.attributes.email ?? '').toLowerCase(), t] as const)); // an invitation needs a build the tester can install; without one Apple answers 409 NO_INSTALLABLE_BUILDS (seen live // on a new external group): add the tester, skip the invite, say when it can go out const installable = new Map(); const ready: { t: J; groups: string[] }[] = [], waiting = new Set(); let waitingTesters = 0; for (const w of want) { const t = fresh.get(w.email); if (!t || !['NOT_INVITED', 'INVITED'].includes(t.attributes.state)) continue; const own = gs.filter(g => w.groups.includes(g.name)); for (const g of own) if (!installable.has(g.id)) installable.set(g.id, await groupHasInstallableBuild(c, appId, g)); if (own.some(g => installable.get(g.id))) ready.push({ t, groups: own.map(g => g.name) }); else { waitingTesters++; for (const g of own) waiting.add(g.name); } } if (waitingTesters) { const msg = `${waitingTesters} ${noInstallableBuildHint([...waiting])}`; warn(TAG, msg); } if (ready.length && await confirm(ctx, `(Re)send the TestFlight invitation email to ${ready.length} tester(s)?`)) { for (const { t, groups } of ready) { try { await execute(c.asc, [inviteReq(appId, t.id, maskEmail(String(t.attributes.email)))], { dry: false, tag: TAG }); } catch (e) { if (!noInstallableBuilds409(e)) throw e; const msg = `1 ${noInstallableBuildHint(groups)}`; warn(TAG, msg); } } } } done(TAG, 'testers done'); return; } if (sub === 'what-to-test') { const number = need(a.get('build'), '--build N (the exact build number)'); const b = await findBuild(c, appId, number, a.get('version')); let want: Record; const locales = a.list('locale'); if (a.get('text')) { const ls = locales.length ? locales : [config.apple?.primaryLocale ?? config.languages.find(l => l.apple)?.apple ?? 'en-US']; want = Object.fromEntries(ls.map(l => [l, a.get('text')!])); } else { const f = resolve(config.apple?.listing ?? 'store/appstore.md'); want = whatToTestFromListing(readFileSync(f, 'utf8')); if (locales.length) want = Object.fromEntries(Object.entries(want).filter(([l]) => locales.includes(l))); if (!Object.keys(want).length) throw new Error(`${f}: no "### whatToTest" or "### whatsNew" block (storebound whats-new drafts one), or pass --text`); } const existing = (await c.all(`/v1/builds/${b.id}/betaBuildLocalizations?limit=200`)).map((l: J) => ({ id: l.id, locale: l.attributes.locale, whatsNew: l.attributes.whatsNew })); const reqs = planWhatToTest(b.id, existing, want); if (!reqs.length) { done(TAG, `build ${number}: What to Test up to date (${Object.keys(want).join(', ')})`); return; } await execute(c.asc, reqs, { dry, tag: TAG }); done(TAG, dry ? `build ${number}: ${reqs.length} locale(s) to set (dry run)` : `build ${number}: What to Test set for ${reqs.length} locale(s)`); return; } if (sub === 'info') { const desc = betaDescriptions(tf, config.languages); if (!Object.keys(desc).length) warn(TAG, 'apple.testflight.description is not set: external testing needs a Beta App Description per locale'); const want = Object.fromEntries(Object.entries(desc).map(([locale, description]) => [locale, { description, feedbackEmail: config.app.contactEmail, privacyPolicyUrl: config.app.privacyPolicyUrl, marketingUrl: config.app.marketingUrl ?? config.app.website }])); const locs = (await c.all(`/v1/apps/${appId}/betaAppLocalizations?limit=200`)).map((l: J) => ({ id: l.id, locale: l.attributes.locale, attributes: l.attributes })); const detail = (await c.maybe(`/v1/apps/${appId}/betaAppReviewDetail`))?.data ?? null; const contact = reviewContact(root); if (!contact) warn(TAG, 'no reviewContact in .storebound/credentials.json: Beta App Review contact left as it is'); const reqs = [...planBetaInfo(appId, locs, want), ...planBetaReviewDetail(detail, contact, tf?.reviewNotes ?? config.apple?.info?.reviewNotes)]; await execute(c.asc, reqs, { dry, tag: TAG }); done(TAG, reqs.length ? (dry ? `${reqs.length} change(s) (dry run)` : `test information updated (${reqs.length})`) : 'test information up to date'); return; } if (sub === 'distribute') { const number = need(a.get('build'), '--build N (the exact build number you uploaded)'); const names = a.list('group'); if (!names.length) throw new Error('--group [,]: which beta groups get the build'); let gs = await groups(c, appId); // groups the config names but App Store Connect does not have yet: the groups sync first (create only the missing // configured ones of --group, never remove any); a name the config does not know stays an error const absent = names.filter(n => !gs.some(g => g.name === n)); const configured = (tf?.groups ?? []).filter(g => absent.includes(g.name)); const unknown = absent.filter(n => !configured.some(g => g.name === n)); if (unknown.length) throw new Error(unknownGroupMessage(unknown, gs, tf?.groups ?? [])); if (configured.length) { const sync = planGroups(appId, gs, configured); log(TAG, `${dry ? 'would create' : 'creating'} beta group(s) from apple.testflight.groups first: ${configured.map(g => `"${g.name}"`).join(', ')} (testers stay the owner's: apple testflight testers)`); await createGroups(c, appId, root, sync.create, dry); if (dry) { for (const g of configured) log(TAG, `would POST /v1/betaGroups//relationships/builds {build ${number}}${g.internal && g.allBuilds ? ' (none: an internal all-builds group gets it by itself)' : ''}`); if (configured.length === names.length) { done(TAG, 'distribute checked (dry run)'); return; } } else gs = await groups(c, appId); } const target = names.map(n => gs.find(g => g.name === n)).filter((g): g is Group => !!g); if (!dry && target.length < names.length) throw new Error(`beta group(s) ${names.filter(n => !target.some(g => g.name === n)).join(', ')} created but not listed yet (App Store Connect lists lag after a create): run distribute again in a minute; nothing is created twice`); const external = target.filter(g => !g.internal); if (a.has('internal-only') && external.length) throw new Error(`external group(s) ${external.map(g => g.name).join(', ')}: distribute to external testers from the terminal (asks first)`); const b = await findBuild(c, appId, number, a.get('version')); const problems = betaReadiness(b.attributes, b.detail); if (problems.length) throw new Error(`build ${number}: ${problems.join('; ')}`); log(TAG, `build ${number} (${b.marketing ?? '?'}): internal ${b.detail?.internalBuildState ?? '?'}, external ${b.detail?.externalBuildState ?? '?'}`); // a build's groups, read per group: GET /v1/betaGroups?filter[builds]= is refused live (400) and the spec has no // GET /v1/builds/{id}/betaGroups; internal all-builds groups have every build and take none by hand const inGroups: string[] = []; for (const g of target) { if (getsAllBuilds(g)) { log(TAG, `build ${number}: already available to ${g.name} (internal, all builds)`); continue; } if ((await c.all(`/v1/betaGroups/${g.id}/relationships/builds?limit=200`)).some((x: J) => x.id === b.id)) { inGroups.push(g.id); log(TAG, `build ${number}: already in ${g.name} (${g.internal ? 'internal' : 'external'})`); } } const reqs = planDistribute(b.id, target, inGroups); const review = a.has('submit-review') && external.length ? (await c.maybe(`/v1/builds/${b.id}/betaAppReviewSubmission`))?.data ?? null : null; const wantsReview = a.has('submit-review') && external.length > 0 && !review && !['BETA_APPROVED', 'IN_BETA_REVIEW', 'WAITING_FOR_BETA_REVIEW', 'IN_BETA_TESTING'].includes(b.detail?.externalBuildState); if (a.has('submit-review') && review) log(TAG, `beta review: already submitted (${review.attributes?.betaReviewState ?? '?'})`); if (external.length && !a.has('submit-review') && b.detail?.externalBuildState === 'READY_FOR_BETA_SUBMISSION') warn(TAG, 'external testers only get the build after Beta App Review: add --submit-review (asks first) or submit it in App Store Connect'); // Beta App Review refuses a build whose app has no beta localization in its primary locale (422 // MISSING_BETA_APP_LOCALIZATION): said before the submit, in the dry run too if (wantsReview) { const primary = String((await c.maybe(`/v1/apps/${appId}?fields[apps]=primaryLocale`))?.data?.attributes?.primaryLocale ?? ''); const have = (await c.all(`/v1/apps/${appId}/betaAppLocalizations?limit=200`)).map((l: J) => String(l.attributes.locale)); if (primary && !have.includes(primary)) { const msg = `Beta App Review needs test information in the app's primary locale ${primary} (has: ${have.join(', ') || 'none'}): add it to apple.testflight.description (key ${primary} or its language id), then storebound apple testflight info`; if (dry) warn(TAG, msg); else throw new Error(msg); } } if (dry) { await execute(c.asc, [...reqs, ...(wantsReview ? [betaReviewReq(b.id)] : [])], { dry, tag: TAG }); done(TAG, 'distribute checked (dry run)'); return; } if (external.length && reqs.some(r => external.some(g => r.path.includes(g.id))) && !(await confirm(ctx, `Give build ${number} to external group(s) ${external.map(g => g.name).join(', ')}? Testers are notified once Beta App Review approves it.`))) return; await execute(c.asc, reqs, { dry, tag: TAG }); if (wantsReview) { if (await confirm(ctx, `Submit build ${number} to Beta App Review now? Apple reviews it for external testing (Test Information must be complete: storebound apple testflight info).`)) await execute(c.asc, [betaReviewReq(b.id)], { dry: false, tag: TAG }); } // testers added to an external group but not invited yet: the invitation needs a build past Beta App Review if (a.has('submit-review')) { const waiting: string[] = []; for (const g of external) if ((await c.all(`/v1/betaGroups/${g.id}/betaTesters?limit=200`)).some((t: J) => t.attributes.state === 'NOT_INVITED')) waiting.push(g.name); if (waiting.length) { log(TAG, noInstallableBuildHint(waiting)); } } const added = target.filter(g => reqs.some(r => r.path.includes(`/betaGroups/${g.id}/`))).map(g => g.name); done(TAG, `build ${number}: ${added.length ? `added to ${added.join(', ')}` : `already available to ${target.map(g => g.name).join(', ')}`}`); return; } if (sub === 'wait') { const number = need(a.get('build'), '--build N (the exact build number you uploaded)'); const marketing = a.get('version'); const r = await waitForBuild(c, appId, number, { marketing, timeoutMs: parseDuration(a.str('timeout', '60m')), once: dry }); const code = r.outcome === 'valid' ? BUILD_WAIT_EXIT.valid : r.outcome === 'failed' ? BUILD_WAIT_EXIT.failed : BUILD_WAIT_EXIT.timeout; if (a.has('json')) printJson({ build: number, version: r.seen.version ?? marketing ?? null, outcome: r.timedOut ? 'timeout' : r.outcome, processingState: r.seen.processingState ?? null, internal: r.seen.internal ?? null, external: r.seen.external ?? null, waitedSeconds: r.waitedSeconds }); log(TAG, r.outcome === 'valid' || r.outcome === 'failed' ? waitMessage(number, r) : dry ? 'dry run: checked once, not waiting' : `timeout after ${Math.round(r.waitedSeconds / 60)} min; still ${r.outcome} (re-run to keep waiting)`); process.exit(code); } if (sub === 'expire') { const keep = a.get('keep') === undefined ? undefined : a.num('keep', 0); const olderThanDays = a.get('older-than') === undefined ? undefined : a.num('older-than', 0); const q = new URLSearchParams({ 'filter[app]': appId, 'filter[expired]': 'false', 'filter[preReleaseVersion.platform]': 'IOS', sort: '-uploadedDate', include: 'preReleaseVersion,appStoreVersion', limit: '200' }); if (a.get('version')) q.set('filter[preReleaseVersion.version]', a.get('version')!); const plan = planExpire(expireCandidates(await pagesOf(c, `/v1/builds?${q}`)), { keep, olderThanDays }); const row = (b: { build: string; version?: string; uploaded: string }, what: string) => [b.version ?? '', b.build, b.uploaded.slice(0, 10), what]; const summary = { expire: plan.expire.map(b => ({ build: b.build, version: b.version ?? null, uploaded: b.uploaded })), keep: plan.keep.length, protected: plan.protected.map(p => ({ build: p.b.build, version: p.b.version ?? null, why: p.why })) }; if (a.has('json') && dry) { printJson({ dryRun: true, ...summary }); return; } if (!a.has('json')) console.log(textTable(['version', 'build', 'uploaded', ''], [...plan.expire.map(b => row(b, 'expire')), ...plan.protected.map(p => row(p.b, `keep: ${p.why}`)), ...plan.keep.map(b => row(b, 'keep'))])); if (!plan.reqs.length) { done(TAG, 'nothing to expire'); if (a.has('json')) printJson({ dryRun: dry, ...summary, expired: [] }); return; } if (dry) { await execute(c.asc, plan.reqs, { dry, tag: TAG }); done(TAG, `dry run: ${plan.reqs.length} build(s) would be expired`); return; } if (!(await confirm(ctx, `Expire ${plan.reqs.length} build(s) (${plan.expire.map(b => b.build).join(', ')})? Testers lose them; an expired build cannot be brought back.`))) return; await execute(c.asc, plan.reqs, { dry: false, tag: TAG }); if (a.has('json')) printJson({ dryRun: false, ...summary, expired: plan.expire.map(b => b.build) }); done(TAG, `${plan.reqs.length} build(s) expired`); return; } throw new Error(`unknown testflight command "${sub}"\n${USAGE}`); }