@imagina-build/ias-cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +184 -21
  2. package/package.json +1 -1
  3. package/schema/ias.schema.json +46 -1
  4. package/shared/vault/index.mjs +25 -0
  5. package/shared/vault/policy-vectors.mjs +237 -0
  6. package/shared/vault/policy.mjs +427 -0
  7. package/shared/vault/test-vectors/ias-policy-1/policy.json +4016 -0
  8. package/shared/vault/vectors.mjs +2 -0
  9. package/src/apple/capabilities.mjs +96 -0
  10. package/src/apple/inventory.mjs +75 -0
  11. package/src/apple/signing.mjs +134 -0
  12. package/src/applications.mjs +114 -0
  13. package/src/cli.mjs +28 -10
  14. package/src/commands/apps/index.mjs +61 -11
  15. package/src/commands/apps/store.mjs +367 -0
  16. package/src/commands/auth/index.mjs +3 -3
  17. package/src/commands/build/index.mjs +2 -0
  18. package/src/commands/builds/index.mjs +20 -0
  19. package/src/commands/credentials/apple.mjs +1118 -0
  20. package/src/commands/credentials/generate.mjs +122 -0
  21. package/src/commands/credentials/index.mjs +114 -44
  22. package/src/commands/credentials/seal.mjs +44 -0
  23. package/src/commands/devices/asc.mjs +50 -8
  24. package/src/commands/devices/index.mjs +2 -1
  25. package/src/commands/devices/sync.mjs +57 -4
  26. package/src/commands/dist/index.mjs +12 -0
  27. package/src/commands/github/index.mjs +13 -3
  28. package/src/commands/policy/index.mjs +583 -0
  29. package/src/commands/policy/policy.mjs +182 -0
  30. package/src/commands/policy/progress.mjs +20 -0
  31. package/src/commands/releases/index.mjs +225 -0
  32. package/src/commands/rollout/index.mjs +186 -0
  33. package/src/commands/runners/index.mjs +19 -0
  34. package/src/commands/sdk/index.mjs +31 -4
  35. package/src/commands/setup/index.mjs +184 -0
  36. package/src/commands/submit/index.mjs +135 -19
  37. package/src/commands/testflight/index.mjs +309 -0
  38. package/src/commands/tokens/index.mjs +99 -0
  39. package/src/commands/update/index.mjs +42 -15
  40. package/src/commands/vault/approvers.mjs +5 -1
  41. package/src/commands/vault/index.mjs +26 -6
  42. package/src/commands/vault/runners.mjs +48 -2
  43. package/src/commands/webhooks/index.mjs +19 -5
  44. package/src/config.mjs +16 -1
  45. package/src/errors.mjs +52 -6
  46. package/src/op.mjs +83 -0
  47. package/src/ota-key.mjs +53 -0
  48. package/src/provisioning.mjs +281 -0
  49. package/src/schema.mjs +3 -0
  50. package/src/setup-state.mjs +76 -0
  51. package/src/setup.mjs +698 -0
  52. package/src/team-vault.mjs +2 -2
  53. package/src/token-store.mjs +1 -1
@@ -74,9 +74,33 @@ export async function grant(context, client, item) {
74
74
  output.progress('');
75
75
  output.progress('Compare the vault kid with the one ias-runner register printed on that Mac. If they differ, stop: the server would');
76
76
  output.progress('get the team\'s credentials wrapped for a key of its own.');
77
- await confirm(context, `Authorize «${runner.name}» to open the team's credentials?`);
78
77
 
79
78
  const inList = signed.runners.some((entry) => entry.kid === runner.kid);
79
+ if (context.values['dry-run']) {
80
+ const sealed = await sealedEnvelopes(client);
81
+ const wrap = [];
82
+ const already = [];
83
+ for (const entry of sealed) {
84
+ const { data } = await client.request('GET', `/credentials/${entry.id}/envelope`);
85
+ (recipientsOf(data.envelope ?? {}).includes(runner.kid) ? already : wrap).push(entry);
86
+ }
87
+ output.progress('');
88
+ output.progress(`runner list ${inList ? `it already has it (serial ${signed.serial})` : `it would be signed as serial ${(signed.serial ?? 0) + 1} and uploaded`}`);
89
+ output.progress(`envelopes it would wrap ${wrap.length} of ${sealed.length}; ${already.length} are open to it already`);
90
+ for (const entry of wrap) output.progress(` wrap ${entry.label}${entry.application ? ` (${entry.application})` : ' (the team)'}`);
91
+ output.progress('dry run: nothing was signed, nothing was uploaded and no envelope changed.');
92
+ return {
93
+ dry_run: true,
94
+ runner: { id: runner.id, name: runner.name, kid: runner.kid },
95
+ runner_list: { serial: inList ? signed.serial : (signed.serial ?? 0) + 1, changed: !inList },
96
+ wrapped: describe(wrap),
97
+ already: describe(already),
98
+ problems: [],
99
+ };
100
+ }
101
+
102
+ await confirm(context, `Authorize «${runner.name}» to open the team's credentials?`);
103
+
80
104
  const list = inList ? null : await uploadList(client, { serial: signed.serial, runners: [...signed.runners, { kid: runner.kid, publicKey: runner.publicKey }] }, signingKey, keys, output);
81
105
  output.progress(list ? `Signed the runner list ${list.serial} with «${runner.name}» and uploaded it.` : `«${runner.name}» is already in the runner list ${signed.serial}.`);
82
106
  const sealed = await sealedEnvelopes(client);
@@ -97,9 +121,31 @@ export async function revoke(context, client, item) {
97
121
  throw new IasError(`«${runner.name}» is not revoked on the server`, { fix: 'revoke it in the panel first (System › Runners), so the server stops giving it jobs and envelopes at once; then run ias vault revoke again', code: 'runner_not_revoked' });
98
122
  }
99
123
  showRunner(runner, output);
100
- await confirm(context, `Remove ${runner.name ? `«${runner.name}»` : runner.kid} from the runner list and from every envelope?`);
101
124
 
102
125
  const inList = signed.runners.some((entry) => entry.kid === runner.kid);
126
+ if (context.values['dry-run']) {
127
+ const sealed = await sealedEnvelopes(client);
128
+ const remove = [];
129
+ for (const entry of sealed) {
130
+ const { data } = await client.request('GET', `/credentials/${entry.id}/envelope`);
131
+ if (recipientsOf(data.envelope ?? {}).includes(runner.kid)) remove.push(entry);
132
+ }
133
+ output.progress('');
134
+ output.progress(`runner list ${inList ? `it would be signed again as serial ${(signed.serial ?? 0) + 1} without it and uploaded` : 'it does not have it'}`);
135
+ output.progress(`envelopes it would remove its wrap from ${remove.length} of ${sealed.length}`);
136
+ for (const entry of remove) output.progress(` remove ${entry.label}${entry.application ? ` (${entry.application})` : ' (the team)'}`);
137
+ output.progress('dry run: nothing was signed, nothing was uploaded and no envelope changed.');
138
+ return {
139
+ dry_run: true,
140
+ runner: { id: runner.id, name: runner.name, kid: runner.kid },
141
+ runner_list: { serial: inList ? (signed.serial ?? 0) + 1 : signed.serial, changed: inList },
142
+ removed: describe(remove),
143
+ problems: [],
144
+ };
145
+ }
146
+
147
+ await confirm(context, `Remove ${runner.name ? `«${runner.name}»` : runner.kid} from the runner list and from every envelope?`);
148
+
103
149
  const list = inList ? await uploadList(client, { serial: signed.serial, runners: signed.runners.filter((entry) => entry.kid !== runner.kid).map(({ kid, publicKey }) => ({ kid, publicKey })) }, signingKey, keys, output) : null;
104
150
  output.progress(list ? `Signed the runner list ${list.serial} without it and uploaded it.` : 'It is not in the runner list the team signed.');
105
151
  const sealed = await sealedEnvelopes(client);
@@ -49,6 +49,14 @@ async function create(context) {
49
49
  if (!url.startsWith('https://')) throw new UsageError(`the address of a webhook is https (not «${url}»)`, { fix: 'the server only calls https addresses of public servers' });
50
50
  const known = values.events?.some((text) => text.split(',').includes('all')) ? (await client.request('GET', `/apps/${code}/webhooks`)).data.events : [];
51
51
  const events = eventsOf(values, known);
52
+ if (values['dry-run']) {
53
+ output.progress(`dry run: would make a webhook of ${code} to ${url}`);
54
+ output.progress(` events ${events.join(', ')}`);
55
+ if (values.description) output.progress(` description ${values.description}`);
56
+ output.progress(' the server would answer with the secret that signs it, shown once (IAS-Signature, HMAC-SHA256)');
57
+ output.progress('nothing was created and no secret was shown.');
58
+ return { dry_run: true, app: code, url, events, description: values.description ?? null, created: false, secret: null };
59
+ }
52
60
  const { data: webhook } = await client.request('POST', `/apps/${code}/webhooks`, { json: { url, events, ...(values.description ? { description: values.description } : {}) } });
53
61
  output.log(`${webhook.id} ${webhook.url} ${webhook.events.join(', ')}`);
54
62
  output.log(`secret ${webhook.secret}`);
@@ -58,12 +66,17 @@ async function create(context) {
58
66
  }
59
67
 
60
68
  async function remove(context) {
61
- const { positionals, output } = context;
69
+ const { values, positionals, output } = context;
62
70
  const project = projectOf(context);
63
71
  const code = applicationOf(context, project);
64
- await connect(context, project).request('DELETE', `/apps/${code}/webhooks/${positionals[0].toLowerCase()}`);
65
- output.log(`Deleted the webhook ${positionals[0]} of ${code}, with its deliveries.`);
66
- return { deleted: positionals[0].toLowerCase() };
72
+ const id = positionals[0].toLowerCase();
73
+ if (values['dry-run']) {
74
+ output.progress(`dry run: would delete the webhook ${id} of ${code}, with its deliveries; nothing was deleted`);
75
+ return { dry_run: true, id, deleted: false };
76
+ }
77
+ await connect(context, project).request('DELETE', `/apps/${code}/webhooks/${id}`);
78
+ output.log(`Deleted the webhook ${id} of ${code}, with its deliveries.`);
79
+ return { deleted: id };
67
80
  }
68
81
 
69
82
  async function test(context) {
@@ -95,10 +108,11 @@ export default {
95
108
  ...common,
96
109
  events: { type: 'string', multiple: true, value: '<events>', description: 'The events it gets, separated by commas, or «all»: build.queued, build.awaiting_approval, build.succeeded, build.failed, submission.succeeded, submission.failed, update.published, update.rollout_paused, update.rollout_completed, credential.expiring, push.message.finished, device.pending.' },
97
110
  description: { type: 'string', value: '"…"', description: 'What it is for (up to 100 characters).' },
111
+ 'dry-run': { type: 'boolean', description: 'Say what it would create, without creating it or showing a secret.' },
98
112
  },
99
113
  run: create,
100
114
  },
101
- { name: 'delete', summary: 'Delete a webhook and its deliveries', positionals: [webhookId], options: common, run: remove },
115
+ { name: 'delete', summary: 'Delete a webhook and its deliveries', positionals: [webhookId], options: { ...common, 'dry-run': { type: 'boolean', description: 'Say what it would delete, and delete nothing.' } }, run: remove },
102
116
  { name: 'test', summary: 'Send a test event now and say how the server answered', positionals: [webhookId], options: common, run: test },
103
117
  ],
104
118
  };
package/src/config.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // The configuration of an app: ias.json (schema/ias.schema.json) or, while the apps move to it, the files of tools/:
2
2
  // ota.config.json and dist.config.json. Secrets are only op:// references or variables of the environment.
3
3
  import { X509Certificate } from 'node:crypto';
4
- import { existsSync, readFileSync } from 'node:fs';
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
5
  import { join, resolve } from 'node:path';
6
6
  import process from 'node:process';
7
7
  import { IasError } from './errors.mjs';
@@ -91,6 +91,21 @@ export function hasConfig(project, service) {
91
91
  return project.source === CONFIG_FILE ? Boolean(project.config[service]) : existsSync(join(project.dir, LEGACY_FILES[service]));
92
92
  }
93
93
 
94
+ /**
95
+ * Writes `config` to the app's ias.json, keeping its indentation. It validates it first and refuses a secret written
96
+ * in it, as init does. `dryRun` says what it would write and writes nothing.
97
+ */
98
+ export function saveProjectConfig(dir, config, { dryRun = false } = {}) {
99
+ const secrets = writtenSecrets(config);
100
+ const errors = validate(config);
101
+ if (secrets.length > 0 || errors.length > 0) throw new IasError(`the ias.json for ${dir} is not valid (${[...secrets, ...errors].join('; ')})`, { fix: 'this is a bug of ias: report it with what you ran' });
102
+ const file = join(dir, CONFIG_FILE);
103
+ const previous = existsSync(file) ? readFileSync(file, 'utf8') : '';
104
+ const indent = previous.match(/^[ \t]+(?=")/m)?.[0] ?? ' ';
105
+ if (!dryRun) writeFileSync(file, `${JSON.stringify(config, null, indent)}\n`);
106
+ return file;
107
+ }
108
+
94
109
  /** The origin of IAS for everything but the OTA updates: IAS_SERVER, then ias.json › server, then ias.imagina.build. */
95
110
  export const serverOf = (project, env = process.env) => env.IAS_SERVER || project.config?.server || DEFAULT_SERVER;
96
111
 
package/src/errors.mjs CHANGED
@@ -30,7 +30,10 @@ export class ApiError extends Error {
30
30
  this.status = status;
31
31
  this.code = code;
32
32
  this.data = data;
33
- this.hint = hintFor(status, code, data);
33
+ /** The fix the server wrote for this answer, when it has one. */
34
+ this.fix = typeof data?.fix === 'string' ? data.fix : undefined;
35
+ /** How to fix it: the server's own fix wins over the static hints, which cannot know the track or the id. */
36
+ this.hint = this.fix ?? hintFor(status, code, data);
34
37
  }
35
38
  }
36
39
 
@@ -38,7 +41,8 @@ export class ApiError extends Error {
38
41
  export const HINTS = {
39
42
  ota_token_invalid: 'the token is not the publishing token of this OTA project, or there is no such OTA project: check IAS_TOKEN or updates.token in ias.json, and updates.url in app.json (php artisan ota:token <project> makes a new token)',
40
43
  distribution_token_invalid: 'the token is not the publishing token of this distribution project, or there is no such distribution project: check distribution.project (or app) and distribution.token in ias.json, or IAS_TOKEN (php artisan dist:token <project> makes a new token)',
41
- project_not_found: 'there is no OTA project with this code: check updates.url in app.json, or create it with php artisan ota:project',
44
+ project_not_found: 'there is no OTA project with this code: check updates.url in app.json, or create it with ias update init (an owner with a personal token)',
45
+ project_exists: 'the application already has an OTA project with another certificate: rotating it needs a new build (php artisan ota:project --force), so keep the key the builds carry or make a new build',
42
46
  signature_key_mismatch: 'the update is signed for another keyid: updates.codeSigningMetadata.keyid in app.json must be the one of the project (php artisan ota:project … --keyid)',
43
47
  signature_invalid: 'the signature does not verify with the project\'s certificate: the private key must be the one of updates.codeSigningCertificate in app.json',
44
48
  created_at_not_newer: 'the server already serves something newer: to go back, republish an older group (it signs a new createdAt) or retire the bad one',
@@ -60,9 +64,13 @@ export const HINTS = {
60
64
  icon_too_large: 'expo.icon in app.json must stay under 512 KB once shrunk to 180 px',
61
65
  // The management API (docs/technical/manage-api.md §10).
62
66
  token_invalid: 'the token does not exist, expired or was revoked: run ias login again, or check IAS_TOKEN',
63
- scope_missing: 'the token lacks that scope: a robot token has the scopes chosen in the panel (the application › Settings › Access); ias login gives a personal one',
67
+ scope_missing: 'the token lacks that scope: ias tokens create --scopes <list> chooses them for a robot token (the panel: the application › Settings › Access); ias login gives a personal one',
64
68
  role_forbidden: 'it needs an owner of the team, or a personal token (ias login) instead of a robot one',
65
69
  application_not_found: 'ias apps lists the applications this token can see; check app in ias.json or --app',
70
+ application_exists: 'the code already exists and cannot change: use that application, or choose another code',
71
+ service_in_use: 'a service still has its pieces (an OTA project, builds…): remove them before turning it off',
72
+ push_not_enabled: 'push is not on for the application: ias apps <code> --services … with push, or ias setup',
73
+ install_key_missing: 'push has no install key yet: ias setup next makes it, or POST /apps/{code}/push/install-key',
66
74
  credential_not_found: 'ias credentials list shows the credentials of the application and the team',
67
75
  variable_not_found: 'ias env list shows the variables of each environment',
68
76
  approval_not_found: 'the approval expired or was asked by another token: run the command again',
@@ -83,6 +91,32 @@ export const HINTS = {
83
91
  build_number_used: 'a store build already used that build number: raise expo.ios.buildNumber or expo.android.versionCode in app.json, or give the profile autoIncrement (ias version get shows the server\'s)',
84
92
  build_not_cancelable: 'the build has ended already: there is nothing to cancel',
85
93
  build_not_submittable: 'only a store build that succeeded, with its IPA or AAB and not being sent already, goes to a store: ias builds view <id> shows how it is',
94
+ release_policy_required: 'an owner creates the policy of the track: ias releases policy <track> --mode staged --max-percent 50 --step 20, or the panel (application › Ajustes › Google Play); without it a track only takes drafts',
95
+ release_not_allowed: 'the policy of the track does not allow it: ias releases policy <track> --mode staged (or full)',
96
+ release_percent_above_cap: 'the ceiling of the policy is lower than that percentage: ias releases policy <track> --max-percent <n>',
97
+ release_step_too_big: 'one change raises the rollout at most by the step of the policy: ias rollout <track> --percent <n> in smaller steps',
98
+ release_too_soon: 'the policy waits a minimum between changes: ias releases view <track> shows when, and ias releases policy <track> --min-minutes <n> changes it',
99
+ release_percent_invalid: 'a rollout serves a whole percentage from 1 to 99; 100 is --complete or ias submit --release-status completed',
100
+ release_state_changed: 'Play is not what you expected: ias releases view <track> --refresh reads it again',
101
+ release_in_flight: 'a submission or a release of that track has not ended: ias builds list shows it; wait for it or cancel it',
102
+ release_in_review: 'there is a review in progress: wait for it, or cancel it in Play Console',
103
+ release_not_found: 'send a store Android build to that track first: ias submit <build> --track <track>; ias releases view <track> --refresh reads what Play has',
104
+ release_not_published: 'Play does not stage the first release of an app: complete it in Play Console, or read the state if the app published before IAS (ias releases view <track> --refresh)',
105
+ release_halt_unavailable: 'Play only pauses a completed release when another completed release remains: check the releases of the track in Play Console',
106
+ release_managed_publishing: 'Play retains the change until someone publishes it: publish in Play Console; IAS does not simulate a publication',
107
+ testflight_not_ready: 'TestFlight needs a store iOS build that succeeded whose ias submit reached App Store Connect: ias submit <build> --wait sends it first',
108
+ testflight_in_progress: 'the build has a TestFlight job of that action that has not ended: ias testflight status <build> shows it; wait for it or cancel it',
109
+ testflight_already_reviewed: 'the build is already in Beta App Review: ias testflight status <build> shows the state; a rejected build needs a new submission, not another review',
110
+ testflight_plan_invalid: 'the plan has a group, a locale or a note App Store Connect does not take: ias testflight <build> --help lists the limits (20 groups, 8 locales of its list, 4000 characters)',
111
+ testflight_groups_missing: 'no group was given and ias.json has none: pass the name of a group that exists in App Store Connect › TestFlight with --groups "<name>"; IAS does not create groups',
112
+ testflight_group_unknown: 'a group of the plan does not exist in App Store Connect: ias testflight status <build> lists the ones the app has; add the build to one of them',
113
+ testflight_group_ambiguous: 'App Store Connect has more than one group with that name: rename one there, or choose another',
114
+ testflight_info_missing: 'Beta App Review needs the app test information first (beta app description, feedback email, contact, demo account if it needs a login): complete it in App Store Connect › TestFlight',
115
+ testflight_export_compliance: 'Apple has not been told whether the build uses encryption, so it does not distribute it: answer export compliance in App Store Connect › TestFlight',
116
+ asc_forbidden: 'the App Store Connect key cannot do this: distributing to external groups and the test information need App Manager or Admin (an Individual Key does not work)',
117
+ asc_unauthorized: 'App Store Connect did not take the key: it was revoked or its JWT was refused; import a current asc_api_key with ias credentials import asc_api_key',
118
+ asc_rate_limited: 'App Store Connect is rate-limiting the key: wait a few minutes and run it again',
119
+ asc_unavailable: 'App Store Connect did not answer: run it again in a minute',
86
120
  approvers_version_backwards: 'someone signed a newer version of the approvers meanwhile: run ias vault approvers again, which signs the next one',
87
121
  runner_list_serial_backwards: 'someone signed a newer runner list meanwhile: run the command again, which signs the next one',
88
122
  source_not_uploaded: 'the code did not reach the bucket: run ias build again',
@@ -90,12 +124,24 @@ export const HINTS = {
90
124
  artifact_mismatch: 'the artifact in the bucket is not the one described: run the build again, and if it goes on, check the runner',
91
125
  artifact_not_found: 'ias builds view <id> lists the artifacts of the build',
92
126
  profile_invalid: 'the build profile does not follow the schema of ias.json, or the runners cannot build it yet (developmentClient and ios.simulator: ias build --local); ias doctor checks ias.json',
93
- credentials_missing: 'the vault has no usable credential of those types for this build: import them with ias credentials import, and check that they are not retired or expired (ias credentials list)',
127
+ credentials_missing: 'the vault has no usable credential of those types for this build: for iOS, ias credentials generate ios makes or reuses the certificate and the App Store profile; otherwise import them with ias credentials import, and check that they are not retired or expired (ias credentials list)',
128
+ asc_app_missing: 'App Store Connect has no app record for the bundle id: a person creates it in appstoreconnect.apple.com › Apps; then ias apps store <code> --check saves its Apple ID. The public API cannot create it',
129
+ asc_app_mismatch: 'the Apple ID saved in IAS is from another app: ias apps store <code> --check reads the right one for the bundle id',
130
+ asc_unreachable: 'App Store Connect did not answer the check: the submission can be run again when the connection is back',
131
+ play_app_missing: 'Google Play has no app with that package: a person creates it in play.google.com/console and completes its setup; the API cannot create it',
132
+ bundle_id_taken: 'Apple already has that bundle id in this team or another: use the existing App ID, or choose another bundle id',
133
+ bundle_id_not_in_apple: 'Apple has no App ID with that bundle id: ias apps store <code> --register-bundle-id registers it in this team',
134
+ asc_key_forbidden: 'the App Store Connect key cannot touch identifiers: a team key with the Admin or App Manager role is needed (an Individual Key does not work)',
94
135
  document_invalid: 'the server did not take the signed document (the reason is the check of docs/technical/vault.md that failed): nothing was stored; update ias, and if it goes on, report it',
136
+ policy_invalid: 'the server did not take the policy: nothing was stored; ias policy check <file> says which rule or field fails (the checks are in docs/technical/vault.md §2.5)',
137
+ policy_version_backwards: 'someone stored a newer policy meanwhile: raise the version in ias.policy.json (ias policy init --version <n>) and sign it again with ias policy sign',
138
+ policy_unmatched: 'no rule of the team\'s policy covers this job, so it is denied: ias policy simulate --app <code> --platform <ios|android> --profile <name> --type <build|submit> [--track <track>] says which rule is missing; add it to ias.policy.json and sign it with ias policy sign',
139
+ policy_missing: 'the team has no usable policy (it expired or was never signed), so nothing new is approved: ias policy status says what is missing; ias policy init and ias policy sign make a valid one',
140
+ policy_changed: 'the policy changed while the job waited, so the job expired: run the command again; ias policy show says what the policy is now',
95
141
  runner_not_found: 'ias vault status and the panel (System › Runners) list the runners',
96
142
  runner_not_authorized: 'the runner is pending, revoked or not in the list the team signed: an owner runs ias vault grant <runner>',
97
143
  runner_signature_invalid: 'the runner signed with a key the server does not have, or the clock of its Mac is wrong: set the time automatically, or register the runner again',
98
- registration_code_invalid: 'the code expired (30 minutes), was used or does not exist: an owner makes another in the panel (System › Runners › Add runner)',
144
+ registration_code_invalid: 'the code expired (30 minutes), was used or does not exist: an owner makes another with ias runners code, or in the panel (System › Runners › Add runner)',
99
145
  job_not_found: 'the job is not this runner\'s, or it ended: ias builds view <id> shows the build',
100
146
  // Push notifications (docs/technical/manage-api.md §14.6).
101
147
  push_not_enabled: 'the application does not use push notifications: turn the service on in the panel (the application › Settings)',
@@ -140,7 +186,7 @@ function hintFor(status, code, data) {
140
186
  if (code && HINTS[code]) return HINTS[code];
141
187
  const message = String(data?.message ?? '');
142
188
  if (status === 404 && message.includes('OtaProject')) return HINTS.project_not_found;
143
- if (status === 404 && message.includes('DistributionProject')) return 'there is no distribution project with this code: check distribution.project (or app) in ias.json, or create it with php artisan dist:project';
189
+ if (status === 404 && message.includes('DistributionProject')) return 'there is no distribution project with this code: check distribution.project (or app) in ias.json, or create it with ias dist init (an owner with a personal token)';
144
190
  if (status === 401) return 'the server did not take the token: check IAS_TOKEN or the token in ias.json';
145
191
  if (status >= 500) return 'the server failed: try again in a minute, and if it goes on, look at its logs';
146
192
  return undefined;
package/src/op.mjs ADDED
@@ -0,0 +1,83 @@
1
+ // 1Password CLI for the provisioning commands: create a secure note with fields (a private key, a token) through stdin,
2
+ // read a field, and add a field to an item that already exists. Nothing here prints a secret: the values travel through
3
+ // stdin or are kept in memory, never as an argument of a process (ps would see them).
4
+ import { spawnSync } from 'node:child_process';
5
+ import process from 'node:process';
6
+ import { IasError } from './errors.mjs';
7
+
8
+ const ITEM = /^op:\/\/([^/\s]+)\/([^/]+)$/;
9
+
10
+ /** The vault and title of an op:// reference, or an error. */
11
+ export function itemOf(reference) {
12
+ const match = ITEM.exec(reference);
13
+ if (!match) throw new IasError(`«${reference}» is not a 1Password item`, { fix: 'it is op://<vault>/<item>, such as op://Private/My app OTA' });
14
+ return { reference, vault: match[1], title: match[2] };
15
+ }
16
+
17
+ const reason = (result) => (result.error ? result.error.message : (result.stderr || '').trim().split('\n').at(-1) || `exit ${result.status}`);
18
+
19
+ const op = (args, { env, input } = {}) => {
20
+ const [command, commandArgs] = input === undefined ? ['op', args] : ['/bin/sh', ['-c', 'cat | op "$@"', 'sh', ...args]];
21
+ return spawnSync(command, commandArgs, { encoding: 'utf8', env, input, maxBuffer: 8 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
22
+ };
23
+
24
+ /** A field of an item, or undefined when it or the item is not there. Throws if op cannot answer for another reason. */
25
+ export function readField(reference, field, env = process.env) {
26
+ const result = op(['read', '--no-newline', `${reference}/${field}`], { env });
27
+ if (result.status === 0) return result.stdout;
28
+ const text = reason(result);
29
+ if (/isn't an item|isn.t a field|not found|no item|could not find|does not have a field/i.test(text)) return undefined;
30
+ throw new IasError(`op could not read ${reference}/${field}: ${text}`, { fix: result.error ? 'install the 1Password CLI (brew install 1password-cli) and turn on its integration with the desktop app' : 'unlock 1Password (op signin) and check that the item and its vault exist' });
31
+ }
32
+
33
+ /** The item as JSON, or undefined when it does not exist. It holds secrets: never print it. */
34
+ export function openItem(reference, env = process.env) {
35
+ const result = op(['item', 'get', reference, '--format', 'json'], { env });
36
+ if (result.status === 0) return JSON.parse(result.stdout);
37
+ const text = reason(result);
38
+ if (/isn't an item|not found|no item|could not find/i.test(text)) return undefined;
39
+ throw new IasError(`op could not read ${reference}: ${text}`, { fix: 'unlock 1Password (op signin) and check that the item and its vault exist' });
40
+ }
41
+
42
+ /**
43
+ * Creates a secure note with `fields` ([{ label, value, type }]) and `attachments` ([{ label, path }]) through stdin.
44
+ * The field template never touches the disk nor the arguments; an attachment only passes its path, never its bytes.
45
+ * `reference` is the op:// item the fields will be read from.
46
+ */
47
+ export function createItem(reference, { notes, fields, attachments = [] }, env = process.env) {
48
+ const item = itemOf(reference);
49
+ const template = {
50
+ title: item.title,
51
+ category: 'SECURE_NOTE',
52
+ fields: [
53
+ { id: 'notesPlain', type: 'STRING', purpose: 'NOTES', label: 'notesPlain', value: notes ?? '' },
54
+ ...fields.map(({ label, value, type = 'STRING' }) => ({ id: label, label, type, value })),
55
+ ],
56
+ };
57
+ const args = ['item', 'create', '--vault', item.vault, '--format', 'json', '-', ...attachments.map(({ label, path }) => `${label}[file]=${path}`)];
58
+ const result = op(args, { env, input: JSON.stringify(template) });
59
+ if (result.status !== 0) throw new IasError(`op could not create ${reference}: ${reason(result)}`, { fix: 'unlock 1Password (op signin) and check that the vault exists and you can write to it' });
60
+ }
61
+
62
+ /** Adds or replaces a file attachment of an item that exists; the path is an argument, never the bytes. */
63
+ export function setAttachment(reference, label, path, env = process.env) {
64
+ const result = op(['item', 'edit', reference, '--format', 'json', `${label}[file]=${path}`], { env });
65
+ if (result.status !== 0) throw new IasError(`op could not attach ${label} to ${reference}: ${reason(result)}`, { fix: 'unlock 1Password (op signin) and try again' });
66
+ }
67
+
68
+ /** Adds or replaces a field of an item that exists, through a JSON template on stdin. */
69
+ export function setField(reference, field, value, env = process.env) {
70
+ const item = openItem(reference, env);
71
+ if (!item) throw new IasError(`there is no 1Password item ${reference}`, { fix: 'create it again, or write the reference in ias.json by hand' });
72
+ const fields = Array.isArray(item.fields) ? item.fields : [];
73
+ const existing = fields.find((candidate) => candidate.label === field || candidate.id === field);
74
+ if (existing) {
75
+ existing.value = value;
76
+ existing.type = existing.type ?? 'CONCEALED';
77
+ } else {
78
+ fields.push({ id: field, label: field, type: 'CONCEALED', value });
79
+ }
80
+ item.fields = fields;
81
+ const result = op(['item', 'edit', reference, '--format', 'json'], { env, input: JSON.stringify(item) });
82
+ if (result.status !== 0) throw new IasError(`op could not edit ${reference}: ${reason(result)}`, { fix: 'unlock 1Password (op signin) and try again' });
83
+ }
@@ -0,0 +1,53 @@
1
+ // The signing key of an OTA channel, made on the Mac: an RSA key and a self-signed certificate the builds carry. The
2
+ // private key only lives in memory and in the temporary folder of the run (mode 700) until it is written to 1Password;
3
+ // the certificate goes to the repository as a public file. `ias update init` and `ias setup` use it.
4
+ import { spawnSync } from 'node:child_process';
5
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { X509Certificate, createPrivateKey } from 'node:crypto';
7
+ import { dirname, join } from 'node:path';
8
+ import { IasError } from './errors.mjs';
9
+ import { temporary } from './process.mjs';
10
+
11
+ const DAYS = 3650;
12
+
13
+ /** The certificate at `path`, checked: RSA, in date. */
14
+ export function readCertificate(path) {
15
+ if (!existsSync(path)) throw new IasError(`no certificate at ${path}`, { fix: 'restore it from git, or run ias update init to make a new key' });
16
+ const certificate = new X509Certificate(readFileSync(path));
17
+ if (certificate.publicKey.asymmetricKeyType !== 'rsa') throw new IasError(`the certificate at ${path} is not RSA`, { fix: 'expo-updates signs with RSA-SHA256: make a new key with ias update init' });
18
+ return certificate;
19
+ }
20
+
21
+ /**
22
+ * A new RSA key and self-signed certificate for `app`, written to `certificatePath` (438 days? no: 10 years). Returns
23
+ * the PEM of both; the private key is also in the temporary folder of the run, which is removed at the end.
24
+ */
25
+ export function generateKeyPair({ dir, app, certificatePath = 'certs/ota-certificate.pem', dryRun = false, output }) {
26
+ const folder = temporary('ias-ota-key-');
27
+ const keyFile = join(folder, 'private-key.pem');
28
+ const certFile = join(folder, 'certificate.pem');
29
+ const target = join(dir, certificatePath);
30
+
31
+ if (dryRun) {
32
+ output?.progress(`${output.prefix}: would make an RSA key and a certificate at ${certificatePath}`);
33
+ return { privateKey: null, certificate: null, certificatePath, target, keyId: `${app}-ota-1` };
34
+ }
35
+
36
+ const result = spawnSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', String(DAYS), '-subj', `/CN=${app} OTA`, '-keyout', keyFile, '-out', certFile], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
37
+ if (result.error) throw new IasError('openssl is not here', { fix: 'it comes with macOS and with OpenSSL on Linux: install it, or make the key and certificate by hand (docs/guide/actualizaciones.md)' });
38
+ if (result.status !== 0) throw new IasError(`openssl could not make the key: ${(result.stderr || '').trim().split('\n').at(-1)}`, { fix: 'check that openssl works, or make the key and certificate by hand' });
39
+
40
+ chmodSync(keyFile, 0o600);
41
+ const privateKey = readFileSync(keyFile, 'utf8');
42
+ const certificate = readFileSync(certFile, 'utf8');
43
+ const parsed = new X509Certificate(certificate);
44
+
45
+ if (parsed.publicKey.asymmetricKeyType !== 'rsa' || !parsed.checkPrivateKey(createPrivateKey(privateKey))) {
46
+ throw new IasError('openssl made a key and certificate that do not match', { fix: 'run ias update init again' });
47
+ }
48
+
49
+ mkdirSync(dirname(target), { recursive: true });
50
+ writeFileSync(target, certificate);
51
+ output?.progress(`${output.prefix}: wrote ${certificatePath} (public; the private key goes to 1Password)`);
52
+ return { privateKey, certificate, certificatePath, target, keyId: `${app}-ota-1`, validTo: parsed.validTo };
53
+ }