@imagina-build/ias-cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +184 -21
  2. package/package.json +1 -1
  3. package/schema/ias.schema.json +46 -1
  4. package/shared/vault/index.mjs +25 -0
  5. package/shared/vault/policy-vectors.mjs +237 -0
  6. package/shared/vault/policy.mjs +427 -0
  7. package/shared/vault/test-vectors/ias-policy-1/policy.json +4016 -0
  8. package/shared/vault/vectors.mjs +2 -0
  9. package/src/apple/capabilities.mjs +96 -0
  10. package/src/apple/inventory.mjs +75 -0
  11. package/src/apple/signing.mjs +134 -0
  12. package/src/applications.mjs +114 -0
  13. package/src/cli.mjs +28 -10
  14. package/src/commands/apps/index.mjs +61 -11
  15. package/src/commands/apps/store.mjs +367 -0
  16. package/src/commands/auth/index.mjs +3 -3
  17. package/src/commands/build/index.mjs +2 -0
  18. package/src/commands/builds/index.mjs +20 -0
  19. package/src/commands/credentials/apple.mjs +1118 -0
  20. package/src/commands/credentials/generate.mjs +122 -0
  21. package/src/commands/credentials/index.mjs +114 -44
  22. package/src/commands/credentials/seal.mjs +44 -0
  23. package/src/commands/devices/asc.mjs +50 -8
  24. package/src/commands/devices/index.mjs +2 -1
  25. package/src/commands/devices/sync.mjs +57 -4
  26. package/src/commands/dist/index.mjs +12 -0
  27. package/src/commands/github/index.mjs +13 -3
  28. package/src/commands/policy/index.mjs +583 -0
  29. package/src/commands/policy/policy.mjs +182 -0
  30. package/src/commands/policy/progress.mjs +20 -0
  31. package/src/commands/releases/index.mjs +225 -0
  32. package/src/commands/rollout/index.mjs +186 -0
  33. package/src/commands/runners/index.mjs +19 -0
  34. package/src/commands/sdk/index.mjs +31 -4
  35. package/src/commands/setup/index.mjs +184 -0
  36. package/src/commands/submit/index.mjs +135 -19
  37. package/src/commands/testflight/index.mjs +309 -0
  38. package/src/commands/tokens/index.mjs +99 -0
  39. package/src/commands/update/index.mjs +42 -15
  40. package/src/commands/vault/approvers.mjs +5 -1
  41. package/src/commands/vault/index.mjs +26 -6
  42. package/src/commands/vault/runners.mjs +48 -2
  43. package/src/commands/webhooks/index.mjs +19 -5
  44. package/src/config.mjs +16 -1
  45. package/src/errors.mjs +52 -6
  46. package/src/op.mjs +83 -0
  47. package/src/ota-key.mjs +53 -0
  48. package/src/provisioning.mjs +281 -0
  49. package/src/schema.mjs +3 -0
  50. package/src/setup-state.mjs +76 -0
  51. package/src/setup.mjs +698 -0
  52. package/src/team-vault.mjs +2 -2
  53. package/src/token-store.mjs +1 -1
@@ -0,0 +1,309 @@
1
+ // ias testflight: what happens after `ias submit` on iOS (docs/plans/gaps/04-testflight.md §3.6 and
2
+ // docs/technical/manage-api.md § TestFlight). A build that is in App Store Connect is distributed to TestFlight groups
3
+ // with its «What to Test», or — separately, with its own passkey — sent to Beta App Review. `status` only reads: it never
4
+ // talks to Apple and never creates a job. The groups come from ias.json when no flag is given, and if there is nothing to
5
+ // use the command lists the groups the app has and says exactly what to run; it never creates one.
6
+ import { readFileSync } from 'node:fs';
7
+ import { resolve } from 'node:path';
8
+ import { loadProject } from '../../config.mjs';
9
+ import { ApiError, IasError } from '../../errors.mjs';
10
+ import { connect, openUrl, sleep } from '../../manage.mjs';
11
+ import { EXIT_CODE } from '../../output.mjs';
12
+ import { buildsClient, isFinished } from '../build/follow.mjs';
13
+ import { policyLine, sayPolicy } from '../policy/progress.mjs';
14
+
15
+ const BUILD_ID = /^[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}$/;
16
+ const DEFAULT_LOCALE = 'es-ES';
17
+ /** The locales of a beta build localization (App Store Connect API, BetaBuildLocalization): the same list the server checks. */
18
+ const LOCALES = ['ar-SA', 'ca', 'cs', 'da', 'de-DE', 'el', 'en-AU', 'en-CA', 'en-GB', 'en-US', 'es-ES', 'es-MX', 'fi', 'fr-CA', 'fr-FR', 'he', 'hi', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'ms', 'nl-NL', 'no', 'pl', 'pt-BR', 'pt-PT', 'ro', 'ru', 'sk', 'sv', 'th', 'tr', 'uk', 'vi', 'zh-Hans', 'zh-Hant'];
19
+ const MAX_GROUPS = 20;
20
+ const MAX_NOTE = 4000;
21
+
22
+ const quote = (name) => `"${name}"`;
23
+ const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
24
+ const clientOf = (context, project = projectOf(context)) => buildsClient(connect(context, project));
25
+
26
+ /** The configuration of TestFlight of a submit profile of ias.json. */
27
+ const settingsOf = (project, build) => project?.config?.submit?.[build.profile]?.ios?.testflight ?? {};
28
+
29
+ /** The groups of the flags, of ias.json, or null (the command then says how to choose one). */
30
+ function groupsOf(context, build, project) {
31
+ const flags = [context.values.groups ?? []].flat().map((group) => String(group).trim()).filter(Boolean);
32
+ if (flags.length > 0) return flags;
33
+ const configured = settingsOf(project, build).groups;
34
+ return Array.isArray(configured) && configured.length > 0 ? configured.map((group) => String(group)) : null;
35
+ }
36
+
37
+ /** The «What to Test» by locale: --notes-file, --what-to-test, ias.json, or the message of the build. */
38
+ function notesOf(context, build, project) {
39
+ const { values } = context;
40
+ if (values['notes-file']) {
41
+ const path = resolve(context.cwd, values['notes-file']);
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
45
+ } catch (error) {
46
+ throw new IasError(`--notes-file ${values['notes-file']} cannot be read as JSON (${error.message})`, { fix: 'it is an object of locale: text, as { "es-ES": "Prueba el alta" }' });
47
+ }
48
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed) || Object.values(parsed).some((note) => typeof note !== 'string')) {
49
+ throw new IasError(`--notes-file ${values['notes-file']} is not an object of locale: text`, { fix: 'as { "es-ES": "Prueba el alta", "en-US": "Try signup" }' });
50
+ }
51
+ return parsed;
52
+ }
53
+ if (values['what-to-test']) return { [values.locale ?? DEFAULT_LOCALE]: values['what-to-test'] };
54
+ const configured = settingsOf(project, build).notes;
55
+ if (configured && typeof configured === 'object' && Object.keys(configured).length > 0) return configured;
56
+ const message = String(build.message ?? '').trim();
57
+ return { [values.locale ?? DEFAULT_LOCALE]: message || `Build ${build.version} (${build.build_number ?? '?'}) from ${String(build.commit ?? '').slice(0, 7)}` };
58
+ }
59
+
60
+ /** The plan as the server would check it, so a typo fails here with the command that fixes it and nothing is created. */
61
+ function checkPlan(context) {
62
+ const { values } = context;
63
+ if (values.locale !== undefined && !LOCALES.includes(values.locale)) throw new IasError(`--locale ${values.locale} is not a locale App Store Connect takes`, { fix: `one of ${LOCALES.join(', ')}` });
64
+ if (values.groups !== undefined) {
65
+ const groups = [values.groups].flat();
66
+ if (groups.length > MAX_GROUPS) throw new IasError(`--groups takes at most ${MAX_GROUPS} groups`, { fix: 'distribute in two rounds, or join the testers in App Store Connect' });
67
+ if (groups.some((group) => String(group).trim() === '')) throw new IasError('--groups takes the name of a group, not an empty one', { fix: 'ias testflight status <build> lists the groups the app has' });
68
+ }
69
+ }
70
+
71
+ /** The state TestFlight has of the build: GET /builds/{id}/testflight, read-only. */
72
+ async function stateOf(client, build) {
73
+ try {
74
+ return (await client.request('GET', `/builds/${build.id}/testflight`)).data;
75
+ } catch (error) {
76
+ if (error instanceof ApiError && error.status === 404) return null;
77
+ throw error;
78
+ }
79
+ }
80
+
81
+ /** The build, checked to be an iOS store build before anything is sent. */
82
+ async function buildOf(client, id) {
83
+ const { data: build } = await client.request('GET', `/builds/${id}`);
84
+ if (build.platform !== 'ios' || build.distribution !== 'store') {
85
+ throw new IasError(`${build.id} is ${build.distribution === 'store' ? 'an Android' : 'an internal'} build (${build.profile})`, {
86
+ fix: 'TestFlight is for a store iOS build that is in App Store Connect: ias builds list --platform ios',
87
+ code: 'testflight_not_ready',
88
+ });
89
+ }
90
+ return build;
91
+ }
92
+
93
+ /** TestFlight distributes a build App Store Connect already has: the submission has to have reached it. */
94
+ function ensureInStore(build) {
95
+ const sent = (build.submissions ?? []).some((submission) => submission.target === 'app_store_connect' && submission.status === 'succeeded');
96
+ if (!sent) {
97
+ throw new IasError(`${build.id} is not in App Store Connect yet`, {
98
+ fix: `ias submit ${build.id} --wait sends it first: TestFlight distributes a build Apple already has`,
99
+ code: 'testflight_not_ready',
100
+ });
101
+ }
102
+ }
103
+
104
+ /** The line of the plan, for people and --dry-run. */
105
+ function printPlan(output, build, plan) {
106
+ output.log(`TestFlight of ${build.id}: ${build.app} ${build.version} (${build.build_number ?? '?'})`);
107
+ output.log(` groups ${plan.groups.map(quote).join(', ')}`);
108
+ for (const [locale, note] of Object.entries(plan.notes)) output.log(` notes ${locale}: ${note}`);
109
+ output.log(` notify ${plan.notify ? 'yes: the testers are notified' : 'no'}`);
110
+ }
111
+
112
+ /** Opens the approval, or says where it is. */
113
+ function openApproval(context, output, job) {
114
+ if (!job.approval_url) return;
115
+ const opened = (context.openUrl ?? ((url) => openUrl(url, context.env)))(job.approval_url);
116
+ output.progress(`${opened ? 'Opened' : 'Open'} ${job.approval_url}: an owner approves it with a passkey (the panel shows the hash ${job.hash}).`);
117
+ }
118
+
119
+ /** Follows the job through GET /builds/{id}/testflight until it ends. */
120
+ async function follow(context, client, build, job) {
121
+ const { output } = context;
122
+ const wait = context.sleep ?? sleep;
123
+ let current = job;
124
+ let said = current.status;
125
+ while (!isFinished(current.status)) {
126
+ await wait(5);
127
+ const state = await stateOf(client, build);
128
+ current = state?.job?.id === job.id ? state.job : current;
129
+ if (current.status !== said) {
130
+ said = current.status;
131
+ output.progress(` ${current.status}${current.step ? ` (${current.step})` : ''}${current.runner?.name ? ` on ${current.runner.name}` : ''}`);
132
+ }
133
+ }
134
+ return current;
135
+ }
136
+
137
+ /** What to say, and the next command, when the job ended. */
138
+ function outcome(output, build, job) {
139
+ if (job.status !== 'succeeded') {
140
+ const error = job.error ? `${job.error.code}${job.error.step ? ` at ${job.error.step}` : ''}${job.error.message ? `: ${job.error.message}` : ''}` : '';
141
+ output.log(`TestFlight ${job.action} of ${build.id}: ${job.status}${error ? ` — ${error}` : ''}`);
142
+ return { ...job, [EXIT_CODE]: 1 };
143
+ }
144
+ const result = job.result ?? {};
145
+ if (job.action === 'review') {
146
+ output.log(`The build is in Beta App Review: ${result.reviewState ?? result.review_state ?? 'WAITING_FOR_REVIEW'} (state ${result.reviewSubmissionId ?? '—'}).`);
147
+ output.log(`ias testflight status ${build.id} reads what Apple says of it; a rejection is not sent again by itself.`);
148
+ return job;
149
+ }
150
+ const groups = (result.groups ?? []).map((group) => group.name).filter(Boolean);
151
+ output.log(`The build is in TestFlight${groups.length > 0 ? ` for ${groups.map(quote).join(', ')}` : ''}${result.notified ? ', and the testers were notified' : ''}.`);
152
+ output.log(`For external testers: ias testflight review ${build.id} sends it to Beta App Review (a separate approval).`);
153
+ return job;
154
+ }
155
+
156
+ async function distribute(context) {
157
+ const { values, output } = context;
158
+ checkPlan(context);
159
+ const project = projectOf(context);
160
+ const client = clientOf(context, project);
161
+ const build = await buildOf(client, context.positionals[0]);
162
+ ensureInStore(build);
163
+ const groups = groupsOf(context, build, project);
164
+
165
+ if (groups === null) {
166
+ const state = await stateOf(client, build);
167
+ const existing = [...new Set((state?.groups ?? []).map((group) => group.name).filter(Boolean))];
168
+ throw new IasError(`ias testflight ${build.id} has no groups to distribute to`, {
169
+ code: 'testflight_groups_missing',
170
+ fix: existing.length > 0
171
+ ? `the app has these TestFlight groups: ${existing.map(quote).join(', ')}. Run ias testflight ${build.id} --groups ${quote(existing[0])}${existing.length > 1 ? ` --groups ${quote(existing[1])}` : ''}, or add submit.${build.profile}.ios.testflight.groups to ias.json`
172
+ : `a person creates a group in App Store Connect › TestFlight › Groups; then ias testflight ${build.id} --groups "<name>" (IAS does not create groups)`,
173
+ });
174
+ }
175
+
176
+ const notes = notesOf(context, build, project);
177
+ const configured = settingsOf(project, build).notify;
178
+ const notify = values.notify === true || configured === true;
179
+ const long = Object.entries(notes).find(([, note]) => [...String(note)].length > MAX_NOTE);
180
+ if (long) throw new IasError(`the note of ${long[0]} has ${[...String(long[1])].length} characters`, { fix: `a beta note has at most ${MAX_NOTE}: shorten it` });
181
+ const unknown = Object.keys(notes).filter((locale) => !LOCALES.includes(locale));
182
+ if (unknown.length > 0) throw new IasError(`«${unknown[0]}» is not a locale App Store Connect takes`, { fix: `one of ${LOCALES.join(', ')}` });
183
+
184
+ const plan = { action: 'distribute', groups, notes, notify };
185
+ printPlan(output, build, plan);
186
+
187
+ if (values['dry-run']) {
188
+ output.progress('Nothing was created: an owner approves the real one with a passkey.');
189
+ return { build: build.id, ...plan, dry_run: true, job: null };
190
+ }
191
+
192
+ const { data: job } = await client.request('POST', `/builds/${build.id}/testflight`, { json: { groups, notes, notify } });
193
+ output.progress(`distribute ${build.version} (${build.build_number ?? '?'}) to ${groups.map(quote).join(', ')}: job ${job.id}, ${job.status}`);
194
+ sayPolicy(output, job);
195
+ openApproval(context, output, job);
196
+ if (!values.wait) {
197
+ output.log(`ias testflight status ${build.id} shows how it goes; the runner distributes it once it is approved.`);
198
+ return { build: build.id, ...plan, job };
199
+ }
200
+ return outcome(output, build, await follow(context, client, build, job));
201
+ }
202
+
203
+ async function review(context) {
204
+ const { values, output } = context;
205
+ const project = projectOf(context);
206
+ const client = clientOf(context, project);
207
+ const build = await buildOf(client, context.positionals[0]);
208
+ ensureInStore(build);
209
+
210
+ output.progress(`This sends ${build.version} (${build.build_number ?? '?'}) to Beta App Review: it is a second approval, separate from distributing. Apple can reject it, and IAS does not send it again by itself.`);
211
+
212
+ if (values['dry-run']) {
213
+ output.progress('Nothing was created: an owner approves the real one with a passkey.');
214
+ return { build: build.id, action: 'review', dry_run: true, job: null };
215
+ }
216
+
217
+ const { data: job } = await client.request('POST', `/builds/${build.id}/testflight/review`, { json: {} });
218
+ output.progress(`review of ${build.id}: job ${job.id}, ${job.status}`);
219
+ sayPolicy(output, job);
220
+ openApproval(context, output, job);
221
+ if (!values.wait) {
222
+ output.log(`ias testflight status ${build.id} shows what Apple says; the runner sends it once it is approved.`);
223
+ return { build: build.id, action: 'review', job };
224
+ }
225
+ return outcome(output, build, await follow(context, client, build, job));
226
+ }
227
+
228
+ async function status(context) {
229
+ const { output } = context;
230
+ const client = clientOf(context);
231
+ const build = await buildOf(client, context.positionals[0]);
232
+ const state = await stateOf(client, build);
233
+ const job = state?.job ?? null;
234
+
235
+ output.log(`TestFlight of ${build.id}: ${build.app} ${build.version} (${build.build_number ?? '?'}), ${build.status}`);
236
+ if (!state) {
237
+ output.log(' state not read yet: ias testflight ' + build.id + ' distributes it');
238
+ return { build: build.id, state: null, next: `ias testflight ${build.id} --groups "<name>"` };
239
+ }
240
+ output.log(` App Store app ${state.asc_app_id ?? '?'}, build ${state.asc_build_id ?? 'not found yet'}${state.asc_prerelease_version ? ` (${state.asc_prerelease_version} / ${state.asc_build_version ?? '?'})` : ''}`);
241
+ output.log(` processing ${state.processing_state ?? '—'}${state.internal_beta_state ? `, internal ${state.internal_beta_state}` : ''}${state.external_beta_state ? `, external ${state.external_beta_state}` : ''}`);
242
+ output.log(` groups ${(state.groups ?? []).length > 0 ? state.groups.map((group) => `${quote(group.name)}${group.internal ? '' : ' (external)'}`).join(', ') : 'none yet'}`);
243
+ output.log(` notes ${(state.localizations ?? []).length > 0 ? state.localizations.map((localization) => localization.locale).join(', ') : 'none yet'}${job?.action === 'distribute' ? ` — ${Object.keys(job.notes ?? {}).join(', ')}` : ''}`);
244
+ output.log(` notified ${state.notified_at ? state.notified_at.slice(0, 16).replace('T', ' ') : 'no'}`);
245
+ output.log(` review ${state.review?.state ?? 'not sent'}${state.review?.submitted_at ? ` (sent ${state.review.submitted_at.slice(0, 16).replace('T', ' ')})` : ''}`);
246
+ output.log(` checked ${state.last_checked_at ? state.last_checked_at.slice(0, 16).replace('T', ' ') : 'never'}`);
247
+ const policy = policyLine(job);
248
+ if (job) output.log(` last job ${job.action}: ${job.status}${policy ? ` · ${policy}` : ''}${job.approval_url ? ` (approve at ${job.approval_url})` : ''}${job.error ? ` — ${job.error.code}` : ''}`);
249
+
250
+ const next = state.review?.state === null || state.review?.state === undefined
251
+ ? (state.groups ?? []).length > 0 ? `ias testflight review ${build.id}` : `ias testflight ${build.id} --groups "<name>"`
252
+ : `ias testflight status ${build.id}`;
253
+ output.log(` next ${next}`);
254
+
255
+ return { build: build.id, state, next };
256
+ }
257
+
258
+ const buildId = { name: 'build', description: 'The id of a store iOS build that is in App Store Connect, from ias builds list.', pattern: BUILD_ID };
259
+
260
+ const distributeOptions = {
261
+ groups: { type: 'string', value: '<name>', multiple: true, description: 'A TestFlight group by name; repeat it for more (ias.json › submit.<profile>.ios.testflight.groups by default).' },
262
+ 'what-to-test': { type: 'string', value: '"…"', description: 'The «What to Test» of the build, in the locale of --locale.' },
263
+ locale: { type: 'string', value: '<locale>', description: `The locale of --what-to-test (${DEFAULT_LOCALE} by default).` },
264
+ 'notes-file': { type: 'string', value: '<file.json>', description: 'A JSON object of locale: text, for several locales (at most 8).' },
265
+ notify: { type: 'boolean', description: 'Notify the testers when the build is added (off by default).' },
266
+ wait: { type: 'boolean', description: 'Wait until the runner finished, and say what Apple answered.' },
267
+ 'dry-run': { type: 'boolean', description: 'Show what would be approved, without creating anything.' },
268
+ };
269
+
270
+ export default {
271
+ name: 'testflight',
272
+ summary: 'Distribute a build that is in App Store Connect to TestFlight groups, or send it to Beta App Review',
273
+ description: [
274
+ 'The build has to be a store iOS build that succeeded and whose ias submit reached App Store Connect: TestFlight distributes a build Apple already has. Both actions are a job of the runner with its own approval: the runner opens the App Store Connect key (the server never sees it) and the owner approves the plan with a passkey.',
275
+ 'Without --groups, the groups come from ias.json › submit.<profile>.ios.testflight.groups; the «What to Test» from --what-to-test with --locale (es-ES by default), --notes-file, ias.json or the message of the build. If there are no groups and none is configured, it lists the groups the app has and the exact command: it never creates one.',
276
+ 'ias testflight <build> and ias testflight distribute <build> are the same command. ias testflight review is a separate command, with its own approval: it sends the build to Beta App Review (Apple). ias testflight status only reads.',
277
+ ].join('\n'),
278
+ positionals: [buildId],
279
+ options: distributeOptions,
280
+ subcommands: [
281
+ {
282
+ name: 'distribute',
283
+ summary: 'Distribute the build to its TestFlight groups; the same as ias testflight <build>',
284
+ description: 'The explicit form of the positional one, for scripts that read better with a verb: it takes the same options and does exactly the same, with the same approval.',
285
+ positionals: [buildId],
286
+ options: distributeOptions,
287
+ run: distribute,
288
+ },
289
+ {
290
+ name: 'review',
291
+ summary: 'Send the build to Beta App Review, with its own passkey (a separate approval)',
292
+ description: 'Beta App Review is what external testers need: it is not done by distributing, it is not App Review, and Apple can reject it. The runner checks the test information of the app first and sends the build once; it does not wait for the review, which is human.',
293
+ positionals: [buildId],
294
+ options: {
295
+ wait: { type: 'boolean', description: 'Wait until the runner has sent it, and say the state Apple gave.' },
296
+ 'dry-run': { type: 'boolean', description: 'Show what would be approved, without creating anything.' },
297
+ },
298
+ run: review,
299
+ },
300
+ {
301
+ name: 'status',
302
+ summary: 'What TestFlight and Beta App Review say of the build; it only reads',
303
+ description: 'Reads the state IAS has of the build: Apple\'s processing, the groups it is in, the locales with notes and the state of Beta App Review. It never creates a job nor calls Apple.',
304
+ positionals: [buildId],
305
+ run: status,
306
+ },
307
+ ],
308
+ run: distribute,
309
+ };
@@ -0,0 +1,99 @@
1
+ // ias tokens: the robot tokens of an application for CI (docs/technical/manage-api.md §5). create makes one and keeps
2
+ // it in 1Password; list shows them without their value; revoke kills one. A robot token works without a person, so only
3
+ // an owner's personal token can make one, and the value is shown by nobody after it is stored.
4
+ import { resolve } from 'node:path';
5
+ import { loadProject } from '../../config.mjs';
6
+ import { IasError } from '../../errors.mjs';
7
+ import { applicationOf, connect } from '../../manage.mjs';
8
+ import { createRobotToken } from '../../provisioning.mjs';
9
+
10
+ const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
11
+
12
+ async function create(context) {
13
+ return createRobotToken({ dir: resolve(context.cwd, context.values.project ?? '.'), env: context.env, output: context.output, values: context.values, sleep: context.sleep, dryRun: context.values['dry-run'] });
14
+ }
15
+
16
+ async function list(context) {
17
+ const project = projectOf(context);
18
+ const app = applicationOf(context, project);
19
+ const client = connect(context, project);
20
+ const { data } = await client.request('GET', `/apps/${app}/tokens`);
21
+ if (data.data.length === 0) context.output.log(`No tokens of ${app}: ias tokens create --name CI --scopes apps:read,builds:create`);
22
+ const width = Math.max(0, ...data.data.map((token) => token.name.length));
23
+ for (const token of data.data) {
24
+ context.output.log(`${token.id} ${token.name.padEnd(width)} ${(token.scopes ?? []).join(',')} ${token.legacy ? 'legacy' : token.expires_at?.slice(0, 10) ?? 'no expiry'}`);
25
+ }
26
+ return data;
27
+ }
28
+
29
+ /** The token `wanted` names: its id, or its name when only one has it (two with the same name are never picked). */
30
+ async function tokenNamed(client, app, wanted) {
31
+ const { data } = await client.request('GET', `/apps/${app}/tokens`);
32
+ const tokens = data.data ?? [];
33
+ const byId = tokens.find((token) => token.id === wanted);
34
+ if (byId) return byId;
35
+ const byName = tokens.filter((token) => token.name === wanted);
36
+ if (byName.length === 1) return byName[0];
37
+ if (byName.length > 1) {
38
+ throw new IasError(`«${wanted}» names ${byName.length} tokens of ${app}`, {
39
+ fix: `pass the id of one: ${byName.map((token) => `${token.id} (${token.expires_at?.slice(0, 10) ?? 'no expiry'})`).join(', ')}`,
40
+ code: 'token_ambiguous',
41
+ details: { candidates: byName.map((token) => ({ id: token.id, name: token.name })) },
42
+ });
43
+ }
44
+ throw new IasError(`there is no token «${wanted}» of ${app}`, { fix: 'ias tokens list shows them', code: 'token_not_found' });
45
+ }
46
+
47
+ async function revoke(context) {
48
+ const { values, output } = context;
49
+ const project = projectOf(context);
50
+ const app = applicationOf(context, project);
51
+ const wanted = context.positionals[0];
52
+ const client = connect(context, project);
53
+ const token = await tokenNamed(client, app, wanted);
54
+ if (values['dry-run']) {
55
+ output.progress(`dry run: would revoke the token «${token.name}» (${token.id}) of ${app}: it would stop working`);
56
+ return { dry_run: true, id: token.id, name: token.name, revoked: false };
57
+ }
58
+ await client.request('DELETE', `/apps/${app}/tokens/${token.id}`);
59
+ output.log(`${output.prefix}: token ${token.id} revoked: it no longer works`);
60
+ return { id: token.id, name: token.name, revoked: true };
61
+ }
62
+
63
+ export default {
64
+ name: 'tokens',
65
+ summary: 'The robot tokens of an application for CI: create one, list them and revoke one',
66
+ description: 'A robot token (iasr_…) works without a person, with the scopes it is given. create needs an owner\'s personal token and keeps the value in 1Password, with its op:// reference; list never shows a value. In CI, set IAS_TOKEN from the item.',
67
+ subcommands: [
68
+ {
69
+ name: 'create',
70
+ summary: 'Make a robot token, keep it in 1Password and print its item and scopes',
71
+ options: {
72
+ name: { type: 'string', value: '"…"', description: 'The name of the token, such as «GitHub Actions» (CI by default).' },
73
+ scopes: { type: 'string', value: '<list>', description: 'Comma-separated scopes: apps:read,builds:create by default. The values: apps:read, builds:create, credentials:read, credentials:write, distribution:upload, env:read, env:write, push:send, updates:publish.' },
74
+ days: { type: 'string', value: '<30|90|180|365>', description: 'How long it lasts (90 by default).' },
75
+ item: { type: 'string', value: 'op://vault/item', description: 'The 1Password item for the token (op://IAS/<app> CI by default).' },
76
+ app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' },
77
+ 'dry-run': { type: 'boolean', description: 'Say what it would make and change, and make nothing.' },
78
+ },
79
+ run: create,
80
+ },
81
+ {
82
+ name: 'list',
83
+ summary: 'The robot tokens of the application, without their value',
84
+ options: { app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' } },
85
+ run: list,
86
+ },
87
+ {
88
+ name: 'revoke',
89
+ summary: 'Revoke a robot token or a legacy publishing token',
90
+ description: 'Takes its id, or its name when only one token has it: two with the same name are never picked silently, it lists them and asks for the id. --dry-run says what it would revoke and revokes nothing.',
91
+ positionals: [{ name: 'token', description: 'The id of the token (ias tokens list), or its name when only one has it.' }],
92
+ options: {
93
+ app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' },
94
+ 'dry-run': { type: 'boolean', description: 'Say what it would revoke, and revoke nothing.' },
95
+ },
96
+ run: revoke,
97
+ },
98
+ ],
99
+ };
@@ -15,13 +15,18 @@ import { api, download } from '../../http.mjs';
15
15
  import { INSTALLS, buildManifests, firstPart, installationInside, promoted, publicKeyOf, readEnvFile, rollbackDirectives, sha256, signatureHeader, verifySignature } from '../../ota.mjs';
16
16
  import { gently, run, temporary } from '../../process.mjs';
17
17
  import { readSecret, readSecretFile, isReference, tokenKind } from '../../secrets.mjs';
18
+ import { provisionOta } from '../../provisioning.mjs';
18
19
 
19
20
  export const PLATFORMS = ['ios', 'android'];
20
21
 
21
- /** The environment of the app's tools: none of this shell's EXPO_PUBLIC_* (they would go into the bundle) or secrets. */
22
- function appEnv() {
22
+ /**
23
+ * The environment of the app's tools: none of this shell's EXPO_PUBLIC_* (they would go into the bundle) or secrets.
24
+ * With `tmp`, TMPDIR is a folder of this run: Metro keeps its cache in TMPDIR (metro-cache) and a cache from another
25
+ * run brings the paths of its folder into the bundle. The runner does the same for each build.
26
+ */
27
+ function appEnv(tmp) {
23
28
  const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(EXPO_PUBLIC_|OTA_|IAS_)/.test(key) && !['EXPO_TOKEN', 'NODE_ENV'].includes(key)));
24
- return { ...env, CI: '1', EXPO_NO_TELEMETRY: '1' };
29
+ return { ...env, CI: '1', EXPO_NO_TELEMETRY: '1', ...(tmp === undefined ? {} : { TMPDIR: tmp }) };
25
30
  }
26
31
 
27
32
  /**
@@ -134,11 +139,13 @@ function copyOf(settings, ref, output) {
134
139
  run('git', ['archive', '--format=tar', '-o', join(dir, 'source.tar'), commit], { cwd: settings.projectDir });
135
140
  const app = join(dir, 'app');
136
141
  run('mkdir', [app]);
142
+ const tmp = join(dir, 'tmp');
143
+ run('mkdir', [tmp]);
137
144
  run('tar', ['-xf', join(dir, 'source.tar'), '-C', app]);
138
145
  const install = INSTALLS.find(([lockfile]) => existsSync(join(app, lockfile)));
139
146
  if (!install) throw new Error('the commit has no lockfile (package-lock.json, yarn.lock, pnpm-lock.yaml or bun.lock): the copy could not install the same dependencies');
140
147
  output.progress(`${install[1]} ${install[2][0]} in a copy of ${commit.slice(0, 10)}…`);
141
- gently(install[1], install[2], { cwd: app, env: appEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
148
+ gently(install[1], install[2], { cwd: app, env: appEnv(tmp), stdio: ['ignore', 'pipe', 'pipe'] });
142
149
  const mustContain = [];
143
150
  if (settings.envFile) {
144
151
  const envFile = join(settings.projectDir, settings.envFile);
@@ -152,11 +159,11 @@ function copyOf(settings, ref, output) {
152
159
  } else if (settings.bundleEnv.length > 0) {
153
160
  throw new Error(`${settings.names.config} lists bundleEnv but no envFile to take them from`);
154
161
  }
155
- return { app, commit, mustContain };
162
+ return { app, tmp, commit, mustContain };
156
163
  }
157
164
 
158
- function runtimeVersion(cwd, platform) {
159
- const output = run('npx', ['expo-updates', 'runtimeversion:resolve', '--platform', platform], { cwd, env: appEnv(), label: 'expo-updates runtimeversion:resolve' });
165
+ function runtimeVersion(cwd, platform, tmp) {
166
+ const output = run('npx', ['expo-updates', 'runtimeversion:resolve', '--platform', platform], { cwd, env: appEnv(tmp), label: 'expo-updates runtimeversion:resolve' });
160
167
  return JSON.parse(output.slice(output.indexOf('{'))).runtimeVersion;
161
168
  }
162
169
 
@@ -174,13 +181,13 @@ export async function publish(settings, { ref = 'HEAD', message, platforms = PLA
174
181
  const copy = copyOf(settings, ref, output);
175
182
  const dist = join(dirname(copy.app), 'dist');
176
183
  output.progress(`exporting ${copy.commit.slice(0, 10)} (${platforms.join(', ')})…`);
177
- gently('npx', ['expo', 'export', ...platforms.flatMap((platform) => ['--platform', platform]), '--output-dir', dist], { cwd: copy.app, env: appEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
184
+ gently('npx', ['expo', 'export', ...platforms.flatMap((platform) => ['--platform', platform]), '--output-dir', dist], { cwd: copy.app, env: appEnv(copy.tmp), stdio: ['ignore', 'pipe', 'pipe'] });
178
185
  const { manifests, files } = buildManifests({
179
186
  dist,
180
187
  metadata: JSON.parse(readFileSync(join(dist, 'metadata.json'), 'utf8')),
181
188
  platforms,
182
- runtimes: Object.fromEntries(platforms.map((platform) => [platform, runtimeVersion(copy.app, platform)])),
183
- expoClient: JSON.parse(run('npx', ['expo', 'config', '--type', 'public', '--json'], { cwd: copy.app, env: appEnv(), label: 'expo config' })),
189
+ runtimes: Object.fromEntries(platforms.map((platform) => [platform, runtimeVersion(copy.app, platform, copy.tmp)])),
190
+ expoClient: JSON.parse(run('npx', ['expo', 'config', '--type', 'public', '--json'], { cwd: copy.app, env: appEnv(copy.tmp), label: 'expo config' })),
184
191
  assetPath: settings.assetPath,
185
192
  createdAt: new Date().toISOString(),
186
193
  mustContain: copy.mustContain,
@@ -380,10 +387,14 @@ export async function list(settings, { channel }, output) {
380
387
  return data;
381
388
  }
382
389
 
383
- /** What a phone with the app's runtime gets now, checked end to end. It needs no key and no token. */
384
- export async function check(settings, { platforms = PLATFORMS }, output) {
390
+ /**
391
+ * What a phone with the app's runtime gets now, checked end to end. It needs no key and no token. `channel` asks as a
392
+ * build of that channel (the header the phone sends) and checks the signature with its certificate in ias.json;
393
+ * without it, it asks for the default channel.
394
+ */
395
+ export async function check(settings, { platforms = PLATFORMS, channel }, output) {
385
396
  const checks = [];
386
- for (const platform of platforms) checks.push(await checkServed(settings, platform, runtimeVersion(settings.projectDir, platform), undefined, output));
397
+ for (const platform of platforms) checks.push(await checkServed(settings, platform, runtimeVersion(settings.projectDir, platform), undefined, output, channel === undefined ? {} : { channel }));
387
398
  return { checks };
388
399
  }
389
400
 
@@ -463,6 +474,18 @@ export default {
463
474
  summary: 'Signed OTA updates (expo-updates): publish, go back, retire and check',
464
475
  description: 'Signs on this Mac with the key of 1Password (updates.signing.privateKey, or updates.channels.<channel>.privateKey for another channel) and publishes to the server of updates.url in app.json. The token is the OTA project\'s (ota_…): IAS_TOKEN or updates.token.',
465
476
  subcommands: [
477
+ {
478
+ name: 'init',
479
+ summary: 'Make the OTA project, its signing key and its publishing token, and write them to ias.json',
480
+ description: 'Makes an RSA key and a self-signed certificate on this Mac, creates the OTA project on the server with the certificate and the keyid, and keeps the private key and the publishing token in 1Password. It writes their op:// references in ias.json and prints the manifest URL. It never prints a key or a token. It needs an owner\'s personal token (ias login) and the 1Password CLI; a project that already exists with the same certificate is left as it is.',
481
+ options: {
482
+ name: { type: 'string', value: '"…"', description: 'The name of the project in the panel (the app name by default).' },
483
+ keyid: { type: 'string', value: '<keyid>', description: 'The keyid the builds carry (the app code + -ota-1 by default).' },
484
+ item: { type: 'string', value: 'op://vault/item', description: 'The 1Password item for the key and the token (op://IAS/<app> OTA by default).' },
485
+ 'dry-run': { type: 'boolean', description: 'Say what it would make and change, and make nothing.' },
486
+ },
487
+ run: (context) => provisionOta({ dir: resolve(context.cwd, context.values.project ?? '.'), env: context.env, output: context.output, values: context.values, sleep: context.sleep, dryRun: context.values['dry-run'] }),
488
+ },
466
489
  {
467
490
  name: 'publish',
468
491
  summary: 'Publish a commit (HEAD by default) for both platforms or one',
@@ -525,8 +548,12 @@ export default {
525
548
  {
526
549
  name: 'check',
527
550
  summary: 'What a phone with the app\'s runtime gets now, checked end to end',
528
- options: pick('platform'),
529
- run: (context) => check(settingsOf(context), { platforms: platformsOf(context.values) }, context.output),
551
+ description: 'Asks for the manifest as a phone would, verifies its signature and every file it points to, and needs no key and no token. Without --channel it checks the default channel of the project (the one of app.json); --channel asks as a build of that channel and checks the signature with its certificate in ias.json › updates.channels.',
552
+ options: {
553
+ ...pick('platform'),
554
+ channel: { type: 'string', value: '<channel>', description: 'The channel to check, as a build of it would (the default channel by default), signed with its certificate in ias.json.' },
555
+ },
556
+ run: (context) => check(settingsOf(context), { platforms: platformsOf(context.values), channel: context.values.channel }, context.output),
530
557
  },
531
558
  ],
532
559
  };
@@ -105,13 +105,17 @@ export async function approvers(context, client, item) {
105
105
  };
106
106
  if (unchanged) {
107
107
  output.log(`\nThe team already signed these ${rows.length} passkey${rows.length === 1 ? '' : 's'} (version ${previous.version}): nothing to sign.`);
108
- return { ...result, version: previous.version, signed: false, in_sync: current.in_sync ?? null };
108
+ return { ...result, version: previous.version, signed: false, in_sync: current.in_sync ?? null, ...(context.values['dry-run'] ? { dry_run: true } : {}) };
109
109
  }
110
110
 
111
111
  const version = Math.max(previousVersion, Number.isInteger(listed.document_version) ? listed.document_version : 0) + 1;
112
112
  output.progress('');
113
113
  output.progress('Check that each passkey is one of an owner you know, above all the NEW and CHANGED ones: from now on, the runners');
114
114
  output.progress('build for the stores only what one of these passkeys approves. The server could have added one of its own.');
115
+ if (context.values['dry-run']) {
116
+ output.progress(`dry run: it would sign version ${version} with these ${rows.length} passkey${rows.length === 1 ? '' : 's'} and upload it (PUT /team/approvers-document); nothing was signed and nothing was uploaded.`);
117
+ return { ...result, dry_run: true, version, signed: false, in_sync: current.in_sync ?? null };
118
+ }
115
119
  // Signed in memory first, so that a target the document cannot hold stops it before asking; uploaded after the yes.
116
120
  let document;
117
121
  try {
@@ -29,7 +29,7 @@ function checkPrivateKeys(item, keys, env) {
29
29
  signingKeyOf(item, keys, env);
30
30
  }
31
31
 
32
- async function init(context) {
32
+ export async function init(context) {
33
33
  const { env, output } = context;
34
34
  const project = projectOf(context);
35
35
  const client = connect(context, project);
@@ -39,6 +39,25 @@ async function init(context) {
39
39
  throw new IasError(`the vault of ${client.server} already has its keys`, { fix: 'ias vault status compares them with the ones in 1Password (changing them is not in v1 of the API)', code: 'vault_keys_exist' });
40
40
  }
41
41
  let keys = publicKeysOf(item, env);
42
+ if (context.values['dry-run']) {
43
+ if (keys) {
44
+ output.progress(`${item.reference} already has the keys of the vault (an earlier ias vault init that was not approved): it would register those.`);
45
+ output.progress(`recovery key ${keys.recovery.kid}`);
46
+ output.progress(`signing key ${keys.signing.kid}`);
47
+ } else {
48
+ output.progress(`It would create ${item.reference} with a new recovery key and a new signing key: their private halves would only live there.`);
49
+ }
50
+ output.progress(`It would register the public keys on ${client.server} (POST /vault/keys), and an owner would approve it with a passkey.`);
51
+ output.progress('dry run: nothing was created in 1Password and nothing was registered.');
52
+ return {
53
+ dry_run: true,
54
+ server: client.server,
55
+ item: item.reference,
56
+ action: keys ? 'register_existing_keys' : 'create_keys',
57
+ recovery_key: keys?.recovery ?? null,
58
+ signing_key: keys?.signing ?? null,
59
+ };
60
+ }
42
61
  if (keys) {
43
62
  output.progress(`${item.reference} already has the keys of the vault (an earlier ias vault init that was not approved): registering those.`);
44
63
  } else {
@@ -102,8 +121,9 @@ async function status(context) {
102
121
  return result;
103
122
  }
104
123
 
105
- const itemOption = { item: { type: 'string', value: '<op://vault/item>', description: 'The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://Private/IAS vault <host>).' } };
124
+ const itemOption = { item: { type: 'string', value: '<op://vault/item>', description: 'The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>).' } };
106
125
  const yesOption = { yes: { type: 'boolean', short: 'y', description: 'Do not ask: you have already compared what it prints (without a terminal it is needed).' } };
126
+ const dryRunOption = { 'dry-run': { type: 'boolean', description: 'Show the plan and change nothing: no 1Password write, no signed document, no runner list and no envelope changed.' } };
107
127
  const runnerArgument = { name: 'runner', description: 'The runner: its id, its name (in quotes if it has spaces) or its vault kid, as ias vault status and the panel list them.' };
108
128
 
109
129
  /** Runs a command that signs with the team's key: the client, the 1Password item and then `run`. */
@@ -122,7 +142,7 @@ export default {
122
142
  name: 'init',
123
143
  summary: 'Create the recovery and signing keys in 1Password and register them (owner, with a passkey)',
124
144
  description: 'Makes both keys, creates the 1Password item with op (the private keys go through stdin and are never printed), and asks the server to register the public keys. A member approves it with a passkey in the panel, after checking that it shows the two kids printed here. If the item already exists from an attempt that was not approved, its keys are registered.',
125
- options: itemOption,
145
+ options: { ...itemOption, ...dryRunOption },
126
146
  run: init,
127
147
  },
128
148
  {
@@ -136,7 +156,7 @@ export default {
136
156
  name: 'approvers',
137
157
  summary: 'Sign the passkeys of the owners that may approve a store build (owner)',
138
158
  description: 'Reads the owners\' passkeys from the server and shows each one with its member and fingerprint, marking what changes from the version the team signed before (verified with the team\'s key). After you confirm, it signs the next version of the ias-approvers-1 document with the team\'s key of 1Password and uploads it: the runners only build for the stores what one of these passkeys approved. Run it again after an owner adds or removes a passkey; the panel says so in Today.',
139
- options: { ...itemOption, ...yesOption, replace: { type: 'boolean', description: 'Sign over a document of the server that does not verify with the team\'s key (after checking why).' } },
159
+ options: { ...itemOption, ...yesOption, ...dryRunOption, replace: { type: 'boolean', description: 'Sign over a document of the server that does not verify with the team\'s key (after checking why).' } },
140
160
  run: signing(approvers),
141
161
  },
142
162
  {
@@ -144,7 +164,7 @@ export default {
144
164
  summary: 'Let a registered runner open the team\'s credentials: the signed list and a wrap in each envelope',
145
165
  description: 'Shows the runner\'s vault kid for you to compare with the one ias-runner register printed on its Mac. Then it signs a runner list with it, uploads it, and wraps every envelope of the team (credentials and secret variables) for it, opening each one with the recovery key of 1Password and signing it again. Envelopes that do not verify are never signed again: it says which.',
146
166
  positionals: [runnerArgument],
147
- options: { ...itemOption, ...yesOption },
167
+ options: { ...itemOption, ...yesOption, ...dryRunOption },
148
168
  run: signing(grant),
149
169
  },
150
170
  {
@@ -152,7 +172,7 @@ export default {
152
172
  summary: 'Take a runner revoked in the panel out of the signed list and of every envelope',
153
173
  description: 'Revoke it in the panel first (System › Runners): the server stops serving it at once. This signs a runner list without it and removes its wrap from every envelope, signing each one again, and lists what it could read, which is what to renew if its Mac may have been compromised (docs/technical/vault.md §10).',
154
174
  positionals: [runnerArgument],
155
- options: { ...itemOption, ...yesOption },
175
+ options: { ...itemOption, ...yesOption, ...dryRunOption },
156
176
  run: signing(revoke),
157
177
  },
158
178
  ],