@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.
Files changed (78) hide show
  1. package/README.md +406 -2
  2. package/bin/ias.mjs +19 -0
  3. package/package.json +31 -12
  4. package/schema/ias.schema.json +535 -0
  5. package/shared/credentials/cms.mjs +105 -0
  6. package/shared/credentials/index.mjs +354 -0
  7. package/shared/credentials/plist.mjs +93 -0
  8. package/shared/vault/approvers-vectors.mjs +188 -0
  9. package/shared/vault/approvers.mjs +234 -0
  10. package/shared/vault/index.mjs +583 -0
  11. package/shared/vault/plaintext.mjs +115 -0
  12. package/shared/vault/test-vectors/ias-vault-2/approvers.json +2122 -0
  13. package/shared/vault/test-vectors/ias-vault-2/keys.json +43 -0
  14. package/shared/vault/test-vectors/ias-vault-2/malformed.json +1739 -0
  15. package/shared/vault/test-vectors/ias-vault-2/plaintexts.json +149 -0
  16. package/shared/vault/test-vectors/ias-vault-2/runners.json +580 -0
  17. package/shared/vault/test-vectors/ias-vault-2/sealed.json +567 -0
  18. package/shared/vault/test-vectors/ias-vault-2/tampered.json +989 -0
  19. package/shared/vault/test-vectors/keys.json +31 -0
  20. package/shared/vault/test-vectors/malformed.json +1341 -0
  21. package/shared/vault/test-vectors/sealed.json +384 -0
  22. package/shared/vault/test-vectors/tampered.json +610 -0
  23. package/shared/vault/vectors.mjs +720 -0
  24. package/src/cli.mjs +231 -0
  25. package/src/commands/apps/index.mjs +36 -0
  26. package/src/commands/auth/index.mjs +141 -0
  27. package/src/commands/build/follow.mjs +168 -0
  28. package/src/commands/build/index.mjs +110 -0
  29. package/src/commands/build/local.mjs +69 -0
  30. package/src/commands/build/profile.mjs +50 -0
  31. package/src/commands/build/source.mjs +71 -0
  32. package/src/commands/builds/index.mjs +148 -0
  33. package/src/commands/credentials/index.mjs +256 -0
  34. package/src/commands/credentials/material.mjs +159 -0
  35. package/src/commands/devices/asc.mjs +116 -0
  36. package/src/commands/devices/index.mjs +65 -0
  37. package/src/commands/devices/sync.mjs +223 -0
  38. package/src/commands/dist/index.mjs +108 -0
  39. package/src/commands/doctor/index.mjs +158 -0
  40. package/src/commands/env/index.mjs +163 -0
  41. package/src/commands/github/index.mjs +53 -0
  42. package/src/commands/init/index.mjs +224 -0
  43. package/src/commands/migrate/credentials.mjs +213 -0
  44. package/src/commands/migrate/eas-cli.mjs +153 -0
  45. package/src/commands/migrate/index.mjs +73 -0
  46. package/src/commands/migrate/numbers.mjs +85 -0
  47. package/src/commands/migrate/profiles.mjs +174 -0
  48. package/src/commands/migrate/project.mjs +249 -0
  49. package/src/commands/migrate/survey.mjs +205 -0
  50. package/src/commands/migrate/variables.mjs +183 -0
  51. package/src/commands/push/index.mjs +334 -0
  52. package/src/commands/sdk/index.mjs +81 -0
  53. package/src/commands/submit/index.mjs +85 -0
  54. package/src/commands/update/index.mjs +526 -0
  55. package/src/commands/usage/index.mjs +57 -0
  56. package/src/commands/vault/approvers.mjs +129 -0
  57. package/src/commands/vault/index.mjs +159 -0
  58. package/src/commands/vault/runners.mjs +115 -0
  59. package/src/commands/vault/team.mjs +111 -0
  60. package/src/commands/version/index.mjs +71 -0
  61. package/src/commands/webhooks/index.mjs +104 -0
  62. package/src/commands/workflows/index.mjs +88 -0
  63. package/src/config.mjs +261 -0
  64. package/src/dist.mjs +99 -0
  65. package/src/errors.mjs +158 -0
  66. package/src/http.mjs +45 -0
  67. package/src/legacy.mjs +73 -0
  68. package/src/manage.mjs +120 -0
  69. package/src/ota.mjs +122 -0
  70. package/src/output.mjs +36 -0
  71. package/src/process.mjs +45 -0
  72. package/src/schema.mjs +131 -0
  73. package/src/sdk.mjs +120 -0
  74. package/src/secret-input.mjs +27 -0
  75. package/src/secrets.mjs +37 -0
  76. package/src/shared.mjs +5 -0
  77. package/src/team-vault.mjs +152 -0
  78. package/src/token-store.mjs +93 -0
package/src/cli.mjs ADDED
@@ -0,0 +1,231 @@
1
+ // The router of ias: ias <command> [<subcommand>] [options]. Each command is a folder of src/commands with an index.mjs
2
+ // whose default export is the command, or a list of them, so a new one (vault, credentials, env…) needs no change here:
3
+ // { name, summary, description?, aliases?, pending?, options?, positionals?, run(context) }
4
+ // { name, summary, description?, subcommands: [<the same, without subcommands>] }
5
+ // run gets { values, positionals, output, env, cwd, sleep?, openUrl? } and returns the result, which --json prints; the
6
+ // tests pass sleep and openUrl so that waiting for a passkey takes no time and no browser opens.
7
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
8
+ import process from 'node:process';
9
+ import { parseArgs } from 'node:util';
10
+ import { UsageError } from './errors.mjs';
11
+ import { EXIT_CODE, createOutput } from './output.mjs';
12
+ import { cleanup } from './process.mjs';
13
+
14
+ const COMMANDS_DIR = new URL('./commands/', import.meta.url);
15
+
16
+ export { EXIT_CODE };
17
+ export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
18
+
19
+ /** The order of the commands in the help; the rest follow by name. */
20
+ const ORDER = ['login', 'logout', 'whoami', 'apps', 'init', 'doctor', 'sdk', 'update', 'dist', 'credentials', 'env', 'vault'];
21
+
22
+ /** The options of every command. */
23
+ export const GLOBAL_OPTIONS = {
24
+ project: { type: 'string', value: '<folder>', description: 'The folder of the app (the current one by default).' },
25
+ json: { type: 'boolean', description: 'The result as one JSON document on stdout; the progress goes to stderr.' },
26
+ help: { type: 'boolean', short: 'h', description: 'The help of the command.' },
27
+ };
28
+
29
+ /** The commands of src/commands, in the order of the help. */
30
+ export async function loadCommands() {
31
+ const folders = readdirSync(COMMANDS_DIR, { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(new URL(`${entry.name}/index.mjs`, COMMANDS_DIR)));
32
+ const commands = [];
33
+ for (const folder of folders) commands.push(...[(await import(new URL(`${folder.name}/index.mjs`, COMMANDS_DIR))).default].flat().filter(Boolean));
34
+ const rank = (command) => (command.pending ? 1000 : ORDER.includes(command.name) ? ORDER.indexOf(command.name) : 100);
35
+ return commands.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
36
+ }
37
+
38
+ const named = (list, name) => list.find((command) => command.name === name || command.aliases?.includes(name));
39
+
40
+ /** The closest name, for «did you mean»: at most two letters away. */
41
+ function closest(name, names) {
42
+ const distance = (a, b) => {
43
+ const row = Array.from({ length: b.length + 1 }, (_, index) => index);
44
+ for (let i = 1; i <= a.length; i += 1) {
45
+ let previous = row[0];
46
+ row[0] = i;
47
+ for (let j = 1; j <= b.length; j += 1) {
48
+ const current = row[j];
49
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
50
+ previous = current;
51
+ }
52
+ }
53
+ return row[b.length];
54
+ };
55
+ return names.map((candidate) => [candidate, distance(name, candidate)]).filter(([, score]) => score <= 2).sort((a, b) => a[1] - b[1])[0]?.[0];
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------------------------------------------------
59
+ // Help.
60
+
61
+ const optionLabel = (name, option) => `${option.short ? `-${option.short}, ` : ''}--${name}${option.value ? ` ${option.value}` : ''}`;
62
+
63
+ function table(rows) {
64
+ const width = Math.max(...rows.map(([left]) => left.length));
65
+ return rows.map(([left, right]) => ` ${left.padEnd(width)} ${right}`).join('\n');
66
+ }
67
+
68
+ function usageLine(path, leaf) {
69
+ const positionals = (leaf.positionals ?? []).map((positional) => (positional.optional ? `[<${positional.name}>]` : `<${positional.name}>`));
70
+ const options = Object.entries(leaf.options ?? {}).map(([name, option]) => `[--${name}${option.value ? ` ${option.value}` : ''}]`);
71
+ return ['ias', ...path, ...positionals, ...options, '[--project <folder>]', '[--json]'].join(' ');
72
+ }
73
+
74
+ export function generalHelp(commands) {
75
+ const ready = commands.filter((command) => !command.pending);
76
+ const pending = commands.filter((command) => command.pending);
77
+ return [
78
+ `ias ${VERSION}: Imagina Application Services from the terminal`,
79
+ '',
80
+ 'usage: ias <command> [<subcommand>] [options]',
81
+ '',
82
+ 'commands:',
83
+ table(ready.map((command) => [command.name, command.summary])),
84
+ ...(pending.length > 0 ? ['', 'coming:', table(pending.map((command) => [command.name, command.summary]))] : []),
85
+ '',
86
+ 'options:',
87
+ table([...Object.entries(GLOBAL_OPTIONS).map(([name, option]) => [optionLabel(name, option), option.description]), ['-v, --version', 'The version of ias.']]),
88
+ '',
89
+ 'ias <command> --help says more; packages/cli/README.md explains ias.json and the move from tools/.',
90
+ ].join('\n');
91
+ }
92
+
93
+ export function commandHelp(command) {
94
+ if (!command.subcommands) return leafHelp([command.name], command);
95
+ return [
96
+ `ias ${command.name}: ${command.summary}`,
97
+ ...(command.description ? ['', command.description] : []),
98
+ '',
99
+ `usage: ias ${command.name} <subcommand> [options]`,
100
+ '',
101
+ 'subcommands:',
102
+ table(command.subcommands.map((subcommand) => [[subcommand.name, ...(subcommand.aliases ?? [])].join(', '), subcommand.summary])),
103
+ '',
104
+ `ias ${command.name} <subcommand> --help says more.`,
105
+ ].join('\n');
106
+ }
107
+
108
+ export function leafHelp(path, leaf) {
109
+ const positionals = leaf.positionals ?? [];
110
+ const options = { ...(leaf.options ?? {}), ...GLOBAL_OPTIONS };
111
+ return [
112
+ `ias ${path.join(' ')}: ${leaf.summary}`,
113
+ ...(leaf.description ? ['', leaf.description] : []),
114
+ '',
115
+ `usage: ${usageLine(path, leaf)}`,
116
+ ...(positionals.length > 0 ? ['', 'arguments:', table(positionals.map((positional) => [`<${positional.name}>`, positional.description ?? '']))] : []),
117
+ '',
118
+ 'options:',
119
+ table(Object.entries(options).map(([name, option]) => [optionLabel(name, option), option.description ?? ''])),
120
+ ].join('\n');
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------------------------------------------------
124
+ // Running a command.
125
+
126
+ /** The arguments of `leaf`, parsed strictly: an unknown option or a missing argument is a UsageError. */
127
+ export function parseCommandLine(path, leaf, args) {
128
+ const spec = Object.fromEntries(Object.entries({ ...(leaf.options ?? {}), ...GLOBAL_OPTIONS }).map(([name, { type, short, multiple }]) => [name, { type, ...(short ? { short } : {}), ...(multiple ? { multiple } : {}) }]));
129
+ const help = `ias ${path.join(' ')} --help lists them`;
130
+ let parsed;
131
+ try {
132
+ parsed = parseArgs({ args, options: spec, allowPositionals: true, strict: true });
133
+ } catch (error) {
134
+ const option = error.message.match(/'([^']+)'/)?.[1] ?? '';
135
+ if (error.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') throw new UsageError(`unknown option ${option}`, { fix: help });
136
+ if (error.code === 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE') throw new UsageError(`${option.replace(/ <value>$/, '')} needs a value`, { fix: help });
137
+ throw new UsageError(error.message, { fix: help });
138
+ }
139
+ const { values, positionals } = parsed;
140
+ if (values.help) return { values, positionals };
141
+ const declared = leaf.positionals ?? [];
142
+ const usage = `usage: ${usageLine(path, leaf)}`;
143
+ if (positionals.length > declared.length) throw new UsageError(`ias ${path.join(' ')} takes ${declared.length === 0 ? 'no arguments' : declared.map((positional) => `<${positional.name}>`).join(' ')}, not «${positionals.slice(declared.length).join(' ')}»`, { fix: usage });
144
+ for (const [index, positional] of declared.entries()) {
145
+ const value = positionals[index];
146
+ if (value === undefined && !positional.optional) throw new UsageError(`ias ${path.join(' ')} needs <${positional.name}>`, { fix: `${positional.description ?? ''} ${usage}`.trim() });
147
+ if (value !== undefined && positional.pattern && !positional.pattern.test(value)) throw new UsageError(`<${positional.name}> is not valid («${value}»)`, { fix: positional.description ?? usage });
148
+ }
149
+ return { values, positionals };
150
+ }
151
+
152
+ /** The index of the first argument that is not an option (nor the value of --project), or -1. */
153
+ function firstWord(args) {
154
+ for (let index = 0; index < args.length; index += 1) {
155
+ if (args[index] === '--project') index += 1;
156
+ else if (!args[index].startsWith('-')) return index;
157
+ }
158
+ return -1;
159
+ }
160
+
161
+ /**
162
+ * Runs ias with `argv` and returns the exit code: 0 when it worked, 1 when it failed and 2 when the command line does
163
+ * not parse. Errors are printed as «ias: what: how», or as { error } with --json.
164
+ */
165
+ export async function main(argv = process.argv.slice(2), { env = process.env, cwd = process.cwd(), stdout = process.stdout, stderr = process.stderr, sleep, openUrl } = {}) {
166
+ const output = createOutput({ json: argv.includes('--json'), stdout, stderr });
167
+ const print = (text) => stdout.write(`${text}\n`);
168
+ try {
169
+ const commands = await loadCommands();
170
+ const at = firstWord(argv);
171
+ if (at === -1) {
172
+ if (argv.includes('-v') || argv.includes('--version')) {
173
+ print(output.json ? JSON.stringify({ version: VERSION }) : `ias ${VERSION}`);
174
+ return 0;
175
+ }
176
+ print(generalHelp(commands));
177
+ return argv.length === 0 || argv.includes('-h') || argv.includes('--help') ? 0 : 2;
178
+ }
179
+ // Options before the command (ias --json doctor) go after it and its subcommand.
180
+ let args = [...argv.slice(at + 1), ...argv.slice(0, at)];
181
+ const name = argv[at];
182
+ if (name === 'help') {
183
+ const words = args.filter((arg) => !arg.startsWith('-'));
184
+ const command = named(commands, words[0]);
185
+ if (!command) {
186
+ print(generalHelp(commands));
187
+ return words.length === 0 ? 0 : 2;
188
+ }
189
+ const subcommand = command.subcommands && named(command.subcommands, words[1]);
190
+ print(subcommand ? leafHelp([command.name, subcommand.name], subcommand) : commandHelp(command));
191
+ return 0;
192
+ }
193
+ const command = named(commands, name);
194
+ if (!command) {
195
+ const guess = closest(name, commands.map((candidate) => candidate.name));
196
+ throw new UsageError(`there is no command «${name}»`, { fix: guess ? `did you mean ias ${guess}? ias --help lists them` : 'ias --help lists them' });
197
+ }
198
+ let leaf = command;
199
+ const path = [command.name];
200
+ if (command.subcommands) {
201
+ const word = args[0];
202
+ if (word === undefined || word.startsWith('-')) {
203
+ if (args.includes('--help') || args.includes('-h')) {
204
+ print(commandHelp(command));
205
+ return 0;
206
+ }
207
+ throw new UsageError(`ias ${command.name} needs a subcommand`, { fix: `one of ${command.subcommands.map((subcommand) => subcommand.name).join(', ')}; ias ${command.name} --help says what each does` });
208
+ }
209
+ leaf = named(command.subcommands, word);
210
+ if (!leaf) {
211
+ const guess = closest(word, command.subcommands.map((subcommand) => subcommand.name));
212
+ throw new UsageError(`ias ${command.name} has no subcommand «${word}»`, { fix: guess ? `did you mean ias ${command.name} ${guess}? ias ${command.name} --help lists them` : `ias ${command.name} --help lists them` });
213
+ }
214
+ path.push(leaf.name);
215
+ args = args.slice(1);
216
+ }
217
+ const { values, positionals } = parseCommandLine(path, leaf, args);
218
+ if (values.help) {
219
+ print(leafHelp(path, leaf));
220
+ return 0;
221
+ }
222
+ const result = await leaf.run({ values, positionals, output, env, cwd, ...(sleep ? { sleep } : {}), ...(openUrl ? { openUrl } : {}) });
223
+ output.result(result);
224
+ return typeof result?.[EXIT_CODE] === 'number' ? result[EXIT_CODE] : 0;
225
+ } catch (error) {
226
+ output.fail(error);
227
+ return error instanceof UsageError ? 2 : 1;
228
+ } finally {
229
+ cleanup();
230
+ }
231
+ }
@@ -0,0 +1,36 @@
1
+ // ias apps: the applications the token can see (docs/technical/manage-api.md §4), or one of them. A robot token sees only its own.
2
+ import { resolve } from 'node:path';
3
+ import { loadProject } from '../../config.mjs';
4
+ import { connect } from '../../manage.mjs';
5
+
6
+ /** The lines that describe an application for people. */
7
+ export function describeApplication(app) {
8
+ const lines = [`${app.code} ${app.name}`, ` services ${app.services.length > 0 ? app.services.join(', ') : 'none'}`];
9
+ if (app.ios) lines.push(` ios ${app.ios.bundle_id ?? '—'} (team ${app.ios.team_id ?? '—'})`);
10
+ if (app.android) lines.push(` android ${app.android.package ?? '—'}`);
11
+ if (app.updates) lines.push(` updates project ${app.updates.project}, channel ${app.updates.default_channel}, ${app.updates.manifest_url}`);
12
+ if (app.distribution) lines.push(` distribution project ${app.distribution.project}, ${app.distribution.install_url}`);
13
+ return lines;
14
+ }
15
+
16
+ async function apps(context) {
17
+ const client = connect(context, loadProject(resolve(context.cwd, context.values.project ?? '.')));
18
+ const [code] = context.positionals;
19
+ if (code) {
20
+ const { data } = await client.request('GET', `/apps/${encodeURIComponent(code)}`);
21
+ for (const line of describeApplication(data)) context.output.log(line);
22
+ return data;
23
+ }
24
+ const { data } = await client.request('GET', '/apps');
25
+ if (data.data.length === 0) context.output.log('No applications: create one in the panel (Aplicaciones › Crear aplicación).');
26
+ const width = Math.max(0, ...data.data.map((app) => app.code.length));
27
+ for (const app of data.data) context.output.log(`${app.code.padEnd(width)} ${app.name} (${app.services.join(', ') || 'no services'})`);
28
+ return data;
29
+ }
30
+
31
+ export default {
32
+ name: 'apps',
33
+ summary: 'The applications the token can see, or one of them in detail',
34
+ positionals: [{ name: 'code', optional: true, description: 'The code of one application, to see it in detail.', pattern: /^[a-z0-9][a-z0-9-]{0,31}$/ }],
35
+ run: apps,
36
+ };
@@ -0,0 +1,141 @@
1
+ // ias login, logout, whoami and teams: the personal token (iasp_…) of the management API (docs/technical/manage-api.md §2 and §3).
2
+ // login authorizes this computer with the device flow (RFC 8628): it shows a code, opens the panel, where a member
3
+ // approves it with a passkey, and asks for the token every `interval` seconds. The token goes to the macOS keychain, to
4
+ // a file with mode 600 elsewhere, or nowhere on CI, where IAS_TOKEN is the token (token-store.mjs). It is never printed.
5
+ // A personal token is of one team (docs/technical/phase-5.md §1.4): --team preselects it in the panel, where the member chooses
6
+ // among theirs; a token for another team is another login, and `ias teams` lists the ones the person is in.
7
+ import { spawnSync } from 'node:child_process';
8
+ import { hostname } from 'node:os';
9
+ import { resolve } from 'node:path';
10
+ import process from 'node:process';
11
+ import { loadProject } from '../../config.mjs';
12
+ import { ApiError, IasError } from '../../errors.mjs';
13
+ import { connect, manageClient, openUrl, serverOrigin, sleep } from '../../manage.mjs';
14
+ import { forgetToken, saveToken, storeKind, storeLabel, storedToken } from '../../token-store.mjs';
15
+ import { VERSION } from '../../cli.mjs';
16
+
17
+ const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
18
+
19
+ /** The code of a team: lowercase letters, digits and hyphens, 2 to 32 (App\\Models\\Team::SLUG_PATTERN). */
20
+ const TEAM_SLUG = /^[a-z0-9][a-z0-9-]{1,31}$/;
21
+
22
+ /** The name of this computer, as the panel shows it: the one of System Settings on macOS. */
23
+ function deviceName(env) {
24
+ if (process.platform === 'darwin') {
25
+ const result = spawnSync('scutil', ['--get', 'ComputerName'], { encoding: 'utf8', env });
26
+ if (result.status === 0 && result.stdout.trim()) return result.stdout.trim().slice(0, 100);
27
+ }
28
+ return hostname().slice(0, 100);
29
+ }
30
+
31
+ /**
32
+ * The device flow, from asking for a code to the token: { token, token_id, expires_at, member, team }. `team` is the
33
+ * code of the team to preselect in the panel. `wait` sleeps (seconds) and `open` opens the browser; both are injected
34
+ * by the tests.
35
+ */
36
+ export async function authorize(client, { name, team, wait = sleep, open = () => false, output }) {
37
+ const { data: device } = await client.request('POST', '/auth/device', { auth: false, json: { client: 'ias-cli', client_version: VERSION, device_name: name, ...(team ? { team } : {}) } });
38
+ output.progress(`Your code: ${device.user_code}`);
39
+ const opened = open(device.verification_uri_complete);
40
+ output.progress(`${opened ? 'Opened' : 'Open'} ${device.verification_uri_complete}, check that the panel shows the same code, and approve it with a passkey.`);
41
+ let interval = Number(device.interval) || 5;
42
+ const deadline = Date.now() + (Number(device.expires_in) || 600) * 1000;
43
+ for (;;) {
44
+ await wait(interval);
45
+ try {
46
+ const { data } = await client.request('POST', '/auth/token', { auth: false, json: { device_code: device.device_code } });
47
+ return data;
48
+ } catch (error) {
49
+ if (!(error instanceof ApiError)) throw error;
50
+ if (error.code === 'authorization_pending' && Date.now() < deadline) continue;
51
+ if (error.code === 'slow_down') {
52
+ interval += 5;
53
+ continue;
54
+ }
55
+ if (error.code === 'access_denied') throw new IasError('a member of the team rejected this authorization', { fix: 'run ias login again if it was a mistake', code: 'access_denied' });
56
+ if (error.code === 'expired_token' || error.code === 'authorization_pending') throw new IasError('the code expired before anyone approved it', { fix: 'run ias login again and approve it within 10 minutes', code: 'expired_token' });
57
+ throw error;
58
+ }
59
+ }
60
+ }
61
+
62
+ async function login(context) {
63
+ const { env, output } = context;
64
+ const server = serverOrigin(projectOf(context), env);
65
+ const kind = storeKind(env);
66
+ const team = context.values.team;
67
+ if (team !== undefined && !TEAM_SLUG.test(team)) throw new IasError(`«${team}» is not the code of a team`, { fix: 'use the code the panel shows (Plataforma › Equipos, or ias teams): lowercase letters, digits and hyphens', code: 'usage' });
68
+ if (kind === 'none') throw new IasError('ias login keeps no token on CI', { fix: 'set IAS_TOKEN to a robot token of the application (iasr_…, created in the panel: the application › Settings › Access)', code: 'no_token_store' });
69
+ if (env.IAS_TOKEN) output.warn('IAS_TOKEN is set and wins over the token of ias login: unset it to use this one');
70
+ const client = manageClient(server, undefined, { wait: context.sleep ?? sleep });
71
+ const granted = await authorize(client, { name: context.values.name ?? deviceName(env), team, wait: context.sleep ?? sleep, open: context.values['no-browser'] ? () => false : (context.openUrl ?? ((url) => openUrl(url, env))), output });
72
+ const where = saveToken(server, granted.token, { env, kind });
73
+ const { member } = granted;
74
+ const role = granted.team ? `${member.role} of ${granted.team.slug}` : member.role;
75
+ output.log(`Logged in to ${server} as ${member.name} <${member.email}> (${role}). The token, valid until ${granted.expires_at}, is in ${where}.`);
76
+ return { server, member, team: granted.team ?? null, token: { id: granted.token_id, expires_at: granted.expires_at }, store: kind };
77
+ }
78
+
79
+ async function logout(context) {
80
+ const { env, output } = context;
81
+ const server = serverOrigin(projectOf(context), env);
82
+ const kind = storeKind(env);
83
+ const token = storedToken(server, { env, kind });
84
+ if (!token) {
85
+ output.log(`Not logged in to ${server}: there is no token in ${storeLabel(kind, env)}.`);
86
+ if (env.IAS_TOKEN) output.warn('IAS_TOKEN is set: ias logout does not revoke it; revoke a robot token in the panel');
87
+ return { server, revoked: false, forgotten: false };
88
+ }
89
+ let revoked = true;
90
+ try {
91
+ await manageClient(server, token, { wait: context.sleep ?? sleep }).request('DELETE', '/me/token');
92
+ } catch (error) {
93
+ if (!(error instanceof ApiError) || error.code !== 'token_invalid') throw error;
94
+ revoked = false; // already expired or revoked: forgetting it is all that is left
95
+ }
96
+ forgetToken(server, { env, kind });
97
+ output.log(revoked ? `Logged out of ${server}: the token is revoked and gone from ${storeLabel(kind, env)}.` : `The token of ${server} was no longer valid; it is gone from ${storeLabel(kind, env)}.`);
98
+ if (env.IAS_TOKEN) output.warn('IAS_TOKEN is still set and still works');
99
+ return { server, revoked, forgotten: true };
100
+ }
101
+
102
+ async function whoami(context) {
103
+ const { output } = context;
104
+ const client = connect(context, projectOf(context));
105
+ const { data } = await client.request('GET', '/me');
106
+ const { member, token, team } = data;
107
+ output.log(member ? `${member.name} <${member.email}>, ${member.role} of the team${team ? ` ${team.slug}` : ''}` : `A robot token of ${token.application}`);
108
+ output.log(`server ${client.server}`);
109
+ if (team) output.log(`team ${team.name} (${team.slug})`);
110
+ output.log(`token ${token.kind} «${token.name}» from ${client.tokenSource}, until ${token.expires_at ?? 'it is revoked'}`);
111
+ output.log(`scopes ${token.scopes.join(', ')}`);
112
+ return { server: client.server, source: client.tokenSource, ...data };
113
+ }
114
+
115
+ async function teams(context) {
116
+ const { output } = context;
117
+ const client = connect(context, projectOf(context));
118
+ const { data } = await client.request('GET', '/teams');
119
+ if (data.data.length === 0) output.log('You are not in any team.');
120
+ const width = Math.max(0, ...data.data.map((team) => team.slug.length));
121
+ for (const team of data.data) output.log(`${team.current ? '*' : ' '} ${team.slug.padEnd(width)} ${team.name} (${team.role})`);
122
+ if (data.data.length > 1) output.log('* is the team of this token: for another, run ias login --team <code>');
123
+ return data;
124
+ }
125
+
126
+ export default [
127
+ {
128
+ name: 'login',
129
+ summary: 'Authorize this computer with a passkey and keep its personal token',
130
+ description: 'Shows a code and opens the panel (Authorize the CLI), where a member of the team approves it with a passkey. The token (iasp_…, 90 days) goes to the macOS keychain, or to a file with mode 600 ($XDG_CONFIG_HOME/ias/tokens.json) elsewhere; IAS_TOKEN_STORE (keychain, file) chooses. A token is of one team: --team preselects it in the panel, where you choose among yours (ias teams lists them); logging in for another team replaces the token of this server. On CI there is no login: IAS_TOKEN holds a robot token. The server is IAS_SERVER, server of ias.json or ias.imagina.build.',
131
+ options: {
132
+ name: { type: 'string', value: '"…"', description: 'The name the panel shows for this computer (its name by default).' },
133
+ team: { type: 'string', value: '<code>', description: 'The code of the team the token is for (the panel asks if you have several and it is not given).' },
134
+ 'no-browser': { type: 'boolean', description: 'Do not open the browser: print the link only.' },
135
+ },
136
+ run: login,
137
+ },
138
+ { name: 'logout', summary: 'Revoke the token of this computer and forget it', run: logout },
139
+ { name: 'whoami', summary: 'Who the token belongs to, its team, where it comes from and its scopes', run: whoami },
140
+ { name: 'teams', summary: 'The teams you are in, and which one the token is of', run: teams },
141
+ ];
@@ -0,0 +1,168 @@
1
+ // Following builds on the server (docs/technical/runner-api.md §4): their status every 5 s and their log as it comes, and the
2
+ // download of their artifacts with the SHA-256 checked. ias build, ias builds and ias submit share it. The upload and
3
+ // download URLs are presigned: they are never printed and never get the token.
4
+ import { createHash } from 'node:crypto';
5
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { Readable, Transform } from 'node:stream';
9
+ import { pipeline } from 'node:stream/promises';
10
+ import { ApiError, IasError } from '../../errors.mjs';
11
+ import { sleep } from '../../manage.mjs';
12
+
13
+ const BUILD_NOT_FOUND = 'ias builds list shows the builds of the application (a robot token sees only its own); ias dist list, the install pages\'';
14
+
15
+ /**
16
+ * The management client with the hint of a build of IAS for build_not_found, which HINTS gives to the distribution
17
+ * API of ias dist.
18
+ */
19
+ export function buildsClient(client) {
20
+ return {
21
+ ...client,
22
+ async request(...args) {
23
+ try {
24
+ return await client.request(...args);
25
+ } catch (error) {
26
+ if (error instanceof ApiError && error.code === 'build_not_found') error.hint = BUILD_NOT_FOUND;
27
+ throw error;
28
+ }
29
+ },
30
+ };
31
+ }
32
+
33
+ /** The states a build or a submission ends in. */
34
+ export const FINISHED = Object.freeze(['succeeded', 'failed', 'canceled', 'expired', 'rejected']);
35
+ export const isFinished = (status) => FINISHED.includes(status);
36
+
37
+ const FILE = /^[A-Za-z0-9][A-Za-z0-9 ._()+-]{0,199}$/;
38
+ const LOCAL = (hostname) => ['localhost', '127.0.0.1', '[::1]'].includes(hostname) || hostname.endsWith('.test');
39
+
40
+ /** A URL the server gave to upload or download a file: https, or a local host to try it. */
41
+ export function transferUrl(text) {
42
+ const url = URL.canParse(text) ? new URL(text) : null;
43
+ if (!url || !(url.protocol === 'https:' || (url.protocol === 'http:' && LOCAL(url.hostname)))) throw new IasError('the server gave a transfer URL that is not https', { fix: 'the code and the builds only travel over https: check the server (FILESYSTEM_DISK and the bucket of the builds)', code: 'transfer_url_invalid' });
44
+ return url;
45
+ }
46
+
47
+ /** PUTs the tarball of the code to the presigned URL of a new build. */
48
+ export async function uploadSource(upload, source) {
49
+ const url = transferUrl(upload.url);
50
+ let response;
51
+ try {
52
+ response = await fetch(url, { method: upload.method ?? 'PUT', headers: { ...(upload.headers ?? {}), 'content-length': String(source.bytes) }, body: readFileSync(source.file) });
53
+ } catch (error) {
54
+ throw new IasError(`uploading the code failed (${error.cause?.code ?? error.message})`, { fix: 'check the connection and run ias build again: the build waits for its code for 60 minutes' });
55
+ }
56
+ if (!response.ok) throw new IasError(`uploading the code failed: the bucket answered ${response.status}`, { fix: 'run ias build again (the upload URL lasts 60 minutes); if it goes on, check the bucket of the builds' });
57
+ }
58
+
59
+ /** One line about a build: platform, profile, version, number and status. */
60
+ export function buildLine(build) {
61
+ const number = build.build_number === null || build.build_number === undefined ? '' : ` (${build.build_number})`;
62
+ const runner = build.runner?.name ? ` on ${build.runner.name}` : '';
63
+ return `${build.platform} ${build.profile} ${build.version}${number}: ${build.status}${build.step ? ` (${build.step})` : ''}${runner}`;
64
+ }
65
+
66
+ /** What to say when a build reaches a status. */
67
+ function statusLine(build) {
68
+ if (build.status === 'awaiting_approval') return `${buildLine(build)}: an owner approves it with a passkey at ${build.approval_url}`;
69
+ if (build.status === 'failed' && build.error) return `${buildLine(build)}: ${build.error.code}${build.error.step ? ` at ${build.error.step}` : ''}${build.error.message ? `: ${build.error.message}` : ''}`;
70
+ return buildLine(build);
71
+ }
72
+
73
+ /**
74
+ * Follows `builds` until each one ends: the status every `interval` seconds and the new chunks of its log, the step as
75
+ * a header. With several builds, each line says its platform. Returns the builds as they ended.
76
+ */
77
+ export async function follow(context, client, builds, { interval = 5, logs = true } = {}) {
78
+ const { output } = context;
79
+ const wait = context.sleep ?? sleep;
80
+ const many = builds.length > 1;
81
+ const state = builds.map((build) => ({ build, next: -1, step: undefined, said: `${build.status}|${build.step}`, logsDone: !logs }));
82
+ const prefix = (build) => (many ? `[${build.platform}] ` : '');
83
+ for (;;) {
84
+ for (const entry of state) {
85
+ if (isFinished(entry.build.status) && entry.logsDone) continue;
86
+ let ended = null;
87
+ if (!isFinished(entry.build.status)) {
88
+ entry.build = (await client.request('GET', `/builds/${entry.build.id}`)).data;
89
+ const said = `${entry.build.status}|${entry.build.step}`;
90
+ if (said !== entry.said) {
91
+ entry.said = said;
92
+ // The status a build ends in goes after the last lines of its log.
93
+ if (isFinished(entry.build.status)) ended = entry.build;
94
+ else output.progress(`${prefix(entry.build)}${statusLine(entry.build)}`);
95
+ }
96
+ }
97
+ if (!entry.logsDone) {
98
+ const { data: page } = await client.request('GET', `/builds/${entry.build.id}/logs?after=${entry.next}`);
99
+ for (const chunk of page.data ?? []) {
100
+ if (chunk.step && chunk.step !== entry.step) {
101
+ entry.step = chunk.step;
102
+ output.progress(`${prefix(entry.build)}── ${chunk.step}`);
103
+ }
104
+ for (const line of String(chunk.text).replace(/\n$/, '').split('\n')) output.progress(`${prefix(entry.build)}${line}`);
105
+ }
106
+ if (Number.isInteger(page.next)) entry.next = page.next;
107
+ entry.logsDone = page.finished === true || (isFinished(entry.build.status) && (page.data ?? []).length === 0);
108
+ }
109
+ if (ended) output.progress(`${prefix(ended)}${statusLine(ended)}`);
110
+ }
111
+ if (state.every((entry) => isFinished(entry.build.status) && entry.logsDone)) return state.map((entry) => entry.build);
112
+ await wait(interval);
113
+ }
114
+ }
115
+
116
+ /** The folder the artifacts of `build` go to by default: ~/ias-builds/<app>/<platform>-<version>-<number>. */
117
+ export function defaultFolder(context, build) {
118
+ return join(context.env.HOME || homedir(), 'ias-builds', build.app, `${build.platform}-${build.version}-${build.build_number ?? build.id.slice(0, 8)}`);
119
+ }
120
+
121
+ /** Streams `url` to `path` while hashing it; the file appears only if its size and SHA-256 are the expected ones. */
122
+ async function downloadTo(url, path, { bytes, sha256 }) {
123
+ const response = await fetch(transferUrl(url));
124
+ if (!response.ok || !response.body) throw new IasError(`downloading ${path} failed: ${response.status}`, { fix: 'run it again: the download links last 15 minutes' });
125
+ const hash = createHash('sha256');
126
+ let size = 0;
127
+ const counted = new Transform({
128
+ transform(chunk, _encoding, done) {
129
+ hash.update(chunk);
130
+ size += chunk.length;
131
+ done(null, chunk);
132
+ },
133
+ });
134
+ const part = `${path}.part`;
135
+ try {
136
+ await pipeline(Readable.fromWeb(response.body), counted, createWriteStream(part, { mode: 0o644 }));
137
+ const got = hash.digest('hex');
138
+ if (size !== bytes || got !== sha256) throw new IasError(`${path} is not the file the server describes (${size} bytes, SHA-256 ${got.slice(0, 12)}…)`, { fix: 'download it again; if it goes on, the file in the bucket was altered', code: 'artifact_mismatch' });
139
+ renameSync(part, path);
140
+ } finally {
141
+ rmSync(part, { force: true });
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Downloads the artifacts of `build` (those of `kinds`, or all) into `folder`, each checked against its size and
147
+ * SHA-256. A file already there with that SHA-256 is kept. Returns [{ kind, file, path, bytes, sha256, kept }].
148
+ */
149
+ export async function downloadArtifacts(context, client, build, { folder = defaultFolder(context, build), kinds } = {}) {
150
+ const { output } = context;
151
+ const { data } = await client.request('GET', `/builds/${build.id}/artifacts`);
152
+ const wanted = (data.data ?? []).filter((artifact) => !kinds || kinds.includes(artifact.kind));
153
+ if (wanted.length === 0) {
154
+ output.progress(`${build.platform}: no artifacts${kinds ? ` of kind ${kinds.join(', ')}` : ''} to download`);
155
+ return [];
156
+ }
157
+ mkdirSync(folder, { recursive: true });
158
+ const saved = [];
159
+ for (const artifact of wanted) {
160
+ if (typeof artifact.file !== 'string' || !FILE.test(artifact.file)) throw new IasError(`the server names an artifact «${artifact.file}», which is not a file name`, { fix: 'nothing was written outside the folder; report it', code: 'artifact_name_invalid' });
161
+ const path = join(folder, artifact.file);
162
+ const kept = existsSync(path) && createHash('sha256').update(readFileSync(path)).digest('hex') === artifact.sha256;
163
+ if (!kept) await downloadTo(artifact.download_url, path, artifact);
164
+ output.progress(` ${artifact.kind.padEnd(8)} ${path}${kept ? ' (already there)' : ''}`);
165
+ saved.push({ kind: artifact.kind, file: artifact.file, path, bytes: artifact.bytes, sha256: artifact.sha256, kept });
166
+ }
167
+ return saved;
168
+ }