@imagina-build/ias-cli 0.2.2 → 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 +26 -5
  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,122 @@
1
+ // ias credentials generate android_upload_keystore: a new upload key for Google Play, made on this Mac with keytool.
2
+ // The passwords are random and never touch the command line; the keystore and the passwords go to one 1Password item
3
+ // (the keystore as a file attachment), and then the existing `ias credentials import` seals the same material in the
4
+ // vault. The item stays for recovery; the temporary file is removed at the end of the run.
5
+ import { spawnSync } from 'node:child_process';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { chmodSync, existsSync, rmSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { join, resolve } from 'node:path';
10
+ import process from 'node:process';
11
+ import { loadProject } from '../../config.mjs';
12
+ import { IasError } from '../../errors.mjs';
13
+ import { createItem, readField, setAttachment, setField } from '../../op.mjs';
14
+ import { temporary } from '../../process.mjs';
15
+
16
+ const BIN = fileURLToPath(new URL('../../../../bin/ias.mjs', import.meta.url));
17
+ const ALIAS = /^[A-Za-z0-9._-]{1,100}$/;
18
+
19
+ const password = () => randomBytes(24).toString('base64url');
20
+
21
+ /** The app folder and code, with a clear error when there is no ias.json. */
22
+ function projectOf(dir) {
23
+ const project = loadProject(dir);
24
+ if (!project.config?.app) throw new IasError(`no valid ias.json in ${dir}`, { fix: 'run ias init there first' });
25
+ return { project, app: project.config.app };
26
+ }
27
+
28
+ /**
29
+ * Makes the upload keystore, keeps it and its passwords in 1Password, and imports it into the vault through the same
30
+ * path as `ias credentials import`. `dryRun` does nothing. `force` replaces an existing item only if it says so.
31
+ */
32
+ export async function generateAndroidKeystore({ dir, env = process.env, output, values = {} }) {
33
+ const { app } = projectOf(dir);
34
+ const alias = values.alias ?? 'upload';
35
+ const item = values.item ?? `op://IAS/${app} Android upload`;
36
+ const dryRun = values['dry-run'] === true;
37
+
38
+ if (!ALIAS.test(alias)) throw new IasError(`«${alias}» is not a valid alias`, { fix: 'letters, digits, dots, hyphens and underscores, up to 100' });
39
+ if (!/^op:\/\/[^/\s]+\/[^/]+$/.test(item)) throw new IasError(`«${item}» is not a 1Password item`, { fix: 'it is op://<vault>/<item>, such as op://IAS/jscheduler Android upload' });
40
+
41
+ if (dryRun) {
42
+ output.log(`${output.prefix}: would make a PKCS12 upload key «${alias}» for ${app}, keep it in ${item}, and seal it in the vault`);
43
+ return { app, alias, item, dry_run: true };
44
+ }
45
+
46
+ const existing = readField(item, 'store-password', env);
47
+ if (existing !== undefined && values.force !== true) {
48
+ throw new IasError(`${item} already has an upload key`, { fix: 'it is already the key of this app: use it (ias credentials list), or pass --force to replace it and make a new build' });
49
+ }
50
+
51
+ const folder = temporary('ias-keystore-');
52
+ const keystore = join(folder, 'upload.p12');
53
+ const storePassword = password();
54
+ const keyPassword = storePassword;
55
+ const keytool = spawnSync('keytool', [
56
+ '-genkeypair',
57
+ '-keystore', keystore,
58
+ '-storetype', 'PKCS12',
59
+ '-alias', alias,
60
+ '-keyalg', 'RSA',
61
+ '-keysize', '2048',
62
+ '-validity', '10000',
63
+ '-dname', `CN=${app} upload, O=Imagina, C=ES`,
64
+ '-storepass:env', 'IAS_KEYSTORE_PASSWORD',
65
+ '-keypass:env', 'IAS_KEYSTORE_PASSWORD',
66
+ ], { encoding: 'utf8', env: { ...env, IAS_KEYSTORE_PASSWORD: storePassword }, stdio: ['ignore', 'pipe', 'pipe'] });
67
+
68
+ if (keytool.error || keytool.status !== 0) {
69
+ const reason = keytool.error ? keytool.error.message : (keytool.stderr || '').trim().split('\n').at(-1);
70
+ throw new IasError(`keytool could not make the upload key: ${reason}`, { fix: 'keytool comes with a JDK: install one (for instance brew install openjdk) and try again' });
71
+ }
72
+ chmodSync(keystore, 0o600);
73
+ if (!existsSync(keystore)) throw new IasError('keytool did not write the keystore', { fix: 'check that keytool works, and try again' });
74
+
75
+ if (existing !== undefined) {
76
+ // --force: the item exists, so add/replace its fields and its attachment instead of creating it.
77
+ setField(item, 'store-password', storePassword, env);
78
+ setField(item, 'key-password', keyPassword, env);
79
+ setField(item, 'key-alias', alias, env);
80
+ setAttachment(item, 'keystore', keystore, env);
81
+ output.log(`${output.prefix}: replaced the upload key in ${item}`);
82
+ } else {
83
+ createItem(item, {
84
+ notes: `The upload key of ${app} for Google Play (PKCS12, alias ${alias}), made by ias credentials generate. The keystore is the attachment; the passwords are the concealed fields. It is sealed in the IAS vault too; this item is for recovery.`,
85
+ fields: [
86
+ { label: 'store-password', value: storePassword, type: 'CONCEALED' },
87
+ { label: 'key-password', value: keyPassword, type: 'CONCEALED' },
88
+ { label: 'key-alias', value: alias },
89
+ { label: 'application', value: app },
90
+ ],
91
+ attachments: [{ label: 'keystore', path: keystore }],
92
+ }, env);
93
+ output.log(`${output.prefix}: created ${item} with the keystore and its passwords`);
94
+ }
95
+
96
+ // The import is another ias: the password reaches it through stdin, never by an argument nor the environment.
97
+ const command = env.IAS_CLI_BIN ?? process.execPath;
98
+ const args = [...(env.IAS_CLI_BIN ? [] : [BIN]), 'credentials', 'import', 'android_upload_keystore', '--project', resolve(dir), '--keystore', keystore, '--store-password', '-', '--key-alias', alias, '--json'];
99
+ const child = spawnSync(command, args, {
100
+ cwd: dir,
101
+ env,
102
+ input: `${storePassword}\n`,
103
+ encoding: 'utf8',
104
+ maxBuffer: 8 * 1024 * 1024,
105
+ stdio: ['pipe', 'pipe', 'pipe'],
106
+ });
107
+
108
+ rmSync(keystore, { force: true });
109
+ if (child.error || child.status !== 0) {
110
+ const reason = child.error ? child.error.message : (child.stderr || '').trim().split('\n').slice(-3).join(' ');
111
+ throw new IasError(`the upload key is in ${item}, but sealing it in the vault failed: ${reason}`, { fix: `unlock 1Password and import it with ias credentials import android_upload_keystore --keystore ${item}/keystore --store-password ${item}/store-password --key-alias ${alias}` });
112
+ }
113
+
114
+ let sealed = null;
115
+ try {
116
+ sealed = JSON.parse(child.stdout || 'null');
117
+ } catch {
118
+ // The import answered text (it should not with --json): leave it as the child wrote it.
119
+ }
120
+ output.log(`${output.prefix}: upload key «${alias}» of ${app} sealed in the vault; the keystore and passwords are in ${item}`);
121
+ return { app, alias, item, sealed };
122
+ }
@@ -1,4 +1,4 @@
1
- // ias credentials import, list, show, rotate and retire (docs/technical/manage-api.md §8). import reads the material (files,
1
+ // ias credentials generate, import, list, show, rotate and retire (docs/technical/manage-api.md §8). import reads the material (files,
2
2
  // op:// references or EAS's credentials.json), takes its metadata with @imagina-build/ias-shared/credentials, makes the id
3
3
  // (a UUID) and seals it in ias-vault-2 on this computer: for the recovery key and the runners of the list the team
4
4
  // signed, both checked against 1Password, and signed with the team's key. The server gets the envelope and the
@@ -8,49 +8,17 @@ import { resolve } from 'node:path';
8
8
  import { loadProject } from '../../config.mjs';
9
9
  import { ApiError, IasError } from '../../errors.mjs';
10
10
  import { applicationOf, connect } from '../../manage.mjs';
11
- import { PLAINTEXT_TYPES, credentials as inspect, digest, encodePlaintext, seal, verifyEnvelope } from '../../shared.mjs';
11
+ import { PLAINTEXT_TYPES, credentials as inspect, digest, verifyEnvelope } from '../../shared.mjs';
12
12
  import { publicKeysOf, sealingKeys, vaultItem } from '../../team-vault.mjs';
13
+ import { generateAndroidKeystore } from './generate.mjs';
14
+ import { approvalLines, approveCertificateChange, generateDistributionCertificate, generateIos, generateProvisioningProfile, oldCertificate, revokeAppleCertificate } from './apple.mjs';
13
15
  import { MATERIAL_OPTIONS, defaultName, inferType, materialFromEas, materialFromOptions, sameMaterial } from './material.mjs';
16
+ import { expiryWarning, metadataOf, ofTeam, plaintextOf, sealCredential } from './seal.mjs';
14
17
 
15
18
  const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
16
19
  const CREDENTIAL_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
17
- const ofTeam = (type) => PLAINTEXT_TYPES[type]?.scope === 'team';
18
20
  const DAY = 24 * 60 * 60 * 1000;
19
21
 
20
- /** The metadata of `material`, or an IasError that names the file or tool, never a secret. */
21
- function metadataOf(type, material) {
22
- try {
23
- return inspect.metadataOf(type, material.inspect);
24
- } catch (error) {
25
- if (error instanceof inspect.CredentialError) throw new IasError(`${type}: ${error.message}`, { fix: 'check the file and its password or alias', code: 'credential_unreadable' });
26
- throw error;
27
- }
28
- }
29
-
30
- /** The plaintext to seal: the fields of the material, those that only the metadata knows, and the application. */
31
- function plaintextOf(type, material, metadata, application) {
32
- return encodePlaintext(type, { ...(ofTeam(type) ? {} : { application }), ...material.fields, ...(material.completes?.(metadata) ?? {}) });
33
- }
34
-
35
- /** Seals `plaintext` as version `version` of credential `id`, and checks the signature it just made. */
36
- function sealCredential(plaintext, { id, type, version }, sealing) {
37
- try {
38
- const envelope = seal(plaintext, { credential: { id, type, version }, recipients: sealing.recipients, signingKey: sealing.signingKey });
39
- verifyEnvelope(envelope, sealing.keys.signing.publicKey);
40
- return envelope;
41
- } finally {
42
- plaintext.fill(0);
43
- }
44
- }
45
-
46
- /** Warnings about an expiry within 30 days. */
47
- function expiryWarning(name, metadata, output, now = Date.now()) {
48
- if (!metadata?.expires) return;
49
- const days = Math.floor((Date.parse(metadata.expires) - now) / DAY);
50
- if (days < 0) output.warn(`${name} expired on ${metadata.expires}`);
51
- else if (days <= 30) output.warn(`${name} expires in ${days} days (${metadata.expires})`);
52
- }
53
-
54
22
  /** The credentials the token can see: those of the application (if any) and the team's (with a personal token). */
55
23
  async function visibleCredentials(client, application) {
56
24
  const found = [];
@@ -70,6 +38,26 @@ async function findCredential(client, id, application) {
70
38
  return credential;
71
39
  }
72
40
 
41
+ /**
42
+ * One credential by its id or, when only one is named that way, by its name. Two with the same name are never picked
43
+ * silently: it lists them and asks for the id.
44
+ */
45
+ async function credentialNamed(client, wanted, application) {
46
+ const found = await visibleCredentials(client, application);
47
+ const byId = found.find((candidate) => candidate.id === wanted);
48
+ if (byId) return byId;
49
+ const byName = found.filter((candidate) => candidate.name === wanted);
50
+ if (byName.length === 1) return byName[0];
51
+ if (byName.length > 1) {
52
+ throw new IasError(`«${wanted}» names ${byName.length} credentials`, {
53
+ fix: `pass the id of one: ${byName.map((candidate) => `${candidate.id} (${candidate.type}, ${candidate.application ?? 'the team'})`).join(', ')}`,
54
+ code: 'credential_ambiguous',
55
+ details: { candidates: byName.map((candidate) => ({ id: candidate.id, type: candidate.type, application: candidate.application ?? null })) },
56
+ });
57
+ }
58
+ throw new IasError(`there is no credential «${wanted}»${application ? ` in ${application} or the team` : ' in the team'}`, { fix: 'ias credentials list shows them; pass --app for one of another application', code: 'credential_not_found' });
59
+ }
60
+
73
61
  const optionalApplication = (context, project) => (context.values.app || project?.config?.app ? applicationOf(context, project) : undefined);
74
62
 
75
63
  async function importCredentials(context) {
@@ -185,30 +173,93 @@ async function rotate(context) {
185
173
  output.log(`dry run: version ${version} of «${credential.name}» sealed, digest ${digest(envelope)}; nothing sent`);
186
174
  return { dry_run: true, id: credential.id, version, metadata, digest: digest(envelope) };
187
175
  }
188
- const { data } = await client.request('PUT', `/credentials/${credential.id}`, { json: { ...(values.name ? { name: values.name } : {}), metadata, envelope } });
176
+ // A new SHA-1 of the team's distribution certificate changes what every build signs with: an owner authorizes it
177
+ // first, and the server takes the new material only with that approval (docs/plans/gaps/01-certificado-ios.md §3.6).
178
+ let approval = null;
179
+ if (type === 'apple_distribution_certificate' && String(credential.metadata?.sha1 ?? '').toUpperCase() !== String(metadata.sha1 ?? '').toUpperCase()) {
180
+ const team = String(values['team-id'] ?? metadata.team ?? credential.metadata?.team ?? '').trim().toUpperCase();
181
+ if (!/^[A-Z0-9]{10}$/.test(team)) throw new IasError(`the new certificate has no Apple team`, { fix: 'the .p12 names its team in the OU: check it with openssl, or pass --team-id <id>', code: 'team_missing' });
182
+ const old = oldCertificate(credential.metadata);
183
+ approval = await approveCertificateChange(context, client, {
184
+ action: 'replace',
185
+ credential: credential.id,
186
+ team,
187
+ old,
188
+ new: { sha1: String(metadata.sha1).toUpperCase() },
189
+ revoke_before_create: false,
190
+ applications: [],
191
+ }, approvalLines({ what: 'replace the distribution certificate with an imported .p12', old, team, material: `SHA-1 importado ${String(metadata.sha1).toUpperCase()}`, applications: [] }));
192
+ }
193
+ const { data } = await client.request('PUT', `/credentials/${credential.id}`, { json: { ...(values.name ? { name: values.name } : {}), metadata, envelope, ...(approval ? { approval } : {}) } });
189
194
  output.log(`replaced «${data.name}» (${data.id}): version ${data.version}${data.expires_at ? `, until ${data.expires_at}` : ''}`);
190
195
  expiryWarning(`«${data.name}»`, metadata, output);
191
196
  return data;
192
197
  }
193
198
 
194
199
  async function retire(context) {
195
- const { data } = await connect(context, projectOf(context)).request('POST', `/credentials/${context.positionals[0]}/retire`);
196
- context.output.log(`retired «${data.name}» (${data.id}) on ${data.retired_at}: it is no longer used and stays in the inventory`);
200
+ const { values, output } = context;
201
+ const project = projectOf(context);
202
+ const client = connect(context, project);
203
+ const credential = await credentialNamed(client, context.positionals[0], optionalApplication(context, project));
204
+ if (values['dry-run']) {
205
+ output.progress(`dry run: would retire «${credential.name}» (${credential.id}, ${credential.type}) on ${client.server}: it would stop being used and stay in the inventory`);
206
+ return { dry_run: true, id: credential.id, name: credential.name, type: credential.type, version: credential.version, retired: false };
207
+ }
208
+ const { data } = await client.request('POST', `/credentials/${credential.id}/retire`);
209
+ output.log(`retired «${data.name}» (${data.id}) on ${data.retired_at}: it is no longer used and stays in the inventory`);
197
210
  return data;
198
211
  }
199
212
 
200
213
  const credentialId = { name: 'id', description: 'The id of the credential, from ias credentials list.', pattern: CREDENTIAL_ID };
214
+ const credentialNamedArgument = { name: 'credential', description: 'The id of the credential (ias credentials list), or its name when only one has it.', pattern: /^[^«»]+$/ };
201
215
  const common = {
202
216
  app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' },
203
217
  };
204
- const item = { 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>).' } };
218
+ const item = { 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>).' } };
205
219
  const types = Object.keys(PLAINTEXT_TYPES).filter((type) => type !== 'environment_variable');
206
220
 
207
221
  export default {
208
222
  name: 'credentials',
209
- summary: 'The signing credentials in the sealed vault: import, list, show, replace and retire',
223
+ summary: 'The signing credentials in the sealed vault: generate, import, list, show, rotate, retire and revoke',
210
224
  description: `Credentials are sealed on this computer (ias-vault-2, docs/technical/vault.md) for the team's recovery key and the authorised runners, and signed by the team; the keys come from the 1Password item of ias vault init. Types: ${types.join(', ')}.`,
211
225
  subcommands: [
226
+ {
227
+ name: 'generate',
228
+ summary: 'Make signing material on this Mac: the Android upload key, the iOS certificate and the App Store profile',
229
+ description: [
230
+ 'ias credentials generate ios (the short path): reuses the team\'s distribution certificate and the App Store profile of the bundle id when they are valid, and makes what is missing — the RSA-2048 key, the CSR and the .p12 here, the certificate and the profile in App Store Connect — sealing them in the vault and keeping a recovery copy in 1Password. It never revokes anything and never picks among several certificates without --certificate. --rotate replaces the certificate with an owner\'s passkey approval and keeps the credential id; --no-profiles leaves the App Store profile for later.',
231
+ 'The granular forms: android_upload_keystore makes a PKCS12 upload key with keytool; apple_distribution_certificate makes only the certificate; apple_provisioning_profile makes only the profile (app-store, or ad-hoc through ias devices sync). With several App Store profiles of the bundle id it never picks one: --profile names it. --sync-capabilities enables in the App ID what app.json › expo › ios › entitlements asks for (and Apple invalidates every profile of the App ID, the ad hoc one too). --delete-previous removes in Apple the App Store profile the new one replaces, after it is sealed and uploaded, and only with the flag.',
232
+ 'Nothing prints a password. It needs openssl (macOS has it), a JDK for the keystore, a Team API key of App Store Connect with the Admin role (or App Manager with Certificates) and an owner of the team for the vault.',
233
+ ].join('\n'),
234
+ positionals: [{ name: 'type', description: 'ios, apple_distribution_certificate, apple_provisioning_profile or android_upload_keystore.', pattern: /^(ios|apple_distribution_certificate|apple_provisioning_profile|android_upload_keystore)$/ }],
235
+ options: {
236
+ ...common,
237
+ alias: { type: 'string', value: '<alias>', description: 'android_upload_keystore: the key alias in the keystore (upload by default).' },
238
+ item: { type: 'string', value: 'op://vault/item', description: 'The 1Password item for the material: the keystore, or the certificate (.p12) and its password. Defaults to op://IAS/<app> Android upload or op://IAS/<team> Apple Distribution <serial>.' },
239
+ name: { type: 'string', value: '"…"', description: 'The team name written in the CSR subject (the app name by default).' },
240
+ force: { type: 'boolean', description: 'Replace the key and the item if they already exist; the old key stops working in a new upload.' },
241
+ reuse: { type: 'boolean', description: 'Reuse the current distribution certificate, even when it expires within --if-expiring days.' },
242
+ rotate: { type: 'boolean', description: 'Make a new distribution certificate even when the current one is valid (Apple\'s limit may refuse it; nothing is revoked automatically).' },
243
+ certificate: { type: 'string', value: '<sha1|id>', description: 'Which distribution certificate of the team to use, when there are several (its SHA-1, its credential id or Apple\'s id).' },
244
+ profile: { type: 'string', value: '<uuid|name>', description: 'apple_provisioning_profile and ios: which App Store profile of Apple to reuse, when there are several (its UUID or its exact name). Never guessed.' },
245
+ 'sync-capabilities': { type: 'boolean', description: 'apple_provisioning_profile and ios: enable in the App ID the capabilities that app.json › expo › ios › entitlements asks for and Apple lacks, then make the profile again. Apple invalidates every profile of the App ID, the ad hoc one too: run ias devices sync --force after it.' },
246
+ 'delete-previous': { type: 'boolean', description: 'apple_provisioning_profile and ios: after a new profile is sealed and uploaded, delete in Apple the App Store profile it replaces (only that one; never the ad hoc one, a dry run or another credential\'s). It asks first.' },
247
+ type: { type: 'string', value: 'app-store|ad-hoc', description: 'apple_provisioning_profile and ios: app-store (the default) or ad-hoc (which runs ias devices sync).' },
248
+ 'bundle-id': { type: 'string', value: '<id>', description: 'The bundle id of the profile (the app\'s by default).' },
249
+ 'team-id': { type: 'string', value: '<id>', description: 'The Apple team (the app\'s, or the key\'s, by default).' },
250
+ 'if-expiring': { type: 'string', value: '<days>', description: 'Renew the certificate only when it expires within these days (30 by default; 0 never renews).' },
251
+ 'no-profiles': { type: 'boolean', description: 'ios: make or reuse the certificate and leave the App Store profile for later; a store build needs it, so ias credentials generate ios finishes it.' },
252
+ yes: { type: 'boolean', description: 'Do not ask before writing in Apple and the vault.' },
253
+ 'dry-run': { type: 'boolean', description: 'Show the inventory and exactly what would be sealed, and make nothing.' },
254
+ },
255
+ run: (context) => {
256
+ const type = context.positionals[0];
257
+ if (type === 'android_upload_keystore') return generateAndroidKeystore({ dir: resolve(context.cwd, context.values.project ?? '.'), env: context.env, output: context.output, values: context.values });
258
+ if (type === 'ios') return generateIos(context);
259
+ if (type === 'apple_distribution_certificate') return generateDistributionCertificate(context);
260
+ return generateProvisioningProfile(context);
261
+ },
262
+ },
212
263
  {
213
264
  name: 'import',
214
265
  summary: 'Seal a credential and upload it, with its metadata',
@@ -241,7 +292,7 @@ export default {
241
292
  {
242
293
  name: 'rotate',
243
294
  summary: 'Replace the material of a credential with a new version',
244
- description: 'Takes the options of import for the type of the credential, and seals the next version.',
295
+ description: 'Takes the options of import for the type of the credential, and seals the next version. When the credential is an apple_distribution_certificate and the new .p12 changes its SHA-1, it asks an owner for a passkey approval first: the server only accepts the new material with it.',
245
296
  positionals: [credentialId],
246
297
  options: { ...common, ...MATERIAL_OPTIONS, name: { type: 'string', value: '"…"', description: 'A new name.' }, 'dry-run': { type: 'boolean', description: 'Read, check and seal, but send nothing.' }, ...item },
247
298
  run: rotate,
@@ -249,8 +300,27 @@ export default {
249
300
  {
250
301
  name: 'retire',
251
302
  summary: 'Stop using a credential (owner); it stays in the inventory',
252
- positionals: [credentialId],
303
+ description: 'Takes its id, or its name when only one credential 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 retire and changes nothing.',
304
+ positionals: [credentialNamedArgument],
305
+ options: { ...common, 'dry-run': { type: 'boolean', description: 'Say what it would retire, and retire nothing.' } },
253
306
  run: retire,
254
307
  },
308
+ {
309
+ name: 'revoke',
310
+ summary: 'Revoke the team\'s Apple distribution certificate in Apple and retire it (owner, with a passkey)',
311
+ description: [
312
+ 'For a compromised certificate, not for renewing: it shows the certificate Apple has, the provisioning profiles and applications that hold it and what happens to the builds, asks an owner to authorize it with a passkey and only then deletes it in App Store Connect (DELETE /v1/certificates) and retires the credential.',
313
+ 'A rejected or expired approval deletes nothing. A certificate Apple no longer has is still retired. The App Store profile of each affected application has to be made again with another certificate: ias credentials generate ios.',
314
+ ].join('\n'),
315
+ positionals: [credentialId],
316
+ options: {
317
+ ...common,
318
+ 'team-id': { type: 'string', value: '<id>', description: 'The Apple team of the certificate (the app\'s, or the App Store Connect key\'s, by default).' },
319
+ yes: { type: 'boolean', description: 'Do not ask before revoking; the owner\'s approval with a passkey is still needed.' },
320
+ 'dry-run': { type: 'boolean', description: 'Show the certificate, the profiles and the impact; ask nothing and change nothing.' },
321
+ ...item,
322
+ },
323
+ run: revokeAppleCertificate,
324
+ },
255
325
  ],
256
326
  };
@@ -0,0 +1,44 @@
1
+ // The pieces that ias credentials import, rotate and generate share (docs/technical/vault.md §3.5): the metadata of a
2
+ // type from its material, the plaintext to seal, the sealed envelope checked with the team's key just after signing it,
3
+ // and the warning of an expiry within 30 days. Nothing here prints or writes a secret.
4
+ import { PLAINTEXT_TYPES, credentials as inspect, encodePlaintext, seal, verifyEnvelope } from '../../shared.mjs';
5
+ import { IasError } from '../../errors.mjs';
6
+
7
+ const DAY = 24 * 60 * 60 * 1000;
8
+
9
+ /** Whether the type belongs to the team (a certificate, a key) instead of an application. */
10
+ export const ofTeam = (type) => PLAINTEXT_TYPES[type]?.scope === 'team';
11
+
12
+ /** The metadata of `material`, or an IasError that names the file or tool, never a secret. */
13
+ export function metadataOf(type, material) {
14
+ try {
15
+ return inspect.metadataOf(type, material.inspect);
16
+ } catch (error) {
17
+ if (error instanceof inspect.CredentialError) throw new IasError(`${type}: ${error.message}`, { fix: 'check the file and its password or alias', code: 'credential_unreadable' });
18
+ throw error;
19
+ }
20
+ }
21
+
22
+ /** The plaintext to seal: the fields of the material, those that only the metadata knows, and the application. */
23
+ export function plaintextOf(type, material, metadata, application) {
24
+ return encodePlaintext(type, { ...(ofTeam(type) ? {} : { application }), ...material.fields, ...(material.completes?.(metadata) ?? {}) });
25
+ }
26
+
27
+ /** Seals `plaintext` as version `version` of credential `id`, and checks the signature it just made. */
28
+ export function sealCredential(plaintext, { id, type, version }, sealing) {
29
+ try {
30
+ const envelope = seal(plaintext, { credential: { id, type, version }, recipients: sealing.recipients, signingKey: sealing.signingKey });
31
+ verifyEnvelope(envelope, sealing.keys.signing.publicKey);
32
+ return envelope;
33
+ } finally {
34
+ plaintext.fill(0);
35
+ }
36
+ }
37
+
38
+ /** Warnings about an expiry within 30 days. */
39
+ export function expiryWarning(name, metadata, output, now = Date.now()) {
40
+ if (!metadata?.expires) return;
41
+ const days = Math.floor((Date.parse(metadata.expires) - now) / DAY);
42
+ if (days < 0) output.warn(`${name} expired on ${metadata.expires}`);
43
+ else if (days <= 30) output.warn(`${name} expires in ${days} days (${metadata.expires})`);
44
+ }
@@ -1,6 +1,7 @@
1
- // The part of the App Store Connect API (https://developer.apple.com/documentation/appstoreconnectapi) that ias
2
- // devices sync uses: the team's devices, its bundle ids, certificates and provisioning profiles. Every request carries
3
- // a JWT of the team's API key (ES256 with node:crypto, 20 minutes at most, as Apple asks). The key comes from the
1
+ // The part of the App Store Connect API (https://developer.apple.com/documentation/appstoreconnectapi) that ias uses from
2
+ // this Mac: the team's devices, its bundle ids, certificates and provisioning profiles, and the same as writes for ias
3
+ // credentials generate (a distribution certificate from a CSR, its revocation, an App Store profile). Every request
4
+ // carries a JWT of the team's API key (ES256 with node:crypto, 20 minutes at most, as Apple asks). The key comes from the
4
5
  // sealed vault, opened in memory on this computer; neither the key nor a token is ever printed, written or an argument.
5
6
  import { createPrivateKey, sign } from 'node:crypto';
6
7
  import { IasError } from '../../errors.mjs';
@@ -39,7 +40,9 @@ function appleProblem(status, body) {
39
40
 
40
41
  /**
41
42
  * A client of the API with the key `key` ({ key_id, issuer_id, p8 }): { request(method, path, json?), all(path),
42
- * devices(), registerDevice(), enableDevice(), bundleId(), certificates(), profilesOf(), devicesOf(), createProfile() }.
43
+ * devices(), registerDevice(), enableDevice(), bundleId(), certificates(), certificatesOfTeam(), createCertificate(),
44
+ * revokeCertificate(), appByBundleId(), bundleIdCapabilities(), enableCapability(), profilesOf(), profile(),
45
+ * deleteProfile(), devicesOf(), createProfile() }.
43
46
  * A token lasts 20 minutes; a new one is made before it ends.
44
47
  */
45
48
  export function ascClient(key, { env = {}, fetch: fetchImpl = fetch, now = Date.now } = {}) {
@@ -94,20 +97,59 @@ export function ascClient(key, { env = {}, fetch: fetchImpl = fetch, now = Date.
94
97
  enableDevice: async (id) => (await request('PATCH', `/v1/devices/${encodeURIComponent(id)}`, { data: { type: 'devices', id, attributes: { status: 'ENABLED' } } })).data,
95
98
  /** The bundle id resource of `identifier` (Apple's filter matches parts too: only the exact one counts). */
96
99
  bundleId: async (identifier) => (await all(`/v1/bundleIds?filter[identifier]=${encodeURIComponent(identifier)}&filter[platform]=IOS&limit=200`)).find((item) => item.attributes?.identifier === identifier) ?? null,
100
+ /** A new App ID of `identifier` (docs/plans/gaps/05-bundle-id-app-record.md §3.4): { id, attributes: { identifier, name, platform } }. */
101
+ createBundleId: async ({ identifier, name }) =>
102
+ (await request('POST', '/v1/bundleIds', { data: { type: 'bundleIds', attributes: { identifier, name, platform: 'IOS' } } })).data,
103
+ /** The app record of `identifier`, or null (Apple's filter matches parts too: only the exact bundle id counts). */
104
+ appByBundleId: async (identifier) =>
105
+ (await all(`/v1/apps?filter[bundleId]=${encodeURIComponent(identifier)}&fields[apps]=name,bundleId,sku,primaryLocale&limit=200`)).find((item) => item.attributes?.bundleId === identifier) ?? null,
97
106
  certificates: () => all('/v1/certificates?filter[certificateType]=DISTRIBUTION,IOS_DISTRIBUTION&limit=200'),
107
+ /** The team's distribution certificates (Apple Distribution and the old iOS Distribution), with their DER. */
108
+ certificatesOfTeam: () => all('/v1/certificates?filter[certificateType]=DISTRIBUTION,IOS_DISTRIBUTION&limit=200'),
109
+ /** One certificate of Apple by its id, re-read right before it is revoked: { id, attributes: { … } }. */
110
+ certificate: async (id) => (await request('GET', `/v1/certificates/${encodeURIComponent(id)}`)).data,
111
+ /** A new certificate from the CSR (PEM text): { id, attributes: { certificateContent, serialNumber, expirationDate, … } }. */
112
+ createCertificate: async ({ certificateType = 'DISTRIBUTION', csr }) =>
113
+ (
114
+ await request('POST', '/v1/certificates', {
115
+ data: { type: 'certificates', attributes: { certificateType, csrContent: csr } },
116
+ })
117
+ ).data,
118
+ /** Revokes the certificate in Apple (DELETE /v1/certificates/{id}). No command of ias calls it on its own. */
119
+ revokeCertificate: (id) => request('DELETE', `/v1/certificates/${encodeURIComponent(id)}`),
120
+ /** The capabilities the App ID has in Apple: [{ id, attributes: { capabilityType } }]. */
121
+ bundleIdCapabilities: (bundleIdId) => all(`/v1/bundleIds/${encodeURIComponent(bundleIdId)}/bundleIdCapabilities?limit=200`),
122
+ /** Enables a capability of the App ID (POST /v1/bundleIdCapabilities), which invalidates every profile of it. */
123
+ enableCapability: async (bundleIdId, capabilityType) =>
124
+ (
125
+ await request('POST', '/v1/bundleIdCapabilities', {
126
+ data: {
127
+ type: 'bundleIdCapabilities',
128
+ attributes: { capabilityType },
129
+ relationships: { bundleId: { data: { type: 'bundleIds', id: bundleIdId } } },
130
+ },
131
+ })
132
+ ).data,
98
133
  profilesOf: (bundleIdId) => all(`/v1/bundleIds/${encodeURIComponent(bundleIdId)}/profiles?limit=200`),
134
+ /** One profile of Apple, with its profileContent. */
135
+ profile: async (id) => (await request('GET', `/v1/profiles/${encodeURIComponent(id)}`)).data,
136
+ /** Deletes a profile in Apple (DELETE /v1/profiles/{id}). No command of ias calls it on its own. */
137
+ deleteProfile: (id) => request('DELETE', `/v1/profiles/${encodeURIComponent(id)}`),
99
138
  devicesOf: (profileId) => all(`/v1/profiles/${encodeURIComponent(profileId)}/devices?limit=200`),
100
- /** A new ad hoc profile of the bundle id with the certificate and the devices: { id, attributes: { profileContent, uuid, … } }. */
101
- createProfile: async ({ name, bundleIdId, certificateId, deviceIds }) =>
139
+ /**
140
+ * A new profile of the bundle id with the certificate: { id, attributes: { profileContent, uuid, … } }. An App Store
141
+ * profile has no devices relationship at all (not an empty one); an ad hoc profile lists exactly `deviceIds`.
142
+ */
143
+ createProfile: async ({ name, type = 'IOS_APP_ADHOC', bundleIdId, certificateId, deviceIds = [] }) =>
102
144
  (
103
145
  await request('POST', '/v1/profiles', {
104
146
  data: {
105
147
  type: 'profiles',
106
- attributes: { name, profileType: 'IOS_APP_ADHOC' },
148
+ attributes: { name, profileType: type },
107
149
  relationships: {
108
150
  bundleId: { data: { type: 'bundleIds', id: bundleIdId } },
109
151
  certificates: { data: [{ type: 'certificates', id: certificateId }] },
110
- devices: { data: deviceIds.map((id) => ({ type: 'devices', id })) },
152
+ ...(type === 'IOS_APP_STORE' ? {} : { devices: { data: deviceIds.map((id) => ({ type: 'devices', id })) } }),
111
153
  },
112
154
  },
113
155
  })
@@ -57,7 +57,8 @@ export default {
57
57
  force: { type: 'boolean', description: 'Make the profile again even if it already lists every approved device.' },
58
58
  'no-resign': { type: 'boolean', description: 'Only the devices and the profile: do not sign the internal build again.' },
59
59
  detach: { type: 'boolean', description: 'Ask for the resign and do not wait for it.' },
60
- 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>).' },
60
+ 'dry-run': { type: 'boolean', description: 'Say which devices it would register, which profile it would make and seal and which resign it would queue, and do none of it (no Apple call, no vault write, no job).' },
61
+ 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>).' },
61
62
  },
62
63
  run: (context) => sync(context, projectOf(context)),
63
64
  },
@@ -33,10 +33,43 @@ const latestExpiring = (list) => [...list].sort((a, b) => (Date.parse(b.expires_
33
33
  /** The name Apple keeps a device with: the tester's, or iOS's, and its model, within 50 characters. */
34
34
  export function appleDeviceName(device) {
35
35
  const model = device.model ? ` (${device.model})` : '';
36
- const name = (device.owner_name || device.name || 'Dispositivo').normalize('NFKD').replace(/[^\x20-\x7e]/g, '');
36
+ const name = (device.owner_name || device.name || 'Device').normalize('NFKD').replace(/[^\x20-\x7e]/g, '');
37
37
  return `${name.slice(0, 50 - model.length).trim() || 'Device'}${model}`;
38
38
  }
39
39
 
40
+ /**
41
+ * What `ias devices sync` would do, from the devices IAS lists and the ad hoc profile of the vault alone: no Apple
42
+ * call, no 1Password item, no vault write and no job. It cannot know which devices Apple already has, so a device IAS
43
+ * has no Apple id for is one it would register; the profile it makes would also keep the devices of the current one
44
+ * that Apple still has enabled.
45
+ */
46
+ export function syncPlan({ app, devices, material, resign = true }) {
47
+ const approved = devices.filter((device) => device.status === 'approved');
48
+ const wanted = devices.filter((device) => device.status === 'approved' || device.status === 'registered');
49
+ const rejected = new Set(devices.filter((device) => device.status === 'rejected').map((device) => normalizeUdid(device.udid)));
50
+ const register = approved.filter((device) => !device.apple_device_id);
51
+ const leave = devices.filter((device) => device.status === 'rejected' && device.apple_device_id);
52
+ const profile = {
53
+ action: material.profile ? 'replace' : 'create',
54
+ credential: material.profile?.id ?? null,
55
+ name: material.profile?.name ?? null,
56
+ version: (material.profile?.version ?? 0) + 1,
57
+ devices: wanted.length,
58
+ certificate: material.certificate.name,
59
+ };
60
+ return {
61
+ bundle_id: app.ios.bundle_id,
62
+ devices: devices.length,
63
+ approved: approved.length,
64
+ rejected: rejected.size,
65
+ register: register.map((device) => ({ udid: device.udid, name: appleDeviceName(device), status: device.status })),
66
+ add_to_profile: wanted.map((device) => ({ udid: device.udid, name: appleDeviceName(device), status: device.status })),
67
+ leave_profile: leave.map((device) => ({ udid: device.udid, name: appleDeviceName(device), status: device.status })),
68
+ profile,
69
+ resign: resign ? { queued: true } : null,
70
+ };
71
+ }
72
+
40
73
  /** The credentials sync needs: the team's App Store Connect key and distribution certificate, and the app's ad hoc profile. */
41
74
  async function vaultCredentials(client, code, app) {
42
75
  const team = (await client.request('GET', '/team/credentials')).data.data.filter((credential) => !credential.retired_at);
@@ -46,14 +79,14 @@ async function vaultCredentials(client, code, app) {
46
79
  const ascKey = team.filter((credential) => credential.type === 'asc_api_key').sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''))[0];
47
80
  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
81
  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' });
82
+ if (!certificate) throw new IasError(`the vault has no usable distribution certificate of the team ${app.ios.team_id ?? ''}`.trim(), { fix: 'make one with ias credentials generate ios (or import the current one with ias credentials import apple_distribution_certificate)', code: 'credentials_missing' });
50
83
  const sha1 = String(certificate.metadata.sha1 ?? '').toUpperCase();
51
84
  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
85
  return { ascKey, certificate, profile };
53
86
  }
54
87
 
55
88
  /** 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 }) {
89
+ export async function openAscKey(client, credential, { recoveryKey, keys }) {
57
90
  const { data } = await client.request('GET', `/credentials/${credential.id}/envelope`);
58
91
  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
92
  let bytes;
@@ -93,6 +126,17 @@ async function followResign(client, code, resign, { output, wait }) {
93
126
  return current;
94
127
  }
95
128
 
129
+ /** The lines of a plan for people, before anything is done. */
130
+ function printSyncPlan(plan, output) {
131
+ output.progress(`dry run: ${plan.bundle_id}: ${plan.devices} device${plan.devices === 1 ? '' : 's'} in IAS, ${plan.approved} approved, ${plan.rejected} rejected`);
132
+ for (const device of plan.register) output.progress(` register in Apple ${device.name} ${device.udid}`);
133
+ for (const device of plan.add_to_profile) output.progress(` add to the profile ${device.name} ${device.udid}`);
134
+ for (const device of plan.leave_profile) output.progress(` leave the profile ${device.name} ${device.udid} (rejected in IAS)`);
135
+ output.progress(` profile would ${plan.profile.action} the ad hoc profile of ${plan.bundle_id} with ${plan.profile.devices} device${plan.profile.devices === 1 ? '' : 's'} (and the ones of the current one Apple still has enabled), signed with «${plan.profile.certificate}», and seal it as version ${plan.profile.version}${plan.profile.name ? ` of «${plan.profile.name}»` : ''} in the vault`);
136
+ output.progress(` resign ${plan.resign ? 'would queue a resign of the newest internal iOS build for a runner' : 'none (--no-resign)'}`);
137
+ output.progress('dry run: nothing was registered in Apple, nothing was sealed and no job was queued.');
138
+ }
139
+
96
140
  export async function sync(context, project) {
97
141
  const { values, env, output } = context;
98
142
  const wait = context.sleep ?? sleep;
@@ -107,6 +151,15 @@ export async function sync(context, project) {
107
151
  const rejected = new Set(devices.filter((device) => device.status === 'rejected').map((device) => normalizeUdid(device.udid)));
108
152
 
109
153
  const item = vaultItem(context, project, client.server);
154
+
155
+ // A dry run reads what IAS keeps and the ad hoc profile of the vault: no Apple call, no 1Password item, no job.
156
+ if (values['dry-run']) {
157
+ const material = await vaultCredentials(client, code, app);
158
+ const plan = syncPlan({ app, devices, material, resign: values['no-resign'] !== true });
159
+ printSyncPlan(plan, output);
160
+ return { dry_run: true, ...plan };
161
+ }
162
+
110
163
  const { vault, keys, signingKey, recoveryKey } = await teamKeys(client, item, env, { recovery: true });
111
164
  const material = await vaultCredentials(client, code, app);
112
165
  const key = await openAscKey(client, material.ascKey, { recoveryKey, keys });
@@ -117,7 +170,7 @@ export async function sync(context, project) {
117
170
 
118
171
  // What the new profile lists: the current profile's devices still enabled, and the approved ones, never the rejected ones.
119
172
  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' });
173
+ if (!bundle) throw new IasError(`Apple has no App ID ${app.ios.bundle_id}`, { fix: 'register it with ias apps store --register-bundle-id, or in Apple Developer › Identifiers', code: 'bundle_id_not_in_apple' });
121
174
  const current = material.profile ? (await asc.profilesOf(bundle.id)).find((profile) => String(profile.attributes?.uuid).toUpperCase() === String(material.profile.metadata.uuid).toUpperCase()) : null;
122
175
  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
176
  const inProfile = current ? await asc.devicesOf(current.id) : [];
@@ -8,6 +8,7 @@ import { IasError } from '../../errors.mjs';
8
8
  import { api } from '../../http.mjs';
9
9
  import { digest, gitCommit, icon, readApk, readIpa } from '../../dist.mjs';
10
10
  import { readSecret, tokenKind } from '../../secrets.mjs';
11
+ import { provisionDistribution } from '../../provisioning.mjs';
11
12
 
12
13
  /** The publishing token of the distribution project: the variable or the op:// reference, read now. */
13
14
  export function publishingToken(settings) {
@@ -80,6 +81,17 @@ export default {
80
81
  summary: 'Internal builds on the install pages: upload an ad hoc IPA or an APK, list, retire and restore',
81
82
  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
83
  subcommands: [
84
+ {
85
+ name: 'init',
86
+ summary: 'Make the distribution project and its publishing token, and write the token reference to ias.json',
87
+ description: 'Creates the distribution project on the server and keeps its token in 1Password, with its op:// reference in distribution.token of ias.json. It prints the stable install link. It needs an owner\'s personal token (ias login) and the 1Password CLI; a project that already exists is left as it is.',
88
+ options: {
89
+ name: { type: 'string', value: '"…"', description: 'The name of the project in the panel (the app name by default).' },
90
+ item: { type: 'string', value: 'op://vault/item', description: 'The 1Password item for the token (op://IAS/<app> distribution by default).' },
91
+ 'dry-run': { type: 'boolean', description: 'Say what it would make and change, and make nothing.' },
92
+ },
93
+ run: (context) => provisionDistribution({ dir: resolve(context.cwd, context.values.project ?? '.'), env: context.env, output: context.output, values: context.values, sleep: context.sleep, dryRun: context.values['dry-run'] }),
94
+ },
83
95
  {
84
96
  name: 'upload',
85
97
  summary: 'Upload a build and print its link and the stable one',