@imagina-build/ias-cli 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +406 -2
  2. package/bin/ias.mjs +19 -0
  3. package/package.json +31 -12
  4. package/schema/ias.schema.json +535 -0
  5. package/shared/credentials/cms.mjs +105 -0
  6. package/shared/credentials/index.mjs +354 -0
  7. package/shared/credentials/plist.mjs +93 -0
  8. package/shared/vault/approvers-vectors.mjs +188 -0
  9. package/shared/vault/approvers.mjs +234 -0
  10. package/shared/vault/index.mjs +583 -0
  11. package/shared/vault/plaintext.mjs +115 -0
  12. package/shared/vault/test-vectors/ias-vault-2/approvers.json +2122 -0
  13. package/shared/vault/test-vectors/ias-vault-2/keys.json +43 -0
  14. package/shared/vault/test-vectors/ias-vault-2/malformed.json +1739 -0
  15. package/shared/vault/test-vectors/ias-vault-2/plaintexts.json +149 -0
  16. package/shared/vault/test-vectors/ias-vault-2/runners.json +580 -0
  17. package/shared/vault/test-vectors/ias-vault-2/sealed.json +567 -0
  18. package/shared/vault/test-vectors/ias-vault-2/tampered.json +989 -0
  19. package/shared/vault/test-vectors/keys.json +31 -0
  20. package/shared/vault/test-vectors/malformed.json +1341 -0
  21. package/shared/vault/test-vectors/sealed.json +384 -0
  22. package/shared/vault/test-vectors/tampered.json +610 -0
  23. package/shared/vault/vectors.mjs +720 -0
  24. package/src/cli.mjs +231 -0
  25. package/src/commands/apps/index.mjs +36 -0
  26. package/src/commands/auth/index.mjs +141 -0
  27. package/src/commands/build/follow.mjs +168 -0
  28. package/src/commands/build/index.mjs +110 -0
  29. package/src/commands/build/local.mjs +69 -0
  30. package/src/commands/build/profile.mjs +50 -0
  31. package/src/commands/build/source.mjs +71 -0
  32. package/src/commands/builds/index.mjs +148 -0
  33. package/src/commands/credentials/index.mjs +256 -0
  34. package/src/commands/credentials/material.mjs +159 -0
  35. package/src/commands/devices/asc.mjs +116 -0
  36. package/src/commands/devices/index.mjs +65 -0
  37. package/src/commands/devices/sync.mjs +223 -0
  38. package/src/commands/dist/index.mjs +108 -0
  39. package/src/commands/doctor/index.mjs +158 -0
  40. package/src/commands/env/index.mjs +163 -0
  41. package/src/commands/github/index.mjs +53 -0
  42. package/src/commands/init/index.mjs +224 -0
  43. package/src/commands/migrate/credentials.mjs +213 -0
  44. package/src/commands/migrate/eas-cli.mjs +153 -0
  45. package/src/commands/migrate/index.mjs +73 -0
  46. package/src/commands/migrate/numbers.mjs +85 -0
  47. package/src/commands/migrate/profiles.mjs +174 -0
  48. package/src/commands/migrate/project.mjs +249 -0
  49. package/src/commands/migrate/survey.mjs +205 -0
  50. package/src/commands/migrate/variables.mjs +183 -0
  51. package/src/commands/push/index.mjs +334 -0
  52. package/src/commands/sdk/index.mjs +81 -0
  53. package/src/commands/submit/index.mjs +85 -0
  54. package/src/commands/update/index.mjs +526 -0
  55. package/src/commands/usage/index.mjs +57 -0
  56. package/src/commands/vault/approvers.mjs +129 -0
  57. package/src/commands/vault/index.mjs +159 -0
  58. package/src/commands/vault/runners.mjs +115 -0
  59. package/src/commands/vault/team.mjs +111 -0
  60. package/src/commands/version/index.mjs +71 -0
  61. package/src/commands/webhooks/index.mjs +104 -0
  62. package/src/commands/workflows/index.mjs +88 -0
  63. package/src/config.mjs +261 -0
  64. package/src/dist.mjs +99 -0
  65. package/src/errors.mjs +158 -0
  66. package/src/http.mjs +45 -0
  67. package/src/legacy.mjs +73 -0
  68. package/src/manage.mjs +120 -0
  69. package/src/ota.mjs +122 -0
  70. package/src/output.mjs +36 -0
  71. package/src/process.mjs +45 -0
  72. package/src/schema.mjs +131 -0
  73. package/src/sdk.mjs +114 -0
  74. package/src/secret-input.mjs +27 -0
  75. package/src/secrets.mjs +37 -0
  76. package/src/shared.mjs +5 -0
  77. package/src/team-vault.mjs +152 -0
  78. package/src/token-store.mjs +93 -0
@@ -0,0 +1,183 @@
1
+ // ias migrate eas env: the variables of EAS's environments into those of IAS (docs/technical/manage-api.md §9), as ias env set
2
+ // stores them: EXPO_PUBLIC_* in plain, because they end up in the bundle, and any other sealed on this Mac for the
3
+ // recovery key and the signed runners. They come from eas env:list, which ias runs without a terminal and reads here,
4
+ // or from the .env of eas env:pull (--env-file). A secret variable of EAS cannot be read outside its builders: those
5
+ // are listed for ias env set. A variable already in IAS is kept unless --force. Only plain values are ever printed.
6
+ import { Buffer } from 'node:buffer';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { existsSync, readFileSync, rmSync, rmdirSync } from 'node:fs';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ import { loadProject } from '../../config.mjs';
11
+ import { IasError, UsageError } from '../../errors.mjs';
12
+ import { applicationOf, connect } from '../../manage.mjs';
13
+ import { encodePlaintext, seal, verifyEnvelope } from '../../shared.mjs';
14
+ import { sealingKeys, vaultItem } from '../../team-vault.mjs';
15
+ import { ENVIRONMENTS } from '../env/index.mjs';
16
+ import { parseEnvFile, parseVariableList, runEas } from './eas-cli.mjs';
17
+ import { easFolder } from './numbers.mjs';
18
+ import { iasCommand, isInside, preparedFolders, projectDir, readApp } from './project.mjs';
19
+
20
+ const NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
21
+ const isPublic = (name) => name.startsWith('EXPO_PUBLIC_');
22
+
23
+ /** The variables of an environment from the .env that eas env:pull wrote. */
24
+ function fromFile(context) {
25
+ const { values, cwd } = context;
26
+ if (!values.environment) throw new UsageError('which environment is the file of?', { fix: '--environment development|preview|production: the one given to eas env:pull' });
27
+ const file = resolve(cwd, values['env-file']);
28
+ if (!existsSync(file)) throw new IasError(`there is no file ${values['env-file']}`, { fix: 'eas env:pull --environment <environment> --path <file> writes it, run in the folder of ias migrate eas prepare' });
29
+ const parsed = parseEnvFile(readFileSync(file, 'utf8'));
30
+ if (parsed.environment && parsed.environment !== values.environment) throw new IasError(`${values['env-file']} holds the environment ${parsed.environment}, not ${values.environment}`, { fix: `pass --environment ${parsed.environment}, or pull that one` });
31
+ return [{ environment: values.environment, from: file, file, variables: parsed.variables, secret: parsed.secret, files: parsed.files, unread: parsed.unread, shared: [] }];
32
+ }
33
+
34
+ /** The variables of each environment from eas env:list (--format long; eas-cli 24 has no --json there). */
35
+ function fromEas(context, app) {
36
+ const { values, env } = context;
37
+ const fix = 'run eas env:pull --environment <environment> --path <file> yourself, in the folder of ias migrate eas prepare, and pass --env-file <file> --environment <environment>';
38
+ const folder = easFolder(app, env);
39
+ if (!folder) throw new IasError('app.json does not name its EAS project, so eas cannot read its variables here', { fix: `ias migrate eas prepare --eas-project <id> makes a folder where it can; or ${fix}` });
40
+ return (values.environment ? [values.environment] : ENVIRONMENTS).map((environment) => {
41
+ const list = parseVariableList(runEas(['env:list', '--environment', environment, '--include-sensitive', '--format', 'long'], { cwd: folder.dir, env, noVcs: folder.noVcs, fix }));
42
+ return {
43
+ environment,
44
+ from: 'eas env:list',
45
+ variables: list.filter((variable) => !variable.file && variable.value !== null),
46
+ secret: list.filter((variable) => !variable.file && variable.value === null).map((variable) => variable.name),
47
+ files: list.filter((variable) => variable.file).map((variable) => ({ name: variable.name })),
48
+ unread: 0,
49
+ shared: list.filter((variable) => variable.scope !== 'project').map((variable) => variable.name),
50
+ };
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Deletes the .env of eas env:pull, and the files of its file variables, when they are in a folder of prepare: they
56
+ * hold the values. One elsewhere is the person's, and only named. Returns the paths deleted.
57
+ */
58
+ function removePulled(source, folders) {
59
+ const folder = folders.find((candidate) => isInside(candidate.dir, source.file));
60
+ if (!folder) return null;
61
+ const removed = [];
62
+ for (const path of [source.file, ...source.files.map((file) => resolve(dirname(source.file), file.path))]) {
63
+ if (!existsSync(path) || !isInside(folder.dir, path) || isInside(join(folder.dir, 'node_modules'), path)) continue;
64
+ rmSync(path, { force: true });
65
+ removed.push(path);
66
+ }
67
+ for (const empty of [join(folder.dir, '.eas', '.env'), join(folder.dir, '.eas')]) {
68
+ try {
69
+ rmdirSync(empty);
70
+ } catch {
71
+ // not empty, or not there
72
+ }
73
+ }
74
+ return removed;
75
+ }
76
+
77
+ export async function variables(context) {
78
+ const { values, env, output } = context;
79
+ if (values.environment !== undefined && !ENVIRONMENTS.includes(values.environment)) throw new UsageError(`there is no environment «${values.environment}»`, { fix: `one of ${ENVIRONMENTS.join(', ')}` });
80
+ const dir = projectDir(context);
81
+ const app = readApp(dir);
82
+ const project = loadProject(dir);
83
+ const application = applicationOf(context, project);
84
+ const client = connect(context, project);
85
+ const sources = values['env-file'] ? fromFile(context) : fromEas(context, app);
86
+ const dryRun = Boolean(values['dry-run']);
87
+ let sealing;
88
+ const report = [];
89
+ let failure;
90
+ try {
91
+ for (const source of sources) {
92
+ const entry = { environment: source.environment, from: source.from, set: [], same: [], kept: [], pending: [], files: source.files.map((file) => file.name), left_out: [] };
93
+ report.push(entry);
94
+ if (source.variables.length + source.secret.length + source.files.length === 0) {
95
+ output.log(`${source.environment}: no variables in EAS`);
96
+ continue;
97
+ }
98
+ output.log(`${source.environment}, from ${source.from}:`);
99
+ const existing = (await client.request('GET', `/apps/${application}/env?environment=${source.environment}`)).data.data;
100
+ for (const { name, value } of source.variables) {
101
+ if (!NAME.test(name)) {
102
+ entry.left_out.push(name);
103
+ output.log(` ${name}: IAS takes capitals, digits and _ (up to 64): left out; set it under another name with ias env set`);
104
+ continue;
105
+ }
106
+ const plain = isPublic(name);
107
+ const there = existing.find((variable) => variable.name === name);
108
+ const shown = plain ? `${name}=${value}` : name;
109
+ if (plain && there?.visibility === 'plain' && there.value === value) {
110
+ entry.same.push(name);
111
+ output.log(` ${shown}: already in IAS`);
112
+ continue;
113
+ }
114
+ if (there && !values.force) {
115
+ entry.kept.push(name);
116
+ output.log(` ${name}: already in IAS (${there.visibility}, version ${there.version}); kept, since ${plain ? 'its value differs' : 'a sealed value cannot be compared'} (--force takes EAS's as version ${there.version + 1})`);
117
+ continue;
118
+ }
119
+ if (plain && Buffer.byteLength(value, 'utf8') > 4096) {
120
+ entry.left_out.push(name);
121
+ output.warn(`${source.environment} ${name}: more than 4 KB, and a plain variable holds up to 4 KB: left out`);
122
+ continue;
123
+ }
124
+ if (dryRun) {
125
+ entry.set.push(name);
126
+ output.log(` ${shown}: would be ${plain ? 'plain' : 'sealed'}`);
127
+ continue;
128
+ }
129
+ const path = `/apps/${application}/env/${source.environment}/${name}`;
130
+ if (plain) {
131
+ const { data } = await client.request('PUT', path, { json: { visibility: 'plain', value } });
132
+ entry.set.push(name);
133
+ output.log(` ${shown}: plain, version ${data.version}`);
134
+ continue;
135
+ }
136
+ sealing ??= await sealingKeys(client, vaultItem(context, project, client.server), env, output);
137
+ const credential = { id: there?.id ?? randomUUID(), type: 'environment_variable', version: there ? there.version + 1 : 1 };
138
+ const plaintext = encodePlaintext('environment_variable', { application, environment: source.environment, name, value });
139
+ let envelope;
140
+ try {
141
+ envelope = seal(plaintext, { credential, recipients: sealing.recipients, signingKey: sealing.signingKey });
142
+ verifyEnvelope(envelope, sealing.keys.signing.publicKey);
143
+ } finally {
144
+ plaintext.fill(0);
145
+ }
146
+ const { data } = await client.request('PUT', path, { json: { visibility: 'secret', id: credential.id, envelope } });
147
+ entry.set.push(name);
148
+ output.log(` ${name}: sealed for ${envelope.recipients.length} key${envelope.recipients.length === 1 ? '' : 's'}, version ${data.version}`);
149
+ }
150
+ for (const name of source.secret) {
151
+ if (existing.some((variable) => variable.name === name)) {
152
+ entry.kept.push(name);
153
+ output.log(` ${name}: secret in EAS, and already in IAS`);
154
+ continue;
155
+ }
156
+ entry.pending.push(name);
157
+ output.log(` ${name}: secret in EAS, where only its builders read it: ias env set ${name} --from op://… --environment ${source.environment}`);
158
+ }
159
+ for (const { name } of source.files) output.log(` ${name}: a file variable of EAS; IAS has none: keep the file in 1Password, or in the app if it is not secret`);
160
+ for (const name of source.shared) output.log(` ${name}: an account-wide variable of EAS, now this application's only`);
161
+ if (source.unread > 0) output.warn(`${source.unread} line${source.unread === 1 ? '' : 's'} of ${source.from} ${source.unread === 1 ? 'is' : 'are'} not NAME=value (a value with line breaks?): check ${source.unread === 1 ? 'it' : 'them'} with ias env list`);
162
+ }
163
+ } catch (error) {
164
+ failure = error;
165
+ } finally {
166
+ const source = values['env-file'] ? sources[0] : undefined;
167
+ if (source && !dryRun) {
168
+ const removed = removePulled(source, preparedFolders(dir, env));
169
+ if (removed) output.log(`deleted ${removed.join(', ')}: ${removed.length === 1 ? 'it held' : 'they held'} the values of EAS${failure ? ', although the import stopped: pull it again to go on' : ''}.`);
170
+ else if (isInside(dir, source.file)) output.warn(`${source.file} holds values of EAS inside the project: check that git ignores it, and delete it when the app no longer reads it`);
171
+ else output.warn(`${source.file} holds values of EAS: delete it when you are done`);
172
+ }
173
+ }
174
+ if (failure) {
175
+ const done = report.flatMap((entry) => entry.set.map((name) => `${entry.environment} ${name}`));
176
+ output.warn(`env stopped: ${done.length > 0 ? `set ${done.join(', ')}` : 'nothing was set'}; run it again to go on: what is in IAS already is kept.`);
177
+ throw failure;
178
+ }
179
+ const pending = report.flatMap((entry) => entry.pending.map((name) => `${entry.environment} ${name}`));
180
+ if (pending.length > 0) output.log(`Left for you: ${pending.join(', ')} (ias env set … --from op://…).`);
181
+ if (!dryRun) output.log(`Next: ${iasCommand(context, 'checklist')}: what is left in expo.dev and the stores.`);
182
+ return { application, dry_run: dryRun, environments: report };
183
+ }
@@ -0,0 +1,334 @@
1
+ // ias push send, messages, cancel, devices and credentials (docs/technical/manage-api.md §14): notifications of an application,
2
+ // straight to APNs and FCM through the server. send builds the notification from its options (or a JSON file with
3
+ // --from) and sends it now, schedules it with --at, or counts whom it would reach with --dry-run. The credentials of
4
+ // APNs and FCM are the exception to the sealed vault, because the server sends with them: they go to the server as
5
+ // they are, over https, and an owner approves them with a passkey; nothing here prints or keeps them.
6
+ import { existsSync, readFileSync, statSync } from 'node:fs';
7
+ import { basename, resolve } from 'node:path';
8
+ import { loadProject } from '../../config.mjs';
9
+ import { ApiError, IasError, UsageError } from '../../errors.mjs';
10
+ import { applicationOf, connect, openUrl, sleep, waitForApproval } from '../../manage.mjs';
11
+ import { filePath } from '../../secret-input.mjs';
12
+
13
+ const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
14
+
15
+ /** The audiences of --to: «all», or a kind and what it names. */
16
+ const TO_KINDS = { topic: 'topics', user: 'users', organization: 'organizations', space: 'spaces', group: 'groups', device: 'devices' };
17
+ const PLATFORMS = ['ios', 'android'];
18
+ const DURATION = /^(\d+)([smhd]?)$/;
19
+ const SECONDS = { '': 1, s: 1, m: 60, h: 3600, d: 86400 };
20
+
21
+ /** The audience of the --to options: all of one kind (topics, users…), or every device. */
22
+ export function audienceOf(values) {
23
+ const targets = values.to ?? [];
24
+ if (targets.length === 0 || (targets.length === 1 && targets[0] === 'all')) return { type: 'all' };
25
+ const kinds = new Set();
26
+ const names = [];
27
+ for (const target of targets) {
28
+ const [kind, ...rest] = target.split(':');
29
+ const name = rest.join(':');
30
+ if (!TO_KINDS[kind] || name === '') throw new UsageError(`--to ${target} is not an audience`, { fix: `--to all, or --to <kind>:<id> with a kind of ${Object.keys(TO_KINDS).join(', ')} (repeat it for more of the same kind)` });
31
+ kinds.add(kind);
32
+ names.push(name);
33
+ }
34
+ if (kinds.size > 1) throw new UsageError(`--to mixes ${[...kinds].join(' and ')}`, { fix: 'a notification goes to one kind of audience: send one per kind' });
35
+ const type = TO_KINDS[[...kinds][0]];
36
+ return type === 'topics' ? { type, topics: names } : { type, ids: names };
37
+ }
38
+
39
+ /** The segment of --platform, --app-version, --runtime, --channel and --language, or nothing. */
40
+ export function segmentOf(values) {
41
+ const segment = {};
42
+ for (const platform of values.platform ?? []) if (!PLATFORMS.includes(platform)) throw new UsageError(`--platform ${platform} is not a platform`, { fix: 'ios or android' });
43
+ if (values.platform?.length) segment.platforms = values.platform;
44
+ if (values['app-version']?.length) segment.app_versions = values['app-version'];
45
+ if (values.runtime?.length) segment.runtime_versions = values.runtime;
46
+ if (values.channel?.length) segment.channels = values.channel;
47
+ if (values.language?.length) segment.locales = values.language;
48
+ return Object.keys(segment).length ? segment : undefined;
49
+ }
50
+
51
+ /** Seconds of «3600», «90m», «12h» or «7d». */
52
+ export function secondsOf(text, option) {
53
+ const match = DURATION.exec(String(text));
54
+ if (!match) throw new UsageError(`${option} ${text} is not a duration`, { fix: 'seconds, or a number with s, m, h or d (12h, 7d)' });
55
+ return Number(match[1]) * SECONDS[match[2]];
56
+ }
57
+
58
+ /** The data of --data name=value, as texts. */
59
+ function dataOf(values) {
60
+ const data = {};
61
+ for (const pair of values.data ?? []) {
62
+ const at = pair.indexOf('=');
63
+ if (at < 1) throw new UsageError(`--data ${pair} is not name=value`, { fix: 'repeat --data name=value for each value the app gets with the notification' });
64
+ data[pair.slice(0, at)] = pair.slice(at + 1);
65
+ }
66
+ return data;
67
+ }
68
+
69
+ /** The notification: --from's JSON, with the options given on top. */
70
+ export function messageOf(context) {
71
+ const { values, cwd } = context;
72
+ let message = {};
73
+ if (values.from !== undefined) {
74
+ const path = resolve(cwd, values.from);
75
+ if (!existsSync(path) || !statSync(path).isFile()) throw new IasError(`there is no file at ${values.from}`, { fix: 'give the path of a JSON file with the notification (docs/technical/manage-api.md §14.2)' });
76
+ try {
77
+ message = JSON.parse(readFileSync(path, 'utf8'));
78
+ } catch (error) {
79
+ throw new IasError(`${values.from} is not JSON: ${error.message}`, { fix: 'check its quotes and commas' });
80
+ }
81
+ if (!message || typeof message !== 'object' || Array.isArray(message)) throw new IasError(`${values.from} is not a JSON object`, { fix: 'write the notification as in docs/technical/manage-api.md §14.2' });
82
+ }
83
+ if (values.to !== undefined || values.platform || values['app-version'] || values.runtime || values.channel || values.language || !message.audience) {
84
+ const audience = values.to !== undefined || !message.audience ? audienceOf(values) : message.audience;
85
+ const segment = segmentOf(values);
86
+ message.audience = { ...audience, ...(segment ? { segment } : {}) };
87
+ }
88
+ for (const [option, field] of [['title', 'title'], ['body', 'body'], ['locale', 'locale'], ['collapse-id', 'collapse_id'], ['android-channel', 'android_channel'], ['priority', 'priority']]) {
89
+ if (values[option] !== undefined) message[field] = values[option];
90
+ }
91
+ if (values.badge !== undefined) message.badge = Number(values.badge);
92
+ if (values.ttl !== undefined) message.ttl = secondsOf(values.ttl, '--ttl');
93
+ if (values.background) message.background = true;
94
+ if (values.data?.length) message.data = { ...(message.data ?? {}), ...dataOf(values) };
95
+ if (values.at !== undefined) {
96
+ const at = new Date(values.at);
97
+ if (Number.isNaN(at.getTime())) throw new UsageError(`--at ${values.at} is not a date`, { fix: 'ISO 8601 with its offset, such as 2026-10-22T08:00:00+02:00' });
98
+ message.scheduled_at = at.toISOString().replace(/\.\d{3}Z$/, '+00:00');
99
+ }
100
+ if (values['dry-run']) message.dry_run = true;
101
+ if (!message.background && !message.title && !message.body) throw new UsageError('the notification says nothing', { fix: 'give --title or --body (or --background with --data)' });
102
+ return message;
103
+ }
104
+
105
+ /** The figures of a notification in one line. */
106
+ export function describeFigures({ figures }) {
107
+ const extra = [figures.failed > 0 && `${figures.failed} failed`, figures.retired > 0 && `${figures.retired} retired`, figures.pending > 0 && `${figures.pending} pending`].filter(Boolean);
108
+ return `${figures.accepted} of ${figures.devices} accepted${extra.length ? ` (${extra.join(', ')})` : ''}`;
109
+ }
110
+
111
+ /** The audience in a few words. */
112
+ export function describeAudience(audience) {
113
+ const target = audience.type === 'all' ? 'every device' : audience.type === 'topics' ? `topics ${audience.topics.join(', ')}` : `${audience.ids.length} ${audience.ids.length === 1 ? audience.type.replace(/s$/, '') : audience.type}`;
114
+ const segment = Object.entries(audience.segment ?? {}).map(([filter, list]) => `${filter.replace(/_/g, ' ')} ${list.join(', ')}`);
115
+ return [target, ...segment].join(' · ');
116
+ }
117
+
118
+ async function send(context) {
119
+ const { values, output } = context;
120
+ const message = messageOf(context);
121
+ const project = projectOf(context);
122
+ const client = connect(context, project);
123
+ const application = applicationOf(context, project);
124
+ let data;
125
+ try {
126
+ ({ data } = await client.request('POST', `/apps/${application}/push/messages`, { json: message }));
127
+ } catch (error) {
128
+ if (error instanceof ApiError && error.code === 'audience_not_found') {
129
+ throw new IasError(`some of the audience is not of ${application}: ${(error.data?.missing ?? []).join(', ')}`, { fix: 'check the ids: users, spaces and groups by their public id, organizations by their code; nothing was sent', code: error.code });
130
+ }
131
+ throw error;
132
+ }
133
+ if (data.dry_run) {
134
+ const platforms = Object.entries(data.platforms).map(([platform, count]) => `${platform} ${count}`).join(', ');
135
+ const locales = Object.entries(data.locales).map(([locale, count]) => `${locale} ${count}`).join(', ');
136
+ output.log(`${application}: it would reach ${data.devices} device${data.devices === 1 ? '' : 's'} now${platforms ? ` (${platforms}; ${locales})` : ''}. Nothing was sent.`);
137
+ return data;
138
+ }
139
+ if (data.status === 'scheduled') {
140
+ output.log(`${application}: notification ${data.id} scheduled for ${data.scheduled_at}, to ${describeAudience(data.audience)}. ias push cancel ${data.id} stops it.`);
141
+ return data;
142
+ }
143
+ output.progress(`${application}: notification ${data.id} going out to ${describeAudience(data.audience)}`);
144
+ if (!values.wait) {
145
+ output.log(`ias push messages ${data.id} shows its figures in a few seconds.`);
146
+ return data;
147
+ }
148
+ let current = data;
149
+ for (let attempt = 0; current.status === 'sending' && attempt < 60; attempt += 1) {
150
+ await (context.sleep ?? sleep)(2);
151
+ current = (await client.request('GET', `/apps/${application}/push/messages/${data.id}`)).data;
152
+ }
153
+ output.log(`${data.id}: ${current.status === 'sending' ? 'still sending' : current.status}, ${describeFigures(current)}`);
154
+ return current;
155
+ }
156
+
157
+ async function messages(context) {
158
+ const { values, positionals, output } = context;
159
+ const project = projectOf(context);
160
+ const client = connect(context, project);
161
+ const application = applicationOf(context, project);
162
+ const [id] = positionals;
163
+ if (id) {
164
+ const { data } = await client.request('GET', `/apps/${application}/push/messages/${encodeURIComponent(id)}`);
165
+ output.log(`${data.id} ${data.status} ${data.background ? '(background)' : (data.title ?? data.body)}`);
166
+ output.log(` to ${describeAudience(data.audience)}`);
167
+ output.log(` figures ${describeFigures(data)}`);
168
+ for (const [platform, figures] of Object.entries(data.platforms)) {
169
+ const reasons = Object.entries(figures.reasons).map(([reason, count]) => `${reason} ${count}`).join(', ');
170
+ output.log(` ${platform.padEnd(10)} ${figures.accepted} of ${figures.devices} accepted${reasons ? `; ${reasons}` : ''}`);
171
+ }
172
+ return data;
173
+ }
174
+ const query = new URLSearchParams({ ...(values.status ? { status: values.status } : {}), ...(values.limit ? { limit: values.limit } : {}) }).toString();
175
+ const { data } = await client.request('GET', `/apps/${application}/push/messages${query ? `?${query}` : ''}`);
176
+ if (data.data.length === 0) output.log(`No notifications in ${application} yet: ias push send sends one.`);
177
+ for (const message of data.data) {
178
+ const when = message.status === 'scheduled' ? `at ${message.scheduled_at}` : (message.sent_at ?? message.created_at);
179
+ output.log(`${message.id} ${message.status.padEnd(9)} ${when} ${message.status === 'scheduled' || message.status === 'canceled' ? '' : `${describeFigures(message)} `}${message.background ? '(background)' : (message.title ?? message.body)}`);
180
+ }
181
+ return data;
182
+ }
183
+
184
+ async function cancel(context) {
185
+ const [id] = context.positionals;
186
+ const project = projectOf(context);
187
+ const client = connect(context, project);
188
+ const application = applicationOf(context, project);
189
+ const { data } = await client.request('POST', `/apps/${application}/push/messages/${encodeURIComponent(id)}/cancel`);
190
+ context.output.log(`${application}: notification ${data.id} canceled; it will not go out.`);
191
+ return data;
192
+ }
193
+
194
+ async function devices(context) {
195
+ const { values, output } = context;
196
+ const project = projectOf(context);
197
+ const client = connect(context, project);
198
+ const application = applicationOf(context, project);
199
+ if (values.platform !== undefined && !PLATFORMS.includes(values.platform)) throw new UsageError(`--platform ${values.platform} is not a platform`, { fix: 'ios or android' });
200
+ const query = new URLSearchParams(Object.fromEntries(['platform', 'user', 'topic', 'status', 'limit'].filter((name) => values[name] !== undefined).map((name) => [name, values[name]]))).toString();
201
+ const { data } = await client.request('GET', `/apps/${application}/push/devices${query ? `?${query}` : ''}`);
202
+ if (data.total === 0) output.log(`No devices in ${application}${query ? ' with these filters' : ''}: the app registers them when it starts (API.md §38).`);
203
+ for (const device of data.data) {
204
+ const who = device.user ? `user ${device.user}${device.signed_in ? '' : ' (signed out)'}` : 'no user';
205
+ const state = device.retired_at ? `retired: ${device.retired_reason}` : `seen ${device.last_seen_at}`;
206
+ output.log(`${device.id} ${device.platform.padEnd(7)} ${device.token_hint} ${[device.app_version, device.channel, device.locale].filter(Boolean).join(' · ')} ${who} ${state}`);
207
+ }
208
+ if (data.total > data.data.length) output.log(`… and ${data.total - data.data.length} more: --limit shows up to 500.`);
209
+ return data;
210
+ }
211
+
212
+ async function credentials(context) {
213
+ const { values, positionals, output, env } = context;
214
+ const [platform, file] = positionals;
215
+ const project = projectOf(context);
216
+ const client = connect(context, project);
217
+ const application = applicationOf(context, project);
218
+ if (platform === undefined) {
219
+ const { data } = await client.request('GET', `/apps/${application}/push/credentials`);
220
+ if (data.data.length === 0) output.log(`${application} has no push credentials: ias push credentials ios <AuthKey_….p8> or android <service-account.json> loads them.`);
221
+ for (const credential of data.data) output.log(describeCredential(credential));
222
+ return data;
223
+ }
224
+ if (!PLATFORMS.includes(platform)) throw new UsageError(`there is no platform «${platform}»`, { fix: 'ios (the .p8 key of APNs) or android (the JSON of the FCM service account)' });
225
+ if (file === undefined) throw new UsageError(`which file for ${platform}?`, { fix: platform === 'ios' ? 'ias push credentials ios <AuthKey_XXXXXXXXXX.p8 or op://…>' : 'ias push credentials android <service-account.json or op://…>' });
226
+ if (platform === 'android' && (values['key-id'] || values['team-id'])) throw new UsageError('--key-id and --team-id are for the APNs key', { fix: 'the FCM service account carries its own' });
227
+ const path = filePath(file, { env, cwd: context.cwd, name: platform === 'ios' ? 'AuthKey.p8' : 'service-account.json' });
228
+ const contents = readFileSync(path, 'utf8');
229
+ const fileName = file.startsWith('op://') ? undefined : basename(path);
230
+ const body = { file: contents, ...(platform === 'ios' && fileName ? { file_name: fileName } : {}), ...(values['key-id'] ? { key_id: values['key-id'] } : {}), ...(values['team-id'] ? { team_id: values['team-id'] } : {}) };
231
+ let data;
232
+ try {
233
+ ({ data } = await client.request('PUT', `/apps/${application}/push/credentials/${platform}`, { json: body }));
234
+ } catch (error) {
235
+ if (error instanceof ApiError && error.code === 'push_credential_invalid') {
236
+ throw new IasError(`the file is not a usable ${platform === 'ios' ? 'APNs key' : 'FCM service account'}`, { fix: error.data?.detail ?? 'check the file', code: error.code });
237
+ }
238
+ throw error;
239
+ }
240
+ const { approval } = data;
241
+ const opened = (context.openUrl ?? ((url) => openUrl(url, env)))(approval.url);
242
+ output.progress(`${opened ? 'Opened' : 'Open'} ${approval.url}: an owner checks the key's id there and approves it with a passkey (until ${approval.expires_at}).`);
243
+ const stored = await waitForApproval(client, approval, { wait: context.sleep ?? sleep, output });
244
+ output.log(`${application}: ${describeCredential(stored)}, stored sealed with PUSH_CREDENTIALS_KEY. Keep the file in 1Password and delete it from here.`);
245
+ return stored;
246
+ }
247
+
248
+ /** A credential in one line, without anything secret (there is nothing secret in the answer). */
249
+ export function describeCredential(credential) {
250
+ const what = credential.platform === 'ios'
251
+ ? `ios APNs key ${credential.key_id}, team ${credential.team_id}, topic ${credential.topic ?? '(no iOS bundle id!)'}`
252
+ : `android FCM ${credential.client_email} (project ${credential.project_id}, key ${credential.key_id.slice(0, 8)}…)`;
253
+ return `${what}${credential.problem ? `; refused in the last send: ${credential.problem.reason}` : ''}`;
254
+ }
255
+
256
+ const app = { app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' } };
257
+ const idArgument = { name: 'id', description: 'The id of a notification (ias push messages lists them).' };
258
+
259
+ export default {
260
+ name: 'push',
261
+ summary: 'Push notifications: send, the history, the devices and the APNs and FCM credentials',
262
+ description: 'Notifications go straight to APNs and FCM through the server (docs/technical/manage-api.md §14). The token needs push:send; loading a credential needs an owner and a passkey.',
263
+ subcommands: [
264
+ {
265
+ name: 'send',
266
+ summary: 'Send a notification now, schedule it with --at, or count whom it reaches with --dry-run',
267
+ description: 'The audience: --to all (by default), or --to topic:<name>, user:<id>, organization:<code>, space:<id>, group:<id> or device:<id>, repeated for more of the same kind; narrowed with --platform, --app-version, --runtime, --channel and --language. Translations and the rest of docs/technical/manage-api.md §14.2 go in a JSON file with --from; the options override it.',
268
+ options: {
269
+ ...app,
270
+ to: { type: 'string', multiple: true, value: '<kind:id>', description: 'Whom it goes to (all, topic:…, user:…, organization:…, space:…, group:…, device:…).' },
271
+ title: { type: 'string', value: '<text>', description: 'The title.' },
272
+ body: { type: 'string', value: '<text>', description: 'The text.' },
273
+ locale: { type: 'string', value: '<language>', description: 'The language of the title and the text (es, en, pt-BR); needed with translations.' },
274
+ data: { type: 'string', multiple: true, value: '<name=value>', description: 'Data the app gets with it, repeated for each value.' },
275
+ platform: { type: 'string', multiple: true, value: '<ios|android>', description: 'Only these platforms.' },
276
+ 'app-version': { type: 'string', multiple: true, value: '<version>', description: 'Only these versions of the app.' },
277
+ runtime: { type: 'string', multiple: true, value: '<runtime>', description: 'Only these runtimes of expo-updates.' },
278
+ channel: { type: 'string', multiple: true, value: '<channel>', description: 'Only these channels of expo-updates.' },
279
+ language: { type: 'string', multiple: true, value: '<language>', description: 'Only devices in this language (es takes es-ES and es-MX).' },
280
+ priority: { type: 'string', value: '<high|normal>', description: 'high (by default) delivers at once; normal when it suits the device.' },
281
+ ttl: { type: 'string', value: '<duration>', description: 'How long APNs and FCM keep it for an unreachable device (0, 1h, 1d…; 28 days by default).' },
282
+ 'collapse-id': { type: 'string', value: '<id>', description: 'A newer one with the same id replaces this one if not yet delivered.' },
283
+ badge: { type: 'string', value: '<number>', description: 'The number of the icon on iOS.' },
284
+ 'android-channel': { type: 'string', value: '<channel>', description: 'The notification channel on Android.' },
285
+ background: { type: 'boolean', description: 'A silent push that only wakes the app with its --data.' },
286
+ at: { type: 'string', value: '<date>', description: 'Schedule it (ISO 8601, between 1 minute and 30 days ahead).' },
287
+ from: { type: 'string', value: '<file.json>', description: 'The notification as JSON (docs/technical/manage-api.md §14.2), with translations.' },
288
+ 'dry-run': { type: 'boolean', description: 'Count whom it would reach now, by platform and language, and send nothing.' },
289
+ wait: { type: 'boolean', description: 'Wait until every device has its result, and print the figures.' },
290
+ },
291
+ run: send,
292
+ },
293
+ {
294
+ name: 'messages',
295
+ summary: 'The notifications sent and scheduled, with their figures, or one of them',
296
+ positionals: [{ ...idArgument, optional: true }],
297
+ options: {
298
+ ...app,
299
+ status: { type: 'string', value: '<status>', description: 'scheduled, sending, sent or canceled.' },
300
+ limit: { type: 'string', value: '<n>', description: 'How many (20 by default, up to 100).' },
301
+ },
302
+ run: messages,
303
+ },
304
+ { name: 'cancel', summary: 'Cancel a scheduled notification', positionals: [idArgument], options: app, run: cancel },
305
+ {
306
+ name: 'devices',
307
+ summary: 'The devices registered for notifications, with the hint of their token',
308
+ options: {
309
+ ...app,
310
+ platform: { type: 'string', value: '<ios|android>', description: 'Only this platform.' },
311
+ user: { type: 'string', value: '<id>', description: 'Only the devices of this user.' },
312
+ topic: { type: 'string', value: '<topic>', description: 'Only the devices subscribed to this topic.' },
313
+ status: { type: 'string', value: '<active|retired|all>', description: 'active (by default), retired or all.' },
314
+ limit: { type: 'string', value: '<n>', description: 'How many (50 by default, up to 500).' },
315
+ },
316
+ run: devices,
317
+ },
318
+ {
319
+ name: 'credentials',
320
+ summary: 'The APNs and FCM credentials, or load one (an owner approves it with a passkey)',
321
+ description: 'Without arguments, their metadata. With a platform and a file (a path or an op:// reference), it loads the .p8 key of APNs (ios) or the JSON of the FCM service account (android): the server checks it, an owner approves it with a passkey, and it is kept sealed with PUSH_CREDENTIALS_KEY.',
322
+ positionals: [
323
+ { name: 'platform', optional: true, description: 'ios or android.' },
324
+ { name: 'file', optional: true, description: 'The .p8 key or the JSON of the service account: a path or an op:// reference.' },
325
+ ],
326
+ options: {
327
+ ...app,
328
+ 'key-id': { type: 'string', value: '<id>', description: 'The id of the APNs key, if the file is not called AuthKey_<id>.p8.' },
329
+ 'team-id': { type: 'string', value: '<team>', description: 'The Apple team of the key (the application\'s by default).' },
330
+ },
331
+ run: credentials,
332
+ },
333
+ ],
334
+ };
@@ -0,0 +1,81 @@
1
+ // ias sdk install: puts the SDK @imagina-build/ias in an Expo app: the package with the app's package manager, its config
2
+ // plugin in the plugins of app.json, and what is left to do by hand (the Expo modules it uses and a new build).
3
+ import { existsSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
5
+ import process from 'node:process';
6
+ import { CONFIG_FILE, DYNAMIC_CONFIGS, loadProject } from '../../config.mjs';
7
+ import { IasError } from '../../errors.mjs';
8
+ import { gently, run } from '../../process.mjs';
9
+ import { SDK_PACKAGE, addCommand, addPlugin, packageManager, sdkSource, sdkState } from '../../sdk.mjs';
10
+
11
+ /** Runs the install with its output on stderr, so --json keeps stdout whole; at a low priority on macOS. */
12
+ const systemExec = (command, commandArgs, options) => (process.platform === 'darwin' ? gently : run)(command, commandArgs, { stdio: ['inherit', 2, 'inherit'], ...options });
13
+
14
+ /** The steps left after the install, from ias.json and the dependencies of the app: [{ step, why }]. */
15
+ export function nextSteps(dir, { plugin, dependencies }) {
16
+ const steps = [];
17
+ let config;
18
+ try {
19
+ config = loadProject(dir).config;
20
+ } catch {
21
+ config = undefined;
22
+ }
23
+ if (plugin.reason === 'dynamic') steps.push({ step: `add "${SDK_PACKAGE}" to the plugins that ${plugin.file} returns, such as plugins: [...(config.plugins ?? []), '${SDK_PACKAGE}']`, why: `ias does not rewrite ${plugin.file}` });
24
+ if (!config) steps.push({ step: 'ias init', why: `the plugin reads ${CONFIG_FILE}, and there is none yet` });
25
+ if (!dependencies['expo-updates']) steps.push({ step: 'npx expo install expo-updates', why: config?.updates ? `${CONFIG_FILE} has updates: the builds need it to take the OTA updates of ias update, and ias.updates uses it` : 'if the app takes the OTA updates of ias update or uses ias.updates' });
26
+ const push = ['expo-notifications', 'expo-task-manager'].filter((name) => !dependencies[name]);
27
+ if (push.length > 0) steps.push({ step: `npx expo install ${push.join(' ')}`, why: config?.push ? `${CONFIG_FILE} has push: ias.push needs them for the notifications and the sync notices` : 'if the app uses push notifications (ias.push)' });
28
+ steps.push({ step: 'npx expo prebuild --clean, or a new build (ias build)', why: 'the plugin writes the native configuration when Expo makes the native projects: an update over the air does not carry it' });
29
+ return steps;
30
+ }
31
+
32
+ /** Installs the SDK in the app in `dir` and adds its plugin. `exec` runs the package manager (the tests pass their own). */
33
+ export function installSdk(dir, { from, dryRun = false, repository } = {}, { output, env = process.env, exec = systemExec } = {}) {
34
+ if (!existsSync(join(dir, 'package.json'))) throw new IasError(`no package.json in ${dir}`, { fix: 'run it in the folder of the Expo app, or pass --project <app folder>' });
35
+ if (!existsSync(join(dir, 'app.json')) && !DYNAMIC_CONFIGS.some((name) => existsSync(join(dir, name)))) throw new IasError(`no app.json nor app.config.* in ${dir}: it is not an Expo app`, { fix: 'run it in the folder of the Expo app, or pass --project <app folder>' });
36
+ const source = sdkSource(dir, { from, env, ...(repository !== undefined ? { repository } : {}) });
37
+ if (source.folder && !existsSync(join(source.folder, 'dist/index.js'))) {
38
+ throw new IasError(`the SDK of ${source.folder} is not built`, { fix: `npm run build --prefix ${source.folder}, then run ias sdk install again; or pass --from <the .tgz of a release>` });
39
+ }
40
+ const manager = packageManager(dir);
41
+ const [program, programArgs] = addCommand(manager.name, source.spec);
42
+ const command = [program, ...programArgs].join(' ');
43
+ output.progress(`${output.prefix}: ${dryRun ? 'would run' : 'running'} ${command} (${manager.name} from ${manager.from}; ${SDK_PACKAGE} from ${source.origin})`);
44
+ if (!dryRun) exec(program, programArgs, { cwd: dir, label: command });
45
+
46
+ const plugin = addPlugin(dir, { dryRun });
47
+ if (plugin.reason === 'added') output.log(`${output.prefix}: ${dryRun ? 'would add' : 'added'} "${SDK_PACKAGE}" to the plugins of app.json`);
48
+ else if (plugin.reason === 'present') output.log(`${output.prefix}: app.json already has the plugin of ${SDK_PACKAGE}`);
49
+ else output.log(`${output.prefix}: ${plugin.file} is code, and ias leaves it alone: add the plugin by hand (below)`);
50
+
51
+ const steps = nextSteps(dir, { plugin, dependencies: sdkState(dir).dependencies });
52
+ output.log('left to do:');
53
+ for (const [index, { step, why }] of steps.entries()) output.log(` ${index + 1}. ${step}\n ${why}`);
54
+ output.log('ias doctor checks the SDK; docs/guide/sdk.md explains how to configure it.');
55
+ return { package: SDK_PACKAGE, spec: source.spec, source: source.origin, package_manager: manager.name, command, installed: !dryRun, plugin: plugin.reason, next: steps };
56
+ }
57
+
58
+ export default {
59
+ name: 'sdk',
60
+ summary: `Put the SDK ${SDK_PACKAGE} in an Expo app`,
61
+ subcommands: [
62
+ {
63
+ name: 'install',
64
+ summary: `Install ${SDK_PACKAGE} with the app's package manager and add its config plugin to app.json`,
65
+ description: [
66
+ 'The package manager is the one of packageManager in package.json or of the lockfile (npm, yarn, pnpm or bun); npm without either.',
67
+ `${SDK_PACKAGE} comes from --from, from IAS_SDK_SOURCE, from the packages/sdk of the repository when ias runs from a clone, or from npm as ${SDK_PACKAGE}@^<version of ias>.`,
68
+ 'With app.config.js (or .ts), ias does not touch it and says what to add. Then it lists what is left: expo-updates, expo-notifications if the app uses push, and a prebuild or a new build.',
69
+ ].join('\n'),
70
+ options: {
71
+ from: { type: 'string', value: '<source>', description: `Where ${SDK_PACKAGE} comes from: a .tgz of a release (a file or its URL), a folder, or a version of the registry.` },
72
+ 'dry-run': { type: 'boolean', description: 'Say what it would run and change, and change nothing.' },
73
+ },
74
+ run: ({ values, cwd, env, output }) => {
75
+ // A file or folder of --from is relative to where ias runs, as the shell completes it.
76
+ const from = values.from && existsSync(resolve(cwd, values.from)) ? resolve(cwd, values.from) : values.from;
77
+ return installSdk(resolve(cwd, values.project ?? '.'), { from, dryRun: values['dry-run'] }, { output, env });
78
+ },
79
+ },
80
+ ],
81
+ };