@imagina-build/ias-cli 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +114 -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,163 @@
|
|
|
1
|
+
// ias env list, set, pull and delete: the variables of an application, per environment (docs/technical/manage-api.md §9).
|
|
2
|
+
// EXPO_PUBLIC_* end up in the bundle, so they are always plain (PLAN §2.5 point 6). Any other variable is secret unless
|
|
3
|
+
// --plain says otherwise: it is sealed here as an environment_variable (docs/technical/vault.md §3.5) for the recovery key and
|
|
4
|
+
// the signed runners, and its value never comes back from the server. pull writes .env.local without the secrets.
|
|
5
|
+
import { Buffer } from 'node:buffer';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { loadProject } from '../../config.mjs';
|
|
10
|
+
import { IasError, UsageError } from '../../errors.mjs';
|
|
11
|
+
import { applicationOf, connect } from '../../manage.mjs';
|
|
12
|
+
import { secretValue } from '../../secret-input.mjs';
|
|
13
|
+
import { encodePlaintext, seal, verifyEnvelope } from '../../shared.mjs';
|
|
14
|
+
import { sealingKeys, vaultItem } from '../../team-vault.mjs';
|
|
15
|
+
|
|
16
|
+
export const ENVIRONMENTS = Object.freeze(['development', 'preview', 'production']);
|
|
17
|
+
const NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
18
|
+
const PULL_HEADER = '# Written by ias env pull';
|
|
19
|
+
|
|
20
|
+
const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
|
|
21
|
+
const isPublic = (name) => name.startsWith('EXPO_PUBLIC_');
|
|
22
|
+
|
|
23
|
+
function environmentOf(values, { required = false, fallback } = {}) {
|
|
24
|
+
const environment = values.environment ?? fallback;
|
|
25
|
+
if (environment === undefined && required) throw new UsageError('which environment?', { fix: `pass --environment ${ENVIRONMENTS.join('|')}` });
|
|
26
|
+
if (environment !== undefined && !ENVIRONMENTS.includes(environment)) throw new UsageError(`there is no environment «${environment}»`, { fix: `one of ${ENVIRONMENTS.join(', ')}` });
|
|
27
|
+
return environment;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function variables(client, application, environment) {
|
|
31
|
+
return (await client.request('GET', `/apps/${application}/env${environment ? `?environment=${environment}` : ''}`)).data.data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function list(context) {
|
|
35
|
+
const project = projectOf(context);
|
|
36
|
+
const client = connect(context, project);
|
|
37
|
+
const application = applicationOf(context, project);
|
|
38
|
+
const environment = environmentOf(context.values);
|
|
39
|
+
const found = await variables(client, application, environment);
|
|
40
|
+
if (found.length === 0) context.output.log(`No variables in ${application}${environment ? ` (${environment})` : ''}: ias env set adds them.`);
|
|
41
|
+
for (const variable of found) {
|
|
42
|
+
context.output.log(`${variable.environment.padEnd(11)} ${variable.name}=${variable.visibility === 'plain' ? variable.value : `(secret, version ${variable.version})`}`);
|
|
43
|
+
}
|
|
44
|
+
return { application, environment: environment ?? null, variables: found };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function set(context) {
|
|
48
|
+
const { values, positionals, env, output } = context;
|
|
49
|
+
const [name, given] = positionals;
|
|
50
|
+
if (!NAME.test(name)) throw new UsageError(`«${name}» is not a name for a variable`, { fix: 'capitals, digits and underscores, starting with a letter (up to 64)' });
|
|
51
|
+
const environment = environmentOf(values, { required: true });
|
|
52
|
+
const secret = isPublic(name) ? false : !values.plain;
|
|
53
|
+
if (isPublic(name) && values.secret) throw new IasError(`${name} cannot be secret`, { fix: 'EXPO_PUBLIC_ variables end up in the bundle: anyone with the app can read them, so they are always plain', code: 'variable_must_be_plain' });
|
|
54
|
+
if (given !== undefined && values.from !== undefined) throw new UsageError('the value comes twice', { fix: 'give it as an argument or with --from, not both' });
|
|
55
|
+
if (secret && given !== undefined) throw new IasError(`${name} is secret, and its value is on the command line`, { fix: 'a secret on the command line is seen by ps and kept in the shell history: use --from op://…, --from env:NAME or --from - (stdin); --plain if it is not a secret', code: 'secret_on_command_line' });
|
|
56
|
+
const value = given ?? (values.from !== undefined ? secretValue(values.from, { env, option: '--from' }) : undefined);
|
|
57
|
+
if (value === undefined) throw new UsageError(`what value for ${name}?`, { fix: secret ? 'give it with --from op://…, --from env:NAME or --from - (stdin)' : 'give it as the second argument, or with --from' });
|
|
58
|
+
const project = projectOf(context);
|
|
59
|
+
const client = connect(context, project);
|
|
60
|
+
const application = applicationOf(context, project);
|
|
61
|
+
const path = `/apps/${application}/env/${environment}/${name}`;
|
|
62
|
+
if (!secret) {
|
|
63
|
+
if (Buffer.byteLength(value, 'utf8') > 4096) throw new IasError(`the value of ${name} has more than 4 KB`, { fix: 'a plain variable holds up to 4 KB' });
|
|
64
|
+
const { data } = await client.request('PUT', path, { json: { visibility: 'plain', value } });
|
|
65
|
+
output.log(`${application} ${environment}: ${name}=${data.value} (plain, version ${data.version})`);
|
|
66
|
+
return data;
|
|
67
|
+
}
|
|
68
|
+
// A secret keeps the id of the variable it replaces and takes the next version; a new one gets a new id.
|
|
69
|
+
const existing = (await variables(client, application, environment)).find((variable) => variable.name === name);
|
|
70
|
+
const credential = { id: existing?.id ?? randomUUID(), type: 'environment_variable', version: existing ? existing.version + 1 : 1 };
|
|
71
|
+
const sealing = await sealingKeys(client, vaultItem(context, project, client.server), env, output);
|
|
72
|
+
const plaintext = encodePlaintext('environment_variable', { application, environment, name, value });
|
|
73
|
+
let envelope;
|
|
74
|
+
try {
|
|
75
|
+
envelope = seal(plaintext, { credential, recipients: sealing.recipients, signingKey: sealing.signingKey });
|
|
76
|
+
verifyEnvelope(envelope, sealing.keys.signing.publicKey);
|
|
77
|
+
} finally {
|
|
78
|
+
plaintext.fill(0);
|
|
79
|
+
}
|
|
80
|
+
const { data } = await client.request('PUT', path, { json: { visibility: 'secret', id: credential.id, envelope } });
|
|
81
|
+
output.log(`${application} ${environment}: ${name} (secret, version ${data.version}), sealed for ${envelope.recipients.length} key${envelope.recipients.length === 1 ? '' : 's'}`);
|
|
82
|
+
return data;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A value as dotenv reads it: bare when it is safe, in double quotes with escapes otherwise. */
|
|
86
|
+
export function dotenvValue(value) {
|
|
87
|
+
return /^[A-Za-z0-9_./:@%+,=-]*$/.test(value) ? value : JSON.stringify(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function pull(context) {
|
|
91
|
+
const { values, cwd, output } = context;
|
|
92
|
+
const project = projectOf(context);
|
|
93
|
+
const client = connect(context, project);
|
|
94
|
+
const application = applicationOf(context, project);
|
|
95
|
+
const environment = environmentOf(values, { fallback: 'development' });
|
|
96
|
+
const file = resolve(project.dir ?? cwd, values.file ?? '.env.local');
|
|
97
|
+
if (existsSync(file) && !values.force && !readFileSync(file, 'utf8').startsWith(PULL_HEADER)) {
|
|
98
|
+
throw new IasError(`${file} exists and ias env pull did not write it`, { fix: 'move it away, or pass --force to write over it' });
|
|
99
|
+
}
|
|
100
|
+
const found = await variables(client, application, environment);
|
|
101
|
+
const plain = found.filter((variable) => variable.visibility === 'plain');
|
|
102
|
+
const secrets = found.filter((variable) => variable.visibility !== 'plain');
|
|
103
|
+
const lines = [
|
|
104
|
+
`${PULL_HEADER} --environment ${environment} (${application}, ${client.server}).`,
|
|
105
|
+
'# Only the plain variables: secrets never leave the vault; the runner opens them when it builds.',
|
|
106
|
+
...plain.map((variable) => `${variable.name}=${dotenvValue(variable.value)}`),
|
|
107
|
+
...secrets.map((variable) => `# ${variable.name} is secret: not written`),
|
|
108
|
+
];
|
|
109
|
+
writeFileSync(file, `${lines.join('\n')}\n`, { mode: 0o600 });
|
|
110
|
+
output.log(`wrote ${plain.length} plain variable${plain.length === 1 ? '' : 's'} of ${environment} to ${file}${secrets.length > 0 ? `; left out ${secrets.length} secret${secrets.length === 1 ? '' : 's'}` : ''}`);
|
|
111
|
+
return { application, environment, file, written: plain.map((variable) => variable.name), secret: secrets.map((variable) => variable.name) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function remove(context) {
|
|
115
|
+
const [name] = context.positionals;
|
|
116
|
+
const environment = environmentOf(context.values, { required: true });
|
|
117
|
+
const project = projectOf(context);
|
|
118
|
+
const client = connect(context, project);
|
|
119
|
+
const application = applicationOf(context, project);
|
|
120
|
+
await client.request('DELETE', `/apps/${application}/env/${environment}/${name}`);
|
|
121
|
+
context.output.log(`${application} ${environment}: ${name} deleted`);
|
|
122
|
+
return { application, environment, name, deleted: true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const app = { app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' } };
|
|
126
|
+
const environmentOption = (description) => ({ environment: { type: 'string', value: '<environment>', description } });
|
|
127
|
+
const nameArgument = { name: 'name', description: 'The name: capitals, digits and underscores (EXPO_PUBLIC_… are always plain).' };
|
|
128
|
+
|
|
129
|
+
export default {
|
|
130
|
+
name: 'env',
|
|
131
|
+
summary: 'The variables of an application per environment: list, set, pull and delete',
|
|
132
|
+
description: 'EXPO_PUBLIC_* are always plain: they end up in the bundle. Any other variable is secret unless --plain: it is sealed on this computer and its value never comes back.',
|
|
133
|
+
subcommands: [
|
|
134
|
+
{ name: 'list', summary: 'The variables, with the values of the plain ones', options: { ...app, ...environmentOption('development, preview or production (all three by default).') }, run: list },
|
|
135
|
+
{
|
|
136
|
+
name: 'set',
|
|
137
|
+
summary: 'Create or replace a variable',
|
|
138
|
+
description: 'A plain value can be the second argument. A secret one comes with --from: op://… (1Password), env:NAME (a variable of this shell) or - (stdin), never on the command line.',
|
|
139
|
+
positionals: [nameArgument, { name: 'value', optional: true, description: 'The value of a plain variable.' }],
|
|
140
|
+
options: {
|
|
141
|
+
...app,
|
|
142
|
+
...environmentOption('development, preview or production.'),
|
|
143
|
+
plain: { type: 'boolean', description: 'Keep it in plain (visible in the panel); EXPO_PUBLIC_* always are.' },
|
|
144
|
+
secret: { type: 'boolean', description: 'Seal it (the default for any name but EXPO_PUBLIC_*).' },
|
|
145
|
+
from: { type: 'string', value: '<op://…|env:NAME|->', description: 'Where the value is.' },
|
|
146
|
+
item: { type: 'string', value: '<op://vault/item>', description: 'The 1Password item of the vault, to seal a secret.' },
|
|
147
|
+
},
|
|
148
|
+
run: set,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'pull',
|
|
152
|
+
summary: 'Write the plain variables of an environment to .env.local, without the secrets',
|
|
153
|
+
options: {
|
|
154
|
+
...app,
|
|
155
|
+
...environmentOption('development (by default), preview or production.'),
|
|
156
|
+
file: { type: 'string', value: '<file>', description: 'Where to write (.env.local of the app by default).' },
|
|
157
|
+
force: { type: 'boolean', description: 'Write over a file that ias env pull did not write.' },
|
|
158
|
+
},
|
|
159
|
+
run: pull,
|
|
160
|
+
},
|
|
161
|
+
{ name: 'delete', summary: 'Delete a variable of an environment', positionals: [nameArgument], options: { ...app, ...environmentOption('development, preview or production.') }, run: remove },
|
|
162
|
+
],
|
|
163
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// ias github link (docs/technical/manage-api.md §16.2): link the GitHub repository an application is built from. The server
|
|
2
|
+
// makes the secret of the webhook GitHub will call and gives it once; the token of GitHub is loaded in the panel by an
|
|
3
|
+
// owner with a passkey, never from here.
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { loadProject } from '../../config.mjs';
|
|
6
|
+
import { UsageError } from '../../errors.mjs';
|
|
7
|
+
import { applicationOf, connect, serverOrigin } from '../../manage.mjs';
|
|
8
|
+
|
|
9
|
+
const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
|
|
10
|
+
|
|
11
|
+
/** owner/name from owner/name, https://github.com/owner/name(.git) or git@github.com:owner/name.git. */
|
|
12
|
+
export function repositoryOf(text) {
|
|
13
|
+
const match = /^(?:https:\/\/github\.com\/|git@github\.com:)?([A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}?)(?:\.git)?\/?$/.exec(text.trim());
|
|
14
|
+
if (!match) throw new UsageError(`«${text}» is not a GitHub repository`, { fix: 'owner/name, such as imagina/jscheduler' });
|
|
15
|
+
return match[1];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function link(context) {
|
|
19
|
+
const { positionals, output } = context;
|
|
20
|
+
const project = projectOf(context);
|
|
21
|
+
const code = applicationOf(context, project);
|
|
22
|
+
const repository = repositoryOf(positionals[0]);
|
|
23
|
+
const { status, data } = await connect(context, project).request('PUT', `/apps/${code}/github`, { json: { repository } });
|
|
24
|
+
const panel = `${serverOrigin(project, context.env)}/admin/applications/${code}/integrations`;
|
|
25
|
+
output.log(`${code} is linked to github.com/${data.repository}.`);
|
|
26
|
+
if (data.webhook_secret) {
|
|
27
|
+
output.log('In the repository on GitHub (Settings › Webhooks › Add webhook):');
|
|
28
|
+
output.log(` Payload URL ${data.webhook_url}`);
|
|
29
|
+
output.log(' Content type application/json');
|
|
30
|
+
output.log(` Secret ${data.webhook_secret}`);
|
|
31
|
+
output.log(' Events Pushes and Pull requests');
|
|
32
|
+
output.log(' This is the only time the secret is shown: keep it in 1Password.');
|
|
33
|
+
} else {
|
|
34
|
+
output.log(` The webhook is the one GitHub already has (${data.webhook_url}); the secret is not shown again.`);
|
|
35
|
+
}
|
|
36
|
+
output.log(data.token_set ? ' The token of GitHub is loaded.' : ` Next: an owner loads the token of GitHub in the panel, with a passkey: ${panel}`);
|
|
37
|
+
return { ...data, created: status === 201 };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default {
|
|
41
|
+
name: 'github',
|
|
42
|
+
summary: 'Link the GitHub repository of an application, so its tags, branches and pull requests start workflows',
|
|
43
|
+
description: 'The tags, branches and pull requests of the repository start the workflows of ias.json, GitHub gets the status of each build on its commit, and the internal builds of a pull request leave their install link in a comment. Only owners link a repository.',
|
|
44
|
+
subcommands: [
|
|
45
|
+
{
|
|
46
|
+
name: 'link',
|
|
47
|
+
summary: 'Link a repository; the first time, print the secret of the webhook GitHub will call',
|
|
48
|
+
positionals: [{ name: 'repository', description: 'owner/name, or its https://github.com URL.' }],
|
|
49
|
+
options: { app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' } },
|
|
50
|
+
run: link,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// ias init: writes the app's ias.json from what it already has: app.json, eas.json (its build and submit profiles),
|
|
2
|
+
// ota.config.json and dist.config.json. Without secrets: only op:// references go in, and what cannot go is said.
|
|
3
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { CONFIG_FILE, DEFAULT_SERVER, LEGACY_FILES, MANIFEST_PATH, readExpo, readJson } from '../../config.mjs';
|
|
6
|
+
import { IasError } from '../../errors.mjs';
|
|
7
|
+
import { validate } from '../../schema.mjs';
|
|
8
|
+
import { isReference, writtenSecrets } from '../../secrets.mjs';
|
|
9
|
+
|
|
10
|
+
const ENVIRONMENTS = ['development', 'preview', 'production'];
|
|
11
|
+
const CODE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
12
|
+
const PROFILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,49}$/;
|
|
13
|
+
|
|
14
|
+
/** Why a field of eas.json does not go to ias.json. */
|
|
15
|
+
const LEFT_OUT = {
|
|
16
|
+
env: 'variables go to ias env (track 1D), sealed when they are secret',
|
|
17
|
+
credentialsSource: 'credentials go to the vault (ias credentials, track 1D)',
|
|
18
|
+
ascApiKeyPath: 'credentials go to the vault (ias credentials, track 1D)',
|
|
19
|
+
ascApiKeyId: 'credentials go to the vault (ias credentials, track 1D)',
|
|
20
|
+
ascApiKeyIssuerId: 'credentials go to the vault (ias credentials, track 1D)',
|
|
21
|
+
serviceAccountKeyPath: 'credentials go to the vault (ias credentials, track 1D)',
|
|
22
|
+
appleId: 'credentials go to the vault (ias credentials, track 1D)',
|
|
23
|
+
};
|
|
24
|
+
const leftOutReason = (key) => LEFT_OUT[key] ?? 'ias.json has no field for it yet (phase 2 completes the build and submit profiles)';
|
|
25
|
+
|
|
26
|
+
/** The code of an application made from a slug: lowercase letters, digits and hyphens, up to 32. */
|
|
27
|
+
const codeFromSlug = (slug) => (typeof slug === 'string' ? slug.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32) : undefined);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The ias.json of the app in `dir`, from its files: { config, from, leftOut }. `from` says where each part came from;
|
|
31
|
+
* `leftOut`, what stayed out and why (a secret written in a file, a variable, a field ias.json does not have yet).
|
|
32
|
+
*/
|
|
33
|
+
export function initialConfig(dir, { app: appOption } = {}) {
|
|
34
|
+
const read = (name) => (existsSync(join(dir, name)) ? readJson(join(dir, name), name, `fix ${name}, or move it away and run ias init again`) : undefined);
|
|
35
|
+
const expo = readExpo(dir);
|
|
36
|
+
const ota = read(LEGACY_FILES.updates);
|
|
37
|
+
const dist = read(LEGACY_FILES.distribution);
|
|
38
|
+
const eas = read('eas.json');
|
|
39
|
+
if (!expo && !ota && !dist && !eas) {
|
|
40
|
+
const dynamic = ['app.config.js', 'app.config.ts'].find((name) => existsSync(join(dir, name)));
|
|
41
|
+
throw new IasError(dynamic ? `${dynamic} is not read: ias init reads app.json` : `no app.json, eas.json, ota.config.json or dist.config.json in ${dir}`, {
|
|
42
|
+
fix: dynamic ? 'write the configuration of the app as app.json, or write ias.json by hand (packages/cli/schema/ias.schema.json)' : 'run ias init in the folder of the Expo app, or pass --project <app folder>',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const from = {};
|
|
46
|
+
const leftOut = [];
|
|
47
|
+
const leave = (path, why) => leftOut.push({ path, why });
|
|
48
|
+
|
|
49
|
+
// Where the builds look for updates says the server and the OTA project.
|
|
50
|
+
const updatesUrl = expo?.updates?.url;
|
|
51
|
+
const manifest = URL.canParse(updatesUrl ?? '') ? new URL(updatesUrl) : undefined;
|
|
52
|
+
const otaProject = manifest?.pathname.match(MANIFEST_PATH)?.[1];
|
|
53
|
+
if (updatesUrl && !otaProject) leave('updates', `updates.url of app.json (${updatesUrl}) is not the manifest of an IAS OTA project`);
|
|
54
|
+
|
|
55
|
+
const distServer = URL.canParse(dist?.server ?? '') ? new URL(dist.server).origin : undefined;
|
|
56
|
+
let server = DEFAULT_SERVER;
|
|
57
|
+
from.server = 'the default, which does not answer yet: until it does, set server (or IAS_SERVER) to https://sync.domibi.com';
|
|
58
|
+
if (otaProject) {
|
|
59
|
+
server = manifest.origin;
|
|
60
|
+
from.server = 'updates.url of app.json';
|
|
61
|
+
if (distServer && distServer !== server) leave('server', `dist.config.json uploads to ${distServer}, but updates.url is on ${server}: ias.json takes ${server} for both`);
|
|
62
|
+
} else if (distServer) {
|
|
63
|
+
server = distServer;
|
|
64
|
+
from.server = 'dist.config.json';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const app = appOption ?? otaProject ?? dist?.project ?? codeFromSlug(expo?.slug);
|
|
68
|
+
from.app = appOption ? '--app' : otaProject ? 'updates.url of app.json' : dist?.project ? 'project of dist.config.json' : 'the slug of app.json (check it is the code of php artisan apps:create)';
|
|
69
|
+
if (!app) throw new IasError('there is no code for the application', { fix: 'pass --app <code>, the one of php artisan apps:create' });
|
|
70
|
+
if (!CODE.test(app)) throw new IasError(`the code of the application (${app}) is not valid`, { fix: 'pass --app <code>: lowercase letters, digits and hyphens, up to 32, as php artisan apps:create takes them' });
|
|
71
|
+
|
|
72
|
+
const config = { app, server };
|
|
73
|
+
|
|
74
|
+
const build = buildProfiles(eas?.build, leave);
|
|
75
|
+
if (build) {
|
|
76
|
+
config.build = build;
|
|
77
|
+
from.build = 'eas.json';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (otaProject || ota) {
|
|
81
|
+
const updates = {};
|
|
82
|
+
const signing = {};
|
|
83
|
+
const keyId = expo?.updates?.codeSigningMetadata?.keyid;
|
|
84
|
+
if (keyId) signing.keyId = keyId;
|
|
85
|
+
if (ota?.privateKey !== undefined) {
|
|
86
|
+
if (isReference(ota.privateKey)) signing.privateKey = ota.privateKey;
|
|
87
|
+
else leave('updates.signing.privateKey', 'ota.config.json points at a file, and ias.json only takes op:// references: keep the key in 1Password, or set IAS_UPDATES_PRIVATE_KEY to that file when publishing');
|
|
88
|
+
}
|
|
89
|
+
if (Object.keys(signing).length > 0) updates.signing = signing;
|
|
90
|
+
if (ota?.token !== undefined) {
|
|
91
|
+
if (isReference(ota.token)) updates.token = ota.token;
|
|
92
|
+
else leave('updates.token', 'ota.config.json has the token itself, which never goes to the repository: keep it in 1Password (and rotate it with php artisan ota:token)');
|
|
93
|
+
}
|
|
94
|
+
if (ota?.envFile) updates.envFile = ota.envFile;
|
|
95
|
+
if (Array.isArray(ota?.bundleEnv) && ota.bundleEnv.length > 0) updates.bundleEnv = ota.bundleEnv;
|
|
96
|
+
if (!otaProject) leave('updates', 'app.json has no updates.url of IAS: ias update needs it, and a build that carries it');
|
|
97
|
+
config.updates = updates;
|
|
98
|
+
from.updates = [keyId && 'app.json', ota && LEGACY_FILES.updates].filter(Boolean).join(' and ') || 'app.json';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (dist) {
|
|
102
|
+
const distribution = {};
|
|
103
|
+
if (dist.project && dist.project !== app) distribution.project = dist.project;
|
|
104
|
+
if (dist.token !== undefined) {
|
|
105
|
+
if (isReference(dist.token)) distribution.token = dist.token;
|
|
106
|
+
else leave('distribution.token', 'dist.config.json has the token itself, which never goes to the repository: keep it in 1Password (and rotate it with php artisan dist:token)');
|
|
107
|
+
}
|
|
108
|
+
config.distribution = distribution;
|
|
109
|
+
from.distribution = LEGACY_FILES.distribution;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const submit = submitProfiles(eas?.submit, leave);
|
|
113
|
+
if (submit) {
|
|
114
|
+
config.submit = submit;
|
|
115
|
+
from.submit = 'eas.json';
|
|
116
|
+
}
|
|
117
|
+
for (const key of Object.keys(eas ?? {}).filter((candidate) => !['build', 'submit'].includes(candidate))) leave(`eas.json › ${key}`, key === 'cli' ? 'it configures eas-cli (and with appVersionSource remote, EAS keeps the build numbers: phase 2 decides where IAS keeps them)' : leftOutReason(key));
|
|
118
|
+
|
|
119
|
+
const secrets = writtenSecrets(config);
|
|
120
|
+
const errors = validate(config);
|
|
121
|
+
if (secrets.length > 0 || errors.length > 0) throw new Error(`ias init made an ias.json that is not valid (${[...secrets, ...errors].join('; ')}): this is a bug of the CLI`);
|
|
122
|
+
return { config, from, leftOut };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** eas.json › build as ias.json › build: the same profiles, the fields ias.json has, and the defaults of EAS made explicit. */
|
|
126
|
+
function buildProfiles(profiles, leave) {
|
|
127
|
+
if (!profiles || typeof profiles !== 'object') return undefined;
|
|
128
|
+
const build = {};
|
|
129
|
+
for (const [name, profile] of Object.entries(profiles)) {
|
|
130
|
+
if (!PROFILE.test(name) || !profile || typeof profile !== 'object') {
|
|
131
|
+
leave(`build.${name}`, 'the name of a profile is letters, digits, dots, hyphens and underscores');
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const out = {};
|
|
135
|
+
const inherits = typeof profile.extends === 'string';
|
|
136
|
+
if (inherits) out.extends = profile.extends;
|
|
137
|
+
if (['store', 'internal'].includes(profile.distribution)) out.distribution = profile.distribution;
|
|
138
|
+
else if (profile.distribution !== undefined) leave(`build.${name}.distribution`, `ias.json takes store or internal, not ${profile.distribution}`);
|
|
139
|
+
if (!inherits) out.distribution ??= 'store';
|
|
140
|
+
if (ENVIRONMENTS.includes(profile.environment)) out.environment = profile.environment;
|
|
141
|
+
else if (!inherits) out.environment = ENVIRONMENTS.includes(name) ? name : profile.developmentClient ? 'development' : out.distribution === 'internal' ? 'preview' : 'production';
|
|
142
|
+
if (typeof profile.channel === 'string' && PROFILE.test(profile.channel)) out.channel = profile.channel;
|
|
143
|
+
if (typeof profile.developmentClient === 'boolean') out.developmentClient = profile.developmentClient;
|
|
144
|
+
if (typeof profile.autoIncrement === 'boolean' || ['version', 'buildNumber', 'versionCode'].includes(profile.autoIncrement)) out.autoIncrement = profile.autoIncrement;
|
|
145
|
+
const ios = {};
|
|
146
|
+
if (typeof profile.ios?.simulator === 'boolean') ios.simulator = profile.ios.simulator;
|
|
147
|
+
if (Object.keys(ios).length > 0) out.ios = ios;
|
|
148
|
+
const android = {};
|
|
149
|
+
const buildType = profile.android?.buildType;
|
|
150
|
+
if (buildType === 'apk' || buildType === 'app-bundle') android.format = buildType === 'apk' ? 'apk' : 'aab';
|
|
151
|
+
else if (!inherits && !profile.android?.gradleCommand) android.format = out.distribution === 'internal' ? 'apk' : 'aab';
|
|
152
|
+
if (Object.keys(android).length > 0) out.android = android;
|
|
153
|
+
const known = ['extends', 'distribution', 'environment', 'channel', 'developmentClient', 'autoIncrement', 'ios', 'android'];
|
|
154
|
+
for (const key of Object.keys(profile).filter((candidate) => !known.includes(candidate))) leave(`build.${name}.${key}`, leftOutReason(key));
|
|
155
|
+
for (const platform of ['ios', 'android']) {
|
|
156
|
+
const taken = platform === 'ios' ? ['simulator'] : ['buildType'];
|
|
157
|
+
for (const key of Object.keys(profile[platform] ?? {}).filter((candidate) => !taken.includes(candidate))) leave(`build.${name}.${platform}.${key}`, leftOutReason(key));
|
|
158
|
+
}
|
|
159
|
+
build[name] = out;
|
|
160
|
+
}
|
|
161
|
+
return Object.keys(build).length > 0 ? build : undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** eas.json › submit as ias.json › submit: the identifiers of the stores, without credentials. */
|
|
165
|
+
function submitProfiles(profiles, leave) {
|
|
166
|
+
if (!profiles || typeof profiles !== 'object') return undefined;
|
|
167
|
+
const fields = { ios: ['ascAppId', 'appleTeamId'], android: ['track', 'releaseStatus', 'rollout', 'changesNotSentForReview'] };
|
|
168
|
+
const submit = {};
|
|
169
|
+
for (const [name, profile] of Object.entries(profiles)) {
|
|
170
|
+
if (!PROFILE.test(name) || !profile || typeof profile !== 'object') {
|
|
171
|
+
leave(`submit.${name}`, 'the name of a profile is letters, digits, dots, hyphens and underscores');
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const out = {};
|
|
175
|
+
for (const [platform, taken] of Object.entries(fields)) {
|
|
176
|
+
const source = profile[platform] ?? {};
|
|
177
|
+
const kept = Object.fromEntries(taken.filter((key) => source[key] !== undefined).map((key) => [key, key === 'ascAppId' ? String(source[key]) : source[key]]));
|
|
178
|
+
if (Object.keys(kept).length > 0) out[platform] = kept;
|
|
179
|
+
for (const key of Object.keys(source).filter((candidate) => !taken.includes(candidate))) leave(`submit.${name}.${platform}.${key}`, leftOutReason(key));
|
|
180
|
+
}
|
|
181
|
+
for (const key of Object.keys(profile).filter((candidate) => !['ios', 'android'].includes(candidate))) leave(`submit.${name}.${key}`, leftOutReason(key));
|
|
182
|
+
submit[name] = out;
|
|
183
|
+
}
|
|
184
|
+
return Object.keys(submit).length > 0 ? submit : undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Writes ias.json (unless it exists, or with force) and says where each part came from and what stayed out. */
|
|
188
|
+
export function init(dir, { app, force = false, dryRun = false }, output) {
|
|
189
|
+
const file = join(dir, CONFIG_FILE);
|
|
190
|
+
if (existsSync(file) && !force && !dryRun) throw new IasError(`${CONFIG_FILE} already exists in ${dir}`, { fix: 'ias doctor checks it; ias init --force writes it again, and loses what was changed by hand' });
|
|
191
|
+
const { config, from, leftOut } = initialConfig(dir, { app });
|
|
192
|
+
const text = `${JSON.stringify(config, null, 2)}\n`;
|
|
193
|
+
if (dryRun) output.progress(text.trimEnd());
|
|
194
|
+
else writeFileSync(file, text);
|
|
195
|
+
output.log(`${output.prefix}: ${dryRun ? 'would write' : 'wrote'} ${file}`);
|
|
196
|
+
for (const part of Object.keys(config)) output.log(` ${part.padEnd(13)} ${describe(part, config[part])}, from ${from[part]}`);
|
|
197
|
+
if (leftOut.length > 0) {
|
|
198
|
+
output.log('left out:');
|
|
199
|
+
for (const { path, why } of leftOut) output.log(` ${path}: ${why}`);
|
|
200
|
+
}
|
|
201
|
+
const legacy = Object.values(LEGACY_FILES).filter((name) => existsSync(join(dir, name)));
|
|
202
|
+
if (legacy.length > 0) output.log(`${legacy.join(' and ')} can go once nothing runs tools/ any more: ias reads ias.json first.`);
|
|
203
|
+
output.log('ias doctor checks it; ias sdk install puts the SDK @imagina-build/ias in the app, with its plugin, which writes ias.json into the builds.');
|
|
204
|
+
return { file, written: !dryRun, config, from, left_out: leftOut };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function describe(part, value) {
|
|
208
|
+
if (part === 'build' || part === 'submit') return Object.keys(value).join(', ');
|
|
209
|
+
if (part === 'updates') return [value.signing?.keyId && `keyid ${value.signing.keyId}`, value.signing?.privateKey && 'key in 1Password', value.token && 'token in 1Password', value.bundleEnv && `bundleEnv ${value.bundleEnv.join(', ')}`].filter(Boolean).join(', ') || 'no key or token yet';
|
|
210
|
+
if (part === 'distribution') return [value.project && `project ${value.project}`, value.token && 'token in 1Password'].filter(Boolean).join(', ') || 'no token yet';
|
|
211
|
+
return String(value);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export default {
|
|
215
|
+
name: 'init',
|
|
216
|
+
summary: 'Write ias.json from app.json, eas.json, ota.config.json and dist.config.json, without secrets',
|
|
217
|
+
description: 'The server comes from updates.url of app.json, or dist.config.json, or is https://ias.imagina.build. The application\'s code, from updates.url, dist.config.json or --app.',
|
|
218
|
+
options: {
|
|
219
|
+
app: { type: 'string', value: '<code>', description: 'The code of the application (php artisan apps:create), if it cannot be read from updates.url or dist.config.json.' },
|
|
220
|
+
force: { type: 'boolean', description: 'Write ias.json again if it exists.' },
|
|
221
|
+
'dry-run': { type: 'boolean', description: 'Print the ias.json it would write, and write nothing.' },
|
|
222
|
+
},
|
|
223
|
+
run: ({ values, cwd, output }) => init(resolve(cwd, values.project ?? '.'), { app: values.app, force: values.force, dryRun: values['dry-run'] }, output),
|
|
224
|
+
};
|