@imagina-build/ias-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +406 -2
  2. package/bin/ias.mjs +19 -0
  3. package/package.json +32 -5
  4. package/schema/ias.schema.json +535 -0
  5. package/shared/credentials/cms.mjs +105 -0
  6. package/shared/credentials/index.mjs +354 -0
  7. package/shared/credentials/plist.mjs +93 -0
  8. package/shared/vault/approvers-vectors.mjs +188 -0
  9. package/shared/vault/approvers.mjs +234 -0
  10. package/shared/vault/index.mjs +583 -0
  11. package/shared/vault/plaintext.mjs +115 -0
  12. package/shared/vault/test-vectors/ias-vault-2/approvers.json +2122 -0
  13. package/shared/vault/test-vectors/ias-vault-2/keys.json +43 -0
  14. package/shared/vault/test-vectors/ias-vault-2/malformed.json +1739 -0
  15. package/shared/vault/test-vectors/ias-vault-2/plaintexts.json +149 -0
  16. package/shared/vault/test-vectors/ias-vault-2/runners.json +580 -0
  17. package/shared/vault/test-vectors/ias-vault-2/sealed.json +567 -0
  18. package/shared/vault/test-vectors/ias-vault-2/tampered.json +989 -0
  19. package/shared/vault/test-vectors/keys.json +31 -0
  20. package/shared/vault/test-vectors/malformed.json +1341 -0
  21. package/shared/vault/test-vectors/sealed.json +384 -0
  22. package/shared/vault/test-vectors/tampered.json +610 -0
  23. package/shared/vault/vectors.mjs +720 -0
  24. package/src/cli.mjs +231 -0
  25. package/src/commands/apps/index.mjs +36 -0
  26. package/src/commands/auth/index.mjs +141 -0
  27. package/src/commands/build/follow.mjs +168 -0
  28. package/src/commands/build/index.mjs +110 -0
  29. package/src/commands/build/local.mjs +69 -0
  30. package/src/commands/build/profile.mjs +50 -0
  31. package/src/commands/build/source.mjs +71 -0
  32. package/src/commands/builds/index.mjs +148 -0
  33. package/src/commands/credentials/index.mjs +256 -0
  34. package/src/commands/credentials/material.mjs +159 -0
  35. package/src/commands/devices/asc.mjs +116 -0
  36. package/src/commands/devices/index.mjs +65 -0
  37. package/src/commands/devices/sync.mjs +223 -0
  38. package/src/commands/dist/index.mjs +108 -0
  39. package/src/commands/doctor/index.mjs +158 -0
  40. package/src/commands/env/index.mjs +163 -0
  41. package/src/commands/github/index.mjs +53 -0
  42. package/src/commands/init/index.mjs +224 -0
  43. package/src/commands/migrate/credentials.mjs +213 -0
  44. package/src/commands/migrate/eas-cli.mjs +153 -0
  45. package/src/commands/migrate/index.mjs +73 -0
  46. package/src/commands/migrate/numbers.mjs +85 -0
  47. package/src/commands/migrate/profiles.mjs +174 -0
  48. package/src/commands/migrate/project.mjs +249 -0
  49. package/src/commands/migrate/survey.mjs +205 -0
  50. package/src/commands/migrate/variables.mjs +183 -0
  51. package/src/commands/push/index.mjs +334 -0
  52. package/src/commands/sdk/index.mjs +81 -0
  53. package/src/commands/submit/index.mjs +85 -0
  54. package/src/commands/update/index.mjs +526 -0
  55. package/src/commands/usage/index.mjs +57 -0
  56. package/src/commands/vault/approvers.mjs +129 -0
  57. package/src/commands/vault/index.mjs +159 -0
  58. package/src/commands/vault/runners.mjs +115 -0
  59. package/src/commands/vault/team.mjs +111 -0
  60. package/src/commands/version/index.mjs +71 -0
  61. package/src/commands/webhooks/index.mjs +104 -0
  62. package/src/commands/workflows/index.mjs +88 -0
  63. package/src/config.mjs +261 -0
  64. package/src/dist.mjs +99 -0
  65. package/src/errors.mjs +158 -0
  66. package/src/http.mjs +45 -0
  67. package/src/legacy.mjs +73 -0
  68. package/src/manage.mjs +120 -0
  69. package/src/ota.mjs +122 -0
  70. package/src/output.mjs +36 -0
  71. package/src/process.mjs +45 -0
  72. package/src/schema.mjs +131 -0
  73. package/src/sdk.mjs +120 -0
  74. package/src/secret-input.mjs +27 -0
  75. package/src/secrets.mjs +37 -0
  76. package/src/shared.mjs +5 -0
  77. package/src/team-vault.mjs +152 -0
  78. package/src/token-store.mjs +93 -0
package/src/errors.mjs ADDED
@@ -0,0 +1,158 @@
1
+ // What went wrong and how to fix it. Every error the CLI throws on purpose says both, in one line: «what: how».
2
+
3
+ /** An error the person can fix: `what` happened, `fix` says how. Its message is «what: fix», the voice of tools/. */
4
+ export class IasError extends Error {
5
+ constructor(what, { fix, code, details } = {}) {
6
+ super(fix ? `${what}: ${fix}` : what);
7
+ this.name = 'IasError';
8
+ this.what = what;
9
+ this.fix = fix;
10
+ this.code = code;
11
+ this.details = details;
12
+ }
13
+ }
14
+
15
+ /** A command line that does not parse: an unknown command or option, or a missing argument. Exits with 2. */
16
+ export class UsageError extends IasError {
17
+ constructor(what, options) {
18
+ super(what, options);
19
+ this.name = 'UsageError';
20
+ }
21
+ }
22
+
23
+ /**
24
+ * An answer of the server that is not a success. Its message is the one tools/ always printed («POST /updates: 422
25
+ * code message (platform)»), and so is its name, Error; `hint` adds how to fix it, from the code of the answer (HINTS).
26
+ */
27
+ export class ApiError extends Error {
28
+ constructor(message, { status, code, data } = {}) {
29
+ super(message);
30
+ this.status = status;
31
+ this.code = code;
32
+ this.data = data;
33
+ this.hint = hintFor(status, code, data);
34
+ }
35
+ }
36
+
37
+ /** How to fix the answers of the OTA (API.md §36) and distribution (§37) APIs that a person can do something about. */
38
+ export const HINTS = {
39
+ 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
+ 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',
42
+ 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
+ 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
+ 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',
45
+ created_at_in_future: 'the clock of this Mac is ahead: set the date and time automatically and publish again',
46
+ update_group_not_found: 'ias update list shows the groups of the project',
47
+ update_archived: 'an archived group cannot be served again: republish its commit with ias update republish <group>',
48
+ channel_not_found: 'the project has no such channel: check --channel, or create it with its own certificate (php artisan ota:project <project> --channel=<channel> --certificate=… --keyid=…)',
49
+ rollout_in_progress: 'a group is rolling out to part of the installations there: complete it (ias update rollout <group> complete) or retire it before publishing another',
50
+ rollout_not_found: 'the group was published to everyone at once: it has no rollout to change',
51
+ rollout_completed: 'the rollout was completed and the group reaches every installation: to stop serving it, retire it (ias update retire <group>)',
52
+ update_retired: 'the group is retired: ias update restore <group> serves it again',
53
+ assets_missing: 'publish again: ias update publish uploads the files the server does not have',
54
+ build_not_found: 'ias dist list shows the builds of the project',
55
+ build_not_uploaded: 'upload it again: ias dist upload <file>',
56
+ build_size_mismatch: 'the file changed or the upload was cut: upload it again with ias dist upload <file>',
57
+ build_hash_mismatch: 'the file changed or the upload was cut: upload it again with ias dist upload <file>',
58
+ build_too_large: 'the server takes builds of up to 1 GiB (DISTRIBUTION_MAX_BUILD_BYTES)',
59
+ icon_invalid: 'expo.icon in app.json must be an image sips can turn into a PNG',
60
+ icon_too_large: 'expo.icon in app.json must stay under 512 KB once shrunk to 180 px',
61
+ // The management API (docs/technical/manage-api.md §10).
62
+ 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',
64
+ role_forbidden: 'it needs an owner of the team, or a personal token (ias login) instead of a robot one',
65
+ application_not_found: 'ias apps lists the applications this token can see; check app in ias.json or --app',
66
+ credential_not_found: 'ias credentials list shows the credentials of the application and the team',
67
+ variable_not_found: 'ias env list shows the variables of each environment',
68
+ approval_not_found: 'the approval expired or was asked by another token: run the command again',
69
+ token_not_found: 'the token was already revoked',
70
+ vault_keys_exist: 'the vault already has its keys: ias vault status compares them with the ones in 1Password',
71
+ vault_not_ready: 'the vault has no keys yet: an owner of the team runs ias vault init',
72
+ credential_exists: 'a credential with this id exists: ias credentials rotate <id> replaces its material',
73
+ credential_version_conflict: 'someone replaced it meanwhile: run the command again, which seals the next version',
74
+ envelope_invalid: 'the server did not take the envelope (the reason is the check of docs/technical/vault.md §6 that failed): nothing was stored; update ias, and if it goes on, report it',
75
+ recovery_key_missing: 'the envelope is not sealed for the team\'s current recovery key: ias vault status compares the keys of 1Password with the server\'s',
76
+ recipient_unknown: 'the envelope is sealed for a key the server does not know (a runner revoked meanwhile?): run the command again; ias vault status shows the keys',
77
+ signature_key_unknown: 'the envelope is signed with a key that is not the team\'s registered one: ias vault status compares the signing key of 1Password with the server\'s',
78
+ variable_must_be_plain: 'EXPO_PUBLIC_ variables end up in the bundle, so they are always plain: set it without --secret',
79
+ credential_type_invalid: 'ias credentials import --help lists the types',
80
+ // Builds, runners and store submissions (docs/technical/runner-api.md §8). build_not_found of a build of IAS gets its own hint in
81
+ // commands/build/follow.mjs: the one above is for ias dist.
82
+ build_number_backwards: 'build numbers only go up on the server: ias version get shows the last ones',
83
+ 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
+ build_not_cancelable: 'the build has ended already: there is nothing to cancel',
85
+ 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',
86
+ approvers_version_backwards: 'someone signed a newer version of the approvers meanwhile: run ias vault approvers again, which signs the next one',
87
+ runner_list_serial_backwards: 'someone signed a newer runner list meanwhile: run the command again, which signs the next one',
88
+ source_not_uploaded: 'the code did not reach the bucket: run ias build again',
89
+ source_mismatch: 'the code in the bucket is not the one described (its size or SHA-256): run ias build again',
90
+ 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
+ artifact_not_found: 'ias builds view <id> lists the artifacts of the build',
92
+ 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)',
94
+ 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',
95
+ runner_not_found: 'ias vault status and the panel (System › Runners) list the runners',
96
+ runner_not_authorized: 'the runner is pending, revoked or not in the list the team signed: an owner runs ias vault grant <runner>',
97
+ 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)',
99
+ job_not_found: 'the job is not this runner\'s, or it ended: ias builds view <id> shows the build',
100
+ // Push notifications (docs/technical/manage-api.md §14.6).
101
+ push_not_enabled: 'the application does not use push notifications: turn the service on in the panel (the application › Settings)',
102
+ push_credentials_missing: 'load the APNs key or the FCM service account first: ias push credentials ios <AuthKey_….p8>, or android <service-account.json>',
103
+ message_not_found: 'ias push messages lists the notifications of the application',
104
+ message_not_cancelable: 'only a scheduled notification can be canceled: this one already went out or was canceled',
105
+ audience_not_found: 'some id of the audience is not a user, organization, space, group or device of the application: nothing was sent',
106
+ push_credential_invalid: 'the file is not a usable APNs key (.p8) or FCM service account (JSON): nothing was stored',
107
+ // Testers' devices and resigns (docs/technical/manage-api.md §15).
108
+ device_not_found: 'ias devices list shows the devices of the application',
109
+ device_not_approved: 'only an approved device goes into Apple: approve it in the panel (Distribución › Dispositivos) and run ias devices sync again',
110
+ resign_source_missing: 'there is no internal iOS build of IAS to sign again: make one with ias build --platform ios --profile preview',
111
+ resign_in_progress: 'the application is being signed again already: wait for it to end (the panel, Distribución › Dispositivos, has its log)',
112
+ resign_not_found: 'the panel (Distribución › Dispositivos) lists the latest resigns of the application',
113
+ build_not_resignable: 'only an internal iOS build that succeeded, with its IPA, is signed again: ias builds list --platform ios shows them',
114
+ profile_not_current: 'the builds use another ad hoc profile of the application (one that expires later): retire the one you do not want with ias credentials retire',
115
+ // Webhooks, GitHub and workflows (docs/technical/manage-api.md §16).
116
+ integrations_key_missing: 'the server has no IAS_INTEGRATIONS_KEY (or not the one that sealed the secret): whoever runs the server sets it (.env.example) and loads the secrets again',
117
+ webhook_not_found: 'ias webhooks list shows the webhooks of the application',
118
+ webhook_disabled: 'the webhook was turned off after too many failures: fix its address and turn it on in the panel (the application › Integraciones)',
119
+ delivery_not_found: 'the panel (the application › Integraciones) lists the deliveries of each webhook',
120
+ github_not_linked: 'link the repository first: ias github link <owner/name>',
121
+ github_token_missing: 'an owner loads the token of GitHub in the panel, with a passkey (the application › Integraciones)',
122
+ github_token_refused: 'GitHub does not take the token (expired, revoked or without access to the repository): an owner loads a new one in the panel',
123
+ github_refused: 'GitHub refused the request: check the repository, the ref and that the token reads its contents',
124
+ github_unreachable: 'the server could not reach GitHub: try again in a minute',
125
+ workflow_not_found: 'ias workflows list shows the workflows of ias.json at that commit',
126
+ workflow_invalid: 'the workflows of ias.json do not follow its schema: ias doctor checks the file',
127
+ workflow_run_not_found: 'the panel (the application › Integraciones) lists the latest runs',
128
+ ias_json_invalid: 'the commit has no valid ias.json at the root of the repository: commit it, or run the workflow at another ref',
129
+ app_mismatch: 'the app of ias.json must be the application the repository is linked to',
130
+ profile_not_found: 'the workflow names a build profile that ias.json does not have: ias.json › build lists them',
131
+ app_json_missing: 'the build reads the version and build numbers from app.json at the root of the repository (an app.config.js is not read)',
132
+ platform_not_configured: 'give the application its bundle id and Android package in the panel (the application › Ajustes)',
133
+ limit_exceeded: 'the team used its monthly limit of that metric (ias usage shows where it is): it starts over on the first day of next month, or a platform administrator raises it in the panel (Plataforma › Uso)',
134
+ rate_limited: 'too many requests: wait a minute and run it again',
135
+ access_denied: 'a member rejected the authorization in the panel: run ias login again if it was a mistake',
136
+ expired_token: 'the code expired (10 minutes) or was already used: run ias login again',
137
+ };
138
+
139
+ function hintFor(status, code, data) {
140
+ if (code && HINTS[code]) return HINTS[code];
141
+ const message = String(data?.message ?? '');
142
+ 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';
144
+ if (status === 401) return 'the server did not take the token: check IAS_TOKEN or the token in ias.json';
145
+ if (status >= 500) return 'the server failed: try again in a minute, and if it goes on, look at its logs';
146
+ return undefined;
147
+ }
148
+
149
+ /** The parts of an error for --json: what happened, how to fix it and the code, if any. */
150
+ export function describeError(error) {
151
+ return {
152
+ message: error.what ?? error.message,
153
+ ...(error.fix || error.hint ? { fix: error.fix ?? error.hint } : {}),
154
+ ...(error.code ? { code: error.code } : {}),
155
+ ...(error.status ? { status: error.status } : {}),
156
+ ...(error.details ? { details: error.details } : {}),
157
+ };
158
+ }
package/src/http.mjs ADDED
@@ -0,0 +1,45 @@
1
+ // Requests to the publishing APIs of IAS (API.md §36 and §37) with a project's token.
2
+ import { ApiError } from './errors.mjs';
3
+
4
+ const wait = (seconds) => new Promise((done) => setTimeout(done, seconds * 1000));
5
+
6
+ /**
7
+ * A request to `settings.base` + `path` with the token; waits out one 429. Returns the parsed JSON. An answer that is
8
+ * not a success throws an ApiError with its status and code; an error page that is not JSON (a proxy's 502) still
9
+ * gives its status.
10
+ */
11
+ export async function api(settings, token, method, path, { json, body, headers = {} } = {}) {
12
+ for (let attempt = 0; ; attempt += 1) {
13
+ const response = await fetch(`${settings.base}${path}`, {
14
+ method,
15
+ headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(json ? { 'content-type': 'application/json' } : {}), ...headers },
16
+ body: json ? JSON.stringify(json) : body,
17
+ });
18
+ if (response.status === 429 && attempt === 0) {
19
+ await wait(Number(response.headers.get('retry-after')) || 10);
20
+ continue;
21
+ }
22
+ const text = await response.text();
23
+ let data = null;
24
+ try {
25
+ data = text ? JSON.parse(text) : null;
26
+ } catch {
27
+ if (response.ok) throw new ApiError(`${method} ${path}: ${response.status}, but the answer is not JSON: ${text.slice(0, 300)}`, { status: response.status });
28
+ }
29
+ if (!response.ok) {
30
+ throw new ApiError(`${method} ${path}: ${response.status} ${data?.code ?? ''} ${data?.message ?? text.slice(0, 300)}${data?.platform ? ` (${data.platform})` : ''}`, { status: response.status, code: data?.code, data });
31
+ }
32
+ return data;
33
+ }
34
+ }
35
+
36
+ /** A GET that waits out each 429: the asset route allows 60 requests a minute per IP, phones on this network included. */
37
+ export async function download(url, headers, output) {
38
+ for (;;) {
39
+ const response = await fetch(url, { headers });
40
+ if (response.status !== 429) return response;
41
+ const seconds = Number(response.headers.get('retry-after')) || 60;
42
+ output.progress(` the server asks to wait ${seconds} s (429)…`);
43
+ await wait(seconds);
44
+ }
45
+ }
package/src/legacy.mjs ADDED
@@ -0,0 +1,73 @@
1
+ // The command lines of tools/expo-ota/publish.mjs and tools/expo-dist/upload.mjs, kept word for word on top of ias
2
+ // update and ias dist while jscheduler-app and jero-invoices-app still call them: the same arguments (an option
3
+ // nobody knows is ignored, as it always was), the same configuration files and the same output, «expo-ota: …» and
4
+ // «expo-dist: …» included. ias.json, if the app has one, is read first, as ias does.
5
+ import process from 'node:process';
6
+ import { resolve } from 'node:path';
7
+ import { distSettings, loadProject, updateSettings } from './config.mjs';
8
+ import { createOutput } from './output.mjs';
9
+ import { cleanup } from './process.mjs';
10
+ import * as dist from './commands/dist/index.mjs';
11
+ import * as update from './commands/update/index.mjs';
12
+
13
+ /** The loose parsing of tools/: `command target` are the first words that are not options or the value of one. */
14
+ function parseLegacy(args, valued) {
15
+ const option = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
16
+ const [command, target] = args.filter((arg, index) => !arg.startsWith('--') && !valued.includes(args[index - 1]));
17
+ return { option, command, target, flag: (name) => args.includes(name) };
18
+ }
19
+
20
+ /** tools/expo-ota/publish.mjs: publish | republish | rollback-to-embedded | retire | restore | list | check. */
21
+ export async function expoOta(args = process.argv.slice(2), { env = process.env, cwd = process.cwd(), stdout, stderr } = {}) {
22
+ const { option, command, target, flag } = parseLegacy(args, ['--message', '--ref', '--platform', '--channel', '--project']);
23
+ const output = createOutput({ prefix: 'expo-ota', hints: false, stdout, stderr });
24
+ const platforms = option('--platform') ? [option('--platform')] : update.PLATFORMS;
25
+ const options = { message: option('--message'), platforms, channel: option('--channel'), dryRun: flag('--dry-run') };
26
+ const COMMANDS = {
27
+ publish: (settings) => update.publish(settings, { ...options, ref: option('--ref') ?? 'HEAD' }, output),
28
+ republish: (settings) => update.republish(settings, { ...options, group: target }, output),
29
+ 'rollback-to-embedded': (settings) => update.rollbackToEmbedded(settings, options, output),
30
+ retire: (settings) => update.setServing(settings, 'retire', { group: target }, output),
31
+ restore: (settings) => update.setServing(settings, 'restore', { group: target }, output),
32
+ list: (settings) => update.list(settings, { channel: option('--channel') }, output),
33
+ check: (settings) => update.check(settings, { platforms }, output),
34
+ };
35
+ const interrupted = () => (stderr ?? process.stderr).write('\nexpo-ota: interrupted, cleaning up\n');
36
+ process.on('SIGINT', interrupted);
37
+ let status = 1;
38
+ try {
39
+ if (!Object.hasOwn(COMMANDS, command ?? '')) throw new Error(`usage: node publish.mjs ${Object.keys(COMMANDS).join(' | ')} [--project <app folder>] (tools/expo-ota/README.md)`);
40
+ if (platforms.some((platform) => !update.PLATFORMS.includes(platform))) throw new Error('--platform is ios or android');
41
+ await COMMANDS[command](updateSettings(loadProject(resolve(cwd, option('--project') ?? '.')), env));
42
+ status = 0;
43
+ } catch (error) {
44
+ output.fail(error);
45
+ } finally {
46
+ process.off('SIGINT', interrupted);
47
+ cleanup();
48
+ }
49
+ return status;
50
+ }
51
+
52
+ /** tools/expo-dist/upload.mjs: upload | list | retire | restore. */
53
+ export async function expoDist(args = process.argv.slice(2), { env = process.env, cwd = process.cwd(), stdout, stderr } = {}) {
54
+ const { option, command, target } = parseLegacy(args, ['--notes', '--project']);
55
+ const output = createOutput({ prefix: 'expo-dist', hints: false, stdout, stderr });
56
+ const COMMANDS = {
57
+ upload: (settings) => dist.upload(settings, { file: target, notes: option('--notes'), cwd }, output),
58
+ list: (settings) => dist.list(settings, output),
59
+ retire: (settings) => dist.setOffered(settings, 'retire', { id: target }, output),
60
+ restore: (settings) => dist.setOffered(settings, 'restore', { id: target }, output),
61
+ };
62
+ let status = 1;
63
+ try {
64
+ if (!Object.hasOwn(COMMANDS, command ?? '')) throw new Error(`usage: node upload.mjs ${Object.keys(COMMANDS).join(' | ')} [--project <app folder>] (tools/expo-dist/README.md)`);
65
+ await COMMANDS[command](distSettings(loadProject(resolve(cwd, option('--project') ?? '.')), env));
66
+ status = 0;
67
+ } catch (error) {
68
+ output.fail(error);
69
+ } finally {
70
+ cleanup();
71
+ }
72
+ return status;
73
+ }
package/src/manage.mjs ADDED
@@ -0,0 +1,120 @@
1
+ // The management API of IAS (/api/manage/v1, docs/technical/manage-api.md): which server, which token, and the requests. The
2
+ // token is IAS_TOKEN (the token or an op:// reference), or the one ias login kept for the server; it is never printed.
3
+ import { spawn } from 'node:child_process';
4
+ import process from 'node:process';
5
+ import { ApiError, IasError } from './errors.mjs';
6
+ import { readSecret, tokenKind } from './secrets.mjs';
7
+ import { serverOf } from './config.mjs';
8
+ import { storeKind, storeLabel, storedToken } from './token-store.mjs';
9
+
10
+ export const MANAGE_PATH = '/api/manage/v1';
11
+
12
+ export const sleep = (seconds) => new Promise((done) => setTimeout(done, seconds * 1000));
13
+
14
+ /** The origin of the server for the management API: https, or localhost and .test to try it locally. */
15
+ export function serverOrigin(project, env = process.env) {
16
+ const text = serverOf(project ?? {}, env);
17
+ const url = URL.canParse(text) ? new URL(text) : undefined;
18
+ if (!url) throw new IasError(`the server (${text}) is not a URL`, { fix: 'set server in ias.json or IAS_SERVER to the origin of IAS, such as https://ias.imagina.build' });
19
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || url.hostname.endsWith('.test');
20
+ if (url.protocol !== 'https:' && !local) throw new IasError(`the server (${url.origin}) is not https`, { fix: 'the token goes in every request: use the https origin of IAS' });
21
+ return url.origin;
22
+ }
23
+
24
+ /**
25
+ * The token for `server` and where it comes from: IAS_TOKEN first, then the one ias login kept. Robot tokens of the
26
+ * old projects (ota_, dist_) do not work in this API.
27
+ */
28
+ export function manageToken(server, env = process.env) {
29
+ let token;
30
+ let source;
31
+ if (env.IAS_TOKEN) {
32
+ token = readSecret(env.IAS_TOKEN, { env });
33
+ source = 'IAS_TOKEN';
34
+ } else {
35
+ const kind = storeKind(env);
36
+ token = storedToken(server, { env, kind });
37
+ source = storeLabel(kind, env);
38
+ }
39
+ if (!token) throw new IasError(`not logged in to ${server}`, { fix: 'run ias login (or set IAS_TOKEN to a robot token on CI)', code: 'not_logged_in' });
40
+ const kind = tokenKind(token);
41
+ if (kind === 'ota' || kind === 'dist') throw new IasError(`the token is a publishing token of a project (${kind}_…), which the management API does not take`, { fix: 'run ias login, or use a robot token of the application (iasr_…, the panel: the application › Settings › Access)' });
42
+ return { token, source };
43
+ }
44
+
45
+ /**
46
+ * A client of the management API: { server, request(method, path, { json, auth }) }. request returns { status, data }
47
+ * for a success (204 has no data, 202 is an approval) and throws an ApiError, with the hint of its code, otherwise.
48
+ */
49
+ export function manageClient(server, token, { fetch: fetchImpl = fetch, wait = sleep } = {}) {
50
+ async function request(method, path, { json, auth = true } = {}) {
51
+ for (let attempt = 0; ; attempt += 1) {
52
+ const response = await fetchImpl(`${server}${MANAGE_PATH}${path}`, {
53
+ method,
54
+ headers: { accept: 'application/json', ...(auth ? { authorization: `Bearer ${token}` } : {}), ...(json !== undefined ? { 'content-type': 'application/json' } : {}) },
55
+ body: json !== undefined ? JSON.stringify(json) : undefined,
56
+ });
57
+ if (response.status === 429 && attempt < 3) {
58
+ await wait(Number(response.headers.get('retry-after')) || 10);
59
+ continue;
60
+ }
61
+ const text = await response.text();
62
+ let data = null;
63
+ try {
64
+ data = text ? JSON.parse(text) : null;
65
+ } catch {
66
+ throw new ApiError(`${method} ${MANAGE_PATH}${path}: ${response.status}, but the answer is not JSON: ${text.slice(0, 200)}`, { status: response.status });
67
+ }
68
+ if (!response.ok) {
69
+ const reason = data?.reason ? ` (reason: ${data.reason})` : '';
70
+ throw new ApiError(`${method} ${MANAGE_PATH}${path}: ${response.status} ${data?.code ?? ''} ${data?.message ?? ''}${reason}`.trimEnd(), { status: response.status, code: data?.code, data });
71
+ }
72
+ return { status: response.status, data };
73
+ }
74
+ }
75
+ return { server, request };
76
+ }
77
+
78
+ /** The server and a client with the token of this machine, for a command. */
79
+ export function connect(context, project) {
80
+ const server = serverOrigin(project, context.env);
81
+ const { token, source } = manageToken(server, context.env);
82
+ return { ...manageClient(server, token, { wait: context.sleep ?? sleep }), tokenSource: source };
83
+ }
84
+
85
+ /** Opens `url` in the browser: $BROWSER if set (none to skip), open on macOS, xdg-open elsewhere. Never fails. */
86
+ export function openUrl(url, env = process.env) {
87
+ const command = env.BROWSER ?? (process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? undefined : 'xdg-open');
88
+ if (!command || command === 'none') return false;
89
+ try {
90
+ const child = spawn(command, [url], { detached: true, stdio: 'ignore', env });
91
+ child.on('error', () => {});
92
+ child.unref();
93
+ return true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Waits for a member to approve an action with a passkey (docs/technical/manage-api.md §6): asks GET /approvals/{id} every 3 s
101
+ * and returns its result. Throws if it is rejected or expires.
102
+ */
103
+ export async function waitForApproval(client, approval, { wait = sleep, interval = 3, output } = {}) {
104
+ for (;;) {
105
+ const { data } = await client.request('GET', `/approvals/${approval.id}`);
106
+ if (data.status === 'approved') return data.result;
107
+ if (data.status === 'rejected') throw new IasError(`a member rejected the approval of ${data.action}`, { fix: 'run the command again if it was a mistake', code: 'approval_rejected' });
108
+ if (data.status === 'expired') throw new IasError(`the approval of ${data.action} expired`, { fix: 'approve it within 10 minutes: run the command again', code: 'approval_expired' });
109
+ output?.progress?.(' waiting for the approval…');
110
+ await wait(interval);
111
+ }
112
+ }
113
+
114
+ /** The code of the application of a command: --app, or app of ias.json. */
115
+ export function applicationOf(context, project) {
116
+ const code = context.values.app ?? project?.config?.app;
117
+ if (!code) throw new IasError('which application?', { fix: 'pass --app <code>, or run it in the folder of an app with an ias.json (ias apps lists them)' });
118
+ if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(code)) throw new IasError(`«${code}» is not the code of an application`, { fix: 'ias apps lists them' });
119
+ return code;
120
+ }
package/src/ota.mjs ADDED
@@ -0,0 +1,122 @@
1
+ // The parts of an OTA update that need no network, git or 1Password: manifests, signatures and the multipart answer.
2
+ import { Buffer } from 'node:buffer';
3
+ import { createHash, createPublicKey, createSign, createVerify, randomUUID } from 'node:crypto';
4
+ import { readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ /** Content types of the files expo export writes, by extension; the bundle is application/javascript. */
8
+ export const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', ttf: 'font/ttf', otf: 'font/otf', woff: 'font/woff', woff2: 'font/woff2', json: 'application/json', mp3: 'audio/mpeg', wav: 'audio/wav', mp4: 'video/mp4' };
9
+
10
+ /** The lockfile of the app and the install that reproduces it in the export copy. */
11
+ export const INSTALLS = [
12
+ ['package-lock.json', 'npm', ['ci', '--no-audit', '--no-fund']],
13
+ ['yarn.lock', 'yarn', ['install', '--frozen-lockfile']],
14
+ ['pnpm-lock.yaml', 'pnpm', ['install', '--frozen-lockfile']],
15
+ ['bun.lock', 'bun', ['install', '--frozen-lockfile']],
16
+ ['bun.lockb', 'bun', ['install', '--frozen-lockfile']],
17
+ ];
18
+
19
+ /** The hash expo-updates gives a file: SHA-256 in base64url. */
20
+ export const sha256 = (data) => createHash('sha256').update(data).digest('base64url');
21
+
22
+ /** The variables `names` of a dotenv file (KEY=value lines; quotes are dropped). */
23
+ export function readEnvFile(path, names) {
24
+ const values = {};
25
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
26
+ const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
27
+ if (match && names.includes(match[1])) values[match[1]] = match[2].replace(/^(['"])(.*)\1$/, '$2');
28
+ }
29
+ return values;
30
+ }
31
+
32
+ /**
33
+ * One manifest per platform from an expo export folder: the same createdAt, the runtime of each platform, relative
34
+ * asset URLs, metadata {} (filters could make phones load an older update) and the public config as extra.expoClient.
35
+ * Returns the manifests and the files to upload, by hash. Throws if a bundle lacks a value it must carry.
36
+ */
37
+ export function buildManifests({ dist, metadata, platforms, runtimes, expoClient, assetPath, createdAt, mustContain = [] }) {
38
+ const files = new Map();
39
+ const entry = (path, ext, contentType) => {
40
+ const contents = readFileSync(join(dist, path));
41
+ const hash = sha256(contents);
42
+ files.set(hash, { path: join(dist, path), contentType });
43
+ return { hash, key: createHash('md5').update(contents).digest('hex'), contentType, fileExtension: `.${ext}`, url: `${assetPath}${hash}` };
44
+ };
45
+ const manifests = {};
46
+ for (const platform of platforms) {
47
+ const { bundle, assets } = metadata.fileMetadata[platform];
48
+ const code = readFileSync(join(dist, bundle));
49
+ for (const [name, value] of mustContain) if (!code.includes(value)) throw new Error(`the ${platform} bundle does not carry ${name}`);
50
+ manifests[platform] = {
51
+ id: randomUUID(),
52
+ createdAt,
53
+ runtimeVersion: runtimes[platform],
54
+ launchAsset: entry(bundle, 'bundle', 'application/javascript'),
55
+ assets: assets.map((asset) => entry(asset.path, asset.ext, MIME[asset.ext] ?? 'application/octet-stream')),
56
+ metadata: {},
57
+ extra: { expoClient },
58
+ };
59
+ }
60
+ return { manifests, files };
61
+ }
62
+
63
+ /** The expo-signature header of `body`: RSA-SHA256 of its exact bytes. */
64
+ export const signatureHeader = (body, privateKeyPem, keyid) => `sig="${createSign('RSA-SHA256').update(body).sign(privateKeyPem, 'base64')}", keyid="${keyid}"`;
65
+
66
+ /** Whether `header` is a valid expo-signature of `body` for this public key and keyid. */
67
+ export function verifySignature(publicKey, keyid, body, header) {
68
+ const sig = header?.match(/sig="([^"]+)"/)?.[1];
69
+ return Boolean(sig) && header.match(/keyid="([^"]+)"/)?.[1] === keyid && createVerify('RSA-SHA256').update(body).verify(publicKey, sig, 'base64');
70
+ }
71
+
72
+ /** The first part of a multipart/mixed body: its name, its expo-signature header and its exact bytes. */
73
+ export function firstPart(buffer, boundary) {
74
+ const start = buffer.indexOf(`--${boundary}\r\n`) + boundary.length + 4;
75
+ const headersEnd = buffer.indexOf('\r\n\r\n', start);
76
+ const end = buffer.indexOf(`\r\n--${boundary}`, headersEnd);
77
+ const headers = buffer.subarray(start, headersEnd).toString('utf8');
78
+ return { name: headers.match(/name="([^"]+)"/)?.[1], signature: headers.match(/^expo-signature: (.+)$/im)?.[1]?.trim(), body: buffer.subarray(headersEnd + 4, end) };
79
+ }
80
+
81
+ /**
82
+ * The platforms of a publication that sends the phones back to the JavaScript of their build: one signed
83
+ * rollBackToEmbedded directive, with the runtime of each platform.
84
+ */
85
+ export function rollbackDirectives({ platforms, runtimes, privateKeyPem, keyid, commitTime }) {
86
+ const body = JSON.stringify({ type: 'rollBackToEmbedded', parameters: { commitTime } });
87
+ const signature = signatureHeader(body, privateKeyPem, keyid);
88
+ return Object.fromEntries(platforms.map((platform) => [platform, { directive: Buffer.from(body).toString('base64'), signature, runtime_version: runtimes[platform] }]));
89
+ }
90
+
91
+ /**
92
+ * Where an installation falls in the gradual rollout of a group, from 0 to 99, as the server decides it (API.md §36.9):
93
+ * the first 32 bits of SHA-256("<group>:<EAS-Client-ID>"), both in lowercase. It gets the group below the share.
94
+ */
95
+ export const rolloutBucket = (group, installation) => createHash('sha256').update(`${group.toLowerCase()}:${installation.toLowerCase()}`).digest().readUInt32BE(0) % 100;
96
+
97
+ /** An EAS-Client-ID that falls inside the share of a group, to ask for the manifest as one of those phones. */
98
+ export function installationInside(group, percent) {
99
+ for (;;) {
100
+ const installation = randomUUID();
101
+ if (rolloutBucket(group, installation) < percent) return installation;
102
+ }
103
+ }
104
+
105
+ /** The SPKI of a certificate (X509Certificate), a public key object or a PEM, to compare two of them. */
106
+ export function publicKeyOf(key) {
107
+ const object = key.publicKey ?? key;
108
+ return (object.type === 'public' ? object : createPublicKey(object)).export({ type: 'spki', format: 'der' }).toString('base64');
109
+ }
110
+
111
+ /**
112
+ * The manifest (or directive) of an earlier group signed again for another channel: the same contents with a new id and
113
+ * createdAt (a new commitTime), since the phones only take what is newer than what they run. Refuses what the Mac must
114
+ * never sign: metadata other than {} (filters could force an older update).
115
+ */
116
+ export function promoted(kind, body, now = new Date().toISOString()) {
117
+ const document = JSON.parse(body);
118
+ if (kind === 'directive') return { ...document, parameters: { ...document.parameters, commitTime: now } };
119
+ if (JSON.stringify(document.metadata) !== '{}') throw new Error('the manifest has metadata other than {}: it is not signed again (filters could make phones load an older update)');
120
+ return { ...document, id: randomUUID(), createdAt: now };
121
+ }
122
+
package/src/output.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // Where a command writes. For people, lines as it goes; with --json, one JSON document on stdout at the end and the
2
+ // progress on stderr, so a script can read stdout whole.
3
+ import process from 'node:process';
4
+ import { describeError } from './errors.mjs';
5
+
6
+ /** A result with this key exits with its value (ias doctor exits with 1 when a check fails); JSON leaves it out. */
7
+ export const EXIT_CODE = Symbol('exit code');
8
+
9
+ /**
10
+ * The output of one run. `prefix` starts warnings and errors (ias, or expo-ota and expo-dist in the tools/ wrappers);
11
+ * `hints` adds how to fix an answer of the server, which the wrappers leave out to print what they always printed.
12
+ */
13
+ export function createOutput({ json = false, prefix = 'ias', hints = true, stdout = process.stdout, stderr = process.stderr } = {}) {
14
+ const write = (stream, line) => stream.write(`${line}\n`);
15
+ return {
16
+ json,
17
+ prefix,
18
+ /** A line of the result, for people. With --json the result goes in the document instead. */
19
+ log: (line) => (json ? undefined : write(stdout, line)),
20
+ /** A step of a long command: on stdout for people, on stderr with --json. */
21
+ progress: (line) => write(json ? stderr : stdout, line),
22
+ /** Something to look at that does not stop the command. */
23
+ warn: (line) => write(stderr, `${prefix}: ${line}`),
24
+ /** The result of a command with --json. */
25
+ result: (value) => (json && value !== undefined ? write(stdout, JSON.stringify(value, null, 2)) : undefined),
26
+ /** An error: «prefix: what: how», and the hint of the server's answer on the next line. */
27
+ fail(error) {
28
+ if (json) {
29
+ write(stdout, JSON.stringify({ error: describeError(error) }, null, 2));
30
+ return;
31
+ }
32
+ write(stderr, `${prefix}: ${error.message}`);
33
+ if (hints && error.hint) write(stderr, `${prefix}: ${error.hint}`);
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,45 @@
1
+ // Other programs (git, op, npx, unzip…) and the temporary folders of a run.
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import process from 'node:process';
7
+
8
+ const temporaries = [];
9
+
10
+ /** A new temporary folder, removed by cleanup() when the run ends. */
11
+ export function temporary(prefix = 'ias-') {
12
+ const dir = mkdtempSync(join(tmpdir(), prefix));
13
+ temporaries.push(dir);
14
+ return dir;
15
+ }
16
+
17
+ /** Removes the temporary folders of this run: they may hold a private key or a copy of the app. */
18
+ export function cleanup() {
19
+ for (const dir of temporaries.splice(0)) rmSync(dir, { recursive: true, force: true });
20
+ }
21
+
22
+ /**
23
+ * Runs a command and returns its stdout. On failure it prints the last lines the command wrote (`tail`: its stdout and
24
+ * stderr, or only its stderr) and throws with `label`, never with the arguments, which may hold a secret.
25
+ */
26
+ export function run(command, commandArgs, { label = `${command} ${commandArgs[0]}`, tail = 'all', lines = 30, ...options } = {}) {
27
+ const result = spawnSync(command, commandArgs, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['inherit', 'pipe', 'inherit'], ...options });
28
+ if (result.error || result.status !== 0) {
29
+ if (typeof result.stderr === 'string') {
30
+ const output = tail === 'stderr' ? result.stderr : [result.stdout, result.stderr].join('\n');
31
+ process.stderr.write(`${output.trimEnd().split('\n').slice(-lines).join('\n')}\n`);
32
+ }
33
+ throw new Error(`${label} failed${result.error ? `: ${result.error.message}` : ` (exit ${result.status})`}`);
34
+ }
35
+ return result.stdout;
36
+ }
37
+
38
+ /** Heavy steps (install, export) at a low priority, mostly on the efficiency cores: the Mac stays usable. */
39
+ export const gently = (command, commandArgs, options) => run('taskpolicy', ['-c', 'utility', 'nice', '-n', '15', command, ...commandArgs], { label: `${command} ${commandArgs[0]}`, ...options });
40
+
41
+ /** Whether a program answers, and what it says (for ias doctor): { ok, output }. Never throws. */
42
+ export function probe(command, commandArgs = [], options = {}) {
43
+ const result = spawnSync(command, commandArgs, { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'], ...options });
44
+ return { ok: !result.error && result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() };
45
+ }