@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
@@ -0,0 +1,281 @@
1
+ // Provisioning of the projects of an application from the Mac: the OTA project, its signing key and its publishing
2
+ // token, and the distribution project and its token. The server only gets public material and returns a token once;
3
+ // the private key and the token go to 1Password, and ias.json keeps their op:// references. `ias update init`,
4
+ // `ias dist init`, `ias tokens create`, `ias runners code` and `ias setup` use this.
5
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { loadProject, readExpo, saveProjectConfig } from './config.mjs';
8
+ import { IasError } from './errors.mjs';
9
+ import { connect, serverOrigin } from './manage.mjs';
10
+ import { createItem, itemOf, readField, setField } from './op.mjs';
11
+ import { generateKeyPair, readCertificate } from './ota-key.mjs';
12
+
13
+ const KEY_ID = /^[A-Za-z0-9._-]{1,64}$/;
14
+ const SECRET = /^op:\/\/[^/]+\/[^/]+$/;
15
+
16
+ /** The app folder, its configuration and the Expo metadata the provisioning needs; errors name what is missing. */
17
+ function projectOf(dir) {
18
+ const project = loadProject(dir);
19
+ if (!project.config) throw new IasError(`no ias.json in ${dir}`, { fix: 'run ias init there first (or ias setup next)', code: 'config_missing' });
20
+ const code = project.config.app;
21
+ if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(code ?? '')) throw new IasError(`ias.json has no valid app (${code ?? 'none'})`, { fix: 'write the code of the application in app, or run ias init --app <code>' });
22
+ return { project, config: project.config, app: code, expo: readExpo(dir) ?? {} };
23
+ }
24
+
25
+ const clientOf = (client, project, env, sleep) => client ?? connect({ env, ...(sleep ? { sleep } : {}) }, project);
26
+ const refOf = (reference) => itemOf(reference).reference;
27
+
28
+ /** An item reference from --item (op://vault/item), or the default one in Private. */
29
+ function itemOption(value, fallback) {
30
+ if (value === undefined) return fallback;
31
+ if (!SECRET.test(value)) throw new IasError(`«${value}» is not a 1Password item`, { fix: 'it is op://<vault>/<item>, such as op://Private/My app OTA' });
32
+ return value;
33
+ }
34
+
35
+ /** The certificate of the app's signing configuration, or undefined if it has none yet. */
36
+ function signingOf(dir, config) {
37
+ const signing = config.updates?.signing;
38
+ if (!signing?.keyId || !signing?.privateKey || !signing?.certificate) return undefined;
39
+ if (!KEY_ID.test(signing.keyId)) throw new IasError(`updates.signing.keyId (${signing.keyId}) is not valid`, { fix: 'letters, digits, dots, hyphens and underscores, up to 64' });
40
+ if (!existsSync(join(dir, signing.certificate))) throw new IasError(`no certificate at ${signing.certificate}`, { fix: 'restore it from git, or remove updates.signing and run ias update init again' });
41
+ return signing;
42
+ }
43
+
44
+ /**
45
+ * Makes sure the app has its OTA project on the server and its signing key and token in 1Password, and writes their
46
+ * references in ias.json. It never prints the private key nor the token. Returns what it did for --json.
47
+ */
48
+ export async function provisionOta({ dir, env = process.env, output, values = {}, client, sleep, dryRun = values['dry-run'] === true }) {
49
+ const { project, config, app, expo } = projectOf(dir);
50
+ const server = serverOrigin(project, env);
51
+ const name = values.name ?? expo.name ?? app;
52
+ const existing = signingOf(dir, config);
53
+ const item = itemOption(values.item, `op://IAS/${app} OTA`);
54
+ const keyId = values.keyid ?? existing?.keyId ?? expo.updates?.codeSigningMetadata?.keyid ?? `${app}-ota-1`;
55
+
56
+ if (!KEY_ID.test(keyId)) throw new IasError(`«${keyId}» is not a valid keyid`, { fix: 'letters, digits, dots, hyphens and underscores, up to 64' });
57
+ if (expo.updates?.codeSigningMetadata?.keyid && expo.updates.codeSigningMetadata.keyid !== keyId) {
58
+ throw new IasError(`app.json carries the keyid ${expo.updates.codeSigningMetadata.keyid}, not ${keyId}`, { fix: `pass --keyid ${expo.updates.codeSigningMetadata.keyid}, or change app.json and make a new build` });
59
+ }
60
+ if (expo.updates?.codeSigningCertificate && !existing) {
61
+ throw new IasError(`app.json already carries updates.codeSigningCertificate (${expo.updates.codeSigningCertificate})`, { fix: 'ias update init is for a new key: keep the certificate the app already has, or remove it and make a new build' });
62
+ }
63
+
64
+ let privateKey = null;
65
+ let certificate = null;
66
+ let certificatePath = existing?.certificate ?? 'certs/ota-certificate.pem';
67
+
68
+ if (!dryRun) {
69
+ const itemKey = readField(item, 'private-key', env) ?? readField(item, 'private-key.pem', env);
70
+ const itemCertificate = readField(item, 'certificate', env) ?? readField(item, 'certificate.pem', env);
71
+ if ((itemKey === undefined) !== (itemCertificate === undefined)) {
72
+ throw new IasError(`${item} has only one of the private key and the certificate`, { fix: 'restore the item from 1Password history, or remove it and run ias update init again' });
73
+ }
74
+
75
+ if (existing) {
76
+ certificate = readFileSync(join(dir, existing.certificate), 'utf8');
77
+ privateKey = readField(item, 'private-key', env) ?? readField(item, 'private-key.pem', env) ?? readField(existing.privateKey, 'private-key', env) ?? readField(existing.privateKey, 'private-key.pem', env);
78
+ if (!privateKey) throw new IasError(`no private key for ${existing.privateKey}`, { fix: 'the key of the certificate is not in 1Password: restore the item, or make a new key and a new build' });
79
+ certificatePath = existing.certificate;
80
+ } else if (itemKey && itemCertificate) {
81
+ privateKey = itemKey;
82
+ certificate = itemCertificate;
83
+ writeFileSync(join(dir, certificatePath), certificate, { mode: 0o644 });
84
+ output?.log(`${output.prefix}: ${item} already has the signing key; wrote ${certificatePath}`);
85
+ } else {
86
+ const made = generateKeyPair({ dir, app, certificatePath, dryRun, output });
87
+ privateKey = made.privateKey;
88
+ certificate = made.certificate;
89
+ }
90
+ } else if (!existing) {
91
+ output?.progress(`${output.prefix}: would make an RSA key and a certificate at ${certificatePath}`);
92
+ }
93
+
94
+ if (dryRun) {
95
+ output?.log(`${output.prefix}: would make the OTA project ${app} on ${server} (keyid ${keyId}) and keep its key and token in ${item}`);
96
+ return { server, app, project: app, keyid: keyId, item, certificate: certificatePath, dry_run: true };
97
+ }
98
+
99
+ const parsed = readCertificate(join(dir, certificatePath));
100
+ const api = clientOf(client, project, env, sleep);
101
+
102
+ let response;
103
+ try {
104
+ response = await api.request('POST', `/apps/${app}/updates`, { json: { certificate, key_id: keyId, name } });
105
+ } catch (error) {
106
+ if (error.code === 'project_exists') throw new IasError(error.message, { fix: 'the application already has an OTA project with another certificate: rotate it with php artisan ota:project --force and a new build, or use the key the builds carry' });
107
+ throw error;
108
+ }
109
+
110
+ const created = response.status === 201;
111
+ const projectSlug = response.data?.project?.project ?? app;
112
+ let token = response.data?.token ?? null;
113
+ const itemToken = readField(item, 'token', env) ?? readField(item, 'credential', env);
114
+ const configToken = config.updates?.token;
115
+
116
+ if (!token && !itemToken && !configToken) {
117
+ const rotated = await api.request('POST', `/apps/${app}/updates/token`, {});
118
+ token = rotated.data?.token ?? null;
119
+ }
120
+
121
+ if (token) {
122
+ if (readField(item, 'private-key', env) === undefined && readField(item, 'private-key.pem', env) === undefined) {
123
+ createItem(item, {
124
+ notes: `The signing key of the OTA updates of ${name} (${server}). The private key signs every manifest; it never leaves this item. Made by ias update init.`,
125
+ fields: [
126
+ { label: 'private-key', value: privateKey, type: 'CONCEALED' },
127
+ { label: 'certificate', value: certificate },
128
+ { label: 'key-id', value: keyId },
129
+ { label: 'token', value: token, type: 'CONCEALED' },
130
+ { label: 'project', value: projectSlug },
131
+ { label: 'server', value: server },
132
+ ],
133
+ }, env);
134
+ output?.log(`${output.prefix}: created ${item} with the signing key and the publishing token`);
135
+ } else {
136
+ setField(item, 'token', token, env);
137
+ output?.log(`${output.prefix}: wrote the publishing token to ${item}`);
138
+ }
139
+ } else if (readField(item, 'private-key', env) === undefined && readField(item, 'private-key.pem', env) === undefined) {
140
+ createItem(item, {
141
+ notes: `The signing key of the OTA updates of ${name} (${server}). The private key signs every manifest; it never leaves this item. Made by ias update init.`,
142
+ fields: [
143
+ { label: 'private-key', value: privateKey, type: 'CONCEALED' },
144
+ { label: 'certificate', value: certificate },
145
+ { label: 'key-id', value: keyId },
146
+ { label: 'project', value: projectSlug },
147
+ { label: 'server', value: server },
148
+ ],
149
+ }, env);
150
+ output?.log(`${output.prefix}: created ${item} with the signing key`);
151
+ }
152
+
153
+ const privateKeyRef = existing?.privateKey ?? `${refOf(item)}/private-key`;
154
+ const tokenRef = token || itemToken ? `${refOf(item)}/token` : configToken;
155
+ config.updates = {
156
+ ...(config.updates ?? {}),
157
+ signing: { keyId, privateKey: privateKeyRef, certificate: certificatePath },
158
+ ...(tokenRef ? { token: tokenRef } : {}),
159
+ };
160
+ saveProjectConfig(dir, config, { dryRun });
161
+ output?.log(`${output.prefix}: OTA project ${projectSlug} on ${server} (keyid ${keyId}, certificate ${certificatePath})`);
162
+ output?.log(`${output.prefix}: key and token in ${item}; ias.json has their op:// references`);
163
+ output?.log(`${output.prefix}: publish with ias update publish --message "…" (a build with this certificate is needed first)`);
164
+ return {
165
+ server,
166
+ app,
167
+ project: projectSlug,
168
+ keyid: keyId,
169
+ item,
170
+ certificate: certificatePath,
171
+ certificate_sha256: parsed.fingerprint256,
172
+ created,
173
+ token_created: Boolean(token),
174
+ manifest_url: response.data?.project?.manifest_url ?? `${server}/api/v1/ota/${projectSlug}/manifest`,
175
+ };
176
+ }
177
+
178
+ /** Makes sure the app has its distribution project and its token in 1Password, and writes the reference in ias.json. */
179
+ export async function provisionDistribution({ dir, env = process.env, output, values = {}, client, sleep, dryRun = values['dry-run'] === true }) {
180
+ const { project, config, app, expo } = projectOf(dir);
181
+ const server = serverOrigin(project, env);
182
+ const name = values.name ?? expo.name ?? app;
183
+ const item = itemOption(values.item, `op://IAS/${app} distribution`);
184
+
185
+ if (dryRun) {
186
+ output?.log(`${output.prefix}: would make the distribution project ${app} on ${server} and keep its token in ${item}`);
187
+ return { server, app, project: app, item, dry_run: true };
188
+ }
189
+
190
+ const api = clientOf(client, project, env, sleep);
191
+ const response = await api.request('POST', `/apps/${app}/distribution`, { json: { name } });
192
+ const projectSlug = response.data?.project?.project ?? app;
193
+ let token = response.data?.token ?? null;
194
+ const itemToken = readField(item, 'token', env) ?? readField(item, 'credential', env);
195
+ const configToken = config.distribution?.token;
196
+
197
+ if (!token && !itemToken && !configToken) {
198
+ const rotated = await api.request('POST', `/apps/${app}/distribution/token`, {});
199
+ token = rotated.data?.token ?? null;
200
+ }
201
+
202
+ if (token) {
203
+ if (itemToken === undefined) {
204
+ createItem(item, {
205
+ notes: `The publishing token of the internal distribution of ${name} (${server}). It uploads and retires builds; it does nothing else. Made by ias dist init.`,
206
+ fields: [
207
+ { label: 'token', value: token, type: 'CONCEALED' },
208
+ { label: 'project', value: projectSlug },
209
+ { label: 'server', value: server },
210
+ ],
211
+ }, env);
212
+ output?.log(`${output.prefix}: created ${item} with the publishing token`);
213
+ } else {
214
+ setField(item, 'token', token, env);
215
+ output?.log(`${output.prefix}: wrote the publishing token to ${item}`);
216
+ }
217
+ }
218
+
219
+ config.distribution = { ...(config.distribution ?? {}), token: `${refOf(item)}/token` };
220
+ saveProjectConfig(dir, config, { dryRun });
221
+ const install = response.data?.project?.install_url ?? `${server}/install/<token>`;
222
+ output?.log(`${output.prefix}: distribution project ${projectSlug} on ${server}: ${install}`);
223
+ output?.log(`${output.prefix}: the token is in ${item}; ias.json has its op:// reference`);
224
+ return { server, app, project: projectSlug, item, install_url: install, created: response.status === 201, token_created: Boolean(token) };
225
+ }
226
+
227
+ /** Makes a robot token of the application for CI, keeps it in 1Password and says where. It never prints the token. */
228
+ export async function createRobotToken({ dir, env = process.env, output, values = {}, client, sleep, dryRun = values['dry-run'] === true }) {
229
+ const { project, app: configuredApp, expo } = projectOf(dir);
230
+ const app = values.app ?? configuredApp;
231
+ if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(app)) throw new IasError(`«${app}» is not the code of an application`, { fix: 'ias apps lists them' });
232
+ const server = serverOrigin(project, env);
233
+ const name = values.name ?? 'CI';
234
+ const item = itemOption(values.item, `op://IAS/${app} CI`);
235
+ const scopes = values.scopes ? String(values.scopes).split(',').map((scope) => scope.trim()).filter(Boolean) : ['apps:read', 'builds:create'];
236
+ const days = Number(values.days ?? 90);
237
+
238
+ if (dryRun) {
239
+ output?.log(`${output.prefix}: would make a robot token «${name}» of ${app} (${scopes.join(', ')}, ${days} days) and keep it in ${item}`);
240
+ return { server, app, name, scopes, days, item, dry_run: true };
241
+ }
242
+
243
+ const api = clientOf(client, project, env, sleep);
244
+ const response = await api.request('POST', `/apps/${app}/tokens`, { json: { name, scopes, days } });
245
+ const token = response.data?.token;
246
+ if (!token) throw new IasError('the server did not return the robot token', { fix: 'this is a bug of ias or the server: report it with what you ran' });
247
+
248
+ if (readField(item, 'token', env) === undefined && readField(item, 'credential', env) === undefined) {
249
+ createItem(item, {
250
+ notes: `Robot token «${name}» of ${app} (${server}), scopes ${scopes.join(', ')}, until ${response.data?.expires_at ?? `${days} days`}. It works without a person: keep it only in CI. Made by ias tokens create.`,
251
+ fields: [
252
+ { label: 'token', value: token, type: 'CONCEALED' },
253
+ { label: 'scopes', value: scopes.join(',') },
254
+ { label: 'expires-at', value: String(response.data?.expires_at ?? '') },
255
+ { label: 'application', value: app },
256
+ ],
257
+ }, env);
258
+ } else {
259
+ setField(item, 'token', token, env);
260
+ }
261
+
262
+ output?.log(`${output.prefix}: robot token «${name}» of ${app} in ${item} (scopes ${scopes.join(', ')}, until ${response.data?.expires_at ?? `${days} days`})`);
263
+ output?.log(`${output.prefix}: in CI, set IAS_TOKEN from that item, or keep the reference ${refOf(item)}/token`);
264
+ return { server, app, id: response.data?.id, name, scopes, expires_at: response.data?.expires_at, item };
265
+ }
266
+
267
+ /** A one-time code to register a runner (valid 30 minutes, once). The code is printed once and not stored. */
268
+ export async function createRunnerCode({ dir, env = process.env, output, client, sleep, dryRun = false }) {
269
+ const { project } = projectOf(dir);
270
+ const server = serverOrigin(project, env);
271
+ if (dryRun) {
272
+ output?.log(`${output.prefix}: would make a one-time runner code on ${server}`);
273
+ return { server, dry_run: true };
274
+ }
275
+ const api = clientOf(client, project, env, sleep);
276
+ const response = await api.request('POST', '/runners/codes', {});
277
+ output?.log(`${output.prefix}: runner code ${response.data.code} (valid until ${response.data.expires_at})`);
278
+ output?.log(`${output.prefix}: on the runner Mac: ${response.data.register}`);
279
+ output?.log(`${output.prefix}: then authorize it here with ias vault grant "<its name>"`);
280
+ return { server, ...response.data };
281
+ }
package/src/schema.mjs CHANGED
@@ -112,6 +112,9 @@ function check(value, node, path, root, errors) {
112
112
  case 'maxItems':
113
113
  if (Array.isArray(value) && value.length > rule) errors.push(`${at(path)} must have at most ${rule} items`);
114
114
  break;
115
+ case 'maxProperties':
116
+ if (typeOf(value) === 'object' && Object.keys(value).length > rule) errors.push(`${at(path)} must have at most ${rule} fields`);
117
+ break;
115
118
  case 'minProperties':
116
119
  if (typeOf(value) === 'object' && Object.keys(value).length < rule) errors.push(`${at(path)} must have at least ${rule} fields`);
117
120
  break;
@@ -0,0 +1,76 @@
1
+ // The state of ias setup: which services the app uses and what a person acknowledged. It lives outside the repository,
2
+ // because ias build refuses a dirty tree and onboarding is per computer (a teammate runs ias setup on theirs).
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import process from 'node:process';
8
+
9
+ export const SETUP_STATE_VERSION = 1;
10
+
11
+ /** The services of an application, as the panel and the management API name them. */
12
+ export const SERVICES = Object.freeze(['updates', 'distribution', 'push']);
13
+
14
+ /** The folder of the state of this computer: IAS_STATE_DIR, or the one of the platform. */
15
+ export function stateHome(env = process.env) {
16
+ if (env.IAS_STATE_DIR) return resolve(env.IAS_STATE_DIR);
17
+ if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support', 'ias');
18
+ return join(env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'ias');
19
+ }
20
+
21
+ /** The file of the setup of `dir`: one per app, named after its real path. */
22
+ export function setupStateFile(dir, env = process.env) {
23
+ const key = createHash('sha256').update(resolve(dir)).digest('hex').slice(0, 24);
24
+ return join(stateHome(env), 'setup', `${key}.json`);
25
+ }
26
+
27
+ /** The state of `dir`: { version, dir, services, acknowledged, updated_at }, or a new one. */
28
+ export function readSetupState(dir, env = process.env) {
29
+ const file = setupStateFile(dir, env);
30
+ if (!existsSync(file)) return { version: SETUP_STATE_VERSION, dir: resolve(dir), services: undefined, acknowledged: {}, updated_at: null };
31
+ try {
32
+ const state = JSON.parse(readFileSync(file, 'utf8'));
33
+ if (state.version !== SETUP_STATE_VERSION) return { version: SETUP_STATE_VERSION, dir: resolve(dir), services: state.services, acknowledged: {}, updated_at: null };
34
+ return { version: SETUP_STATE_VERSION, dir: resolve(dir), services: state.services, acknowledged: state.acknowledged ?? {}, updated_at: state.updated_at ?? null };
35
+ } catch {
36
+ return { version: SETUP_STATE_VERSION, dir: resolve(dir), services: undefined, acknowledged: {}, updated_at: null };
37
+ }
38
+ }
39
+
40
+ /** Writes the state of `dir` with mode 600, in a folder with mode 700. It holds no secret. */
41
+ export function writeSetupState(dir, { services, acknowledged }, env = process.env) {
42
+ const file = setupStateFile(dir, env);
43
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
44
+ const state = { version: SETUP_STATE_VERSION, dir: resolve(dir), services, acknowledged, updated_at: new Date().toISOString() };
45
+ writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
46
+ return state;
47
+ }
48
+
49
+ /** Forgets the state of `dir` (ias setup reset): the checklist starts from what the files and the server say. */
50
+ export function resetSetupState(dir, env = process.env) {
51
+ rmSync(setupStateFile(dir, env), { force: true });
52
+ }
53
+
54
+ /** The services of `--services`: a comma-separated list of updates, distribution and push, or one of them. */
55
+ export function parseServices(text) {
56
+ const names = String(text).split(',').map((name) => name.trim()).filter(Boolean);
57
+ const unknown = names.filter((name) => !SERVICES.includes(name));
58
+ if (unknown.length > 0) return { error: `unknown service ${unknown.join(', ')}` };
59
+ return { services: [...new Set(names)] };
60
+ }
61
+
62
+ /**
63
+ * The services of an app when nobody said: those its configuration and its app.json show, or updates and distribution
64
+ * for a new one, which is what ias is for.
65
+ */
66
+ export function inferServices({ config, expo, dependencies = {} }) {
67
+ const services = new Set();
68
+ if (config?.updates || expo?.updates?.url) services.add('updates');
69
+ if (config?.distribution) services.add('distribution');
70
+ if (config?.push || dependencies['expo-notifications']) services.add('push');
71
+ if (services.size === 0) {
72
+ services.add('updates');
73
+ services.add('distribution');
74
+ }
75
+ return [...services];
76
+ }