@imagina-build/ias-cli 0.0.0 → 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 +31 -12
- 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,110 @@
|
|
|
1
|
+
// ias build: a build of the app on a runner of IAS (docs/technical/runner-api.md §4), or on this Mac with --local. It sends the
|
|
2
|
+
// commit of a clean working tree: the tarball of git archive, its SHA-256 and size, and the build profile of ias.json
|
|
3
|
+
// resolved; uploads the code to the presigned URL; and completes it. A store build then waits for an owner's approval
|
|
4
|
+
// with a passkey, so ias build opens its page. It follows the status and the log until the build ends, and with --wait
|
|
5
|
+
// downloads its artifacts, checked against their SHA-256. Nothing secret travels: the credentials are sealed in the
|
|
6
|
+
// vault and only the runner opens them.
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
import { loadProject } from '../../config.mjs';
|
|
9
|
+
import { IasError } from '../../errors.mjs';
|
|
10
|
+
import { connect, openUrl } from '../../manage.mjs';
|
|
11
|
+
import { EXIT_CODE } from '../../output.mjs';
|
|
12
|
+
import { buildLine, buildsClient, downloadArtifacts, follow, uploadSource } from './follow.mjs';
|
|
13
|
+
import { localBuilds } from './local.mjs';
|
|
14
|
+
import { platformsOf, profileConfig, remoteProblem } from './profile.mjs';
|
|
15
|
+
import { appConfigOf, archive, buildNumberOf, repository } from './source.mjs';
|
|
16
|
+
|
|
17
|
+
const MESSAGE_LENGTH = 1000;
|
|
18
|
+
|
|
19
|
+
/** The ias.json of the app, which a build needs: its app and its build profiles. */
|
|
20
|
+
function configOf(context) {
|
|
21
|
+
const project = loadProject(resolve(context.cwd, context.values.project ?? '.'));
|
|
22
|
+
if (!project.config) throw new IasError(`there is no ias.json in ${project.dir}`, { fix: 'ias init writes it, with the build profiles of eas.json' });
|
|
23
|
+
if (!project.config.app) throw new IasError('ias.json has no app', { fix: 'set app to the code of the application in IAS (ias apps lists them)' });
|
|
24
|
+
return project;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function build(context) {
|
|
28
|
+
const { values, env, output } = context;
|
|
29
|
+
const project = configOf(context);
|
|
30
|
+
const platforms = platformsOf(values.platform);
|
|
31
|
+
const profileName = values.profile ?? 'production';
|
|
32
|
+
const profile = profileConfig(project.config, profileName);
|
|
33
|
+
if (values.message !== undefined && values.message.length > MESSAGE_LENGTH) throw new IasError(`--message has ${values.message.length} characters`, { fix: `keep it under ${MESSAGE_LENGTH}` });
|
|
34
|
+
const repo = repository(project.dir);
|
|
35
|
+
|
|
36
|
+
if (values.local) {
|
|
37
|
+
if (values.detach) throw new IasError('--detach is for builds on the server', { fix: 'a local build runs here until it ends' });
|
|
38
|
+
const results = await localBuilds(context, { root: repo.root, platforms, profileName });
|
|
39
|
+
return { local: true, commit: repo.commit, builds: results };
|
|
40
|
+
}
|
|
41
|
+
for (const platform of platforms) {
|
|
42
|
+
const problem = remoteProblem(profile, platform);
|
|
43
|
+
if (problem) throw new IasError(`the profile ${profileName} is ${problem}, which the runners of IAS do not build yet`, { fix: `build it on this Mac: ias build --local --platform ${platform} --profile ${profileName}`, code: 'profile_not_remote' });
|
|
44
|
+
}
|
|
45
|
+
const expo = appConfigOf(repo.root, repo.commit);
|
|
46
|
+
const client = buildsClient(connect(context, project));
|
|
47
|
+
const source = await archive(repo.root, repo.commit);
|
|
48
|
+
output.progress(`${project.config.app} ${profileName} (${profile.distribution}, ${profile.environment}) ${expo.version} from ${repo.commit.slice(0, 10)}: ${source.bytes} bytes of code, SHA-256 ${source.sha256}`);
|
|
49
|
+
const message = (values.message ?? repo.subject).slice(0, MESSAGE_LENGTH) || null;
|
|
50
|
+
|
|
51
|
+
const launched = [];
|
|
52
|
+
for (const platform of platforms) {
|
|
53
|
+
const body = {
|
|
54
|
+
platform,
|
|
55
|
+
profile: profileName,
|
|
56
|
+
profile_config: profile,
|
|
57
|
+
commit: repo.commit,
|
|
58
|
+
source: { sha256: source.sha256, bytes: source.bytes },
|
|
59
|
+
version: expo.version,
|
|
60
|
+
build_number: buildNumberOf(expo, platform),
|
|
61
|
+
message,
|
|
62
|
+
bundle_env: project.config.updates?.bundleEnv ?? [],
|
|
63
|
+
};
|
|
64
|
+
const { data: created } = await client.request('POST', `/apps/${project.config.app}/builds`, { json: body });
|
|
65
|
+
const { upload, ...made } = created;
|
|
66
|
+
output.progress(`${platform}: build ${made.id}, number ${made.build_number ?? 'of the runner'}; uploading the code…`);
|
|
67
|
+
await uploadSource(upload, source);
|
|
68
|
+
const { data: completed } = await client.request('POST', `/builds/${made.id}/source/complete`);
|
|
69
|
+
if (completed.status === 'awaiting_approval') {
|
|
70
|
+
const opened = (context.openUrl ?? ((url) => openUrl(url, env)))(completed.approval_url);
|
|
71
|
+
output.progress(`${platform}: ${opened ? 'opened' : 'open'} ${completed.approval_url}`);
|
|
72
|
+
output.progress(` an owner checks it and approves it with a passkey; the panel shows the hash ${completed.hash}`);
|
|
73
|
+
} else {
|
|
74
|
+
output.progress(`${platform}: ${buildLine(completed)}`);
|
|
75
|
+
}
|
|
76
|
+
launched.push(completed);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (values.detach) {
|
|
80
|
+
for (const made of launched) output.log(`${made.id} ${buildLine(made)} (ias builds logs ${made.id} --follow)`);
|
|
81
|
+
return { commit: repo.commit, source: { sha256: source.sha256, bytes: source.bytes }, builds: launched };
|
|
82
|
+
}
|
|
83
|
+
output.progress('Following the build (Ctrl-C stops following, not the build: ias builds cancel <id> cancels it)…');
|
|
84
|
+
const finished = await follow(context, client, launched);
|
|
85
|
+
const downloads = [];
|
|
86
|
+
for (const done of finished) {
|
|
87
|
+
output.log(`${done.id} ${buildLine(done)}`);
|
|
88
|
+
if (values.wait && done.status === 'succeeded') downloads.push(...(await downloadArtifacts(context, client, done, values.out ? { folder: resolve(context.cwd, values.out, done.platform) } : {})).map((file) => ({ build: done.id, ...file })));
|
|
89
|
+
}
|
|
90
|
+
const result = { commit: repo.commit, source: { sha256: source.sha256, bytes: source.bytes }, builds: finished, ...(values.wait ? { downloads } : {}) };
|
|
91
|
+
if (finished.some((done) => done.status !== 'succeeded')) result[EXIT_CODE] = 1;
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default {
|
|
96
|
+
name: 'build',
|
|
97
|
+
summary: 'Build the app on a runner of IAS (or on this Mac with --local), from the commit of a clean working tree',
|
|
98
|
+
description: 'Sends the commit (git archive), its SHA-256 and the build profile of ias.json resolved, and waits: a store build for the approval of an owner with a passkey (it opens the page), then the runner. It follows the status and the log until the build ends; Ctrl-C stops following, not the build. The version and the build number come from app.json; with autoIncrement, the server gives the next number (ias version). A store build needs a personal token (ias login); an internal one also takes a robot token with builds:create.',
|
|
99
|
+
options: {
|
|
100
|
+
platform: { type: 'string', value: 'ios|android|all', description: 'The platforms (all by default: one build each, iOS first).' },
|
|
101
|
+
profile: { type: 'string', value: '<name>', description: 'The build profile of ias.json (production by default).' },
|
|
102
|
+
message: { type: 'string', value: '"…"', description: 'What the build is (the subject of the commit by default); internal builds show it as their notes.' },
|
|
103
|
+
wait: { type: 'boolean', description: 'When it succeeds, download its artifacts (to ~/ias-builds/<app>/… or --out). With --local, wait for a build of this Mac that holds the lock.' },
|
|
104
|
+
out: { type: 'string', value: '<folder>', description: 'Where the artifacts go (one folder per platform), or the output of --local.' },
|
|
105
|
+
detach: { type: 'boolean', description: 'Do not follow the build: launch it, open the approval and leave (ias builds logs <id> --follow follows it later).' },
|
|
106
|
+
local: { type: 'boolean', description: 'Build on this Mac with @imagina/ias-runner, one platform after the other: nothing goes to the server.' },
|
|
107
|
+
credentials: { type: 'string', value: '<op://vault/item|folder>', description: 'With --local: the signing material (IAS_IOS_CREDENTIALS or IAS_ANDROID_CREDENTIALS by default).' },
|
|
108
|
+
},
|
|
109
|
+
run: build,
|
|
110
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// ias build --local: the build on this Mac, by @imagina/ias-runner imported here (it is the same pipeline the runners
|
|
2
|
+
// use), one platform after the other, never two at a time. The signing material is the runner's CredentialSource
|
|
3
|
+
// (op://vault/item or a 700 folder); nothing goes to the server. IAS_RUNNER_MODULE points at another copy of the runner.
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { IasError } from '../../errors.mjs';
|
|
8
|
+
|
|
9
|
+
const RUNNER = new URL('../../../../runner/src/index.mjs', import.meta.url);
|
|
10
|
+
|
|
11
|
+
/** The runner module: IAS_RUNNER_MODULE, or the one of this repository (packages/runner). */
|
|
12
|
+
async function runnerModule(context) {
|
|
13
|
+
const where = context.env.IAS_RUNNER_MODULE ? pathToFileURL(resolve(context.cwd, context.env.IAS_RUNNER_MODULE)) : RUNNER;
|
|
14
|
+
try {
|
|
15
|
+
return await import(where.href);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
throw new IasError(`@imagina/ias-runner is not at ${where.pathname} (${error.code ?? error.message})`, { fix: 'ias build --local needs the runner of packages/runner beside the CLI, or IAS_RUNNER_MODULE pointing at its src/index.mjs', code: 'runner_missing' });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The errors of the runner that a person fixes, with their message and a code; others go on as they are. */
|
|
22
|
+
const FIXES = {
|
|
23
|
+
ProjectError: 'fix ias.json or app.json and commit them',
|
|
24
|
+
CredentialSourceError: 'pass --credentials op://<vault>/<item> or a 700 folder, or set IAS_IOS_CREDENTIALS and IAS_ANDROID_CREDENTIALS',
|
|
25
|
+
BuildLockedError: 'wait for it to end, or pass --wait to wait for it here',
|
|
26
|
+
BuildError: 'the log of each step is in the folder of the build; fix it and run it again',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Builds each platform of `platforms` with profile `profileName` on this Mac. Returns [{ platform, ...result }]. */
|
|
30
|
+
export async function localBuilds(context, { root, platforms, profileName }) {
|
|
31
|
+
const { values, env, output } = context;
|
|
32
|
+
const runner = await runnerModule(context);
|
|
33
|
+
const results = [];
|
|
34
|
+
for (const platform of platforms) {
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const stop = (signal) => {
|
|
37
|
+
if (controller.signal.aborted) return;
|
|
38
|
+
output.warn(`${signal}: stopping the build and cleaning up`);
|
|
39
|
+
controller.abort();
|
|
40
|
+
};
|
|
41
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
42
|
+
for (const signal of signals) process.on(signal, stop);
|
|
43
|
+
try {
|
|
44
|
+
const { job, warnings } = runner.createLocalJob({ projectDir: root, platform, profileName });
|
|
45
|
+
for (const warning of warnings) output.warn(warning);
|
|
46
|
+
const shell = runner.createShell();
|
|
47
|
+
const spec = values.credentials ?? env[platform === 'ios' ? 'IAS_IOS_CREDENTIALS' : 'IAS_ANDROID_CREDENTIALS'];
|
|
48
|
+
const credentials = runner.credentialSource(spec, { shell, projectDir: root });
|
|
49
|
+
const out = values.out ? resolve(context.cwd, values.out) : runner.defaultOut(job.app);
|
|
50
|
+
const jobs = runner.defaultJobs();
|
|
51
|
+
output.progress(`${job.app} ${platform} ${job.profile} (${job.distribution}, ${job.environment}, ${job.format}) from ${job.source.commit.slice(0, 10)} on this Mac, ${jobs} jobs, low priority`);
|
|
52
|
+
const reporter = {
|
|
53
|
+
step: ({ index, total, title }) => output.progress(` ${index + 1}/${total} ${title}`),
|
|
54
|
+
info: (text) => output.progress(` ${text}`),
|
|
55
|
+
warn: (text) => output.warn(text),
|
|
56
|
+
};
|
|
57
|
+
const result = await runner.runBuild(job, { shell, credentials, out, jobs, wait: values.wait === true, reporter, signal: controller.signal });
|
|
58
|
+
const artifact = result.artifacts?.find((file) => file.kind === job.format);
|
|
59
|
+
output.log(`${platform}: ${artifact ? `${result.folder}/${artifact.file}` : result.folder} — ${result.identifier} ${result.version} (${result.buildNumber})`);
|
|
60
|
+
results.push({ platform, folder: result.folder, artifacts: result.artifacts ?? [], identifier: result.identifier, version: result.version, buildNumber: result.buildNumber, resultFile: result.resultFile ?? null });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (FIXES[error.name]) throw new IasError(`${platform}: ${error.message}`, { fix: FIXES[error.name], code: `local_${error.name.replace(/Error$/, '').replace(/[A-Z]/g, (letter, at) => `${at ? '_' : ''}${letter.toLowerCase()}`)}` });
|
|
63
|
+
throw error;
|
|
64
|
+
} finally {
|
|
65
|
+
for (const signal of signals) process.removeListener(signal, stop);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The build profile of ias.json that a build sends (docs/technical/runner-api.md §4.2): `extends` followed and merged, as eas.json
|
|
2
|
+
// does (ios and android key by key), with distribution and environment made explicit. The server validates it against
|
|
3
|
+
// packages/cli/schema/ias.schema.json and keeps it in the job; the runner reads the same profile from the commit.
|
|
4
|
+
import { IasError } from '../../errors.mjs';
|
|
5
|
+
|
|
6
|
+
export const PLATFORMS = Object.freeze(['ios', 'android']);
|
|
7
|
+
const ENVIRONMENTS = ['development', 'preview', 'production'];
|
|
8
|
+
|
|
9
|
+
/** The platforms of --platform: ios, android or all (both, iOS first). */
|
|
10
|
+
export function platformsOf(value = 'all') {
|
|
11
|
+
if (value === 'all') return [...PLATFORMS];
|
|
12
|
+
if (PLATFORMS.includes(value)) return [value];
|
|
13
|
+
throw new IasError(`--platform is ios, android or all, not «${value}»`, { fix: 'ias build --platform ios builds only for iOS' });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The profile `name` of `config` as the server takes it: what it extends merged under it, without `extends`, and its
|
|
18
|
+
* distribution (store by default) and environment (the profile's name if it is one, else by its distribution) filled.
|
|
19
|
+
*/
|
|
20
|
+
export function profileConfig(config, name) {
|
|
21
|
+
const profiles = config?.build ?? {};
|
|
22
|
+
const chain = [];
|
|
23
|
+
for (let current = name; current !== undefined; current = profiles[current].extends) {
|
|
24
|
+
if (!profiles[current]) {
|
|
25
|
+
throw new IasError(chain.length === 0 ? `ias.json has no build profile «${name}»` : `the build profile ${chain.at(-1)} extends ${current}, which does not exist`, {
|
|
26
|
+
fix: Object.keys(profiles).length > 0 ? `the profiles are ${Object.keys(profiles).join(', ')}: pass one with --profile` : 'add the build profiles to ias.json (ias init writes development, preview and production)',
|
|
27
|
+
code: 'profile_not_found',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (chain.includes(current)) throw new IasError(`the build profile ${name} extends itself (${[...chain, current].join(' → ')})`, { fix: 'fix the extends of ias.json' });
|
|
31
|
+
chain.push(current);
|
|
32
|
+
}
|
|
33
|
+
const merged = {};
|
|
34
|
+
for (const profile of chain.map((key) => profiles[key]).reverse()) {
|
|
35
|
+
const { extends: _, ios, android, ...rest } = profile;
|
|
36
|
+
Object.assign(merged, rest);
|
|
37
|
+
if (ios) merged.ios = { ...merged.ios, ...ios };
|
|
38
|
+
if (android) merged.android = { ...merged.android, ...android };
|
|
39
|
+
}
|
|
40
|
+
const distribution = merged.distribution ?? 'store';
|
|
41
|
+
const environment = merged.environment ?? (ENVIRONMENTS.includes(name) ? name : merged.developmentClient ? 'development' : distribution === 'internal' ? 'preview' : 'production');
|
|
42
|
+
return { ...merged, distribution, environment };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Why the runners of the server cannot make this build yet (a development client, the Simulator), or null. */
|
|
46
|
+
export function remoteProblem(profile, platform) {
|
|
47
|
+
if (profile.developmentClient === true) return 'a development build (developmentClient)';
|
|
48
|
+
if (platform === 'ios' && profile.ios?.simulator === true) return 'a build for the iOS Simulator';
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The code a build sends: the commit of a clean working tree at the root of its repository, as the tarball of git
|
|
2
|
+
// archive, with its SHA-256 and size (docs/technical/runner-api.md §4.2), and the version and build number of its app.json.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { createReadStream, realpathSync, statSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { IasError } from '../../errors.mjs';
|
|
8
|
+
import { temporary } from '../../process.mjs';
|
|
9
|
+
|
|
10
|
+
const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The repository of the app: { root, commit, subject }. Stops unless the app is at the root of a git repository (the
|
|
14
|
+
* build sends the whole commit and the runner reads ias.json at its root) and the working tree is clean (the server
|
|
15
|
+
* only sees the commit, so a change that is not committed would not be in the build).
|
|
16
|
+
*/
|
|
17
|
+
export function repository(projectDir) {
|
|
18
|
+
const top = git(projectDir, ['rev-parse', '--show-toplevel']);
|
|
19
|
+
if (top.error) throw new IasError('git is not installed', { fix: 'install the command line tools of Xcode (xcode-select --install)' });
|
|
20
|
+
if (top.status !== 0) throw new IasError(`${projectDir} is not in a git repository`, { fix: 'a build is made from a commit: git init, and commit the app' });
|
|
21
|
+
const root = top.stdout.trim();
|
|
22
|
+
if (realpathSync(root) !== realpathSync(projectDir)) {
|
|
23
|
+
throw new IasError(`the app is not at the root of its repository (${root})`, { fix: 'ias build sends the whole commit and the runner reads ias.json at its root: run it there, or give the app a repository of its own', code: 'not_repository_root' });
|
|
24
|
+
}
|
|
25
|
+
const head = git(root, ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}']);
|
|
26
|
+
if (head.status !== 0) throw new IasError('the repository has no commit yet', { fix: 'commit the app first: the build is made from the commit' });
|
|
27
|
+
const status = git(root, ['status', '--porcelain']);
|
|
28
|
+
const changes = status.stdout.split('\n').filter(Boolean);
|
|
29
|
+
if (changes.length > 0) {
|
|
30
|
+
throw new IasError(`the working tree has ${changes.length} change${changes.length === 1 ? '' : 's'} that ${changes.length === 1 ? 'is' : 'are'} not committed (${changes.slice(0, 3).map((line) => line.slice(3)).join(', ')}${changes.length > 3 ? ', …' : ''})`, {
|
|
31
|
+
fix: 'commit them, stash them or add them to .gitignore: the build is made from the commit, and what is not in it would not be in the build',
|
|
32
|
+
code: 'working_tree_dirty',
|
|
33
|
+
details: changes,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return { root: projectDir, commit: head.stdout.trim(), subject: git(root, ['log', '-1', '--format=%s', 'HEAD']).stdout.trim() };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The expo object of app.json in `commit`: the version and build numbers the build takes. */
|
|
40
|
+
export function appConfigOf(root, commit) {
|
|
41
|
+
const shown = git(root, ['show', `${commit}:app.json`]);
|
|
42
|
+
if (shown.status !== 0) throw new IasError(`the commit ${commit.slice(0, 10)} has no app.json`, { fix: 'ias build reads the version and the build numbers from app.json (an app.config.js is not read yet)' });
|
|
43
|
+
let expo;
|
|
44
|
+
try {
|
|
45
|
+
expo = JSON.parse(shown.stdout).expo;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw new IasError(`app.json of ${commit.slice(0, 10)} is not valid JSON (${error.message})`, { fix: 'fix it and commit it' });
|
|
48
|
+
}
|
|
49
|
+
if (typeof expo?.version !== 'string' || expo.version === '') throw new IasError('app.json has no expo.version', { fix: 'set the version of the app in app.json, such as "1.3.0"' });
|
|
50
|
+
return expo;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The build number of app.json for `platform` (ios.buildNumber, android.versionCode), or null: the server takes its
|
|
55
|
+
* own with autoIncrement, and needs this one otherwise.
|
|
56
|
+
*/
|
|
57
|
+
export function buildNumberOf(expo, platform) {
|
|
58
|
+
const value = platform === 'ios' ? expo.ios?.buildNumber : expo.android?.versionCode;
|
|
59
|
+
const number = typeof value === 'string' && /^[1-9][0-9]{0,9}$/.test(value) ? Number(value) : value;
|
|
60
|
+
return Number.isInteger(number) && number >= 1 && number <= 2147483647 ? number : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The tarball of `commit` (git archive), in a temporary folder of this run: { file, sha256, bytes }. */
|
|
64
|
+
export async function archive(root, commit) {
|
|
65
|
+
const file = join(temporary('ias-build-'), 'source.tar');
|
|
66
|
+
const result = git(root, ['archive', '--format=tar', '-o', file, commit]);
|
|
67
|
+
if (result.status !== 0) throw new IasError(`git archive of ${commit.slice(0, 10)} failed: ${result.stderr.trim().split('\n').at(-1)}`, { fix: 'check the repository with git fsck' });
|
|
68
|
+
const hash = createHash('sha256');
|
|
69
|
+
for await (const chunk of createReadStream(file)) hash.update(chunk);
|
|
70
|
+
return { file, sha256: hash.digest('hex'), bytes: statSync(file).size };
|
|
71
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// ias builds list, view, logs, cancel and download: the builds of an application on the server (docs/technical/runner-api.md
|
|
2
|
+
// §4.3 and §4.4). The log is the one the runner sent, already cleaned of secrets; artifacts are downloaded through
|
|
3
|
+
// links of 15 minutes and checked against their SHA-256.
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { loadProject } from '../../config.mjs';
|
|
6
|
+
import { IasError } from '../../errors.mjs';
|
|
7
|
+
import { applicationOf, connect } from '../../manage.mjs';
|
|
8
|
+
import { EXIT_CODE } from '../../output.mjs';
|
|
9
|
+
import { buildLine, buildsClient, downloadArtifacts, follow, isFinished } from '../build/follow.mjs';
|
|
10
|
+
|
|
11
|
+
const BUILD_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 STATUSES = ['uploading', 'awaiting_approval', 'queued', 'assigned', 'running', 'uploading_artifacts', 'succeeded', 'failed', 'rejected', 'canceled', 'expired'];
|
|
13
|
+
const KINDS = ['ipa', 'aab', 'apk', 'dsyms', 'mapping', 'result', 'logs'];
|
|
14
|
+
|
|
15
|
+
const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
|
|
16
|
+
const clientOf = (context, project = projectOf(context)) => buildsClient(connect(context, project));
|
|
17
|
+
const day = (iso) => (iso ? iso.slice(0, 16).replace('T', ' ') : '—');
|
|
18
|
+
|
|
19
|
+
async function list(context) {
|
|
20
|
+
const { values, output } = context;
|
|
21
|
+
const project = projectOf(context);
|
|
22
|
+
const client = clientOf(context, project);
|
|
23
|
+
const application = applicationOf(context, project);
|
|
24
|
+
if (values.status && !STATUSES.includes(values.status)) throw new IasError(`--status is one of ${STATUSES.join(', ')}`, { fix: 'ias builds list --status awaiting_approval lists those waiting for an approval' });
|
|
25
|
+
if (values.platform && !['ios', 'android'].includes(values.platform)) throw new IasError('--platform is ios or android', { fix: 'leave it out for both' });
|
|
26
|
+
const query = new URLSearchParams(Object.entries({ status: values.status, platform: values.platform }).filter(([, value]) => value)).toString();
|
|
27
|
+
const { data } = await client.request('GET', `/apps/${application}/builds${query ? `?${query}` : ''}`);
|
|
28
|
+
const builds = data.data ?? [];
|
|
29
|
+
if (builds.length === 0) output.log(`No builds${values.status ? ` ${values.status}` : ''} of ${application}: ias build launches one.`);
|
|
30
|
+
for (const build of builds) {
|
|
31
|
+
output.log(`${build.id} ${build.platform.padEnd(7)} ${build.profile.padEnd(11)} ${`${build.version} (${build.build_number ?? '?'})`.padEnd(13)} ${build.status.padEnd(19)} ${day(build.created_at)} ${build.message ?? ''}`);
|
|
32
|
+
}
|
|
33
|
+
return { builds };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function view(context) {
|
|
37
|
+
const { output } = context;
|
|
38
|
+
const client = clientOf(context);
|
|
39
|
+
const { data: build } = await client.request('GET', `/builds/${context.positionals[0]}`);
|
|
40
|
+
output.log(`${build.id} ${build.app} ${buildLine(build)}`);
|
|
41
|
+
output.log(` profile ${build.profile} (${build.distribution}, ${build.environment}, ${build.format}${build.channel ? `, channel ${build.channel}` : ''})`);
|
|
42
|
+
output.log(` commit ${build.commit}${build.message ? ` ${build.message}` : ''}`);
|
|
43
|
+
output.log(` code ${build.source?.bytes ?? '?'} bytes, SHA-256 ${build.source?.sha256 ?? '?'}`);
|
|
44
|
+
output.log(` launched ${day(build.created_at)} by ${build.created_by ?? '?'}`);
|
|
45
|
+
if (build.status === 'awaiting_approval') output.log(` approve at ${build.approval_url} (hash ${build.hash})`);
|
|
46
|
+
if (build.approved_by) output.log(` approved ${day(build.approved_at)} by ${build.approved_by}`);
|
|
47
|
+
if (build.runner) output.log(` runner ${build.runner.name} (${build.runner.id})`);
|
|
48
|
+
output.log(` times queued ${day(build.queued_at)}, started ${day(build.started_at)}, finished ${day(build.finished_at)}`);
|
|
49
|
+
if (build.error) output.log(` error ${build.error.code}${build.error.step ? ` at ${build.error.step}` : ''}${build.error.message ? `: ${build.error.message}` : ''}`);
|
|
50
|
+
for (const artifact of build.artifacts ?? []) output.log(` artifact ${artifact.kind.padEnd(8)} ${artifact.file} (${artifact.bytes} bytes)`);
|
|
51
|
+
for (const submission of build.submissions ?? []) output.log(` submission ${submission.id} ${submission.target}${submission.track ? ` ${submission.track}` : ''}: ${submission.status}${submission.approval_url ? ` (approve at ${submission.approval_url})` : ''}${submission.error ? ` — ${submission.error.code}` : ''}`);
|
|
52
|
+
return build;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function logs(context) {
|
|
56
|
+
const { values, output } = context;
|
|
57
|
+
const client = clientOf(context);
|
|
58
|
+
const { data: build } = await client.request('GET', `/builds/${context.positionals[0]}`);
|
|
59
|
+
if (values.follow) {
|
|
60
|
+
const [done] = await follow(context, client, [build]);
|
|
61
|
+
output.log(buildLine(done));
|
|
62
|
+
return done.status === 'succeeded' ? done : { ...done, [EXIT_CODE]: 1 };
|
|
63
|
+
}
|
|
64
|
+
const chunks = [];
|
|
65
|
+
let after = -1;
|
|
66
|
+
for (;;) {
|
|
67
|
+
const { data: page } = await client.request('GET', `/builds/${build.id}/logs?after=${after}`);
|
|
68
|
+
let step;
|
|
69
|
+
for (const chunk of page.data ?? []) {
|
|
70
|
+
if (chunk.step && chunk.step !== step) {
|
|
71
|
+
step = chunk.step;
|
|
72
|
+
output.log(`── ${chunk.step}`);
|
|
73
|
+
}
|
|
74
|
+
output.log(String(chunk.text).replace(/\n$/, ''));
|
|
75
|
+
chunks.push(chunk);
|
|
76
|
+
}
|
|
77
|
+
if ((page.data ?? []).length === 0 || !Number.isInteger(page.next) || page.next === after) break;
|
|
78
|
+
after = page.next;
|
|
79
|
+
}
|
|
80
|
+
if (chunks.length === 0) output.log(isFinished(build.status) ? 'The build has no log.' : `No log yet (${build.status}): ias builds logs ${build.id} --follow waits for it.`);
|
|
81
|
+
return { build: build.id, status: build.status, logs: chunks };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function cancel(context) {
|
|
85
|
+
const { data: build } = await clientOf(context).request('POST', `/builds/${context.positionals[0]}/cancel`);
|
|
86
|
+
context.output.log(build.status === 'canceled' ? `Canceled ${build.id} (${buildLine(build)}).` : `Asked the runner to stop ${build.id}: ${buildLine(build)}.`);
|
|
87
|
+
return build;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function download(context) {
|
|
91
|
+
const { values, output } = context;
|
|
92
|
+
const client = clientOf(context);
|
|
93
|
+
const { data: build } = await client.request('GET', `/builds/${context.positionals[0]}`);
|
|
94
|
+
const kinds = values.kind ? values.kind.split(',').map((kind) => kind.trim()) : undefined;
|
|
95
|
+
const unknown = kinds?.filter((kind) => !KINDS.includes(kind)) ?? [];
|
|
96
|
+
if (unknown.length > 0) throw new IasError(`--kind takes ${KINDS.join(', ')}, not ${unknown.join(', ')}`, { fix: 'separate several with commas: --kind ipa,dsyms' });
|
|
97
|
+
if (build.status !== 'succeeded') output.warn(`the build is ${build.status}: it may have no artifacts`);
|
|
98
|
+
const saved = await downloadArtifacts(context, client, build, { ...(values.out ? { folder: resolve(context.cwd, values.out) } : {}), kinds });
|
|
99
|
+
return { build: build.id, downloads: saved };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const buildId = { name: 'build', description: 'The id of the build, from ias builds list.', pattern: BUILD_ID };
|
|
103
|
+
|
|
104
|
+
export default {
|
|
105
|
+
name: 'builds',
|
|
106
|
+
summary: 'The builds of the application on IAS: list, view, logs, cancel and download',
|
|
107
|
+
subcommands: [
|
|
108
|
+
{
|
|
109
|
+
name: 'list',
|
|
110
|
+
summary: 'The builds of the application, the newest first',
|
|
111
|
+
options: {
|
|
112
|
+
app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' },
|
|
113
|
+
status: { type: 'string', value: '<status>', description: `Only those in a status: ${STATUSES.join(', ')}.` },
|
|
114
|
+
platform: { type: 'string', value: 'ios|android', description: 'Only those of a platform.' },
|
|
115
|
+
},
|
|
116
|
+
run: list,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'view',
|
|
120
|
+
summary: 'One build: its profile, commit, status, runner, error, artifacts and submissions',
|
|
121
|
+
positionals: [buildId],
|
|
122
|
+
run: view,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'logs',
|
|
126
|
+
summary: 'The log of a build, as the runner sent it',
|
|
127
|
+
positionals: [buildId],
|
|
128
|
+
options: { follow: { type: 'boolean', short: 'f', description: 'Follow the status and the log until the build ends.' } },
|
|
129
|
+
run: logs,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'cancel',
|
|
133
|
+
summary: 'Cancel a build (who launched it, or an owner); one in progress stops on its runner',
|
|
134
|
+
positionals: [buildId],
|
|
135
|
+
run: cancel,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'download',
|
|
139
|
+
summary: 'Download the artifacts of a build, each checked against its SHA-256',
|
|
140
|
+
positionals: [buildId],
|
|
141
|
+
options: {
|
|
142
|
+
kind: { type: 'string', value: '<kinds>', description: `Only these kinds, separated by commas: ${KINDS.join(', ')}.` },
|
|
143
|
+
out: { type: 'string', value: '<folder>', description: 'Where they go (~/ias-builds/<app>/<platform>-<version>-<number> by default).' },
|
|
144
|
+
},
|
|
145
|
+
run: download,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
};
|