@imagina-build/ias-cli 0.0.0 → 0.1.1

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 +31 -12
  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 +114 -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,223 @@
1
+ // ias devices sync (docs/technical/manage-api.md §15, docs/technical/runner-api.md §11), on a member's computer, because it is the one
2
+ // thing that touches Apple and the vault at once: the server cannot open the vault nor sign, and the runner has no
3
+ // team signing key. In this order:
4
+ // 1. the team's keys from 1Password, checked against the server's (vault/team.mjs), and the App Store Connect API
5
+ // key of the vault opened in memory with the recovery key, after checking the team's signature;
6
+ // 2. the approved devices registered in Apple (the API, a JWT of node:crypto), and the server told;
7
+ // 3. a new ad hoc profile of the bundle id with the team's certificate and every device that belongs in it: those
8
+ // of the current profile still enabled in Apple, and the approved ones, without the rejected ones;
9
+ // 4. the profile sealed for the recovery key and the runners of the list the team signed, signed by the team
10
+ // (ias-vault-2), and uploaded as the next version of the credential of the profile; the server told which
11
+ // devices it lists;
12
+ // 5. a resign of the newest internal iOS build, followed until its IPA is on the install pages (--detach does not
13
+ // wait; --no-resign skips it).
14
+ // What it does in Apple and in the vault, it shows and asks to confirm first (--yes in a script). Nothing it opens
15
+ // is printed or written to disk.
16
+ import { createHash, randomUUID } from 'node:crypto';
17
+ import { ApiError, IasError } from '../../errors.mjs';
18
+ import { applicationOf, connect, sleep } from '../../manage.mjs';
19
+ import { credentials as inspect, decodePlaintext, digest, encodePlaintext, open, seal, verifyEnvelope } from '../../shared.mjs';
20
+ import { signedRunners, vaultItem } from '../../team-vault.mjs';
21
+ import { confirm, teamKeys } from '../vault/team.mjs';
22
+ import { ascClient } from './asc.mjs';
23
+
24
+ const DAY = 24 * 60 * 60 * 1000;
25
+ const FINISHED = ['succeeded', 'failed', 'rejected', 'canceled', 'expired'];
26
+
27
+ /** A UDID as Apple writes it (the old form in lowercase, the new one in uppercase), to compare them. */
28
+ export const normalizeUdid = (udid) => (String(udid).trim().length === 40 ? String(udid).trim().toLowerCase() : String(udid).trim().toUpperCase());
29
+
30
+ /** The latest expiring of `list`, by expires_at (none last). */
31
+ const latestExpiring = (list) => [...list].sort((a, b) => (Date.parse(b.expires_at ?? '9999') || 0) - (Date.parse(a.expires_at ?? '9999') || 0))[0] ?? null;
32
+
33
+ /** The name Apple keeps a device with: the tester's, or iOS's, and its model, within 50 characters. */
34
+ export function appleDeviceName(device) {
35
+ const model = device.model ? ` (${device.model})` : '';
36
+ const name = (device.owner_name || device.name || 'Dispositivo').normalize('NFKD').replace(/[^\x20-\x7e]/g, '');
37
+ return `${name.slice(0, 50 - model.length).trim() || 'Device'}${model}`;
38
+ }
39
+
40
+ /** The credentials sync needs: the team's App Store Connect key and distribution certificate, and the app's ad hoc profile. */
41
+ async function vaultCredentials(client, code, app) {
42
+ const team = (await client.request('GET', '/team/credentials')).data.data.filter((credential) => !credential.retired_at);
43
+ const own = (await client.request('GET', `/apps/${code}/credentials`)).data.data.filter((credential) => !credential.retired_at);
44
+ const now = Date.now();
45
+ const usable = (credential) => !credential.expires_at || Date.parse(credential.expires_at) > now;
46
+ const ascKey = team.filter((credential) => credential.type === 'asc_api_key').sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''))[0];
47
+ if (!ascKey) throw new IasError('the vault has no App Store Connect API key', { fix: 'import the team\'s with ias credentials import asc_api_key --p8 <AuthKey_….p8> --key-id <id> --issuer-id <uuid>', code: 'credentials_missing' });
48
+ const certificate = latestExpiring(team.filter((credential) => credential.type === 'apple_distribution_certificate' && usable(credential) && credential.metadata?.kind === 'distribution' && (!app.ios.team_id || credential.metadata?.team === app.ios.team_id)));
49
+ if (!certificate) throw new IasError(`the vault has no usable distribution certificate of the team ${app.ios.team_id ?? ''}`.trim(), { fix: 'import it with ias credentials import apple_distribution_certificate', code: 'credentials_missing' });
50
+ const sha1 = String(certificate.metadata.sha1 ?? '').toUpperCase();
51
+ const profile = latestExpiring(own.filter((credential) => credential.type === 'apple_provisioning_profile' && credential.metadata?.type === 'ad-hoc' && credential.metadata?.bundleId === app.ios.bundle_id && (credential.metadata?.certificates ?? []).map((value) => String(value).toUpperCase()).includes(sha1)));
52
+ return { ascKey, certificate, profile };
53
+ }
54
+
55
+ /** The App Store Connect key of the vault, opened with the recovery key after checking the team signed it. */
56
+ async function openAscKey(client, credential, { recoveryKey, keys }) {
57
+ const { data } = await client.request('GET', `/credentials/${credential.id}/envelope`);
58
+ if (data.envelope?.credential?.id !== credential.id || data.envelope?.credential?.type !== 'asc_api_key') throw new IasError('the server gives the envelope of another credential for the App Store Connect key', { fix: 'nothing was opened: ias credentials show checks it', code: 'envelope_of_another_credential' });
59
+ let bytes;
60
+ try {
61
+ bytes = open(data.envelope, recoveryKey.privateKey, { signedBy: keys.signing.publicKey });
62
+ return decodePlaintext('asc_api_key', bytes);
63
+ } catch (error) {
64
+ throw new IasError(`the envelope of the App Store Connect key does not verify with the team's key or does not open (${error.code ?? error.message})`, { fix: 'nothing was sent to Apple: ias credentials show --verify checks it', code: 'envelope_invalid' });
65
+ } finally {
66
+ bytes?.fill?.(0);
67
+ }
68
+ }
69
+
70
+ /** The Apple id of the team's certificate of the vault: the one whose DER has its SHA-1. */
71
+ async function appleCertificate(asc, certificate) {
72
+ const sha1 = String(certificate.metadata.sha1 ?? '').toUpperCase();
73
+ const found = (await asc.certificates()).find((item) => item.attributes?.certificateContent && createHash('sha1').update(Buffer.from(item.attributes.certificateContent, 'base64')).digest('hex').toUpperCase() === sha1);
74
+ if (!found) throw new IasError(`Apple has no distribution certificate with the SHA-1 ${sha1} of the vault`, { fix: 'it was revoked or is of another team: import the current one with ias credentials import apple_distribution_certificate', code: 'certificate_not_in_apple' });
75
+ return found;
76
+ }
77
+
78
+ /** Follows a resign until it ends, as ias build follows a build. */
79
+ async function followResign(client, code, resign, { output, wait }) {
80
+ let current = resign;
81
+ let said = null;
82
+ while (!FINISHED.includes(current.status)) {
83
+ const step = `${current.status}${current.step ? ` (${current.step})` : ''}`;
84
+ if (step !== said) output.progress(` resign ${current.id}: ${step}`);
85
+ said = step;
86
+ await wait(5);
87
+ current = (await client.request('GET', `/apps/${code}/resigns/${current.id}`)).data;
88
+ }
89
+ for (let tries = 0; current.status === 'succeeded' && !current.distribution && !current.publish_error && tries < 24; tries += 1) {
90
+ await wait(5);
91
+ current = (await client.request('GET', `/apps/${code}/resigns/${current.id}`)).data;
92
+ }
93
+ return current;
94
+ }
95
+
96
+ export async function sync(context, project) {
97
+ const { values, env, output } = context;
98
+ const wait = context.sleep ?? sleep;
99
+ const client = connect(context, project);
100
+ const code = applicationOf(context, project);
101
+ const { data: app } = await client.request('GET', `/apps/${code}`);
102
+ if (!app.ios?.bundle_id) throw new IasError(`${code} has no iOS bundle id`, { fix: 'set it in the panel (the application › Settings)', code: 'application_without_ios' });
103
+ const { data: listed } = await client.request('GET', `/apps/${code}/devices`);
104
+ const devices = listed.data;
105
+ const approved = devices.filter((device) => device.status === 'approved');
106
+ const wanted = devices.filter((device) => device.status === 'approved' || device.status === 'registered');
107
+ const rejected = new Set(devices.filter((device) => device.status === 'rejected').map((device) => normalizeUdid(device.udid)));
108
+
109
+ const item = vaultItem(context, project, client.server);
110
+ const { vault, keys, signingKey, recoveryKey } = await teamKeys(client, item, env, { recovery: true });
111
+ const material = await vaultCredentials(client, code, app);
112
+ const key = await openAscKey(client, material.ascKey, { recoveryKey, keys });
113
+ const asc = ascClient(key, { env, fetch: context.fetch ?? fetch });
114
+ try {
115
+ output.progress(`App Store Connect: key ${key.key_id}, from the vault (${material.ascKey.id} v${material.ascKey.version})`);
116
+ const inApple = new Map((await asc.devices()).map((device) => [normalizeUdid(device.attributes.udid), device]));
117
+
118
+ // What the new profile lists: the current profile's devices still enabled, and the approved ones, never the rejected ones.
119
+ const bundle = await asc.bundleId(app.ios.bundle_id);
120
+ if (!bundle) throw new IasError(`Apple has no App ID ${app.ios.bundle_id}`, { fix: 'register it in Apple Developer › Identifiers (ias build does it for a store build)', code: 'bundle_id_not_in_apple' });
121
+ const current = material.profile ? (await asc.profilesOf(bundle.id)).find((profile) => String(profile.attributes?.uuid).toUpperCase() === String(material.profile.metadata.uuid).toUpperCase()) : null;
122
+ if (material.profile && !current) output.warn(`the profile of the vault (${material.profile.metadata.uuid}) is not in Apple any more: the new one lists only the devices of IAS`);
123
+ const inProfile = current ? await asc.devicesOf(current.id) : [];
124
+ const kept = inProfile.filter((device) => device.attributes?.status === 'ENABLED' && !rejected.has(normalizeUdid(device.attributes.udid)));
125
+ // A device IAS has as registered that Apple no longer has (deleted in Apple Developer) is registered again.
126
+ const toRegister = wanted.filter((device) => !inApple.has(normalizeUdid(device.udid)));
127
+ const toEnable = wanted.filter((device) => inApple.get(normalizeUdid(device.udid))?.attributes?.status === 'DISABLED');
128
+ const listedUdids = new Set([...kept.map((device) => normalizeUdid(device.attributes.udid)), ...wanted.map((device) => normalizeUdid(device.udid))]);
129
+ const before = new Set(kept.map((device) => normalizeUdid(device.attributes.udid)));
130
+ const added = wanted.filter((device) => !before.has(normalizeUdid(device.udid)));
131
+ const leaving = inProfile.filter((device) => rejected.has(normalizeUdid(device.attributes.udid)));
132
+ const unchanged = material.profile && current && added.length === 0 && leaving.length === 0 && toRegister.length === 0 && toEnable.length === 0;
133
+
134
+ output.progress(`${app.name} (${app.ios.bundle_id}): ${devices.length} device${devices.length === 1 ? '' : 's'} in IAS, ${approved.length} approved and not yet in Apple`);
135
+ if (unchanged && !values.force) {
136
+ output.log(`The ad hoc profile v${material.profile.version} already lists every approved device (${listedUdids.size}): nothing to do. --force makes it again.`);
137
+ return { changed: false, profile: { credential: material.profile.id, version: material.profile.version, devices: listedUdids.size }, registered: [], resign: null };
138
+ }
139
+ for (const device of toRegister) output.progress(` register in Apple ${appleDeviceName(device)} ${device.udid}`);
140
+ for (const device of toEnable) output.progress(` enable in Apple ${appleDeviceName(device)} ${device.udid}`);
141
+ for (const device of added) output.progress(` add to the profile ${appleDeviceName(device)} ${device.udid}`);
142
+ for (const device of leaving) output.progress(` leave the profile ${device.attributes.name} ${device.attributes.udid} (rejected in IAS)`);
143
+ output.progress(`The new ad hoc profile of ${app.ios.bundle_id} will list ${listedUdids.size} device${listedUdids.size === 1 ? '' : 's'}, signed with «${material.certificate.name}», and replace ${material.profile ? `version ${material.profile.version} of «${material.profile.name}»` : 'nothing (it is the first)'} in the vault.`);
144
+ output.progress('Check the devices: the server says which ones were approved, and whatever goes into the profile can install the internal builds.');
145
+ await confirm(context, `Register them in Apple and make the new profile of ${app.ios.bundle_id}?`);
146
+
147
+ // 2. Apple: register and enable, then tell the server.
148
+ const registered = [];
149
+ for (const device of [...toRegister, ...toEnable]) {
150
+ const done = inApple.has(normalizeUdid(device.udid)) ? await asc.enableDevice(inApple.get(normalizeUdid(device.udid)).id) : await asc.registerDevice({ name: appleDeviceName(device), udid: device.udid });
151
+ inApple.set(normalizeUdid(device.udid), done);
152
+ output.progress(` ${toRegister.includes(device) ? 'registered' : 'enabled'} ${appleDeviceName(device)} as ${done.id}`);
153
+ }
154
+ const report = wanted
155
+ .filter((device) => inApple.has(normalizeUdid(device.udid)) && (device.status === 'approved' || device.apple_device_id !== inApple.get(normalizeUdid(device.udid)).id))
156
+ .map((device) => ({ id: device.id, apple_device_id: inApple.get(normalizeUdid(device.udid)).id }));
157
+ if (report.length > 0) {
158
+ await client.request('POST', `/apps/${code}/devices/registered`, { json: { devices: report } });
159
+ registered.push(...report);
160
+ }
161
+
162
+ // 3. The new profile, made by Apple with every device that belongs in it.
163
+ const deviceIds = [...new Set([...kept.map((device) => device.id), ...wanted.map((device) => inApple.get(normalizeUdid(device.udid))?.id).filter(Boolean)])];
164
+ if (deviceIds.length === 0) throw new IasError('no device would go into the profile', { fix: 'approve a device in the panel (Distribución › Dispositivos) first', code: 'no_devices' });
165
+ const certificate = await appleCertificate(asc, material.certificate);
166
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '-');
167
+ const made = await asc.createProfile({ name: `IAS ad hoc ${app.ios.bundle_id} ${stamp}`, bundleIdId: bundle.id, certificateId: certificate.id, deviceIds });
168
+ const bytes = Buffer.from(made.attributes?.profileContent ?? '', 'base64');
169
+ let metadata;
170
+ try {
171
+ metadata = inspect.metadataOf('apple_provisioning_profile', { mobileprovision: bytes });
172
+ } catch (error) {
173
+ throw new IasError(`Apple gave a profile that does not read (${error.message})`, { fix: 'run it again; the profile stays in Apple Developer and can be deleted there', code: 'profile_unreadable' });
174
+ }
175
+ const problems = inspect.checkIos({ certificate: material.certificate.metadata, profile: metadata }, { type: 'ad-hoc', teamId: app.ios.team_id ?? undefined, bundleId: app.ios.bundle_id });
176
+ if (metadata.devices !== deviceIds.length) problems.push(`it lists ${metadata.devices} devices, not ${deviceIds.length}`);
177
+ if (problems.length > 0) throw new IasError(`the profile Apple made is not the one asked for: ${problems.join('; ')}`, { fix: 'nothing was stored in the vault: check the App ID and the certificate in Apple Developer', code: 'profile_unexpected' });
178
+ output.progress(`profile «${metadata.name}» ${metadata.uuid}: ${metadata.devices} devices, until ${metadata.expires}`);
179
+
180
+ // 4. Sealed for the recovery key and the signed runners, signed by the team, as the next version.
181
+ const { runners } = signedRunners(vault, keys.signing, output);
182
+ const id = material.profile?.id ?? randomUUID();
183
+ const version = (material.profile?.version ?? 0) + 1;
184
+ const plaintext = encodePlaintext('apple_provisioning_profile', { application: code, mobileprovision: bytes });
185
+ let envelope;
186
+ try {
187
+ envelope = seal(plaintext, { credential: { id, type: 'apple_provisioning_profile', version }, recipients: [keys.recovery, ...runners], signingKey });
188
+ verifyEnvelope(envelope, keys.signing.publicKey);
189
+ } finally {
190
+ plaintext.fill(0);
191
+ }
192
+ const { data: stored } = material.profile
193
+ ? await client.request('PUT', `/credentials/${id}`, { json: { metadata, envelope } })
194
+ : await client.request('POST', `/apps/${code}/credentials`, { json: { id, type: 'apple_provisioning_profile', name: `Perfil ad hoc de ${app.name}`, metadata, envelope } });
195
+ output.log(`stored «${stored.name}» (${stored.id}) version ${stored.version}, digest ${digest(envelope)}, sealed for ${envelope.recipients.length} key${envelope.recipients.length === 1 ? '' : 's'}`);
196
+ await client.request('POST', `/apps/${code}/devices/registered`, { json: { profile: { credential: stored.id, version: stored.version, devices: [...listedUdids].sort() } } });
197
+ if (metadata.expires && Date.parse(metadata.expires) - Date.now() < 30 * DAY) output.warn(`the new profile expires on ${metadata.expires}: the certificate expires soon`);
198
+
199
+ // 5. Sign the newest internal iOS build again with it.
200
+ let resign = null;
201
+ if (!values['no-resign']) {
202
+ try {
203
+ resign = (await client.request('POST', `/apps/${code}/resigns`, { json: { profile: stored.id } })).data;
204
+ } catch (error) {
205
+ if (!(error instanceof ApiError) || error.code !== 'resign_source_missing') throw error;
206
+ output.warn('there is no internal iOS build of IAS to sign again: the next ias build --platform ios --profile preview uses the new profile');
207
+ }
208
+ if (resign && resign.profile?.version !== stored.version) throw new IasError(`the server signs again with version ${resign.profile?.version} of the profile, not ${stored.version}`, { fix: 'someone replaced it meanwhile: run ias devices sync again', code: 'profile_not_current' });
209
+ if (resign) {
210
+ output.log(`resign ${resign.id} of ${resign.version} (${resign.build_number ?? '?'}) queued for a runner`);
211
+ if (!values.detach) {
212
+ resign = await followResign(client, code, resign, { output, wait });
213
+ if (resign.status !== 'succeeded') throw new IasError(`the resign ${resign.id} ended ${resign.status}${resign.error ? `: ${resign.error.message ?? resign.error.code}` : ''}`, { fix: 'the profile is in the vault: the panel (Distribución › Dispositivos) has its log; ias devices sync --force asks again', code: 'resign_failed', details: resign });
214
+ if (resign.distribution) output.log(`signed again and published: ${resign.distribution.install_url}${resign.distribution.stable_url ? ` (the stable link ${resign.distribution.stable_url} gives it)` : ''}`);
215
+ else if (resign.publish_error) output.warn(`signed again, but not published in Distribution: ${resign.publish_error}`);
216
+ }
217
+ }
218
+ }
219
+ return { changed: true, registered, profile: { credential: stored.id, version: stored.version, uuid: metadata.uuid, devices: metadata.devices, expires: metadata.expires }, resign };
220
+ } finally {
221
+ key.p8?.fill?.(0);
222
+ }
223
+ }
@@ -0,0 +1,108 @@
1
+ // ias dist: internal builds (an ad hoc IPA or an APK) on the install pages of IAS (API.md §37). What the page shows is
2
+ // read from the file; the file goes straight to the bucket and the server checks its size and SHA-256. It was
3
+ // tools/expo-dist.
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { basename, extname, resolve } from 'node:path';
6
+ import { distSettings, projectFor } from '../../config.mjs';
7
+ import { IasError } from '../../errors.mjs';
8
+ import { api } from '../../http.mjs';
9
+ import { digest, gitCommit, icon, readApk, readIpa } from '../../dist.mjs';
10
+ import { readSecret, tokenKind } from '../../secrets.mjs';
11
+
12
+ /** The publishing token of the distribution project: the variable or the op:// reference, read now. */
13
+ export function publishingToken(settings) {
14
+ const source = settings.token || settings.loggedIn?.();
15
+ if (!source) throw settings.needs.token;
16
+ const token = readSecret(source);
17
+ if (tokenKind(token) === 'ota') throw new IasError('the token is the one of an OTA project (ota_…)', { fix: 'ias dist needs the publishing token of the distribution project (dist_…, php artisan dist:token): check IAS_TOKEN, DIST_TOKEN or the token of the configuration' });
18
+ return token;
19
+ }
20
+
21
+ /** Reads the build from the file, uploads it and prints its link and the stable one. */
22
+ export async function upload(settings, { file: target, notes, cwd }, output) {
23
+ if (!target) throw new Error('usage: upload <file.ipa|file.apk> [--notes "…"]');
24
+ const file = resolve(cwd, target);
25
+ if (!existsSync(file)) throw new Error(`no file at ${target}`);
26
+ const extension = extname(file).toLowerCase();
27
+ if (!['.ipa', '.apk'].includes(extension)) throw new Error('upload takes an .ipa (ad hoc) or an .apk; an .aab only installs through Google Play');
28
+
29
+ const metadata = extension === '.ipa' ? readIpa(file) : readApk(file);
30
+ const { sha256, bytes } = await digest(file);
31
+ if (metadata.profile_expires_at && Date.parse(metadata.profile_expires_at) < Date.now()) throw new Error(`the ad hoc profile expired on ${metadata.profile_expires_at}: iOS will not open this build`);
32
+ output.progress(`${output.prefix}: ${basename(file)}: ${metadata.name} ${metadata.version} (${metadata.build_number}), ${metadata.identifier}, ${(bytes / 1048576).toFixed(1)} MB${metadata.provisioned_devices !== undefined ? `, ${metadata.provisioned_devices} devices until ${metadata.profile_expires_at}` : ''}`);
33
+
34
+ const token = publishingToken(settings);
35
+ return sendBuild(settings, { token, file, build: { ...metadata, bytes, sha256, notes, git_commit: gitCommit(settings.projectDir), icon: icon(settings.projectDir) } }, output);
36
+ }
37
+
38
+ /** The network half of upload: creates the build, PUTs the file to the bucket and asks the server to check it. */
39
+ export async function sendBuild(settings, { token, file, build: fields }, output) {
40
+ const { data: build } = await api(settings, token, 'POST', '/builds', { json: fields });
41
+
42
+ const response = await fetch(build.upload.url, { method: build.upload.method ?? 'PUT', headers: { ...build.upload.headers, 'content-length': String(fields.bytes) }, body: readFileSync(file) });
43
+ if (!response.ok) throw new Error(`uploading the file: ${response.status} ${(await response.text()).slice(0, 300)}`);
44
+
45
+ const { data: done } = await api(settings, token, 'POST', `/builds/${build.id}/complete`);
46
+ const { data: listing } = await api(settings, token, 'GET', '/builds?limit=1');
47
+ output.log(`${output.prefix}: build ${done.id} ready`);
48
+ output.log(` this build: ${done.install_url}`);
49
+ output.log(` latest (both): ${listing.install_url}`);
50
+ return { build: done, install_url: listing.install_url };
51
+ }
52
+
53
+ /** The stable link and the last 30 builds, with their id, state and link. */
54
+ export async function list(settings, output) {
55
+ const { data } = await api(settings, publishingToken(settings), 'GET', '/builds?limit=30');
56
+ output.log(`latest (both platforms): ${data.install_url}`);
57
+ for (const build of data.builds) {
58
+ output.log(`${String(build.id).padStart(5)} ${build.platform.padEnd(7)} ${`${build.version} (${build.build_number})`.padEnd(16)} ${build.status.padEnd(11)} ${(build.uploaded_at ?? build.created_at ?? '').slice(0, 16)} ${build.install_url}${build.notes ? ` ${build.notes}` : ''}`);
59
+ }
60
+ return data;
61
+ }
62
+
63
+ /** Stops offering a build (retire) or offers it again (restore); also in the panel. */
64
+ export async function setOffered(settings, action, { id }, output) {
65
+ if (!/^\d+$/.test(id ?? '')) throw new Error(`usage: ${action} <build id> (see list)`);
66
+ const { data } = await api(settings, publishingToken(settings), 'POST', `/builds/${id}/${action}`);
67
+ output.log(`${output.prefix}: build ${data.id} ${data.status}${data.changed ? '' : ' (it already was)'}`);
68
+ return data;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------------------------------------------------
72
+ // The command line: ias dist <subcommand>.
73
+
74
+ const settingsOf = ({ values, cwd, env }) => distSettings(projectFor(resolve(cwd, values.project ?? '.'), 'distribution'), env);
75
+
76
+ const buildId = { name: 'id', description: 'The id of the build, from ias dist list.', pattern: /^\d+$/ };
77
+
78
+ export default {
79
+ name: 'dist',
80
+ summary: 'Internal builds on the install pages: upload an ad hoc IPA or an APK, list, retire and restore',
81
+ description: 'The server and project come from ias.json (server, and distribution.project or app); the token is the distribution project\'s (dist_…): IAS_TOKEN or distribution.token. IPAs need the tools of macOS (unzip, plutil, security, sips); APKs, aapt2 of the Android SDK.',
82
+ subcommands: [
83
+ {
84
+ name: 'upload',
85
+ summary: 'Upload a build and print its link and the stable one',
86
+ positionals: [{ name: 'file', description: 'An ad hoc .ipa or an .apk (an .aab only installs through Google Play).' }],
87
+ options: { notes: { type: 'string', value: '"…"', description: 'What the build brings; the install page shows it.' } },
88
+ run: (context) => upload(settingsOf(context), { file: context.positionals[0], notes: context.values.notes, cwd: context.cwd }, context.output),
89
+ },
90
+ {
91
+ name: 'list',
92
+ summary: 'The stable link and the last 30 builds',
93
+ run: (context) => list(settingsOf(context), context.output),
94
+ },
95
+ {
96
+ name: 'retire',
97
+ summary: 'Stop offering a build (also in the panel)',
98
+ positionals: [buildId],
99
+ run: (context) => setOffered(settingsOf(context), 'retire', { id: context.positionals[0] }, context.output),
100
+ },
101
+ {
102
+ name: 'restore',
103
+ summary: 'Offer a retired build again',
104
+ positionals: [buildId],
105
+ run: (context) => setOffered(settingsOf(context), 'restore', { id: context.positionals[0] }, context.output),
106
+ },
107
+ ],
108
+ };
@@ -0,0 +1,158 @@
1
+ // ias doctor: whether this Mac and this app have what the commands need: Node, git, the 1Password CLI, Xcode and its
2
+ // tools (plutil, security, sips), aapt2 of the Android SDK, a configuration that loads and, in an app with a
3
+ // package.json, the SDK @imagina-build/ias and its plugin. It reads no secret: an op:// reference is checked for its form,
4
+ // never opened (that would ask for Touch ID).
5
+ import { existsSync } from 'node:fs';
6
+ import { join, resolve } from 'node:path';
7
+ import process from 'node:process';
8
+ import { VERSION } from '../../cli.mjs';
9
+ import { CONFIG_FILE, LEGACY_FILES, distSettings, hasConfig, loadProject, updateSettings } from '../../config.mjs';
10
+ import { androidSdk, findAapt2 } from '../../dist.mjs';
11
+ import { EXIT_CODE } from '../../output.mjs';
12
+ import { probe as systemProbe } from '../../process.mjs';
13
+ import { SDK_PACKAGE, compatibleRange, isCompatible, packageManager, sdkState } from '../../sdk.mjs';
14
+ import { isReference } from '../../secrets.mjs';
15
+
16
+ const MIN_NODE = 22;
17
+ const MAC_TOOLS = ['plutil', 'security', 'sips', 'unzip'];
18
+
19
+ const where = (value, variable) => (isReference(value) ? 'in 1Password' : `from ${variable}`);
20
+
21
+ /**
22
+ * The checks, in order: { name, status (ok, warn, fail or skip), detail, fix }. `probe` runs a program and says whether
23
+ * it answered (tests pass their own); a fail means a command of ias will not work here.
24
+ */
25
+ export function diagnose({ dir, env = process.env, platform = process.platform, nodeVersion = process.versions.node, probe = systemProbe, aapt2 = findAapt2, now = Date.now() }) {
26
+ const checks = [];
27
+ const add = (name, status, detail, fix) => checks.push({ name, status, detail, ...(fix ? { fix } : {}) });
28
+
29
+ let project;
30
+ let configError;
31
+ try {
32
+ project = loadProject(dir);
33
+ } catch (error) {
34
+ configError = error;
35
+ }
36
+ const uses = (service) => Boolean(project && hasConfig(project, service));
37
+ const references = project ? JSON.stringify(project.config ?? {}).includes('op://') || Object.values(LEGACY_FILES).some((name) => existsSync(join(dir, name))) : false;
38
+
39
+ const major = Number(nodeVersion.split('.')[0]);
40
+ if (major >= MIN_NODE) add('node', 'ok', `Node ${nodeVersion}`);
41
+ else add('node', 'fail', `Node ${nodeVersion}`, `ias needs Node ${MIN_NODE} or later (fetch, util.parseArgs and node:test): install it, for instance with brew install node@22`);
42
+
43
+ const git = probe('git', ['--version']);
44
+ if (git.ok) add('git', 'ok', git.output.replace(/^git version /, 'git '));
45
+ else add('git', uses('updates') ? 'fail' : 'warn', 'git does not answer', 'ias update publishes commits with git: install it (xcode-select --install)');
46
+
47
+ const op = probe('op', ['--version']);
48
+ if (op.ok) add('op', 'ok', `1Password CLI ${op.output}`);
49
+ else add('op', references ? 'fail' : 'warn', 'the 1Password CLI (op) does not answer', 'the keys and tokens are op:// references: install it (brew install 1password-cli) and turn on its integration with the 1Password app');
50
+
51
+ if (platform !== 'darwin') {
52
+ add('xcode', 'skip', 'Xcode is only on macOS');
53
+ add('macos-tools', 'skip', `${MAC_TOOLS.join(', ')} are only on macOS: IPAs are uploaded from a Mac`);
54
+ } else {
55
+ const developer = probe('xcode-select', ['-p']);
56
+ const xcode = probe('xcodebuild', ['-version']);
57
+ if (developer.ok && xcode.ok) add('xcode', 'ok', `${xcode.output.split('\n')[0]} (${developer.output})`);
58
+ else add('xcode', 'warn', developer.ok ? `${developer.output} has only the command line tools` : 'no Xcode', 'iOS builds need Xcode (phase 2): install it from the App Store and run sudo xcode-select -s /Applications/Xcode.app');
59
+ const missing = MAC_TOOLS.filter((tool) => !probe('/usr/bin/which', [tool]).ok);
60
+ if (missing.length === 0) add('macos-tools', 'ok', MAC_TOOLS.join(', '));
61
+ else add('macos-tools', uses('distribution') ? 'fail' : 'warn', `missing ${missing.join(', ')}`, 'ias dist reads IPAs with them: they come with macOS and its command line tools (xcode-select --install)');
62
+ }
63
+
64
+ const aapt2Path = aapt2(env);
65
+ if (aapt2Path) add('aapt2', 'ok', aapt2Path);
66
+ else add('aapt2', 'warn', `no aapt2 in ${join(androidSdk(env), 'build-tools')}`, 'ias dist reads APKs with it: install the build tools of the Android SDK (Android Studio › SDK Manager) or set ANDROID_HOME');
67
+
68
+ if (existsSync(join(dir, 'package.json'))) sdkCheck(dir, add);
69
+
70
+ if (configError) {
71
+ add('config', 'fail', configError.what ?? configError.message, configError.fix);
72
+ return checks;
73
+ }
74
+ if (project.source === CONFIG_FILE) {
75
+ const old = Object.values(LEGACY_FILES).filter((name) => existsSync(join(dir, name)));
76
+ add('config', 'ok', `${CONFIG_FILE}: app ${project.config.app}, server ${project.config.server ?? 'https://ias.imagina.build (the default)'}${old.length > 0 ? `; ${old.join(' and ')} are no longer read` : ''}`);
77
+ } else if (uses('updates') || uses('distribution')) {
78
+ const old = Object.values(LEGACY_FILES).filter((name) => existsSync(join(dir, name)));
79
+ add('config', 'warn', `no ${CONFIG_FILE}: ${old.join(' and ')}, as tools/ reads them`, 'ias init writes ias.json from them');
80
+ } else {
81
+ add('config', 'warn', `no ${CONFIG_FILE} in ${dir}`, 'run ias init in the folder of the app (or pass --project) to check its configuration');
82
+ return checks;
83
+ }
84
+
85
+ if (uses('updates')) {
86
+ try {
87
+ const settings = updateSettings(project, env);
88
+ const days = Math.floor((Date.parse(settings.certificate.validTo) - now) / 86_400_000);
89
+ const missing = [!settings.privateKey && settings.needs?.privateKey, !settings.token && settings.needs?.token].filter(Boolean);
90
+ const detail = `project ${settings.project} on ${new URL(settings.base).origin}, keyid ${settings.keyid}, certificate until ${settings.certificate.validTo} (${days} days)${settings.privateKey ? `, key ${where(settings.privateKey, 'the environment')}` : ''}${settings.token ? `, token ${where(settings.token, 'the environment')}` : ''}`;
91
+ if (days < 0) add('updates', 'fail', detail, 'the builds that carry this certificate reject every update: a new certificate needs a new build');
92
+ else if (missing.length > 0) add('updates', 'fail', `${detail}; ${missing.map((error) => error.what).join(' and ')}`, missing.map((error) => error.fix).join('; '));
93
+ else if (days < 365) add('updates', 'warn', detail, 'rotate the certificate with the next store build');
94
+ else add('updates', 'ok', detail);
95
+ } catch (error) {
96
+ add('updates', 'fail', error.what ?? error.message, error.fix);
97
+ }
98
+ }
99
+ if (uses('distribution')) {
100
+ try {
101
+ const settings = distSettings(project, env);
102
+ const detail = `project ${settings.project} on ${new URL(settings.base).origin}${settings.token ? `, token ${where(settings.token, 'the environment')}` : ''}`;
103
+ if (!settings.token) add('distribution', 'fail', `${detail}; ${settings.needs.token.what}`, settings.needs.token.fix);
104
+ else add('distribution', 'ok', detail);
105
+ } catch (error) {
106
+ add('distribution', 'fail', error.what ?? error.message, error.fix);
107
+ }
108
+ }
109
+ return checks;
110
+ }
111
+
112
+ /** The SDK in the app: installed, compatible with this ias and with its plugin in app.json; a warning if the app does not use it. */
113
+ function sdkCheck(dir, add) {
114
+ let state;
115
+ try {
116
+ state = sdkState(dir);
117
+ } catch (error) {
118
+ add('sdk', 'fail', error.what ?? error.message, error.fix);
119
+ return;
120
+ }
121
+ const { declared, installed, plugin } = state;
122
+ if (!declared && !installed) {
123
+ if (plugin === 'yes') add('sdk', 'fail', `app.json has the plugin of ${SDK_PACKAGE}, but the app does not have the package`, 'ias sdk install installs it: expo config and the builds fail without it');
124
+ else add('sdk', 'warn', `the app does not use ${SDK_PACKAGE}`, `ias sdk install, if it uses ias.sync, ias.push or ias.updates, or takes its configuration from ias.json`);
125
+ return;
126
+ }
127
+ if (!installed) {
128
+ add('sdk', 'fail', `${SDK_PACKAGE} ${declared} is in package.json, but not installed`, `${packageManager(dir).name} install`);
129
+ return;
130
+ }
131
+ if (!isCompatible(installed, VERSION)) {
132
+ add('sdk', 'fail', `${SDK_PACKAGE} ${installed}, and ias ${VERSION} works with ${compatibleRange()}`, `ias sdk install installs ${compatibleRange()}, or install the ias of the same version as the SDK: they must read ias.json the same way`);
133
+ return;
134
+ }
135
+ const detail = `${SDK_PACKAGE} ${installed}, compatible with ias ${VERSION}`;
136
+ if (plugin === 'yes') add('sdk', 'ok', `${detail}, plugin in app.json`);
137
+ else if (plugin === 'dynamic') add('sdk', 'warn', `${detail}; ias cannot read ${state.dynamic}`, `check that it adds "${SDK_PACKAGE}" to the plugins: it writes ias.json into the builds`);
138
+ else add('sdk', 'warn', `${detail}, but app.json does not have its plugin`, `ias sdk install adds "${SDK_PACKAGE}" to the plugins of app.json, in a new build: it writes ias.json into the builds`);
139
+ }
140
+
141
+ export function doctor(dir, { env, output }) {
142
+ const checks = diagnose({ dir, env });
143
+ for (const check of checks) {
144
+ output.log(` ${check.status.padEnd(5)} ${check.name.padEnd(13)} ${check.detail}`);
145
+ if (check.fix && check.status !== 'ok') output.log(` ${''.padEnd(5)} ${''.padEnd(13)} → ${check.fix}`);
146
+ }
147
+ const count = (status) => checks.filter((check) => check.status === status).length;
148
+ const failures = count('fail');
149
+ output.log(`${output.prefix}: ${failures === 0 ? 'ready' : `${failures} ${failures === 1 ? 'thing stops' : 'things stop'} a command`}${count('warn') > 0 ? `, ${count('warn')} to look at` : ''}`);
150
+ return { ok: failures === 0, checks, [EXIT_CODE]: failures > 0 ? 1 : 0 };
151
+ }
152
+
153
+ export default {
154
+ name: 'doctor',
155
+ summary: 'Check Node, git, op, Xcode and its tools, the Android SDK, the app\'s configuration and the SDK @imagina-build/ias',
156
+ description: 'It reads no secret. A fail means a command of ias will not work here; a warning, that something may be needed later.',
157
+ run: ({ values, cwd, env, output }) => doctor(resolve(cwd, values.project ?? '.'), { env, output }),
158
+ };