@imagina-build/ias-cli 0.0.0-stage → 0.1.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 (78) hide show
  1. package/README.md +406 -2
  2. package/bin/ias.mjs +19 -0
  3. package/package.json +32 -5
  4. package/schema/ias.schema.json +535 -0
  5. package/shared/credentials/cms.mjs +105 -0
  6. package/shared/credentials/index.mjs +354 -0
  7. package/shared/credentials/plist.mjs +93 -0
  8. package/shared/vault/approvers-vectors.mjs +188 -0
  9. package/shared/vault/approvers.mjs +234 -0
  10. package/shared/vault/index.mjs +583 -0
  11. package/shared/vault/plaintext.mjs +115 -0
  12. package/shared/vault/test-vectors/ias-vault-2/approvers.json +2122 -0
  13. package/shared/vault/test-vectors/ias-vault-2/keys.json +43 -0
  14. package/shared/vault/test-vectors/ias-vault-2/malformed.json +1739 -0
  15. package/shared/vault/test-vectors/ias-vault-2/plaintexts.json +149 -0
  16. package/shared/vault/test-vectors/ias-vault-2/runners.json +580 -0
  17. package/shared/vault/test-vectors/ias-vault-2/sealed.json +567 -0
  18. package/shared/vault/test-vectors/ias-vault-2/tampered.json +989 -0
  19. package/shared/vault/test-vectors/keys.json +31 -0
  20. package/shared/vault/test-vectors/malformed.json +1341 -0
  21. package/shared/vault/test-vectors/sealed.json +384 -0
  22. package/shared/vault/test-vectors/tampered.json +610 -0
  23. package/shared/vault/vectors.mjs +720 -0
  24. package/src/cli.mjs +231 -0
  25. package/src/commands/apps/index.mjs +36 -0
  26. package/src/commands/auth/index.mjs +141 -0
  27. package/src/commands/build/follow.mjs +168 -0
  28. package/src/commands/build/index.mjs +110 -0
  29. package/src/commands/build/local.mjs +69 -0
  30. package/src/commands/build/profile.mjs +50 -0
  31. package/src/commands/build/source.mjs +71 -0
  32. package/src/commands/builds/index.mjs +148 -0
  33. package/src/commands/credentials/index.mjs +256 -0
  34. package/src/commands/credentials/material.mjs +159 -0
  35. package/src/commands/devices/asc.mjs +116 -0
  36. package/src/commands/devices/index.mjs +65 -0
  37. package/src/commands/devices/sync.mjs +223 -0
  38. package/src/commands/dist/index.mjs +108 -0
  39. package/src/commands/doctor/index.mjs +158 -0
  40. package/src/commands/env/index.mjs +163 -0
  41. package/src/commands/github/index.mjs +53 -0
  42. package/src/commands/init/index.mjs +224 -0
  43. package/src/commands/migrate/credentials.mjs +213 -0
  44. package/src/commands/migrate/eas-cli.mjs +153 -0
  45. package/src/commands/migrate/index.mjs +73 -0
  46. package/src/commands/migrate/numbers.mjs +85 -0
  47. package/src/commands/migrate/profiles.mjs +174 -0
  48. package/src/commands/migrate/project.mjs +249 -0
  49. package/src/commands/migrate/survey.mjs +205 -0
  50. package/src/commands/migrate/variables.mjs +183 -0
  51. package/src/commands/push/index.mjs +334 -0
  52. package/src/commands/sdk/index.mjs +81 -0
  53. package/src/commands/submit/index.mjs +85 -0
  54. package/src/commands/update/index.mjs +526 -0
  55. package/src/commands/usage/index.mjs +57 -0
  56. package/src/commands/vault/approvers.mjs +129 -0
  57. package/src/commands/vault/index.mjs +159 -0
  58. package/src/commands/vault/runners.mjs +115 -0
  59. package/src/commands/vault/team.mjs +111 -0
  60. package/src/commands/version/index.mjs +71 -0
  61. package/src/commands/webhooks/index.mjs +104 -0
  62. package/src/commands/workflows/index.mjs +88 -0
  63. package/src/config.mjs +261 -0
  64. package/src/dist.mjs +99 -0
  65. package/src/errors.mjs +158 -0
  66. package/src/http.mjs +45 -0
  67. package/src/legacy.mjs +73 -0
  68. package/src/manage.mjs +120 -0
  69. package/src/ota.mjs +122 -0
  70. package/src/output.mjs +36 -0
  71. package/src/process.mjs +45 -0
  72. package/src/schema.mjs +131 -0
  73. package/src/sdk.mjs +120 -0
  74. package/src/secret-input.mjs +27 -0
  75. package/src/secrets.mjs +37 -0
  76. package/src/shared.mjs +5 -0
  77. package/src/team-vault.mjs +152 -0
  78. package/src/token-store.mjs +93 -0
@@ -0,0 +1,85 @@
1
+ // ias submit: sends a store build that succeeded to App Store Connect or Google Play (docs/technical/runner-api.md §6, decision
2
+ // D7). The runner does it, with the keys sealed in the vault, after an owner approves it with a passkey: always as a
3
+ // draft, to send for review in the store. It opens the approval and, with --wait, follows the submission to its end.
4
+ import { resolve } from 'node:path';
5
+ import { loadProject } from '../../config.mjs';
6
+ import { IasError } from '../../errors.mjs';
7
+ import { connect, openUrl, sleep } from '../../manage.mjs';
8
+ import { EXIT_CODE } from '../../output.mjs';
9
+ import { buildsClient, isFinished } from '../build/follow.mjs';
10
+
11
+ 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}$/;
12
+ const TRACK = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
13
+ const DEFAULT_TRACK = 'internal';
14
+
15
+ /** What the store says of a submission that ended: the delivery of App Store Connect, or the edit of Google Play. */
16
+ function outcome(submission) {
17
+ const result = submission.result ?? {};
18
+ const parts = [result.deliveryUuid && `delivery ${result.deliveryUuid}`, result.editId && `edit ${result.editId}`, result.versionCode && `version code ${result.versionCode}`].filter(Boolean);
19
+ if (submission.error) parts.push(`${submission.error.code}${submission.error.message ? `: ${submission.error.message}` : ''}`);
20
+ return parts.length > 0 ? ` (${parts.join(', ')})` : '';
21
+ }
22
+
23
+ /** The body of POST /builds/{id}/submit for `build`, from the options and the submit profile of ias.json. */
24
+ function targetOf(context, build, config) {
25
+ const { values, output } = context;
26
+ const profile = config?.submit?.[build.profile] ?? {};
27
+ if (build.platform === 'ios') {
28
+ if (values.track) throw new IasError('--track is for Google Play', { fix: 'an iOS build goes to App Store Connect (TestFlight), without a track' });
29
+ const ascAppId = values['asc-app-id'] ?? profile.ios?.ascAppId;
30
+ if (ascAppId !== undefined && !/^[0-9]{1,20}$/.test(ascAppId)) throw new IasError(`«${ascAppId}» is not the Apple ID of an app`, { fix: 'it is the number App Store Connect shows in App Information' });
31
+ return { target: 'app_store_connect', ...(ascAppId ? { asc_app_id: ascAppId } : {}) };
32
+ }
33
+ if (values['asc-app-id']) throw new IasError('--asc-app-id is for App Store Connect', { fix: 'an Android build goes to Google Play: --track production, beta, alpha or internal' });
34
+ const track = values.track ?? profile.android?.track ?? DEFAULT_TRACK;
35
+ if (!TRACK.test(track)) throw new IasError(`«${track}» is not a track of Google Play`, { fix: 'production, beta, alpha, internal or the name of a closed track' });
36
+ if (profile.android?.releaseStatus && profile.android.releaseStatus !== 'draft') output.warn(`submit.${build.profile}.android.releaseStatus is ${profile.android.releaseStatus}, but IAS always sends a draft (D7): send it for review in Play Console`);
37
+ return { target: 'google_play', track, release_status: 'draft' };
38
+ }
39
+
40
+ async function submit(context) {
41
+ const { values, env, output } = context;
42
+ const project = loadProject(resolve(context.cwd, values.project ?? '.'));
43
+ const client = buildsClient(connect(context, project));
44
+ const { data: build } = await client.request('GET', `/builds/${context.positionals[0]}`);
45
+ if (build.distribution !== 'store') throw new IasError(`${build.id} is an internal build (${build.profile})`, { fix: 'only a store build goes to the stores: ias build --profile production', code: 'build_not_submittable' });
46
+ const body = targetOf(context, build, project.config);
47
+ const { data: submission } = await client.request('POST', `/builds/${build.id}/submit`, { json: body });
48
+ const where = body.target === 'google_play' ? `Google Play (${body.track}, draft)` : 'App Store Connect';
49
+ output.progress(`${build.platform} ${build.version} (${build.build_number}) to ${where}: submission ${submission.id}, ${submission.status}`);
50
+ if (submission.approval_url) {
51
+ const opened = (context.openUrl ?? ((url) => openUrl(url, env)))(submission.approval_url);
52
+ output.progress(`${opened ? 'Opened' : 'Open'} ${submission.approval_url}: an owner approves it with a passkey (the panel shows the hash ${submission.hash}).`);
53
+ }
54
+ if (!values.wait) {
55
+ output.log(`ias builds view ${build.id} shows how it goes; the runner sends it once it is approved.`);
56
+ return submission;
57
+ }
58
+ const wait = context.sleep ?? sleep;
59
+ let current = submission;
60
+ let said = current.status;
61
+ while (!isFinished(current.status)) {
62
+ await wait(5);
63
+ const { data } = await client.request('GET', `/builds/${build.id}`);
64
+ current = (data.submissions ?? []).find((candidate) => candidate.id === submission.id) ?? current;
65
+ if (current.status !== said) {
66
+ said = current.status;
67
+ output.progress(` ${current.status}${current.step ? ` (${current.step})` : ''}${current.runner?.name ? ` on ${current.runner.name}` : ''}`);
68
+ }
69
+ }
70
+ output.log(`submission ${current.id} to ${where}: ${current.status}${outcome(current)}`);
71
+ return current.status === 'succeeded' ? current : { ...current, [EXIT_CODE]: 1 };
72
+ }
73
+
74
+ export default {
75
+ name: 'submit',
76
+ summary: 'Send a store build to App Store Connect or Google Play, as a draft, after an owner approves it',
77
+ description: 'The runner sends it with the keys of the vault (asc_api_key, google_service_account), and always as a draft: the review is asked in the store. The track and the Apple ID of the app come from the options or from submit.<profile> of ias.json; Google Play goes to the internal track by default.',
78
+ positionals: [{ name: 'build', description: 'The id of a store build that succeeded, from ias builds list.', pattern: BUILD_ID }],
79
+ options: {
80
+ track: { type: 'string', value: '<track>', description: 'Google Play: production, beta, alpha, internal (the default) or a closed track.' },
81
+ 'asc-app-id': { type: 'string', value: '<id>', description: 'App Store Connect: the Apple ID of the app (submit.<profile>.ios.ascAppId by default).' },
82
+ wait: { type: 'boolean', description: 'Wait until the runner has sent it, and say what the store answered.' },
83
+ },
84
+ run: submit,
85
+ };
@@ -0,0 +1,526 @@
1
+ // ias update: signed OTA updates of an Expo app (API.md §36), from the Mac that holds the signing key; the server never
2
+ // sees it. It exports a commit, signs one manifest per platform, uploads only the files the server lacks and publishes;
3
+ // then it asks for the manifest as a phone would and checks the signature and every file. It was tools/expo-ota. Each
4
+ // channel has a key of its own (§36.8: updates.channels of ias.json), a publication can start with a share of the
5
+ // installations (§36.9), and promote signs the contents of a group again for another channel, never copying a signature.
6
+ import { Buffer } from 'node:buffer';
7
+ import { X509Certificate, createPublicKey } from 'node:crypto';
8
+ import { copyFileSync, existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
9
+ import { dirname, join, relative, resolve } from 'node:path';
10
+ import process from 'node:process';
11
+ import { gzipSync } from 'node:zlib';
12
+ import { projectFor, updateSettings } from '../../config.mjs';
13
+ import { IasError, UsageError } from '../../errors.mjs';
14
+ import { api, download } from '../../http.mjs';
15
+ import { INSTALLS, buildManifests, firstPart, installationInside, promoted, publicKeyOf, readEnvFile, rollbackDirectives, sha256, signatureHeader, verifySignature } from '../../ota.mjs';
16
+ import { gently, run, temporary } from '../../process.mjs';
17
+ import { readSecret, readSecretFile, isReference, tokenKind } from '../../secrets.mjs';
18
+
19
+ export const PLATFORMS = ['ios', 'android'];
20
+
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() {
23
+ 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' };
25
+ }
26
+
27
+ /**
28
+ * The channels of updates.channels in ias.json, each with its keyid, the op:// reference of its private key and the
29
+ * certificate its builds carry (a file of the repository). The channel of app.json needs none: it signs as always.
30
+ */
31
+ export function withChannels(settings, project) {
32
+ const channels = {};
33
+ for (const [name, channel] of Object.entries(project.config?.updates?.channels ?? {})) {
34
+ channels[name] = {
35
+ channel: name,
36
+ keyid: channel.keyId,
37
+ privateKey: channel.privateKey,
38
+ get certificate() {
39
+ const file = join(settings.projectDir, channel.certificate);
40
+ if (!existsSync(file)) throw new IasError(`no certificate at ${channel.certificate} (updates.channels.${name}.certificate of ias.json)`, { fix: 'it is the public certificate the builds of that channel carry: keep it in the repository' });
41
+ return new X509Certificate(readFileSync(file));
42
+ },
43
+ source: `updates.channels.${name} of ias.json`,
44
+ needs: { privateKey: new IasError(`no private key for the channel ${name}`, { fix: `set updates.channels.${name}.privateKey in ias.json (an op:// reference)` }) },
45
+ };
46
+ }
47
+ return { ...settings, channels };
48
+ }
49
+
50
+ /**
51
+ * How to sign for a channel: updates.channels.<channel> of ias.json if it has it, or else the certificate, keyid and
52
+ * key of the default channel (app.json's, or updates.signing of ias.json), the channel its builds ask for. The server
53
+ * checks it with the channel's own certificate.
54
+ */
55
+ export function signingFor(settings, channel) {
56
+ if (channel && settings.channels?.[channel]) return settings.channels[channel];
57
+ return { channel, keyid: settings.keyid, privateKey: settings.privateKey, certificate: settings.certificate, source: settings.signingSource ?? 'app.json', needs: settings.needs };
58
+ }
59
+
60
+ /** Stops before signing with a certificate the builds would reject, and warns a year before it expires. */
61
+ export function checkCertificate(settings, output, signing = signingFor(settings)) {
62
+ const daysLeft = Math.floor((Date.parse(signing.certificate.validTo) - Date.now()) / 86_400_000);
63
+ if (daysLeft < 0) throw new Error(`the certificate expired on ${signing.certificate.validTo}: the builds that carry it reject every update; a new one needs a new build`);
64
+ if (daysLeft < 365) output.warn(`the certificate expires in ${daysLeft} days (${signing.certificate.validTo}); rotate it with the next store build`);
65
+ return daysLeft;
66
+ }
67
+
68
+ /** The private key (PEM), checked against the certificate; from 1Password or a file outside the project (mode 600). */
69
+ export function privateKeyPem(settings, signing = signingFor(settings)) {
70
+ if (!signing.privateKey) throw signing.needs?.privateKey ?? settings.needs.privateKey;
71
+ let path;
72
+ if (isReference(signing.privateKey)) {
73
+ path = readSecretFile(signing.privateKey, 'private-key.pem');
74
+ } else {
75
+ try {
76
+ path = realpathSync(resolve(signing.privateKey));
77
+ } catch {
78
+ throw new Error(`no private key at ${signing.privateKey}`);
79
+ }
80
+ if (!relative(realpathSync(settings.projectDir), path).startsWith('..')) throw new Error('the private key is inside the project: move it out (and never commit it)');
81
+ if (statSync(path).mode & 0o077) throw new Error(`the private key can be read by other users: chmod 600 ${signing.privateKey}`);
82
+ }
83
+ const pem = readFileSync(path, 'utf8');
84
+ const der = (key) => key.export({ type: 'spki', format: 'der' }).toString('base64');
85
+ if (der(signing.certificate.publicKey) !== der(createPublicKey(pem))) throw new Error(signing.source === 'app.json' ? 'the private key is not the one of the certificate in app.json' : `the private key is not the one of the certificate of ${signing.source}`);
86
+ return pem;
87
+ }
88
+
89
+ /**
90
+ * Before signing for a channel named on the command line: the server must have it, with the keyid and certificate
91
+ * this Mac signs with, or every build of the channel would refuse the update. Only compares: what the Mac signs is
92
+ * never checked against what the server says.
93
+ */
94
+ export async function checkChannel(settings, token, signing, known = undefined) {
95
+ if (!signing.channel || settings.source === 'legacy') return undefined;
96
+ const data = known ?? (await api(settings, token, 'GET', '/channels')).data;
97
+ const remote = data.channels.find((candidate) => candidate.name === signing.channel);
98
+ if (!remote) throw new IasError(`the project has no channel ${signing.channel}`, { fix: `create it on the server with its own certificate: php artisan ota:project ${settings.project} --channel=${signing.channel} --certificate=… --keyid=…`, code: 'channel_not_found' });
99
+ if (remote.key_id !== signing.keyid || publicKeyOf(signing.certificate) !== publicKeyOf(new X509Certificate(remote.certificate.pem))) {
100
+ throw new IasError(`the channel ${signing.channel} of the server checks signatures with keyid ${remote.key_id} and another certificate than ${signing.source} (keyid ${signing.keyid})`, {
101
+ fix: `set updates.channels.${signing.channel} in ias.json with its keyId, privateKey and certificate`,
102
+ code: 'channel_signing_mismatch',
103
+ });
104
+ }
105
+ return remote;
106
+ }
107
+
108
+ /** The share of installations a publication starts with: 1 to 100 (100, or nothing, is everyone). */
109
+ export function rolloutOf(value) {
110
+ if (value === undefined) return undefined;
111
+ const percent = Number(value);
112
+ if (!Number.isInteger(percent) || percent < 1 || percent > 100) throw new UsageError(`--rollout is a whole number from 1 to 100, not ${value}`, { fix: 'leave it out to publish to every installation' });
113
+ return percent;
114
+ }
115
+
116
+ /** The publishing token of the OTA project: the variable or the op:// reference, read now. */
117
+ export function publishingToken(settings) {
118
+ const source = settings.token || settings.loggedIn?.();
119
+ if (!source) throw settings.needs.token;
120
+ const token = readSecret(source);
121
+ if (tokenKind(token) === 'dist') throw new IasError('the token is the one of a distribution project (dist_…)', { fix: 'ias update needs the publishing token of the OTA project (ota_…, php artisan ota:token): check IAS_TOKEN, OTA_TOKEN or the token of the configuration' });
122
+ return token;
123
+ }
124
+
125
+ /**
126
+ * A copy of `ref` to export from: git archive, a clean install from the lockfile and the environment file. Not a linked
127
+ * node_modules: with one, expo-router found no routes in the bundle and the app failed to start on the update.
128
+ */
129
+ function copyOf(settings, ref, output) {
130
+ const prefix = run('git', ['rev-parse', '--show-prefix'], { cwd: settings.projectDir }).trim();
131
+ if (prefix) throw new Error('run it from the root of the app\'s git repository (or pass --project <that folder>)');
132
+ const commit = run('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { cwd: settings.projectDir, label: `git rev-parse ${ref} (no such commit?)` }).trim();
133
+ const dir = temporary();
134
+ run('git', ['archive', '--format=tar', '-o', join(dir, 'source.tar'), commit], { cwd: settings.projectDir });
135
+ const app = join(dir, 'app');
136
+ run('mkdir', [app]);
137
+ run('tar', ['-xf', join(dir, 'source.tar'), '-C', app]);
138
+ const install = INSTALLS.find(([lockfile]) => existsSync(join(app, lockfile)));
139
+ 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
+ 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'] });
142
+ const mustContain = [];
143
+ if (settings.envFile) {
144
+ const envFile = join(settings.projectDir, settings.envFile);
145
+ if (!existsSync(envFile)) throw new Error(`no ${settings.envFile} (${settings.names.envFile})`);
146
+ copyFileSync(envFile, join(app, settings.envFile));
147
+ const values = readEnvFile(envFile, settings.bundleEnv);
148
+ for (const name of settings.bundleEnv) {
149
+ if (!values[name]) throw new Error(`${settings.envFile} has no ${name}, which ${settings.names.bundleEnv} says the bundle must carry`);
150
+ mustContain.push([name, values[name]]);
151
+ }
152
+ } else if (settings.bundleEnv.length > 0) {
153
+ throw new Error(`${settings.names.config} lists bundleEnv but no envFile to take them from`);
154
+ }
155
+ return { app, commit, mustContain };
156
+ }
157
+
158
+ function runtimeVersion(cwd, platform) {
159
+ const output = run('npx', ['expo-updates', 'runtimeversion:resolve', '--platform', platform], { cwd, env: appEnv(), label: 'expo-updates runtimeversion:resolve' });
160
+ return JSON.parse(output.slice(output.indexOf('{'))).runtimeVersion;
161
+ }
162
+
163
+ /** Publishes a commit (HEAD by default) for the platforms; with dryRun the server checks it all and keeps nothing. */
164
+ export async function publish(settings, { ref = 'HEAD', message, platforms = PLATFORMS, channel, rollout, dryRun = false }, output) {
165
+ if (!message) throw new Error('--message "…" says what the update changes');
166
+ const signing = signingFor(settings, channel);
167
+ checkCertificate(settings, output, signing);
168
+ const key = privateKeyPem(settings, signing);
169
+ const token = publishingToken(settings);
170
+ await checkChannel(settings, token, signing);
171
+ if (ref === 'HEAD' && run('git', ['status', '--porcelain'], { cwd: settings.projectDir }).trim()) {
172
+ output.warn('the working tree has changes that are not committed; they are not published, the commit is');
173
+ }
174
+ const copy = copyOf(settings, ref, output);
175
+ const dist = join(dirname(copy.app), 'dist');
176
+ 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'] });
178
+ const { manifests, files } = buildManifests({
179
+ dist,
180
+ metadata: JSON.parse(readFileSync(join(dist, 'metadata.json'), 'utf8')),
181
+ 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' })),
184
+ assetPath: settings.assetPath,
185
+ createdAt: new Date().toISOString(),
186
+ mustContain: copy.mustContain,
187
+ });
188
+ return sendUpdate(settings, { token, key, signing, manifests, files, commit: copy.commit, message, channel, rollout, dryRun }, output);
189
+ }
190
+
191
+ /**
192
+ * The network half of publish: signs each manifest (and checks the signature with the certificate), warns about a
193
+ * runtime no phone asks for, uploads with gzip only the files the server lacks, publishes the platforms at once and,
194
+ * unless it is a dry run, checks what a phone now gets.
195
+ */
196
+ export async function sendUpdate(settings, { token, key, channel, signing = signingFor(settings, channel), manifests, files, commit, message, rollout, dryRun }, output) {
197
+ const signed = Object.fromEntries(
198
+ Object.entries(manifests).map(([platform, manifest]) => {
199
+ const body = JSON.stringify(manifest);
200
+ const header = signatureHeader(body, key, signing.keyid);
201
+ if (!verifySignature(signing.certificate.publicKey, signing.keyid, body, header)) throw new Error('the signature does not verify with the certificate');
202
+ return [platform, { body, header }];
203
+ }),
204
+ );
205
+
206
+ const history = (await api(settings, token, 'GET', '/updates?limit=1')).data;
207
+ for (const [platform, manifest] of Object.entries(manifests)) {
208
+ if (!history.runtimes.some((seen) => seen.platform === platform && seen.runtime_version === manifest.runtimeVersion)) {
209
+ output.warn(`no ${platform} phone has asked for runtime ${manifest.runtimeVersion.slice(0, 8)} in 30 days (normal before its build is out)`);
210
+ }
211
+ }
212
+ const { data: missing } = await api(settings, token, 'POST', '/assets/missing', { json: { hashes: [...files.keys()] } });
213
+ let bytes = 0;
214
+ for (const hash of missing.missing) {
215
+ const { path, contentType } = files.get(hash);
216
+ const gzip = gzipSync(readFileSync(path), { level: 9 });
217
+ bytes += gzip.length;
218
+ await api(settings, token, 'PUT', `/assets/${hash}`, { body: gzip, headers: { 'content-type': contentType, 'content-encoding': 'gzip' } });
219
+ }
220
+ output.progress(`files: ${files.size}, ${missing.missing.length} uploaded (${(bytes / 1e6).toFixed(2)} MB gzipped)`);
221
+
222
+ const body = Object.fromEntries(Object.entries(signed).map(([platform, { body: manifest, header }]) => [platform, { manifest: Buffer.from(manifest).toString('base64'), signature: header }]));
223
+ const published = await postUpdate(settings, token, { commit, message, channel, rollout, dryRun, platforms: body }, output);
224
+ const checks = [];
225
+ if (!dryRun) for (const [platform, manifest] of Object.entries(manifests)) checks.push(await checkServed(settings, platform, manifest.runtimeVersion, manifest.id, output, servedAs(published, signing)));
226
+ return { ...published, commit, message, files: { total: files.size, uploaded: missing.missing.length, gzipped_bytes: bytes }, checks };
227
+ }
228
+
229
+ /**
230
+ * How to ask for what was just published as a phone would: in its channel and, during a rollout, as an installation
231
+ * inside the share (without EAS-Client-ID, or outside it, a phone gets the update before).
232
+ */
233
+ const servedAs = (published, signing) => ({
234
+ channel: published.channel,
235
+ signing,
236
+ ...(published.rollout && published.rollout.percent < 100 ? { installation: installationInside(published.group, published.rollout.percent) } : {}),
237
+ });
238
+
239
+ /** Publishes the signed platforms (manifests or directives) as one group, and prints what the server kept. */
240
+ export async function postUpdate(settings, token, { commit, message, channel, rollout, dryRun, platforms }, output) {
241
+ const { data } = await api(settings, token, 'POST', '/updates', {
242
+ json: { ...(channel ? { channel } : {}), message, ...(commit ? { commit: commit.slice(0, 40) } : {}), ...(rollout !== undefined ? { rollout } : {}), dry_run: dryRun, platforms },
243
+ });
244
+ output.log(`${dryRun ? 'dry run, nothing published' : `published group ${data.group}`} (channel ${data.channel}${data.rollout ? `, to ${data.rollout.percent} % of the installations` : ''})`);
245
+ for (const update of data.updates) output.log(` ${update.platform} ${update.kind} ${update.id ?? ''} runtime ${update.runtime_version.slice(0, 8)} ${update.created_at}`);
246
+ return data;
247
+ }
248
+
249
+ /** Publishes the commit of an earlier group again, with another id and createdAt: the way back to older code. */
250
+ export async function republish(settings, { group, message, ...options }, output) {
251
+ if (!group) throw new Error('republish <group>: the group, from list');
252
+ const found = (await api(settings, publishingToken(settings), 'GET', '/updates?limit=100')).data.groups.find((candidate) => candidate.group === group);
253
+ if (!found?.commit) throw new Error(`no group ${group} with a commit among the last 100`);
254
+ return publish(settings, { ...options, ref: found.commit, message: message ?? `Again: ${found.message ?? found.group} (${found.group.slice(0, 8)})` }, output);
255
+ }
256
+
257
+ /** Publishes a signed directive: the phones go back to the JavaScript inside their build. */
258
+ export async function rollbackToEmbedded(settings, { platforms = PLATFORMS, message, channel, rollout, dryRun = false }, output) {
259
+ const signing = signingFor(settings, channel);
260
+ checkCertificate(settings, output, signing);
261
+ const key = privateKeyPem(settings, signing);
262
+ const token = publishingToken(settings);
263
+ await checkChannel(settings, token, signing);
264
+ const runtimes = Object.fromEntries(platforms.map((platform) => [platform, runtimeVersion(settings.projectDir, platform)]));
265
+ const directives = rollbackDirectives({ platforms, runtimes, privateKeyPem: key, keyid: signing.keyid, commitTime: new Date().toISOString() });
266
+ const commit = run('git', ['rev-parse', 'HEAD'], { cwd: settings.projectDir }).trim();
267
+ const published = await postUpdate(settings, token, { commit, message: message ?? 'Back to the JavaScript inside the build', channel, rollout, dryRun, platforms: directives }, output);
268
+ const checks = [];
269
+ if (!dryRun) for (const platform of platforms) checks.push(await checkServed(settings, platform, runtimes[platform], undefined, output, servedAs(published, signing)));
270
+ return { ...published, checks };
271
+ }
272
+
273
+ /**
274
+ * Publishes an earlier group in another channel: its bytes come from the server and are checked with the certificate
275
+ * of their channel this Mac trusts (app.json or updates.channels), then their contents are signed again, with a new id
276
+ * and createdAt, with the key of the channel they go to. A signature is never copied from one channel to another.
277
+ */
278
+ export async function promote(settings, { group, to, message, rollout, dryRun = false }, output) {
279
+ if (!group) throw new UsageError('promote <group> --to <channel>: the group, from ias update list');
280
+ if (!to) throw new UsageError('promote needs --to <channel>: the channel it goes to');
281
+ const token = publishingToken(settings);
282
+ const { data: channels } = await api(settings, token, 'GET', '/channels');
283
+ const { data: source } = await api(settings, token, 'GET', `/updates/${group}`);
284
+ if (source.channel === to) throw new IasError(`the group ${group} is already in ${to}`, { fix: 'to serve it again, ias update restore <group>; to go back to it, ias update republish <group>' });
285
+ if (source.archived) throw new IasError(`the group ${group} is archived: its files are gone`, { fix: `publish its commit again in ${to}: ias update republish ${group} --channel ${to}` });
286
+ if (source.retired_at) output.warn(`the group ${group} is retired in ${source.channel}`);
287
+
288
+ const from = trustedSigning(settings, channels, source.channel);
289
+ const target = signingFor(settings, to);
290
+ checkCertificate(settings, output, target);
291
+ const key = privateKeyPem(settings, target);
292
+ await checkChannel(settings, token, target, channels);
293
+
294
+ const now = new Date().toISOString();
295
+ const platforms = {};
296
+ const expected = {};
297
+ for (const update of source.updates) {
298
+ const body = Buffer.from(update[update.kind], 'base64');
299
+ if (!verifySignature(from.certificate.publicKey, from.keyid, body, update.signature)) {
300
+ throw new IasError(`the ${update.platform} ${update.kind} of ${group} does not verify with the certificate of ${source.channel} (${from.source})`, { fix: 'nothing was signed: the server served something its channel never signed; look into it before publishing anything' });
301
+ }
302
+ const document = promoted(update.kind, body.toString('utf8'), now);
303
+ const signed = JSON.stringify(document);
304
+ const signature = signatureHeader(signed, key, target.keyid);
305
+ if (!verifySignature(target.certificate.publicKey, target.keyid, signed, signature)) throw new Error('the signature does not verify with the certificate');
306
+ platforms[update.platform] = update.kind === 'directive'
307
+ ? { directive: Buffer.from(signed).toString('base64'), signature, runtime_version: update.runtime_version }
308
+ : { manifest: Buffer.from(signed).toString('base64'), signature };
309
+ expected[update.platform] = { runtime: update.runtime_version, id: document.id };
310
+ }
311
+
312
+ const published = await postUpdate(settings, token, { commit: source.commit, message: message ?? `${source.message ?? source.group} (from ${source.channel})`, channel: to, rollout, dryRun, platforms }, output);
313
+ const checks = [];
314
+ if (!dryRun) for (const [platform, { runtime, id }] of Object.entries(expected)) checks.push(await checkServed(settings, platform, runtime, id, output, servedAs(published, target)));
315
+ return { ...published, promoted_from: { channel: source.channel, group: source.group }, checks };
316
+ }
317
+
318
+ /**
319
+ * The signing this Mac trusts for a channel of the server, to check what it re-signs: the one of updates.channels, or
320
+ * app.json's when the server's channel has its keyid. Whatever it is, the server's certificate must be the same.
321
+ */
322
+ function trustedSigning(settings, channels, name) {
323
+ const remote = channels.channels.find((candidate) => candidate.name === name);
324
+ const local = settings.channels?.[name] ?? (remote && remote.key_id === settings.keyid ? signingFor(settings) : undefined);
325
+ if (!local || !remote) {
326
+ throw new IasError(`this Mac has no certificate of the channel ${name} to check the group with`, { fix: `set updates.channels.${name} in ias.json with its keyId, privateKey and certificate: promote only signs again what it can verify` });
327
+ }
328
+ if (remote.key_id !== local.keyid || publicKeyOf(local.certificate) !== publicKeyOf(new X509Certificate(remote.certificate.pem))) {
329
+ throw new IasError(`the server's certificate of the channel ${name} is not the one of ${local.source}`, { fix: 'compare the keyid and certificate of ias update channels with those of the repository' });
330
+ }
331
+ return local;
332
+ }
333
+
334
+ /** Changes the rollout of a group: a share from 1 to 99, pause, or complete (100 completes too). */
335
+ export async function rollout(settings, { group, action }, output) {
336
+ if (!group || action === undefined) throw new UsageError('rollout <group> <percent>|pause|complete: the group, from ias update list, and what to do');
337
+ const percent = /^\d+$/.test(action) ? Number(action) : undefined;
338
+ if (percent === undefined && !['pause', 'complete'].includes(action)) throw new UsageError(`rollout takes a percentage (1–100), pause or complete, not ${action}`);
339
+ if (percent !== undefined && (percent < 1 || percent > 100)) throw new UsageError(`the percentage goes from 1 to 100, not ${percent}`, { fix: '100 completes the rollout: every installation gets the group' });
340
+ const path = percent === undefined ? `/updates/${group}/rollout/${action}` : percent === 100 ? `/updates/${group}/rollout/complete` : `/updates/${group}/rollout`;
341
+ const { data } = await api(settings, publishingToken(settings), 'POST', path, percent !== undefined && percent < 100 ? { json: { percent } } : {});
342
+ output.log(`rollout of ${data.group}: ${describeRollout(data.rollout)}`);
343
+ return data;
344
+ }
345
+
346
+ /** A rollout in a few words: «25 % (rolling)», «25 % (paused automatically)», «completed». */
347
+ const describeRollout = (state) => (state.state === 'completed' ? 'completed' : `${state.percent} % (${state.state === 'paused' ? (state.paused_reason === 'automatic' ? 'paused automatically: the new update fails to launch more than the one before' : 'paused') : 'rolling'})`);
348
+
349
+ /** The channels of the project with their keyid and certificate, and whether this Mac signs for each. */
350
+ export async function channels(settings, output) {
351
+ const { data } = await api(settings, publishingToken(settings), 'GET', '/channels');
352
+ for (const channel of data.channels) {
353
+ const local = settings.channels?.[channel.name] ?? (channel.key_id === settings.keyid ? signingFor(settings) : undefined);
354
+ const signs = local && local.keyid === channel.key_id && publicKeyOf(local.certificate) === publicKeyOf(new X509Certificate(channel.certificate.pem));
355
+ output.log(`${channel.name}${channel.default ? ' (default)' : ''}: keyid ${channel.key_id}, certificate until ${channel.certificate.valid_until?.slice(0, 10) ?? '?'}, ${signs ? `signed with ${local.source}` : 'this Mac has no key for it'}`);
356
+ }
357
+ return data;
358
+ }
359
+
360
+ /** Stops serving a group (retire) or serves it again (restore): the emergency brake, also in the panel. */
361
+ export async function setServing(settings, action, { group }, output) {
362
+ if (!group) throw new Error(`${action} <group>: the group, from list`);
363
+ const { data } = await api(settings, publishingToken(settings), 'POST', `/updates/${group}/${action}`);
364
+ output.log(`${action === 'retire' ? 'retired' : 'restored'} ${data.group}${data.retired_at ? ` (retired ${data.retired_at})` : ''}`);
365
+ return data;
366
+ }
367
+
368
+ /** The last groups and the runtimes the phones asked for in the last 30 days. */
369
+ export async function list(settings, { channel }, output) {
370
+ const query = channel ? `&channel=${encodeURIComponent(channel)}` : '';
371
+ const { data } = await api(settings, publishingToken(settings), 'GET', `/updates?limit=20${query}`);
372
+ output.log(`default channel ${data.default_channel}, keyid ${data.key_id}`);
373
+ for (const group of data.groups) {
374
+ const state = group.archived ? 'archived' : group.retired_at ? 'retired' : 'live';
375
+ output.log(`${group.published_at} ${group.group} ${state} ${group.channel} ${group.commit ?? ''} «${group.message ?? ''}»${group.rollout && group.rollout.state !== 'completed' ? ` rollout ${describeRollout(group.rollout)}` : ''}`);
376
+ for (const update of group.updates) output.log(` ${update.platform} ${update.kind} runtime ${update.runtime_version.slice(0, 8)} ${update.created_at}`);
377
+ }
378
+ output.log('runtimes asked for in the last 30 days:');
379
+ for (const seen of data.runtimes) output.log(` ${seen.platform} ${seen.channel} ${seen.runtime_version.slice(0, 8)}: ${seen.requests} requests, last ${seen.last_seen}`);
380
+ return data;
381
+ }
382
+
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) {
385
+ const checks = [];
386
+ for (const platform of platforms) checks.push(await checkServed(settings, platform, runtimeVersion(settings.projectDir, platform), undefined, output));
387
+ return { checks };
388
+ }
389
+
390
+ /**
391
+ * Asks for the manifest as a phone would and checks the signature, the id and every file it points to. `checked`
392
+ * holds the files already checked in this run (one per settings): the platforms share most of them. `channel` and
393
+ * `installation` ask as a build of that channel and as that installation (EAS-Client-ID); `signing`, the certificate.
394
+ */
395
+ export async function checkServed(settings, platform, runtime, expectedId, output, { channel, installation, signing = signingFor(settings, channel), checked = (settings.checkedFiles ??= new Set()) } = {}) {
396
+ const headers = { 'expo-protocol-version': '1', 'expo-platform': platform, 'expo-runtime-version': runtime, accept: 'multipart/mixed' };
397
+ if (channel) headers['expo-channel-name'] = channel;
398
+ if (installation) headers['eas-client-id'] = installation;
399
+ const response = await fetch(settings.manifestUrl, { headers });
400
+ if (response.status === 204) {
401
+ if (expectedId) throw new Error(`${platform}: the server serves nothing for runtime ${runtime.slice(0, 8)}, not ${expectedId}`);
402
+ output.log(`check ${platform} ${runtime.slice(0, 8)}: nothing (204)`);
403
+ return { platform, runtime_version: runtime, served: 'nothing' };
404
+ }
405
+ if (!response.ok) throw new Error(`${platform}: the manifest answered ${response.status}`);
406
+ const boundary = response.headers.get('content-type')?.match(/boundary=([^;]+)/)?.[1];
407
+ const part = firstPart(Buffer.from(await response.arrayBuffer()), boundary);
408
+ if (!verifySignature(signing.certificate.publicKey, signing.keyid, part.body, part.signature)) throw new Error(`${platform}: the served ${part.name} does not verify with the certificate`);
409
+ const served = JSON.parse(part.body.toString('utf8'));
410
+ if (part.name === 'directive') {
411
+ output.log(`check ${platform} ${runtime.slice(0, 8)}: directive ${served.type}, signature valid`);
412
+ return { platform, runtime_version: runtime, served: 'directive', type: served.type };
413
+ }
414
+ if (expectedId && served.id !== expectedId) throw new Error(`${platform}: the server serves ${served.id}, not ${expectedId}`);
415
+ const files = [served.launchAsset, ...served.assets];
416
+ for (const [index, asset] of files.entries()) {
417
+ if (checked.has(asset.hash)) continue;
418
+ checked.add(asset.hash);
419
+ // Each file once, and the bundle also without gzip.
420
+ for (const encoding of index === 0 ? [undefined, 'identity'] : [undefined]) {
421
+ const answer = await download(new URL(asset.url, settings.manifestUrl), encoding ? { 'accept-encoding': encoding } : {}, output);
422
+ if (!answer.ok) throw new Error(`${platform}: ${asset.url} answered ${answer.status}`);
423
+ if (sha256(Buffer.from(await answer.arrayBuffer())) !== asset.hash) throw new Error(`${platform}: ${asset.url} does not match its hash`);
424
+ }
425
+ }
426
+ output.log(`check ${platform} ${runtime.slice(0, 8)}: ${served.id} (${served.createdAt}), signature valid, ${files.length} files match`);
427
+ return { platform, runtime_version: runtime, served: 'manifest', id: served.id, created_at: served.createdAt, files: files.length };
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------------------------------------------------
431
+ // The command line: ias update <subcommand>.
432
+
433
+ const platformsOf = (values) => {
434
+ if (values.platform && !PLATFORMS.includes(values.platform)) throw new UsageError(`--platform is ios or android, not ${values.platform}`, { fix: 'leave it out to publish both' });
435
+ return values.platform ? [values.platform] : PLATFORMS;
436
+ };
437
+
438
+ const settingsOf = ({ values, cwd, env }) => {
439
+ const project = projectFor(resolve(cwd, values.project ?? '.'), 'updates');
440
+ return withChannels(updateSettings(project, env), project);
441
+ };
442
+
443
+ const OPTIONS = {
444
+ message: { type: 'string', short: 'm', value: '"…"', description: 'What the update changes; the people who publish read it in list and in the panel.' },
445
+ ref: { type: 'string', value: '<commit>', description: 'The commit to publish (HEAD by default). Only what is committed is published.' },
446
+ platform: { type: 'string', value: 'ios|android', description: 'Only one platform (both by default).' },
447
+ channel: { type: 'string', value: '<channel>', description: 'The channel (the project\'s default one by default), signed with its own key: updates.channels.<channel> of ias.json, or app.json\'s.' },
448
+ rollout: { type: 'string', value: '<1-100>', description: 'Start with this share of the installations (all by default); ias update rollout raises, pauses or completes it.' },
449
+ to: { type: 'string', value: '<channel>', description: 'The channel it goes to, signed again with its key.' },
450
+ 'dry-run': { type: 'boolean', description: 'Everything but publishing: the server checks it all and keeps nothing.' },
451
+ };
452
+
453
+ const pick = (...names) => Object.fromEntries(names.map((name) => [name, OPTIONS[name]]));
454
+
455
+ export default {
456
+ name: 'update',
457
+ summary: 'Signed OTA updates (expo-updates): publish, go back, retire and check',
458
+ 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.',
459
+ subcommands: [
460
+ {
461
+ name: 'publish',
462
+ summary: 'Publish a commit (HEAD by default) for both platforms or one',
463
+ options: pick('message', 'ref', 'platform', 'channel', 'rollout', 'dry-run'),
464
+ run: (context) => publish(settingsOf(context), { ref: context.values.ref, message: context.values.message, platforms: platformsOf(context.values), channel: context.values.channel, rollout: rolloutOf(context.values.rollout), dryRun: context.values['dry-run'] }, context.output),
465
+ },
466
+ {
467
+ name: 'republish',
468
+ summary: 'Publish the commit of an earlier group again, with a new id and createdAt: the way back',
469
+ positionals: [{ name: 'group', description: 'The group, from ias update list.' }],
470
+ options: pick('message', 'platform', 'channel', 'rollout', 'dry-run'),
471
+ run: (context) => republish(settingsOf(context), { group: context.positionals[0], message: context.values.message, platforms: platformsOf(context.values), channel: context.values.channel, rollout: rolloutOf(context.values.rollout), dryRun: context.values['dry-run'] }, context.output),
472
+ },
473
+ {
474
+ name: 'rollback-to-embedded',
475
+ aliases: ['rollback'],
476
+ summary: 'Publish a signed directive: the phones go back to the JavaScript of their build',
477
+ options: pick('message', 'platform', 'channel', 'rollout', 'dry-run'),
478
+ run: (context) => rollbackToEmbedded(settingsOf(context), { message: context.values.message, platforms: platformsOf(context.values), channel: context.values.channel, rollout: rolloutOf(context.values.rollout), dryRun: context.values['dry-run'] }, context.output),
479
+ },
480
+ {
481
+ name: 'promote',
482
+ summary: 'Publish an earlier group in another channel, signed again on this Mac with that channel\'s key',
483
+ positionals: [{ name: 'group', description: 'The group, from ias update list --channel <its channel>.' }],
484
+ options: pick('to', 'message', 'rollout', 'dry-run'),
485
+ run: (context) => promote(settingsOf(context), { group: context.positionals[0], to: context.values.to, message: context.values.message, rollout: rolloutOf(context.values.rollout), dryRun: context.values['dry-run'] }, context.output),
486
+ },
487
+ {
488
+ name: 'rollout',
489
+ summary: 'Change the rollout of a group: a share from 1 to 99, pause, or complete',
490
+ positionals: [
491
+ { name: 'group', description: 'The group, from ias update list.' },
492
+ { name: 'percent|pause|complete', description: 'The share of installations (100 completes), pause (nobody new gets it), or complete (everyone does).' },
493
+ ],
494
+ run: (context) => rollout(settingsOf(context), { group: context.positionals[0], action: context.positionals[1] }, context.output),
495
+ },
496
+ {
497
+ name: 'channels',
498
+ summary: 'The channels of the project, each with its keyid and certificate, and whether this Mac signs for it',
499
+ run: (context) => channels(settingsOf(context), context.output),
500
+ },
501
+ {
502
+ name: 'retire',
503
+ summary: 'Stop serving a group: the emergency brake (also in the panel)',
504
+ positionals: [{ name: 'group', description: 'The group, from ias update list.' }],
505
+ run: (context) => setServing(settingsOf(context), 'retire', { group: context.positionals[0] }, context.output),
506
+ },
507
+ {
508
+ name: 'restore',
509
+ summary: 'Serve a retired group again',
510
+ positionals: [{ name: 'group', description: 'The group, from ias update list.' }],
511
+ run: (context) => setServing(settingsOf(context), 'restore', { group: context.positionals[0] }, context.output),
512
+ },
513
+ {
514
+ name: 'list',
515
+ summary: 'The last groups, and the runtimes the phones ask for',
516
+ options: pick('channel'),
517
+ run: (context) => list(settingsOf(context), { channel: context.values.channel }, context.output),
518
+ },
519
+ {
520
+ name: 'check',
521
+ summary: 'What a phone with the app\'s runtime gets now, checked end to end',
522
+ options: pick('platform'),
523
+ run: (context) => check(settingsOf(context), { platforms: platformsOf(context.values) }, context.output),
524
+ },
525
+ ],
526
+ };