@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.
- package/README.md +406 -2
- package/bin/ias.mjs +19 -0
- package/package.json +32 -5
- package/schema/ias.schema.json +535 -0
- package/shared/credentials/cms.mjs +105 -0
- package/shared/credentials/index.mjs +354 -0
- package/shared/credentials/plist.mjs +93 -0
- package/shared/vault/approvers-vectors.mjs +188 -0
- package/shared/vault/approvers.mjs +234 -0
- package/shared/vault/index.mjs +583 -0
- package/shared/vault/plaintext.mjs +115 -0
- package/shared/vault/test-vectors/ias-vault-2/approvers.json +2122 -0
- package/shared/vault/test-vectors/ias-vault-2/keys.json +43 -0
- package/shared/vault/test-vectors/ias-vault-2/malformed.json +1739 -0
- package/shared/vault/test-vectors/ias-vault-2/plaintexts.json +149 -0
- package/shared/vault/test-vectors/ias-vault-2/runners.json +580 -0
- package/shared/vault/test-vectors/ias-vault-2/sealed.json +567 -0
- package/shared/vault/test-vectors/ias-vault-2/tampered.json +989 -0
- package/shared/vault/test-vectors/keys.json +31 -0
- package/shared/vault/test-vectors/malformed.json +1341 -0
- package/shared/vault/test-vectors/sealed.json +384 -0
- package/shared/vault/test-vectors/tampered.json +610 -0
- package/shared/vault/vectors.mjs +720 -0
- package/src/cli.mjs +231 -0
- package/src/commands/apps/index.mjs +36 -0
- package/src/commands/auth/index.mjs +141 -0
- package/src/commands/build/follow.mjs +168 -0
- package/src/commands/build/index.mjs +110 -0
- package/src/commands/build/local.mjs +69 -0
- package/src/commands/build/profile.mjs +50 -0
- package/src/commands/build/source.mjs +71 -0
- package/src/commands/builds/index.mjs +148 -0
- package/src/commands/credentials/index.mjs +256 -0
- package/src/commands/credentials/material.mjs +159 -0
- package/src/commands/devices/asc.mjs +116 -0
- package/src/commands/devices/index.mjs +65 -0
- package/src/commands/devices/sync.mjs +223 -0
- package/src/commands/dist/index.mjs +108 -0
- package/src/commands/doctor/index.mjs +158 -0
- package/src/commands/env/index.mjs +163 -0
- package/src/commands/github/index.mjs +53 -0
- package/src/commands/init/index.mjs +224 -0
- package/src/commands/migrate/credentials.mjs +213 -0
- package/src/commands/migrate/eas-cli.mjs +153 -0
- package/src/commands/migrate/index.mjs +73 -0
- package/src/commands/migrate/numbers.mjs +85 -0
- package/src/commands/migrate/profiles.mjs +174 -0
- package/src/commands/migrate/project.mjs +249 -0
- package/src/commands/migrate/survey.mjs +205 -0
- package/src/commands/migrate/variables.mjs +183 -0
- package/src/commands/push/index.mjs +334 -0
- package/src/commands/sdk/index.mjs +81 -0
- package/src/commands/submit/index.mjs +85 -0
- package/src/commands/update/index.mjs +526 -0
- package/src/commands/usage/index.mjs +57 -0
- package/src/commands/vault/approvers.mjs +129 -0
- package/src/commands/vault/index.mjs +159 -0
- package/src/commands/vault/runners.mjs +115 -0
- package/src/commands/vault/team.mjs +111 -0
- package/src/commands/version/index.mjs +71 -0
- package/src/commands/webhooks/index.mjs +104 -0
- package/src/commands/workflows/index.mjs +88 -0
- package/src/config.mjs +261 -0
- package/src/dist.mjs +99 -0
- package/src/errors.mjs +158 -0
- package/src/http.mjs +45 -0
- package/src/legacy.mjs +73 -0
- package/src/manage.mjs +120 -0
- package/src/ota.mjs +122 -0
- package/src/output.mjs +36 -0
- package/src/process.mjs +45 -0
- package/src/schema.mjs +131 -0
- package/src/sdk.mjs +120 -0
- package/src/secret-input.mjs +27 -0
- package/src/secrets.mjs +37 -0
- package/src/shared.mjs +5 -0
- package/src/team-vault.mjs +152 -0
- package/src/token-store.mjs +93 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ias version get and set: the build numbers the server keeps for each platform of an application (docs/technical/runner-api.md
|
|
2
|
+
// §4.1), as appVersionSource: remote of EAS. A build with autoIncrement takes the next one; set brings them from EAS or
|
|
3
|
+
// app.json (owner), and they only go up.
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { loadProject, readExpo } from '../../config.mjs';
|
|
6
|
+
import { IasError } from '../../errors.mjs';
|
|
7
|
+
import { applicationOf, connect } from '../../manage.mjs';
|
|
8
|
+
import { buildNumberOf } from '../build/source.mjs';
|
|
9
|
+
|
|
10
|
+
const NUMBER = /^[1-9][0-9]{0,9}$/;
|
|
11
|
+
const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
|
|
12
|
+
const shown = (numbers) => `iOS ${numbers.ios ?? 'none yet'}, Android ${numbers.android ?? 'none yet'}`;
|
|
13
|
+
|
|
14
|
+
async function get(context) {
|
|
15
|
+
const project = projectOf(context);
|
|
16
|
+
const application = applicationOf(context, project);
|
|
17
|
+
const { data } = await connect(context, project).request('GET', `/apps/${application}/build-numbers`);
|
|
18
|
+
context.output.log(`${application}: the last build numbers are ${shown(data)}`);
|
|
19
|
+
return { app: application, ...data };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The numbers to set: --ios and --android, or those of app.json with --from-app-json. */
|
|
23
|
+
function wanted(context, project) {
|
|
24
|
+
const { values } = context;
|
|
25
|
+
if (values['from-app-json']) {
|
|
26
|
+
if (values.ios || values.android) throw new IasError('--from-app-json takes both numbers from app.json', { fix: 'leave out --ios and --android, or leave out --from-app-json' });
|
|
27
|
+
const expo = readExpo(project.dir);
|
|
28
|
+
if (!expo) throw new IasError(`there is no app.json in ${project.dir}`, { fix: 'run it in the folder of the app, or give the numbers with --ios and --android' });
|
|
29
|
+
const numbers = Object.fromEntries(['ios', 'android'].map((platform) => [platform, buildNumberOf(expo, platform)]).filter(([, number]) => number !== null));
|
|
30
|
+
if (Object.keys(numbers).length === 0) throw new IasError('app.json has neither expo.ios.buildNumber nor expo.android.versionCode', { fix: 'give the numbers with --ios and --android' });
|
|
31
|
+
return numbers;
|
|
32
|
+
}
|
|
33
|
+
const numbers = {};
|
|
34
|
+
for (const platform of ['ios', 'android']) {
|
|
35
|
+
if (values[platform] === undefined) continue;
|
|
36
|
+
if (!NUMBER.test(values[platform])) throw new IasError(`--${platform} takes a whole number from 1, not «${values[platform]}»`, { fix: 'the last build number used, such as the one of the newest build in the store' });
|
|
37
|
+
numbers[platform] = Number(values[platform]);
|
|
38
|
+
}
|
|
39
|
+
if (Object.keys(numbers).length === 0) throw new IasError('which numbers?', { fix: 'ias version set --ios 4 --android 4, or --from-app-json' });
|
|
40
|
+
return numbers;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function set(context) {
|
|
44
|
+
const project = projectOf(context);
|
|
45
|
+
const application = applicationOf(context, project);
|
|
46
|
+
const numbers = wanted(context, project);
|
|
47
|
+
const { data } = await connect(context, project).request('PUT', `/apps/${application}/build-numbers`, { json: numbers });
|
|
48
|
+
context.output.log(`${application}: the last build numbers are now ${shown(data)}; the next build with autoIncrement takes the one after.`);
|
|
49
|
+
return { app: application, ...data };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const app = { app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' } };
|
|
53
|
+
|
|
54
|
+
export default {
|
|
55
|
+
name: 'version',
|
|
56
|
+
summary: 'The build numbers the server keeps for each platform (autoIncrement takes the next)',
|
|
57
|
+
subcommands: [
|
|
58
|
+
{ name: 'get', summary: 'The last build number used on iOS and on Android', options: app, run: get },
|
|
59
|
+
{
|
|
60
|
+
name: 'set',
|
|
61
|
+
summary: 'Set the last build numbers, to bring them from EAS or app.json (owner); they only go up',
|
|
62
|
+
options: {
|
|
63
|
+
...app,
|
|
64
|
+
ios: { type: 'string', value: '<number>', description: 'The last build number of iOS (CFBundleVersion).' },
|
|
65
|
+
android: { type: 'string', value: '<number>', description: 'The last version code of Android.' },
|
|
66
|
+
'from-app-json': { type: 'boolean', description: 'Take them from expo.ios.buildNumber and expo.android.versionCode of app.json.' },
|
|
67
|
+
},
|
|
68
|
+
run: set,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// ias webhooks list, create, delete and test (docs/technical/manage-api.md §16.1): the outgoing webhooks of an application, which
|
|
2
|
+
// get a POST signed with IAS-Signature for each event they subscribe to. The secret that signs them comes in the
|
|
3
|
+
// answer that makes the webhook and nowhere else, so create prints it once and the CLI keeps nothing.
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { loadProject } from '../../config.mjs';
|
|
6
|
+
import { IasError, UsageError } from '../../errors.mjs';
|
|
7
|
+
import { applicationOf, connect } from '../../manage.mjs';
|
|
8
|
+
import { EXIT_CODE } from '../../output.mjs';
|
|
9
|
+
|
|
10
|
+
const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
|
|
11
|
+
const ID = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
|
|
12
|
+
const day = (iso) => (iso ? iso.slice(0, 16).replace('T', ' ') : '—');
|
|
13
|
+
|
|
14
|
+
const common = {
|
|
15
|
+
app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
async function list(context) {
|
|
19
|
+
const { output } = context;
|
|
20
|
+
const project = projectOf(context);
|
|
21
|
+
const code = applicationOf(context, project);
|
|
22
|
+
const { data } = await connect(context, project).request('GET', `/apps/${code}/webhooks`);
|
|
23
|
+
if (data.data.length === 0) output.log(`${code} has no webhooks: ias webhooks create <url> --events build.failed,device.pending`);
|
|
24
|
+
for (const webhook of data.data) {
|
|
25
|
+
const state = webhook.active ? 'on ' : 'OFF';
|
|
26
|
+
const events = webhook.events.length === data.events.length ? 'all events' : webhook.events.join(', ');
|
|
27
|
+
output.log(`${webhook.id} ${state} ${webhook.url} ${events}${webhook.description ? ` «${webhook.description}»` : ''}`);
|
|
28
|
+
if (!webhook.active) output.log(` turned off ${day(webhook.disabled_at)} after ${webhook.failures} failed attempts in a row: fix the address and turn it on in the panel (the application › Integraciones)`);
|
|
29
|
+
else if (webhook.failures > 0) output.log(` ${webhook.failures} failed attempts in a row (it is turned off at 20)`);
|
|
30
|
+
}
|
|
31
|
+
return { webhooks: data.data, events: data.events };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The events of --events: «all», or names separated by commas (and repeated options). */
|
|
35
|
+
export function eventsOf(values, known = []) {
|
|
36
|
+
const given = (values.events ?? []).flatMap((text) => text.split(',')).map((name) => name.trim()).filter(Boolean);
|
|
37
|
+
if (given.length === 0) throw new UsageError('--events says which events the webhook gets', { fix: '--events all, or a list such as --events build.succeeded,build.failed,device.pending' });
|
|
38
|
+
const events = given.includes('all') ? known : [...new Set(given)];
|
|
39
|
+
if (events.length === 0) throw new IasError('the server did not say which events there are', { fix: 'ias webhooks list shows them; pass them by name' });
|
|
40
|
+
return events;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function create(context) {
|
|
44
|
+
const { values, positionals, output } = context;
|
|
45
|
+
const project = projectOf(context);
|
|
46
|
+
const code = applicationOf(context, project);
|
|
47
|
+
const client = connect(context, project);
|
|
48
|
+
const url = positionals[0];
|
|
49
|
+
if (!url.startsWith('https://')) throw new UsageError(`the address of a webhook is https (not «${url}»)`, { fix: 'the server only calls https addresses of public servers' });
|
|
50
|
+
const known = values.events?.some((text) => text.split(',').includes('all')) ? (await client.request('GET', `/apps/${code}/webhooks`)).data.events : [];
|
|
51
|
+
const events = eventsOf(values, known);
|
|
52
|
+
const { data: webhook } = await client.request('POST', `/apps/${code}/webhooks`, { json: { url, events, ...(values.description ? { description: values.description } : {}) } });
|
|
53
|
+
output.log(`${webhook.id} ${webhook.url} ${webhook.events.join(', ')}`);
|
|
54
|
+
output.log(`secret ${webhook.secret}`);
|
|
55
|
+
output.log(' This is the only time the secret is shown. Each event arrives as a POST with the header IAS-Signature: t=<time>,v1=<hex of HMAC-SHA256(secret, "<time>.<body>")>;');
|
|
56
|
+
output.log(' keep the secret in 1Password and reject a time more than 5 minutes from your clock. ias webhooks test checks that the address answers.');
|
|
57
|
+
return webhook;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function remove(context) {
|
|
61
|
+
const { positionals, output } = context;
|
|
62
|
+
const project = projectOf(context);
|
|
63
|
+
const code = applicationOf(context, project);
|
|
64
|
+
await connect(context, project).request('DELETE', `/apps/${code}/webhooks/${positionals[0].toLowerCase()}`);
|
|
65
|
+
output.log(`Deleted the webhook ${positionals[0]} of ${code}, with its deliveries.`);
|
|
66
|
+
return { deleted: positionals[0].toLowerCase() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function test(context) {
|
|
70
|
+
const { positionals, output } = context;
|
|
71
|
+
const project = projectOf(context);
|
|
72
|
+
const code = applicationOf(context, project);
|
|
73
|
+
const { data: delivery } = await connect(context, project).request('POST', `/apps/${code}/webhooks/${positionals[0].toLowerCase()}/test`);
|
|
74
|
+
if (delivery.status === 'succeeded') {
|
|
75
|
+
output.log(`The test arrived: the server answered ${delivery.status_code}.`);
|
|
76
|
+
return delivery;
|
|
77
|
+
}
|
|
78
|
+
output.log(`The test failed${delivery.status_code ? ` (the server answered ${delivery.status_code})` : ''}: ${delivery.error ?? 'no answer'}.`);
|
|
79
|
+
return { ...delivery, [EXIT_CODE]: 1 };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const webhookId = { name: 'webhook', description: 'The id of the webhook (ias webhooks list).', pattern: ID };
|
|
83
|
+
|
|
84
|
+
export default {
|
|
85
|
+
name: 'webhooks',
|
|
86
|
+
summary: 'The outgoing webhooks of an application: list, create, delete and test them',
|
|
87
|
+
description: 'A webhook gets a POST signed with IAS-Signature (HMAC-SHA256 with its secret, as Stripe signs) for each event it subscribes to: builds, store submissions, updates, rollouts, credentials, notifications and testers\' devices. Only owners create, delete and test them; the secret is shown once.',
|
|
88
|
+
subcommands: [
|
|
89
|
+
{ name: 'list', summary: 'The webhooks of the application, with their events and whether they are on', options: common, run: list },
|
|
90
|
+
{
|
|
91
|
+
name: 'create',
|
|
92
|
+
summary: 'Make a webhook and print its secret, once',
|
|
93
|
+
positionals: [{ name: 'url', description: 'An https address of a public server.' }],
|
|
94
|
+
options: {
|
|
95
|
+
...common,
|
|
96
|
+
events: { type: 'string', multiple: true, value: '<events>', description: 'The events it gets, separated by commas, or «all»: build.queued, build.awaiting_approval, build.succeeded, build.failed, submission.succeeded, submission.failed, update.published, update.rollout_paused, update.rollout_completed, credential.expiring, push.message.finished, device.pending.' },
|
|
97
|
+
description: { type: 'string', value: '"…"', description: 'What it is for (up to 100 characters).' },
|
|
98
|
+
},
|
|
99
|
+
run: create,
|
|
100
|
+
},
|
|
101
|
+
{ name: 'delete', summary: 'Delete a webhook and its deliveries', positionals: [webhookId], options: common, run: remove },
|
|
102
|
+
{ name: 'test', summary: 'Send a test event now and say how the server answered', positionals: [webhookId], options: common, run: test },
|
|
103
|
+
],
|
|
104
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// ias workflows list and run (docs/technical/manage-api.md §16.3): the workflows of ias.json that GitHub starts, read from a
|
|
2
|
+
// commit of the linked repository, and a manual run. Steps go in order (build, submit); the first that fails stops the
|
|
3
|
+
// run; store builds and submissions still wait for an owner's passkey.
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { loadProject } from '../../config.mjs';
|
|
6
|
+
import { applicationOf, connect, sleep } from '../../manage.mjs';
|
|
7
|
+
import { EXIT_CODE } from '../../output.mjs';
|
|
8
|
+
|
|
9
|
+
const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
|
|
10
|
+
const FINISHED = ['succeeded', 'failed', 'canceled', 'skipped'];
|
|
11
|
+
|
|
12
|
+
const common = {
|
|
13
|
+
app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' },
|
|
14
|
+
ref: { type: 'string', value: '<ref>', description: 'A branch, tag or commit of the repository (its default branch by default).' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** «tag v*», «branch main», «pull requests» for the `on` of a workflow. */
|
|
18
|
+
function triggers(on) {
|
|
19
|
+
return [on.tag && `tag ${on.tag}`, on.branch && `branch ${on.branch}`, on.pull_request && 'pull requests'].filter(Boolean).join(', ');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** «build production (all)» or «submit ios, android» for a step. */
|
|
23
|
+
function stepText(step) {
|
|
24
|
+
if (step.build) return `build ${step.build.profile} (${step.build.platform ?? 'all'})`;
|
|
25
|
+
return `submit ${Object.keys(step.submit).join(', ')}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function list(context) {
|
|
29
|
+
const { values, output } = context;
|
|
30
|
+
const project = projectOf(context);
|
|
31
|
+
const code = applicationOf(context, project);
|
|
32
|
+
const path = `/apps/${code}/workflows${values.ref ? `?ref=${encodeURIComponent(values.ref)}` : ''}`;
|
|
33
|
+
const { data } = await connect(context, project).request('GET', path);
|
|
34
|
+
if (data.data.length === 0) output.log(`ias.json at ${data.commit.slice(0, 10)} has no workflows: add workflows to it (packages/cli/README.md).`);
|
|
35
|
+
for (const workflow of data.data) {
|
|
36
|
+
output.log(`${workflow.name.padEnd(12)} on ${triggers(workflow.on)}${workflow.description ? ` «${workflow.description}»` : ''}`);
|
|
37
|
+
workflow.steps.forEach((step, index) => output.log(` ${index + 1}. ${stepText(step)}`));
|
|
38
|
+
}
|
|
39
|
+
return data;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function show(output, run) {
|
|
43
|
+
output.log(`${run.id} ${run.workflow} ${run.status} ${run.commit.slice(0, 10)}${run.message ? ` ${run.message}` : ''}`);
|
|
44
|
+
for (const step of run.steps) {
|
|
45
|
+
const items = step.items.map((item) => `${item.platform}${item.build ? ` build ${item.build}` : ''}${item.submission ? ` submission ${item.submission}` : ''}${item.status ? ` (${item.status})` : ''}`).join(', ');
|
|
46
|
+
output.log(` ${step.position}. ${step.kind.padEnd(6)} ${step.status.padEnd(9)} ${items}${step.error ? ` — ${step.error.message ?? step.error.code}` : ''}`);
|
|
47
|
+
}
|
|
48
|
+
if (run.error) output.log(` ${run.error.code}${run.error.message ? `: ${run.error.message}` : ''}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function run(context) {
|
|
52
|
+
const { values, positionals, output } = context;
|
|
53
|
+
const project = projectOf(context);
|
|
54
|
+
const code = applicationOf(context, project);
|
|
55
|
+
const client = connect(context, project);
|
|
56
|
+
const { data: started } = await client.request('POST', `/apps/${code}/workflows/${positionals[0]}/runs`, { json: values.ref ? { ref: values.ref } : {} });
|
|
57
|
+
let current = started;
|
|
58
|
+
show(output, current);
|
|
59
|
+
if (current.steps.some((step) => step.items.some((item) => item.status === 'awaiting_approval'))) output.log(' A store build waits for an owner\'s passkey: ias builds view <build> says where to approve it.');
|
|
60
|
+
if (!values.wait) {
|
|
61
|
+
output.log(` ias workflows run --wait follows a run; the panel (the application › Integraciones) shows it too.`);
|
|
62
|
+
return current;
|
|
63
|
+
}
|
|
64
|
+
const wait = context.sleep ?? sleep;
|
|
65
|
+
while (!FINISHED.includes(current.status)) {
|
|
66
|
+
output.progress(' waiting for the workflow…');
|
|
67
|
+
await wait(5);
|
|
68
|
+
({ data: current } = await client.request('GET', `/workflow-runs/${started.id}`));
|
|
69
|
+
}
|
|
70
|
+
show(output, current);
|
|
71
|
+
return current.status === 'succeeded' ? current : { ...current, [EXIT_CODE]: 1 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default {
|
|
75
|
+
name: 'workflows',
|
|
76
|
+
summary: 'The workflows of ias.json that GitHub starts: list them and run one by hand',
|
|
77
|
+
description: 'A workflow is a list of steps, build and submit, that a tag, a branch or a pull request of the linked repository starts (ias github link). There is no step that publishes an OTA update: its signing key lives on a Mac. The code is the commit\'s tarball from GitHub; store builds and submissions wait for an owner\'s passkey as always.',
|
|
78
|
+
subcommands: [
|
|
79
|
+
{ name: 'list', summary: 'The workflows of ias.json at a commit of the repository, with their triggers and steps', options: { ...common }, run: list },
|
|
80
|
+
{
|
|
81
|
+
name: 'run',
|
|
82
|
+
summary: 'Start a workflow by hand at a ref of the repository',
|
|
83
|
+
positionals: [{ name: 'workflow', description: 'The name of the workflow (ias workflows list).', pattern: /^[A-Za-z0-9][A-Za-z0-9._-]{0,49}$/ }],
|
|
84
|
+
options: { ...common, wait: { type: 'boolean', description: 'Follow the run until it ends; the exit code is 1 if it did not succeed.' } },
|
|
85
|
+
run,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// The configuration of an app: ias.json (schema/ias.schema.json) or, while the apps move to it, the files of tools/:
|
|
2
|
+
// ota.config.json and dist.config.json. Secrets are only op:// references or variables of the environment.
|
|
3
|
+
import { X509Certificate } from 'node:crypto';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import { IasError } from './errors.mjs';
|
|
8
|
+
import { validate } from './schema.mjs';
|
|
9
|
+
import { writtenSecrets } from './secrets.mjs';
|
|
10
|
+
import { storedToken } from './token-store.mjs';
|
|
11
|
+
|
|
12
|
+
export const CONFIG_FILE = 'ias.json';
|
|
13
|
+
export const LEGACY_FILES = { updates: 'ota.config.json', distribution: 'dist.config.json' };
|
|
14
|
+
export const DEFAULT_SERVER = 'https://ias.imagina.build';
|
|
15
|
+
|
|
16
|
+
/** The path of an OTA manifest, the URL the builds carry in updates.url: /api/v1/ota/<project>/manifest. */
|
|
17
|
+
export const MANIFEST_PATH = /^\/api\/v1\/ota\/([^/]+)\/manifest$/;
|
|
18
|
+
|
|
19
|
+
/** The names the config plugin of @imagina/ias (packages/sdk/app.plugin.cjs) goes by in the plugins of app.json. */
|
|
20
|
+
export const IAS_PLUGINS = ['@imagina/ias', '@imagina/ias/plugin', '@imagina/ias/app.plugin.cjs'];
|
|
21
|
+
/** The app configs Expo runs as code: ias cannot read nor rewrite them. */
|
|
22
|
+
export const DYNAMIC_CONFIGS = ['app.config.js', 'app.config.ts', 'app.config.mjs', 'app.config.cjs'];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The updates.url the config plugin of @imagina/ias gives the builds of an app.json that has none: the server of
|
|
26
|
+
* ias.json and the application. Not IAS_SERVER: the plugin never reads it, so the builds do not ask there.
|
|
27
|
+
*/
|
|
28
|
+
export const pluginManifestUrl = (config) => `${(config.server ?? DEFAULT_SERVER).replace(/\/+$/, '')}/api/v1/ota/${config.app}/manifest`;
|
|
29
|
+
|
|
30
|
+
/** Whether the builds run the config plugin: app.json names it, or an app.config.* may add it (ias cannot read it). */
|
|
31
|
+
function runsIasPlugin(dir, expo) {
|
|
32
|
+
if (DYNAMIC_CONFIGS.some((name) => existsSync(join(dir, name)))) return true;
|
|
33
|
+
return (Array.isArray(expo.plugins) ? expo.plugins : []).some((entry) => IAS_PLUGINS.includes(Array.isArray(entry) ? entry[0] : entry));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A JSON file, or an error that says which file and how to fix it. */
|
|
37
|
+
export function readJson(path, name, fix) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new IasError(`${name} is not valid JSON (${error.message})`, { fix });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The expo object of the app's app.json, or undefined if there is no app.json. */
|
|
46
|
+
export function readExpo(dir) {
|
|
47
|
+
const file = join(dir, 'app.json');
|
|
48
|
+
if (!existsSync(file)) return undefined;
|
|
49
|
+
const appJson = readJson(file, 'app.json', 'fix app.json: ias reads updates, the identifiers and the icon from it');
|
|
50
|
+
if (!appJson?.expo || typeof appJson.expo !== 'object') throw new IasError('app.json has no expo object', { fix: 'ias reads the configuration of the Expo app from app.json › expo' });
|
|
51
|
+
return appJson.expo;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The project in `dir`. With ias.json, its configuration, checked: no secret written in it and valid against the
|
|
56
|
+
* schema. Without it, `source` is legacy and the settings come from ota.config.json and dist.config.json, as in tools/.
|
|
57
|
+
*/
|
|
58
|
+
export function loadProject(dir) {
|
|
59
|
+
const file = join(dir, CONFIG_FILE);
|
|
60
|
+
if (!existsSync(file)) return { dir, source: 'legacy', file: undefined, config: undefined };
|
|
61
|
+
const config = readJson(file, CONFIG_FILE, 'fix it, or write it again with ias init --force');
|
|
62
|
+
const secrets = writtenSecrets(config);
|
|
63
|
+
if (secrets.length > 0) {
|
|
64
|
+
throw new IasError(`ias.json has a secret written in it (${secrets.join(', ')})`, {
|
|
65
|
+
fix: 'keep it in 1Password (an op:// reference) or in IAS_TOKEN, never in the repository; if it was committed, rotate it',
|
|
66
|
+
code: 'secret_in_config',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const errors = validate(config);
|
|
70
|
+
if (errors.length > 0) {
|
|
71
|
+
throw new IasError(`ias.json is not valid: ${errors[0]}${errors.length > 1 ? ` (and ${errors.length - 1} more)` : ''}`, {
|
|
72
|
+
fix: 'fix it as packages/cli/schema/ias.schema.json says, or check it with ias doctor',
|
|
73
|
+
code: 'config_invalid',
|
|
74
|
+
details: errors,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return { dir, source: CONFIG_FILE, file, config };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The project of an ias command for `service`: ias.json or, without it, the old file of the service. */
|
|
81
|
+
export function projectFor(dir, service) {
|
|
82
|
+
const project = loadProject(dir);
|
|
83
|
+
if (project.source === 'legacy' && !existsSync(join(dir, LEGACY_FILES[service]))) {
|
|
84
|
+
throw new IasError(`no ias.json in ${dir}`, { fix: 'run ias init there (it reads app.json, eas.json, ota.config.json and dist.config.json), or pass --project <app folder>', code: 'config_missing' });
|
|
85
|
+
}
|
|
86
|
+
return project;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether the project has a configuration for `service` (updates or distribution): ias.json or its old file. */
|
|
90
|
+
export function hasConfig(project, service) {
|
|
91
|
+
return project.source === CONFIG_FILE ? Boolean(project.config[service]) : existsSync(join(project.dir, LEGACY_FILES[service]));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The origin of IAS for everything but the OTA updates: IAS_SERVER, then ias.json › server, then ias.imagina.build. */
|
|
95
|
+
export const serverOf = (project, env = process.env) => env.IAS_SERVER || project.config?.server || DEFAULT_SERVER;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The personal token ias login kept for the server of the project (token-store.mjs), the last source of a publishing
|
|
99
|
+
* token: IAS_TOKEN and the token of ias.json win over it. Read only when a command needs a token and has no other.
|
|
100
|
+
*/
|
|
101
|
+
export function loggedInToken(project, env = process.env) {
|
|
102
|
+
const server = serverOf(project, env);
|
|
103
|
+
return URL.canParse(server) ? storedToken(new URL(server).origin, { env }) : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
107
|
+
// OTA updates (ias update).
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What publishing needs: the update URL, certificate and keyid the builds carry, and where the key and token are, the
|
|
111
|
+
* environment file and the variables the bundle must carry. From ias.json, the key and the token are checked when a
|
|
112
|
+
* command needs them (check needs neither); from ota.config.json, all at once, as tools/expo-ota always did.
|
|
113
|
+
*/
|
|
114
|
+
export function updateSettings(project, env = process.env) {
|
|
115
|
+
return project.source === CONFIG_FILE ? iasUpdateSettings(project, env) : legacyUpdateSettings(project.dir, env);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The URL, certificate and keyid of app.json or, where it has none, what the config plugin of @imagina/ias writes into
|
|
120
|
+
* the builds from ias.json (packages/sdk/app.plugin.cjs), by the same rules: updates.url from the server and the
|
|
121
|
+
* application, the certificate and keyid of the default channel from updates.signing. A value of ias.json that
|
|
122
|
+
* contradicts app.json is an error, as in the plugin.
|
|
123
|
+
*/
|
|
124
|
+
function iasUpdateSettings({ dir, config }, env) {
|
|
125
|
+
const expo = readExpo(dir);
|
|
126
|
+
if (!expo) throw new IasError(`no app.json in ${dir}`, { fix: 'ias update reads updates.url and the certificate from the app.json of the app: run it from the app\'s folder or pass --project' });
|
|
127
|
+
const updates = expo.updates ?? {};
|
|
128
|
+
const signing = config.updates?.signing ?? {};
|
|
129
|
+
const fromPlugin = [];
|
|
130
|
+
|
|
131
|
+
if (updates.url === undefined) fromPlugin.push('updates.url');
|
|
132
|
+
const url = updates.url ?? pluginManifestUrl(config);
|
|
133
|
+
const manifestUrl = URL.canParse(url) ? new URL(url) : undefined;
|
|
134
|
+
if (!manifestUrl || !MANIFEST_PATH.test(manifestUrl.pathname)) {
|
|
135
|
+
throw new IasError(`updates.url in app.json (${updates.url}) is not the manifest of an IAS OTA project`, { fix: `it must be ${serverOf({ config }, env).replace(/\/$/, '')}/api/v1/ota/<project>/manifest, and changing it needs a new build; without it, the config plugin of @imagina/ias writes ${pluginManifestUrl(config)}` });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const appKeyid = updates.codeSigningMetadata?.keyid;
|
|
139
|
+
if (signing.keyId && appKeyid && signing.keyId !== appKeyid) {
|
|
140
|
+
throw new IasError(`updates.signing.keyId of ias.json (${signing.keyId}) is not the keyid of app.json (${appKeyid})`, { fix: 'the builds check the keyid of app.json: write the same one in ias.json' });
|
|
141
|
+
}
|
|
142
|
+
const keyid = appKeyid ?? signing.keyId;
|
|
143
|
+
if (!keyid) throw new IasError('there is no keyid for the certificate', { fix: 'set updates.signing.keyId in ias.json (or updates.codeSigningMetadata.keyid in app.json): the builds carry it, and the server only takes signed updates (docs/ota-code-signing.md)' });
|
|
144
|
+
if (!appKeyid) fromPlugin.push('updates.codeSigningMetadata.keyid');
|
|
145
|
+
|
|
146
|
+
const appCertificate = updates.codeSigningCertificate;
|
|
147
|
+
if (appCertificate && signing.certificate && resolve(dir, appCertificate) !== resolve(dir, signing.certificate)) {
|
|
148
|
+
throw new IasError(`updates.signing.certificate of ias.json (${signing.certificate}) is not updates.codeSigningCertificate of app.json (${appCertificate})`, { fix: 'the builds carry the one of app.json: write the same file in ias.json, or leave it only there' });
|
|
149
|
+
}
|
|
150
|
+
const certificatePath = appCertificate ?? signing.certificate;
|
|
151
|
+
if (!certificatePath) throw new IasError('there is no certificate for the default channel', { fix: 'set updates.signing.certificate in ias.json (or updates.codeSigningCertificate in app.json) to the public certificate the builds carry: the server only takes signed updates, and a new one needs a new build (docs/ota-code-signing.md)' });
|
|
152
|
+
if (!appCertificate) fromPlugin.push('updates.codeSigningCertificate');
|
|
153
|
+
|
|
154
|
+
if (fromPlugin.length > 0 && !runsIasPlugin(dir, expo)) {
|
|
155
|
+
throw new IasError(`app.json has no ${fromPlugin.join(', ')}, and its plugins do not have @imagina/ias, which writes ${fromPlugin.length === 1 ? 'it' : 'them'} into the builds from ias.json`, { fix: 'ias sdk install adds "@imagina/ias" to the plugins of app.json (packages/sdk/README.md §10), for a new build; or write them in app.json' });
|
|
156
|
+
}
|
|
157
|
+
const certificateFile = join(dir, certificatePath);
|
|
158
|
+
if (!existsSync(certificateFile)) throw new IasError(`no certificate at ${certificatePath} (${appCertificate ? 'updates.codeSigningCertificate of app.json' : 'updates.signing.certificate of ias.json'})`, { fix: 'it is the public certificate the builds carry: restore it from git' });
|
|
159
|
+
const base = manifestUrl.href.replace(/\/manifest$/, '');
|
|
160
|
+
return {
|
|
161
|
+
source: CONFIG_FILE,
|
|
162
|
+
projectDir: dir,
|
|
163
|
+
manifestUrl: manifestUrl.href,
|
|
164
|
+
base,
|
|
165
|
+
project: manifestUrl.pathname.match(MANIFEST_PATH)[1],
|
|
166
|
+
assetPath: `${new URL(base).pathname}/assets/`,
|
|
167
|
+
keyid,
|
|
168
|
+
certificate: new X509Certificate(readFileSync(certificateFile)),
|
|
169
|
+
signingSource: appCertificate ? 'app.json' : 'updates.signing of ias.json',
|
|
170
|
+
privateKey: env.IAS_UPDATES_PRIVATE_KEY || env.OTA_PRIVATE_KEY || signing.privateKey,
|
|
171
|
+
token: env.OTA_TOKEN || env.IAS_TOKEN || config.updates?.token,
|
|
172
|
+
loggedIn: () => loggedInToken({ config }, env),
|
|
173
|
+
envFile: config.updates?.envFile,
|
|
174
|
+
bundleEnv: config.updates?.bundleEnv ?? [],
|
|
175
|
+
needs: {
|
|
176
|
+
privateKey: new IasError('no private key', { fix: 'set updates.signing.privateKey in ias.json (an op:// reference) or IAS_UPDATES_PRIVATE_KEY' }),
|
|
177
|
+
token: new IasError('no publishing token', { fix: 'set updates.token in ias.json (an op:// reference) or IAS_TOKEN, or run ias login' }),
|
|
178
|
+
},
|
|
179
|
+
names: { config: CONFIG_FILE, envFile: 'updates.envFile of ias.json', bundleEnv: 'updates.bundleEnv of ias.json' },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** tools/expo-ota's settings, word for word: from app.json and ota.config.json, overridden by OTA_PRIVATE_KEY and OTA_TOKEN. */
|
|
184
|
+
function legacyUpdateSettings(projectDir, env) {
|
|
185
|
+
const { updates } = JSON.parse(readFileSync(join(projectDir, 'app.json'), 'utf8')).expo;
|
|
186
|
+
if (!updates?.codeSigningCertificate || !updates.codeSigningMetadata?.keyid) {
|
|
187
|
+
throw new Error('app.json has no updates.codeSigningCertificate and codeSigningMetadata.keyid: the server only takes signed updates');
|
|
188
|
+
}
|
|
189
|
+
const manifestUrl = new URL(updates.url ?? '');
|
|
190
|
+
if (!MANIFEST_PATH.test(manifestUrl.pathname)) throw new Error(`updates.url (${updates.url}) is not a sync-service OTA manifest URL`);
|
|
191
|
+
const certificate = new X509Certificate(readFileSync(join(projectDir, updates.codeSigningCertificate)));
|
|
192
|
+
const configFile = join(projectDir, LEGACY_FILES.updates);
|
|
193
|
+
const config = existsSync(configFile) ? JSON.parse(readFileSync(configFile, 'utf8')) : {};
|
|
194
|
+
const privateKey = env.OTA_PRIVATE_KEY || config.privateKey;
|
|
195
|
+
const token = env.OTA_TOKEN || env.IAS_TOKEN || config.token;
|
|
196
|
+
if (!privateKey) throw new Error('no private key: set privateKey in ota.config.json (an op:// reference) or OTA_PRIVATE_KEY');
|
|
197
|
+
if (!token) throw new Error('no publishing token: set token in ota.config.json (an op:// reference) or OTA_TOKEN');
|
|
198
|
+
if (config.token && !config.token.startsWith('op://')) throw new Error('ota.config.json has the token itself: keep it in 1Password (an op:// reference) or in OTA_TOKEN, never in the repository');
|
|
199
|
+
const base = manifestUrl.href.replace(/\/manifest$/, '');
|
|
200
|
+
return {
|
|
201
|
+
source: 'legacy',
|
|
202
|
+
projectDir,
|
|
203
|
+
manifestUrl: manifestUrl.href,
|
|
204
|
+
base,
|
|
205
|
+
project: manifestUrl.pathname.match(MANIFEST_PATH)[1],
|
|
206
|
+
assetPath: `${new URL(base).pathname}/assets/`,
|
|
207
|
+
keyid: updates.codeSigningMetadata.keyid,
|
|
208
|
+
certificate,
|
|
209
|
+
privateKey,
|
|
210
|
+
token,
|
|
211
|
+
envFile: config.envFile,
|
|
212
|
+
bundleEnv: config.bundleEnv ?? [],
|
|
213
|
+
names: { config: LEGACY_FILES.updates, envFile: 'envFile of ota.config.json', bundleEnv: 'ota.config.json' },
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
218
|
+
// Internal distribution (ias dist).
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Where to upload: the server, the project and the publishing token. From ias.json, the server is IAS_SERVER or its
|
|
222
|
+
* server and the project distribution.project or app; from dist.config.json, as tools/expo-dist always did.
|
|
223
|
+
*/
|
|
224
|
+
export function distSettings(project, env = process.env) {
|
|
225
|
+
return project.source === CONFIG_FILE ? iasDistSettings(project, env) : legacyDistSettings(project.dir, env);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Install pages need https: iOS only installs from https. localhost and *.test are for trying it locally. */
|
|
229
|
+
const installable = (url) => url.protocol === 'https:' || ['localhost', '127.0.0.1'].includes(url.hostname) || url.hostname.endsWith('.test');
|
|
230
|
+
|
|
231
|
+
function iasDistSettings({ dir, config }, env) {
|
|
232
|
+
if (!config.distribution) throw new IasError('ias.json has no distribution', { fix: 'add "distribution": { "token": "op://…" } with the token of php artisan dist:token (ias init writes it from dist.config.json)' });
|
|
233
|
+
const origin = env.DIST_SERVER || serverOf({ config }, env);
|
|
234
|
+
const server = URL.canParse(origin) ? new URL(origin) : undefined;
|
|
235
|
+
if (!server) throw new IasError(`the server (${origin}) is not a URL`, { fix: 'set server in ias.json or IAS_SERVER to the origin of IAS, such as https://ias.imagina.build' });
|
|
236
|
+
if (!installable(server)) throw new IasError(`the server (${server.href}) is not https`, { fix: 'iOS only installs from https: use an https server (or localhost or a .test domain to try it locally)' });
|
|
237
|
+
const code = config.distribution.project ?? config.app;
|
|
238
|
+
return {
|
|
239
|
+
source: CONFIG_FILE,
|
|
240
|
+
projectDir: dir,
|
|
241
|
+
base: `${server.origin}/api/v1/distribution/${code}`,
|
|
242
|
+
project: code,
|
|
243
|
+
token: env.DIST_TOKEN || env.IAS_TOKEN || config.distribution.token,
|
|
244
|
+
loggedIn: () => loggedInToken({ config }, env),
|
|
245
|
+
needs: { token: new IasError('no publishing token', { fix: 'set distribution.token in ias.json (an op:// reference) or IAS_TOKEN, or run ias login' }) },
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** tools/expo-dist's settings, word for word: dist.config.json, overridden by DIST_TOKEN and DIST_SERVER. */
|
|
250
|
+
function legacyDistSettings(projectDir, env) {
|
|
251
|
+
const configFile = join(projectDir, LEGACY_FILES.distribution);
|
|
252
|
+
if (!existsSync(configFile)) throw new Error(`no dist.config.json in ${projectDir} (tools/expo-dist/README.md)`);
|
|
253
|
+
const config = JSON.parse(readFileSync(configFile, 'utf8'));
|
|
254
|
+
const server = new URL(env.DIST_SERVER || config.server || '');
|
|
255
|
+
if (!installable(server)) throw new Error(`server (${server.href}) must be https: iOS only installs from https`);
|
|
256
|
+
if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(config.project ?? '')) throw new Error('dist.config.json needs project, the code of dist:project');
|
|
257
|
+
const token = env.DIST_TOKEN || env.IAS_TOKEN || config.token;
|
|
258
|
+
if (!token) throw new Error('no publishing token: set token in dist.config.json (an op:// reference) or DIST_TOKEN');
|
|
259
|
+
if (config.token && !config.token.startsWith('op://')) throw new Error('dist.config.json has the token itself: keep it in 1Password (an op:// reference) or in DIST_TOKEN, never in the repository');
|
|
260
|
+
return { source: 'legacy', projectDir, base: `${server.origin}/api/v1/distribution/${config.project}`, project: config.project, token };
|
|
261
|
+
}
|
package/src/dist.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// What an internal build is, read from the file: an ad hoc IPA (unzip, plutil, security) or an APK (aapt2).
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { createReadStream, existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import { readJson } from './config.mjs';
|
|
9
|
+
import { run, temporary } from './process.mjs';
|
|
10
|
+
|
|
11
|
+
export const MAX_ICON_BYTES = 512 * 1024;
|
|
12
|
+
|
|
13
|
+
/** The tools of macOS and the Android SDK print their own errors on stderr: only those, the last 20 lines. */
|
|
14
|
+
const tool = (command, commandArgs, options) => run(command, commandArgs, { stdio: ['inherit', 'pipe', 'pipe'], tail: 'stderr', lines: 20, ...options });
|
|
15
|
+
|
|
16
|
+
/** The fields of `aapt2 dump badging` the server needs. */
|
|
17
|
+
export function parseBadging(output) {
|
|
18
|
+
const field = (pattern) => output.match(pattern)?.[1];
|
|
19
|
+
const badging = {
|
|
20
|
+
identifier: field(/^package: name='([^']+)'/m),
|
|
21
|
+
build_number: field(/versionCode='([^']+)'/),
|
|
22
|
+
version: field(/versionName='([^']+)'/),
|
|
23
|
+
name: field(/^application-label:'([^']*)'/m) ?? field(/^application: label='([^']*)'/m),
|
|
24
|
+
};
|
|
25
|
+
for (const [key, value] of Object.entries(badging)) if (!value) throw new Error(`aapt2 dump badging gave no ${key}`);
|
|
26
|
+
return badging;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The SHA-256 (hex) and size of a file, read as a stream. */
|
|
30
|
+
export async function digest(path) {
|
|
31
|
+
const hash = createHash('sha256');
|
|
32
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
33
|
+
return { sha256: hash.digest('hex'), bytes: statSync(path).size };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Name, identifier, versions and the ad hoc profile of an IPA, with unzip, plutil and security cms. */
|
|
37
|
+
export function readIpa(file) {
|
|
38
|
+
const dir = temporary();
|
|
39
|
+
tool('unzip', ['-q', '-o', file, 'Payload/*.app/Info.plist', 'Payload/*.app/embedded.mobileprovision', '-d', dir], { label: 'unzip the IPA' });
|
|
40
|
+
const [app] = readdirSync(join(dir, 'Payload')).filter((entry) => entry.endsWith('.app'));
|
|
41
|
+
if (!app) throw new Error('the IPA has no Payload/*.app');
|
|
42
|
+
const info = JSON.parse(tool('plutil', ['-convert', 'json', '-o', '-', join(dir, 'Payload', app, 'Info.plist')]));
|
|
43
|
+
const profileFile = join(dir, 'profile.plist');
|
|
44
|
+
const provision = join(dir, 'Payload', app, 'embedded.mobileprovision');
|
|
45
|
+
if (!existsSync(provision)) throw new Error('the IPA has no embedded.mobileprovision: it is not an ad hoc build (use the preview profile with distribution internal)');
|
|
46
|
+
tool('security', ['cms', '-D', '-i', provision, '-o', profileFile], { label: 'security cms (provisioning profile)' });
|
|
47
|
+
const extract = (key) => {
|
|
48
|
+
const result = spawnSync('plutil', ['-extract', key, 'raw', '-o', '-', profileFile], { encoding: 'utf8' });
|
|
49
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
50
|
+
};
|
|
51
|
+
if (extract('ProvisionsAllDevices') === 'true') throw new Error('this IPA is signed for enterprise distribution, not ad hoc');
|
|
52
|
+
const devices = extract('ProvisionedDevices');
|
|
53
|
+
if (devices === undefined) throw new Error('the profile lists no devices: this IPA is for the App Store, not ad hoc');
|
|
54
|
+
return {
|
|
55
|
+
platform: 'ios',
|
|
56
|
+
name: info.CFBundleDisplayName || info.CFBundleName,
|
|
57
|
+
identifier: info.CFBundleIdentifier,
|
|
58
|
+
version: info.CFBundleShortVersionString,
|
|
59
|
+
build_number: String(info.CFBundleVersion),
|
|
60
|
+
provisioned_devices: Number(devices),
|
|
61
|
+
profile_expires_at: extract('ExpirationDate'),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Where the Android SDK is: ANDROID_HOME, ANDROID_SDK_ROOT or the place Android Studio puts it on a Mac. */
|
|
66
|
+
export const androidSdk = (env = process.env) => env.ANDROID_HOME || env.ANDROID_SDK_ROOT || join(homedir(), 'Library/Android/sdk');
|
|
67
|
+
|
|
68
|
+
/** aapt2 of the newest build tools of the Android SDK, or undefined. */
|
|
69
|
+
export function findAapt2(env = process.env) {
|
|
70
|
+
const tools = join(androidSdk(env), 'build-tools');
|
|
71
|
+
const versions = existsSync(tools) ? readdirSync(tools).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) : [];
|
|
72
|
+
return versions.map((version) => join(tools, version, 'aapt2')).filter((candidate) => existsSync(candidate)).pop();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** aapt2 of the newest build tools of the Android SDK; throws with how to get it. */
|
|
76
|
+
export function aapt2(env = process.env) {
|
|
77
|
+
const path = findAapt2(env);
|
|
78
|
+
if (!path) throw new Error(`no aapt2 in ${join(androidSdk(env), 'build-tools')}: install the Android SDK build tools or set ANDROID_HOME`);
|
|
79
|
+
return path;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const readApk = (file) => ({ platform: 'android', ...parseBadging(tool(aapt2(), ['dump', 'badging', file], { label: 'aapt2 dump badging' })) });
|
|
83
|
+
|
|
84
|
+
/** The app's icon from app.json, shrunk with sips to a PNG of at most 180 px, in base64; none if there is none. */
|
|
85
|
+
export function icon(projectDir) {
|
|
86
|
+
const appJson = join(projectDir, 'app.json');
|
|
87
|
+
const source = existsSync(appJson) ? readJson(appJson, 'app.json', 'fix app.json').expo?.icon : undefined;
|
|
88
|
+
if (!source || !existsSync(join(projectDir, source))) return undefined;
|
|
89
|
+
const out = join(temporary(), 'icon.png');
|
|
90
|
+
tool('sips', ['-s', 'format', 'png', '-Z', '180', join(projectDir, source), '--out', out], { label: 'sips (icon)' });
|
|
91
|
+
const png = readFileSync(out);
|
|
92
|
+
return png.length <= MAX_ICON_BYTES ? png.toString('base64') : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The commit the app's folder is at, if it is a git repository. */
|
|
96
|
+
export function gitCommit(projectDir) {
|
|
97
|
+
const result = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: projectDir, encoding: 'utf8' });
|
|
98
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
99
|
+
}
|