@imagina-build/ias-cli 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -21
- package/package.json +1 -1
- package/schema/ias.schema.json +46 -1
- package/shared/vault/index.mjs +25 -0
- package/shared/vault/policy-vectors.mjs +237 -0
- package/shared/vault/policy.mjs +427 -0
- package/shared/vault/test-vectors/ias-policy-1/policy.json +4016 -0
- package/shared/vault/vectors.mjs +2 -0
- package/src/apple/capabilities.mjs +96 -0
- package/src/apple/inventory.mjs +75 -0
- package/src/apple/signing.mjs +134 -0
- package/src/applications.mjs +114 -0
- package/src/cli.mjs +28 -10
- package/src/commands/apps/index.mjs +61 -11
- package/src/commands/apps/store.mjs +367 -0
- package/src/commands/auth/index.mjs +3 -3
- package/src/commands/build/index.mjs +2 -0
- package/src/commands/builds/index.mjs +20 -0
- package/src/commands/credentials/apple.mjs +1118 -0
- package/src/commands/credentials/generate.mjs +122 -0
- package/src/commands/credentials/index.mjs +114 -44
- package/src/commands/credentials/seal.mjs +44 -0
- package/src/commands/devices/asc.mjs +50 -8
- package/src/commands/devices/index.mjs +2 -1
- package/src/commands/devices/sync.mjs +57 -4
- package/src/commands/dist/index.mjs +12 -0
- package/src/commands/github/index.mjs +13 -3
- package/src/commands/policy/index.mjs +583 -0
- package/src/commands/policy/policy.mjs +182 -0
- package/src/commands/policy/progress.mjs +20 -0
- package/src/commands/releases/index.mjs +225 -0
- package/src/commands/rollout/index.mjs +186 -0
- package/src/commands/runners/index.mjs +19 -0
- package/src/commands/sdk/index.mjs +31 -4
- package/src/commands/setup/index.mjs +184 -0
- package/src/commands/submit/index.mjs +135 -19
- package/src/commands/testflight/index.mjs +309 -0
- package/src/commands/tokens/index.mjs +99 -0
- package/src/commands/update/index.mjs +42 -15
- package/src/commands/vault/approvers.mjs +5 -1
- package/src/commands/vault/index.mjs +26 -6
- package/src/commands/vault/runners.mjs +48 -2
- package/src/commands/webhooks/index.mjs +19 -5
- package/src/config.mjs +16 -1
- package/src/errors.mjs +52 -6
- package/src/op.mjs +83 -0
- package/src/ota-key.mjs +53 -0
- package/src/provisioning.mjs +281 -0
- package/src/schema.mjs +3 -0
- package/src/setup-state.mjs +76 -0
- package/src/setup.mjs +698 -0
- package/src/team-vault.mjs +2 -2
- package/src/token-store.mjs +1 -1
|
@@ -16,12 +16,19 @@ export function repositoryOf(text) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
async function link(context) {
|
|
19
|
-
const { positionals, output } = context;
|
|
19
|
+
const { values, positionals, output } = context;
|
|
20
20
|
const project = projectOf(context);
|
|
21
21
|
const code = applicationOf(context, project);
|
|
22
22
|
const repository = repositoryOf(positionals[0]);
|
|
23
|
-
const { status, data } = await connect(context, project).request('PUT', `/apps/${code}/github`, { json: { repository } });
|
|
24
23
|
const panel = `${serverOrigin(project, context.env)}/admin/applications/${code}/integrations`;
|
|
24
|
+
if (values['dry-run']) {
|
|
25
|
+
output.progress(`dry run: would link ${code} to github.com/${repository} (PUT /apps/${code}/github)`);
|
|
26
|
+
output.progress(' the server would make the secret of the webhook GitHub calls, or keep the one it already has, and show it once');
|
|
27
|
+
output.progress(` the token of GitHub is loaded in the panel, with a passkey: ${panel}`);
|
|
28
|
+
output.progress('nothing was linked and no secret was shown.');
|
|
29
|
+
return { dry_run: true, app: code, repository, created: false, webhook_url: null, webhook_secret: null, token_set: false };
|
|
30
|
+
}
|
|
31
|
+
const { status, data } = await connect(context, project).request('PUT', `/apps/${code}/github`, { json: { repository } });
|
|
25
32
|
output.log(`${code} is linked to github.com/${data.repository}.`);
|
|
26
33
|
if (data.webhook_secret) {
|
|
27
34
|
output.log('In the repository on GitHub (Settings › Webhooks › Add webhook):');
|
|
@@ -46,7 +53,10 @@ export default {
|
|
|
46
53
|
name: 'link',
|
|
47
54
|
summary: 'Link a repository; the first time, print the secret of the webhook GitHub will call',
|
|
48
55
|
positionals: [{ name: 'repository', description: 'owner/name, or its https://github.com URL.' }],
|
|
49
|
-
options: {
|
|
56
|
+
options: {
|
|
57
|
+
app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' },
|
|
58
|
+
'dry-run': { type: 'boolean', description: 'Say what it would link, without linking it or showing the webhook secret.' },
|
|
59
|
+
},
|
|
50
60
|
run: link,
|
|
51
61
|
},
|
|
52
62
|
],
|
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
// ias policy: the signed approval policy of the team (ias-policy-1, docs/plans/gaps/06-politica-aprobacion.md §3.8 and
|
|
2
|
+
// docs/technical/vault.md §2.5). init writes ias.policy.json equivalent to today (one * rule, one approval, every owner
|
|
3
|
+
// with a passkey); sign reads the team's Ed25519 key from 1Password, shows the diff, signs and uploads it, and an owner
|
|
4
|
+
// approves the upload with a passkey. check, diff, simulate, show and status read and compare; they never write. The
|
|
5
|
+
// server evaluates the policy when it creates a store job and when it approves one; the runner verifies it again.
|
|
6
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { loadProject } from '../../config.mjs';
|
|
10
|
+
import { IasError, UsageError } from '../../errors.mjs';
|
|
11
|
+
import { connect, openUrl, sleep, waitForApproval } from '../../manage.mjs';
|
|
12
|
+
import { EXIT_CODE } from '../../output.mjs';
|
|
13
|
+
import { POLICY_FORMAT, policyHash, signPolicy, simulate, summary, verifyPolicy } from '../../shared.mjs';
|
|
14
|
+
import { vaultItem } from '../../team-vault.mjs';
|
|
15
|
+
import { confirm, teamKeys } from '../vault/team.mjs';
|
|
16
|
+
import {
|
|
17
|
+
MAX_VERSION,
|
|
18
|
+
POLICY_FILE,
|
|
19
|
+
daysUntil,
|
|
20
|
+
defaultNotAfter,
|
|
21
|
+
diffLine,
|
|
22
|
+
diffPolicies,
|
|
23
|
+
holdersOf,
|
|
24
|
+
membersOf,
|
|
25
|
+
namesOf,
|
|
26
|
+
notAfterOf,
|
|
27
|
+
policyPath,
|
|
28
|
+
problemOf,
|
|
29
|
+
readPolicyFile,
|
|
30
|
+
requirementOf,
|
|
31
|
+
scopeOf,
|
|
32
|
+
serverPolicy,
|
|
33
|
+
signingKeyOfTeam,
|
|
34
|
+
unsignedOf,
|
|
35
|
+
verifiedPolicy,
|
|
36
|
+
} from './policy.mjs';
|
|
37
|
+
|
|
38
|
+
const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
|
|
39
|
+
|
|
40
|
+
/** The version to write: --version, or the next one above the server's. */
|
|
41
|
+
function versionOf(value, current) {
|
|
42
|
+
const version = value === undefined ? (current?.version ?? 0) + 1 : Number(value);
|
|
43
|
+
if (!Number.isInteger(version) || version < 1 || version > MAX_VERSION) {
|
|
44
|
+
throw new IasError(`«${value}» is not a policy version`, { fix: `an integer from 1 to ${MAX_VERSION}, above the one the server has`, code: 'policy.version' });
|
|
45
|
+
}
|
|
46
|
+
if (current?.version !== null && current?.version !== undefined && version <= current.version) {
|
|
47
|
+
throw new IasError(`the newest policy is version ${current.version}`, { fix: `sign a higher version: ias policy init --version ${current.version + 1}`, code: 'policy_version_backwards', details: { current: current.version } });
|
|
48
|
+
}
|
|
49
|
+
return version;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The rules an init writes: one * rule equivalent to today, with every owner that has a passkey. */
|
|
53
|
+
const wildcardRule = (members) => ({ id: 'heredado', app: '*', platform: '*', profile: '*', type: '*', target: '*', track: '*', require: { members, count: 1 } });
|
|
54
|
+
|
|
55
|
+
/** ias policy init: ias.policy.json with a policy equivalent to today. It never signs it. */
|
|
56
|
+
async function init(context) {
|
|
57
|
+
const { values, output } = context;
|
|
58
|
+
const project = projectOf(context);
|
|
59
|
+
const client = connect(context, project);
|
|
60
|
+
const current = await serverPolicy(client);
|
|
61
|
+
const listed = (await client.request('GET', '/team/approvers')).data;
|
|
62
|
+
const members = holdersOf(listed);
|
|
63
|
+
|
|
64
|
+
if (members.length === 0) {
|
|
65
|
+
throw new IasError('no owner of the team has a passkey to name in the policy', { fix: 'an owner signs the passkeys of the owners first: ias vault approvers', code: 'no_approvers' });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const version = versionOf(values.version, current);
|
|
69
|
+
const notAfter = values['not-after'] === undefined ? defaultNotAfter() : notAfterOf(values['not-after']);
|
|
70
|
+
if (Date.parse(notAfter) <= Date.now()) {
|
|
71
|
+
throw new IasError(`not_after ${notAfter} has already passed`, { fix: 'choose a future date, such as ias policy init --not-after 2027-04-28', code: 'policy.not_after' });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const document = { format: POLICY_FORMAT, version, not_after: notAfter, rules: [wildcardRule(members)] };
|
|
75
|
+
const problem = problemOf(document);
|
|
76
|
+
if (problem) throw new IasError(`the policy ias would write is not valid (${problem.code}): ${problem.message}`, { fix: 'report it: ias policy init builds it from the same rules the server checks', code: problem.code });
|
|
77
|
+
|
|
78
|
+
const path = policyPath(context, values.out);
|
|
79
|
+
if (existsSync(path) && values.force !== true) {
|
|
80
|
+
throw new IasError(`${path} already exists`, { fix: `edit it and sign it (ias policy sign ${path === policyPath(context) ? '' : path}), or pass --force to write it again`, code: 'policy_file_exists' });
|
|
81
|
+
}
|
|
82
|
+
writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
|
|
83
|
+
|
|
84
|
+
output.log(`${path}: version ${version}, 1 wildcard rule and ${members.length} member${members.length === 1 ? '' : 's'} with a passkey. It expires on ${notAfter.slice(0, 10)}.`);
|
|
85
|
+
output.log(`It is not signed yet: ias policy sign${path === policyPath(context) ? '' : ` ${path}`} (an owner, with the 1Password key and a passkey).`);
|
|
86
|
+
return {
|
|
87
|
+
file: path,
|
|
88
|
+
version,
|
|
89
|
+
not_after: notAfter,
|
|
90
|
+
rules: 1,
|
|
91
|
+
members,
|
|
92
|
+
signed: false,
|
|
93
|
+
next: `ias policy sign${path === policyPath(context) ? '' : ` ${path}`}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A problem the check found, with the command or the place that fixes it. */
|
|
98
|
+
const problem = (code, message, fix) => ({ code, message, fix: fix ?? 'edit the field the message names in the file, or write a new one with ias policy init --force' });
|
|
99
|
+
|
|
100
|
+
/** The profiles of the ias.json of the project, which the check compares with the rules. */
|
|
101
|
+
const profilesOf = (project) => Object.keys(project?.config?.build ?? {});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* ias policy check [file]: the shape and the semantics, the signature if the file has one, the version, the members,
|
|
105
|
+
* the date and the coverage, against the server. It writes nothing; exits 1 when something must be fixed.
|
|
106
|
+
*/
|
|
107
|
+
async function check(context) {
|
|
108
|
+
const { output } = context;
|
|
109
|
+
const [file] = context.positionals;
|
|
110
|
+
const path = policyPath(context, file);
|
|
111
|
+
const { document } = readPolicyFile(path);
|
|
112
|
+
const problems = [];
|
|
113
|
+
const warnings = [];
|
|
114
|
+
const local = problemOf(document);
|
|
115
|
+
|
|
116
|
+
if (local) {
|
|
117
|
+
problems.push(problem(local.code, local.message));
|
|
118
|
+
output.log(`${path}: it cannot be checked beyond its shape (${local.code}).`);
|
|
119
|
+
for (const entry of problems) output.log(` · ${entry.code}: ${entry.message}\n ${entry.fix}`);
|
|
120
|
+
return { file: path, ok: false, signed: document.signature !== undefined, problems, warnings, next: `ias policy init --force`, [EXIT_CODE]: 1 };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const project = projectOf(context);
|
|
124
|
+
const client = connect(context, project);
|
|
125
|
+
const team = await signingKeyOfTeam(client, context, project);
|
|
126
|
+
if (team.warning) warnings.push({ code: 'policy.key', message: team.warning, fix: 'an owner can share the 1Password item: ias vault status compares the keys' });
|
|
127
|
+
const signed = document.signature !== undefined;
|
|
128
|
+
|
|
129
|
+
if (signed) {
|
|
130
|
+
try {
|
|
131
|
+
verifyPolicy(document, team.key, { checkExpiry: false });
|
|
132
|
+
} catch (error) {
|
|
133
|
+
problems.push(problem(error.code, error.message, 'the signature no longer covers what the file says: ias policy sign signs it again (and if you did not edit it, do not upload it)'));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const listed = (await client.request('GET', '/team/approvers')).data;
|
|
138
|
+
const holders = holdersOf(listed);
|
|
139
|
+
const current = await serverPolicy(client);
|
|
140
|
+
const unsigned = unsignedOf(document);
|
|
141
|
+
const sameAsServer = current?.document && current.version === unsigned.version && isDeepStrictEqual(unsignedOf(current.document), unsigned);
|
|
142
|
+
|
|
143
|
+
if (current?.document && !sameAsServer && unsigned.version <= current.version) {
|
|
144
|
+
problems.push(problem('policy_version_backwards', `version ${unsigned.version} does not go above the server's ${current.version}`, `raise the version in ${path} (ias policy init --version ${current.version + 1}) and sign it again`));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const member of membersOf(unsigned)) {
|
|
148
|
+
if (!holders.includes(member)) {
|
|
149
|
+
problems.push(problem('policy.member.passkey', `member ${member} has no passkey in the current ias-approvers-1`, 'add their passkey to the approvers document (ias vault approvers), or remove the member from the rule'));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (unsigned.not_after !== undefined) {
|
|
154
|
+
const days = daysUntil(unsigned.not_after);
|
|
155
|
+
if (days <= 0) {
|
|
156
|
+
problems.push(problem('policy.not_after', `it expired on ${unsigned.not_after}`, `renew not_after in ${path} (ias policy init --not-after ${defaultNotAfter().slice(0, 10)}) and sign it with ias policy sign`));
|
|
157
|
+
} else if (days <= 30) {
|
|
158
|
+
warnings.push({ code: 'policy.expiring', message: `it expires in ${days} day${days === 1 ? '' : 's'} (${unsigned.not_after.slice(0, 10)})`, fix: 'renew not_after and sign a new version before it passes' });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let apps = [];
|
|
163
|
+
try {
|
|
164
|
+
apps = (await client.request('GET', '/apps')).data.data.map((application) => application.code);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
warnings.push({ code: 'policy.apps', message: `the applications could not be read (${error.message})`, fix: 'the rest of the check goes on; ias policy check with a personal token reads them' });
|
|
167
|
+
}
|
|
168
|
+
for (const rule of unsigned.rules) {
|
|
169
|
+
if (rule.app !== '*' && apps.length > 0 && !apps.includes(rule.app)) {
|
|
170
|
+
warnings.push({ code: 'policy.rule.app', message: `rule ${rule.id} names ${rule.app}, which is not an application of the team`, fix: 'check the code, or create the application before signing' });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const report = summary(unsigned, { apps, profiles: profilesOf(project), members: holders, now: new Date() });
|
|
175
|
+
for (const entry of report.unsatisfiable) {
|
|
176
|
+
if (!problems.some((found) => found.code === 'policy.member.passkey')) {
|
|
177
|
+
problems.push(problem('policy.unsatisfiable', `rule ${entry.id} asks for ${entry.count} approvals and only ${entry.members.filter((member) => holders.includes(member)).length} of its members have a passkey`, 'add passkeys to the members of the rule, or lower its count'));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (report.uncovered.length > 0) {
|
|
181
|
+
warnings.push({ code: 'policy.uncovered', message: `${report.uncovered.length} combinations of app, platform, profile and job have no rule: ${report.uncovered.slice(0, 3).map((entry) => `${entry.app}/${entry.platform}/${entry.profile}/${entry.type}${entry.target === '*' ? '' : `/${entry.target}`}${entry.track === '*' ? '' : `/${entry.track}`}`).join(', ')}${report.uncovered.length > 3 ? '…' : ''}`, fix: 'with a policy, a denial is the default: add a wildcard rule, or the rules that are missing' });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const ok = problems.length === 0;
|
|
185
|
+
output.log(`${path}: ${ok ? 'valid' : `${problems.length} problem${problems.length === 1 ? '' : 's'}`} · version ${unsigned.version} · ${signed ? 'signed' : 'not signed'} · ${unsigned.not_after === undefined ? 'no expiry' : `expires on ${unsigned.not_after.slice(0, 10)}`}`);
|
|
186
|
+
for (const entry of problems) output.log(` · ${entry.code}: ${entry.message}\n ${entry.fix}`);
|
|
187
|
+
for (const entry of warnings) output.warn(`${entry.code}: ${entry.message}${entry.fix ? ` — ${entry.fix}` : ''}`);
|
|
188
|
+
if (ok) output.log(`Next: ${signed ? 'ias policy diff' : `ias policy sign${file === undefined ? '' : ` ${file}`}`}.`);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
file: path,
|
|
192
|
+
ok,
|
|
193
|
+
signed,
|
|
194
|
+
version: unsigned.version,
|
|
195
|
+
not_after: unsigned.not_after ?? null,
|
|
196
|
+
problems,
|
|
197
|
+
warnings,
|
|
198
|
+
summary: report,
|
|
199
|
+
next: ok ? (signed ? 'ias policy diff' : 'ias policy sign') : `ias policy check ${path}`,
|
|
200
|
+
[EXIT_CODE]: ok ? 0 : 1,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** ias policy sign [file]: shows the diff and the hash, asks, signs with the team key of 1Password and uploads it. */
|
|
205
|
+
async function sign(context) {
|
|
206
|
+
const { values, env, output } = context;
|
|
207
|
+
const [file] = context.positionals;
|
|
208
|
+
const path = policyPath(context, file);
|
|
209
|
+
const { document } = readPolicyFile(path);
|
|
210
|
+
const local = problemOf(document);
|
|
211
|
+
|
|
212
|
+
if (local) {
|
|
213
|
+
throw new IasError(`${path} is not a valid policy (${local.code}): ${local.message}`, { fix: `ias policy check ${file ?? ''}`.trim() + ' says what to fix', code: local.code });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const project = projectOf(context);
|
|
217
|
+
const client = connect(context, project);
|
|
218
|
+
const item = vaultItem(context, project, client.server);
|
|
219
|
+
const { keys, signingKey } = await teamKeys(client, item, env);
|
|
220
|
+
|
|
221
|
+
if (document.signature !== undefined) {
|
|
222
|
+
try {
|
|
223
|
+
verifyPolicy(document, keys.signing, { checkExpiry: false });
|
|
224
|
+
} catch (error) {
|
|
225
|
+
throw new IasError(`${path} has a signature that does not verify (${error.code})`, { fix: 'the file changed after it was signed (or it is another team\'s): restore it, or remove the signature to sign the rules it has now', code: 'policy.signature' });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const current = await serverPolicy(client);
|
|
230
|
+
const previous = verifiedPolicy(current, keys.signing);
|
|
231
|
+
const unsigned = unsignedOf(document);
|
|
232
|
+
|
|
233
|
+
if (unsigned.not_after !== undefined && Date.parse(unsigned.not_after) <= Date.now()) {
|
|
234
|
+
throw new IasError(`the policy expires on ${unsigned.not_after}, which has passed`, { fix: `renew not_after in ${path} (ias policy init --not-after ${defaultNotAfter().slice(0, 10)}) and sign it`, code: 'policy.not_after' });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const signedDocument = signPolicy(unsigned, signingKey);
|
|
238
|
+
verifyPolicy(signedDocument, keys.signing);
|
|
239
|
+
|
|
240
|
+
if (previous !== null && isDeepStrictEqual(previous, signedDocument)) {
|
|
241
|
+
output.log(`${path} is already the policy of the server (version ${previous.version}): nothing to sign.`);
|
|
242
|
+
return { file: path, version: previous.version, hash: policyHash(previous), signed: false, uploaded: false, in_sync: current.in_sync ?? null, next: 'ias policy diff' };
|
|
243
|
+
}
|
|
244
|
+
if (current?.version !== null && current?.version !== undefined && signedDocument.version <= current.version) {
|
|
245
|
+
throw new IasError(`the server already has version ${current.version}`, { fix: `raise the version in ${path} (ias policy init --version ${current.version + 1}) and sign again`, code: 'policy_version_backwards', details: { current: current.version } });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const diff = diffPolicies(previous, unsigned);
|
|
249
|
+
output.progress(`Version ${signedDocument.version}${current?.version ? ` (the server's is ${current.version})` : ' (the first one)'}`);
|
|
250
|
+
output.progress(`Expires ${signedDocument.not_after ?? 'never'}`);
|
|
251
|
+
output.progress(`Signature ${signingKey.kid}`);
|
|
252
|
+
output.progress(`Hash ${policyHash(signedDocument)}`);
|
|
253
|
+
output.progress(`Changes ${previous === null ? 'the first policy of the team' : diffLine(diff)}`);
|
|
254
|
+
for (const rule of signedDocument.rules) output.progress(` ${rule.id}: ${scopeOf(rule)} · ${requirementOf(rule)}`);
|
|
255
|
+
for (const member of diff.members_added) output.progress(` + member ${member}`);
|
|
256
|
+
for (const member of diff.members_removed) output.progress(` − member ${member}`);
|
|
257
|
+
output.progress('');
|
|
258
|
+
output.progress('With this policy, from now on every job no rule covers is denied, and each job keeps the version it was approved with.');
|
|
259
|
+
await confirm(context, `Sign version ${signedDocument.version} and ask an owner to approve it?`);
|
|
260
|
+
|
|
261
|
+
const { status, data } = await client.request('PUT', '/team/policy', { json: signedDocument });
|
|
262
|
+
let stored = data?.document;
|
|
263
|
+
let inSync = data?.in_sync ?? true;
|
|
264
|
+
let approval = null;
|
|
265
|
+
|
|
266
|
+
if (status === 202) {
|
|
267
|
+
approval = data.approval;
|
|
268
|
+
const opened = (context.openUrl ?? ((url) => openUrl(url, env)))(approval.url);
|
|
269
|
+
output.progress(`${opened ? 'Opened' : 'Open'} ${approval.url}: an owner compares the hash and approves it with a passkey (until ${approval.expires_at}).`);
|
|
270
|
+
const result = await waitForApproval(client, approval, { wait: context.sleep ?? sleep, output });
|
|
271
|
+
stored = result?.document ?? null;
|
|
272
|
+
inSync = result?.in_sync ?? true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (stored === null || stored === undefined || !isDeepStrictEqual(stored, signedDocument)) {
|
|
276
|
+
throw new IasError('the server answered with another policy than the one signed here', { fix: 'ias policy show compares the policy of the server with 1Password; ias policy sign again if nothing was stored', code: 'policy_out_of_sync' });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
output.log(`Signed version ${signedDocument.version} and stored it with an owner's approval. Hash ${policyHash(signedDocument)}.`);
|
|
280
|
+
output.log('From now on it decides which builds and submissions are approved; the jobs that were waiting with the previous version expire with policy_changed.');
|
|
281
|
+
return {
|
|
282
|
+
file: path,
|
|
283
|
+
version: signedDocument.version,
|
|
284
|
+
hash: policyHash(signedDocument),
|
|
285
|
+
kid: signedDocument.signature.kid,
|
|
286
|
+
document: signedDocument,
|
|
287
|
+
uploaded: true,
|
|
288
|
+
approval: approval === null ? null : { id: approval.id, url: approval.url, status: 'approved' },
|
|
289
|
+
in_sync: inSync,
|
|
290
|
+
next: 'ias policy status',
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The policy of the server verified and printed, or null. */
|
|
295
|
+
async function show(context) {
|
|
296
|
+
const { output } = context;
|
|
297
|
+
const project = projectOf(context);
|
|
298
|
+
const client = connect(context, project);
|
|
299
|
+
const team = await signingKeyOfTeam(client, context, project);
|
|
300
|
+
if (team.warning) output.warn(team.warning);
|
|
301
|
+
const current = await serverPolicy(client);
|
|
302
|
+
const policy = verifiedPolicy(current, team.key);
|
|
303
|
+
|
|
304
|
+
if (policy === null) {
|
|
305
|
+
output.log('The team has no policy: builds and submissions still take one owner\'s passkey, as today.');
|
|
306
|
+
output.log('To settle who approves what: ias policy init and then ias policy sign.');
|
|
307
|
+
return { policy: null, version: null, next: 'ias policy init' };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const names = namesOf((await client.request('GET', '/team/approvers')).data);
|
|
311
|
+
output.log(`Approval policy · version ${policy.version} · ${policy.not_after === undefined ? 'no expiry' : `expires on ${policy.not_after.slice(0, 10)} (${daysUntil(policy.not_after)} days)${daysUntil(policy.not_after) <= 0 ? ' · EXPIRED: ias policy init and ias policy sign' : ''}`}`);
|
|
312
|
+
output.log(`Key ${policy.signature.kid} · hash ${policyHash(policy)} · ${current.in_sync ? 'every member has a passkey' : 'THERE ARE MEMBERS WITHOUT A PASSKEY in the approvers'}`);
|
|
313
|
+
for (const rule of policy.rules) output.log(` ${rule.id}: ${scopeOf(rule)} · ${requirementOf(rule, names)}`);
|
|
314
|
+
output.log('It decides the builds and store submissions: with a policy, whatever no rule covers is denied.');
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
policy,
|
|
318
|
+
version: policy.version,
|
|
319
|
+
kid: policy.signature.kid,
|
|
320
|
+
not_after: policy.not_after ?? null,
|
|
321
|
+
hash: policyHash(policy),
|
|
322
|
+
rules: policy.rules.map((rule) => ({ ...rule, text: scopeOf(rule), members: rule.require.members.map((member) => ({ id: member, name: names[member] ?? null })) })),
|
|
323
|
+
in_sync: current.in_sync ?? null,
|
|
324
|
+
signed: true,
|
|
325
|
+
next: 'ias policy status',
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** ias policy status: what the server has, whether it verifies, how it compares with the file and what is missing. */
|
|
330
|
+
async function status(context) {
|
|
331
|
+
const { output } = context;
|
|
332
|
+
const project = projectOf(context);
|
|
333
|
+
const client = connect(context, project);
|
|
334
|
+
const team = await signingKeyOfTeam(client, context, project);
|
|
335
|
+
if (team.warning) output.warn(team.warning);
|
|
336
|
+
const current = await serverPolicy(client);
|
|
337
|
+
const holders = holdersOf((await client.request('GET', '/team/approvers')).data);
|
|
338
|
+
let policy = null;
|
|
339
|
+
let verified = false;
|
|
340
|
+
let verification = null;
|
|
341
|
+
|
|
342
|
+
if (current.document) {
|
|
343
|
+
try {
|
|
344
|
+
policy = verifiedPolicy(current, team.key);
|
|
345
|
+
verified = true;
|
|
346
|
+
} catch (error) {
|
|
347
|
+
verification = { code: error.code, message: error.what ?? error.message };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const path = policyPath(context);
|
|
352
|
+
let local = null;
|
|
353
|
+
if (existsSync(path)) {
|
|
354
|
+
const read = readPolicyFile(path);
|
|
355
|
+
const localProblem = problemOf(read.document);
|
|
356
|
+
local = {
|
|
357
|
+
file: path,
|
|
358
|
+
version: read.document.version ?? null,
|
|
359
|
+
signed: read.document.signature !== undefined,
|
|
360
|
+
valid: localProblem === null,
|
|
361
|
+
...(localProblem === null ? {} : { problem: { code: localProblem.code, message: localProblem.message } }),
|
|
362
|
+
in_sync: policy !== null && localProblem === null && isDeepStrictEqual(unsignedOf(policy), unsignedOf(read.document)),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let apps = [];
|
|
367
|
+
try {
|
|
368
|
+
apps = (await client.request('GET', '/apps')).data.data.map((application) => application.code);
|
|
369
|
+
} catch {
|
|
370
|
+
apps = [];
|
|
371
|
+
}
|
|
372
|
+
const report = policy === null ? null : summary(policy, { apps, profiles: profilesOf(project), members: holders, now: new Date() });
|
|
373
|
+
const inSync = policy !== null && verified && report.unsatisfiable.length === 0;
|
|
374
|
+
const next = policy === null
|
|
375
|
+
? 'ias policy init'
|
|
376
|
+
: verification !== null
|
|
377
|
+
? 'ias policy sign'
|
|
378
|
+
: report.expiring?.expired
|
|
379
|
+
? `ias policy init --version ${policy.version + 1}`
|
|
380
|
+
: local !== null && !local.in_sync
|
|
381
|
+
? 'ias policy diff'
|
|
382
|
+
: null;
|
|
383
|
+
|
|
384
|
+
output.log(`Policy ${current.document === null ? 'none: today\'s behaviour' : policy === null ? `version ${current.version} · SIGNATURE NOT VERIFIED` : `version ${policy.version} · ${verified ? 'signature verified' : 'SIGNATURE NOT VERIFIED'}`}`);
|
|
385
|
+
if (policy !== null) {
|
|
386
|
+
output.log(`Expiry ${policy.not_after === undefined ? 'never' : `${policy.not_after.slice(0, 10)} · ${daysUntil(policy.not_after)} days${report.expiring?.expired ? ' · EXPIRED' : report.expiring?.warning ? ' · renew soon' : ''}`}`);
|
|
387
|
+
output.log(`Members ${report.unsatisfiable.length === 0 ? 'all with a passkey' : `${report.unsatisfiable.length} rules without enough passkeys`}`);
|
|
388
|
+
output.log(`Coverage ${report.uncovered.length === 0 ? 'the apps and profiles in use have a rule' : `${report.uncovered.length} combinations without a rule`}`);
|
|
389
|
+
}
|
|
390
|
+
output.log(`File ${local === null ? `${path} does not exist` : `${path} · version ${local.version ?? '?'} · ${local.valid ? (local.signed ? 'signed' : 'not signed') : `invalid (${local.problem.code})`}${local.in_sync ? ' · the same as the server\'s' : ''}`}`);
|
|
391
|
+
if (verification !== null) output.warn(`the policy of the server does not verify (${verification.code}): ${verification.message}`);
|
|
392
|
+
if (next !== null) output.log(`Next: ${next}.`);
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
policy: policy === null ? null : { version: policy.version, verified, not_after: policy.not_after ?? null, days: policy.not_after === undefined ? null : daysUntil(policy.not_after), hash: policyHash(policy) },
|
|
396
|
+
verification,
|
|
397
|
+
local,
|
|
398
|
+
in_sync: inSync,
|
|
399
|
+
unsatisfiable: report?.unsatisfiable.map((entry) => entry.id) ?? [],
|
|
400
|
+
uncovered: report?.uncovered ?? [],
|
|
401
|
+
expiring: report?.expiring ?? null,
|
|
402
|
+
next,
|
|
403
|
+
...(current.document && !verified ? { [EXIT_CODE]: 1 } : {}),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** ias policy diff [file]: the rules and members that change from the server's policy to the file's. */
|
|
408
|
+
async function diff(context) {
|
|
409
|
+
const { output } = context;
|
|
410
|
+
const [file] = context.positionals;
|
|
411
|
+
const path = policyPath(context, file);
|
|
412
|
+
const { document } = readPolicyFile(path);
|
|
413
|
+
const local = problemOf(document);
|
|
414
|
+
if (local) throw new IasError(`${path} is not a valid policy (${local.code}): ${local.message}`, { fix: `ias policy check ${file ?? ''}`.trim(), code: local.code });
|
|
415
|
+
|
|
416
|
+
const project = projectOf(context);
|
|
417
|
+
const client = connect(context, project);
|
|
418
|
+
const team = await signingKeyOfTeam(client, context, project);
|
|
419
|
+
if (team.warning) output.warn(team.warning);
|
|
420
|
+
const current = await serverPolicy(client);
|
|
421
|
+
const previous = verifiedPolicy(current, team.key);
|
|
422
|
+
const changes = diffPolicies(previous, unsignedOf(document));
|
|
423
|
+
|
|
424
|
+
output.log(previous === null ? 'The server has no policy: the whole file is new.' : `From the server's (version ${previous.version}) to the file's (version ${document.version}):`);
|
|
425
|
+
for (const rule of changes.added) output.log(` + ${rule.id}: ${scopeOf(rule)} · ${requirementOf(rule)}`);
|
|
426
|
+
for (const change of changes.changed) output.log(` ~ ${change.after.id}: ${scopeOf(change.before)} → ${scopeOf(change.after)} · ${requirementOf(change.before)} → ${requirementOf(change.after)}`);
|
|
427
|
+
for (const rule of changes.removed) output.log(` − ${rule.id}: ${scopeOf(rule)} · ${requirementOf(rule)}`);
|
|
428
|
+
for (const member of changes.members_added) output.log(` + member ${member}`);
|
|
429
|
+
for (const member of changes.members_removed) output.log(` − member ${member}`);
|
|
430
|
+
output.log(` ${diffLine(changes)}`);
|
|
431
|
+
output.log(`Next: ${changes.added.length + changes.changed.length + changes.removed.length === 0 && changes.members_added.length + changes.members_removed.length === 0 ? 'nothing to sign' : `ias policy sign${file === undefined ? '' : ` ${file}`}`}.`);
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
file: path,
|
|
435
|
+
server_version: previous?.version ?? null,
|
|
436
|
+
local_version: document.version ?? null,
|
|
437
|
+
added: changes.added.map((rule) => rule.id),
|
|
438
|
+
changed: changes.changed.map((entry) => entry.after.id),
|
|
439
|
+
removed: changes.removed.map((rule) => rule.id),
|
|
440
|
+
members_added: changes.members_added,
|
|
441
|
+
members_removed: changes.members_removed,
|
|
442
|
+
in_sync: previous !== null && isDeepStrictEqual(unsignedOf(previous), unsignedOf(document)),
|
|
443
|
+
next: `ias policy sign${file === undefined ? '' : ` ${file}`}`,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The target a submit goes to unless --target says otherwise. */
|
|
448
|
+
const targetOf = (platform, values) => values.target ?? (platform === 'android' ? 'google_play' : 'app_store_connect');
|
|
449
|
+
|
|
450
|
+
/** ias policy simulate: what the local policy would decide for a job, without approving anything. */
|
|
451
|
+
function simulateCommand(context) {
|
|
452
|
+
const { values, output } = context;
|
|
453
|
+
const [file] = context.positionals;
|
|
454
|
+
if (file !== undefined && values.file !== undefined && file !== values.file) {
|
|
455
|
+
throw new UsageError(`the file is «${file}» and --file says «${values.file}»`, { fix: 'give it once: ias policy simulate <file> --platform ios --profile production' });
|
|
456
|
+
}
|
|
457
|
+
const path = policyPath(context, file ?? values.file);
|
|
458
|
+
const { document } = readPolicyFile(path);
|
|
459
|
+
const local = problemOf(document);
|
|
460
|
+
if (local) throw new IasError(`${path} is not a valid policy (${local.code}): ${local.message}`, { fix: `ias policy check ${file ?? values.file ?? ''}`.trim(), code: local.code });
|
|
461
|
+
|
|
462
|
+
const project = projectOf(context);
|
|
463
|
+
const app = values.app ?? project.config?.app;
|
|
464
|
+
if (!app) throw new IasError('which application?', { fix: 'pass --app <code>, or run it in the folder of an app with an ias.json', code: 'application_missing' });
|
|
465
|
+
if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(app)) throw new IasError(`«${app}» is not the code of an application`, { fix: 'ias apps lists them', code: 'application_invalid' });
|
|
466
|
+
if (values.platform === undefined) throw new IasError('which platform?', { fix: 'pass --platform ios or --platform android', code: 'policy.platform' });
|
|
467
|
+
if (!['ios', 'android'].includes(values.platform)) throw new IasError(`«${values.platform}» is not a platform`, { fix: '--platform ios or --platform android', code: 'policy.platform' });
|
|
468
|
+
if (values.profile === undefined) throw new IasError('which profile?', { fix: 'pass --profile production (the name of a profile of ias.json)', code: 'policy.profile' });
|
|
469
|
+
const type = values.type ?? 'build';
|
|
470
|
+
if (!['build', 'submit', 'testflight', 'release'].includes(type)) throw new IasError(`«${type}» is not a job type`, { fix: '--type build, submit, testflight or release', code: 'policy.type' });
|
|
471
|
+
const submit = type === 'submit';
|
|
472
|
+
const release = type === 'release';
|
|
473
|
+
const target = submit ? targetOf(values.platform, values) : type === 'testflight' ? 'app_store_connect' : release ? 'google_play' : undefined;
|
|
474
|
+
const track = target === 'google_play' ? values.track ?? (release ? 'production' : 'internal') : undefined;
|
|
475
|
+
const job = { app, platform: values.platform, profile: values.profile, type, ...(target === undefined ? {} : { target, track }) };
|
|
476
|
+
|
|
477
|
+
let result;
|
|
478
|
+
try {
|
|
479
|
+
result = simulate(document, job, { now: new Date() });
|
|
480
|
+
} catch (error) {
|
|
481
|
+
throw new IasError(error.message, { fix: 'check --target and --track: a submit to Google Play names its track, and to App Store Connect has none', code: error.code });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
output.log(`${app} · ${values.platform === 'ios' ? 'iOS' : 'Android'} · ${values.profile} · ${type}${target === undefined ? '' : ` · ${target}${track ? ` · track ${track}` : ''}`}`);
|
|
485
|
+
if (result.allowed) {
|
|
486
|
+
output.log(`Rule ${result.rule.id} (specificity ${result.specificity}): ${scopeOf(result.rule)}`);
|
|
487
|
+
output.log(`Approved by ${requirementOf(result.rule)}.`);
|
|
488
|
+
} else {
|
|
489
|
+
output.log(`Denied (${result.code}): ${result.message}`);
|
|
490
|
+
}
|
|
491
|
+
const next = result.allowed ? null : result.code === 'policy.expired' ? 'ias policy sign' : 'ias policy diff';
|
|
492
|
+
if (next !== null) output.log(`Next: ${next} (or add the rule to ias.policy.json and check it with ias policy check).`);
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
job,
|
|
496
|
+
allowed: result.allowed,
|
|
497
|
+
code: result.code,
|
|
498
|
+
message: result.message,
|
|
499
|
+
rule: result.rule === null ? null : { id: result.rule.id, ...result.rule, text: scopeOf(result.rule), requirement: requirementOf(result.rule) },
|
|
500
|
+
specificity: result.specificity,
|
|
501
|
+
version: result.version,
|
|
502
|
+
next,
|
|
503
|
+
...(result.allowed ? {} : { [EXIT_CODE]: 1 }),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const itemOption = { item: { type: 'string', value: '<op://vault/item>', description: 'The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>).' } };
|
|
508
|
+
const yesOption = { yes: { type: 'boolean', short: 'y', description: 'Do not ask: you have already compared the diff and the hash (without a terminal it is needed).' } };
|
|
509
|
+
const fileArgument = { name: 'file', optional: true, description: `The policy file (${POLICY_FILE} in the project by default).` };
|
|
510
|
+
|
|
511
|
+
export default {
|
|
512
|
+
name: 'policy',
|
|
513
|
+
summary: 'The signed policy of who approves which build or submission, and how many',
|
|
514
|
+
description: `The team signs an ${POLICY_FORMAT} document with the key of 1Password (docs/technical/vault.md §2.5): for each application, platform, profile, job type and, for a submission, store and track, it names the members who may approve and how many of them. Without a policy everything behaves as today; with one, a store job no rule covers is denied and the runner verifies the same signed policy before running it.`,
|
|
515
|
+
subcommands: [
|
|
516
|
+
{
|
|
517
|
+
name: 'init',
|
|
518
|
+
summary: 'Write ias.policy.json equivalent to today: one * rule, one approval, every owner with a passkey',
|
|
519
|
+
description: 'Reads the owners with a passkey from the server and writes the source of the policy; it does not sign it. The version is the next above the server\'s unless --version says otherwise, and it expires in 180 days unless --not-after says otherwise. It never overwrites an existing file without --force.',
|
|
520
|
+
options: {
|
|
521
|
+
out: { type: 'string', value: '<file>', description: `Where to write it (${POLICY_FILE} in the project by default).` },
|
|
522
|
+
version: { type: 'string', value: '<n>', description: 'The version to write (the next above the server\'s by default).' },
|
|
523
|
+
'not-after': { type: 'string', value: '<date>', description: 'When it expires: 2027-04-28 or a whole UTC timestamp (180 days from today by default).' },
|
|
524
|
+
force: { type: 'boolean', description: 'Write the file even if it already exists.' },
|
|
525
|
+
},
|
|
526
|
+
run: init,
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
name: 'check',
|
|
530
|
+
summary: 'Check a policy file against the format, the members, the version and the server; writes nothing',
|
|
531
|
+
description: 'Validates the shape and the semantics, verifies the signature if the file has one, and compares the version, the members (each needs a passkey in the current ias-approvers-1), the date and the coverage with the server. Exits 1 when something must be fixed and says the exact command or edit for each problem.',
|
|
532
|
+
positionals: [fileArgument],
|
|
533
|
+
options: itemOption,
|
|
534
|
+
run: check,
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
name: 'sign',
|
|
538
|
+
summary: 'Sign the policy with the team key of 1Password and upload it (owner, with a passkey)',
|
|
539
|
+
description: 'Shows the version, the date, the hash, the diff from the policy the server has and the rules, then asks. It signs with the Ed25519 key of the vault item (read with op, never printed), uploads the document and opens the approval URL: an owner compares the hash and approves it with a passkey before anything is stored.',
|
|
540
|
+
positionals: [fileArgument],
|
|
541
|
+
options: { ...itemOption, ...yesOption },
|
|
542
|
+
run: sign,
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
name: 'show',
|
|
546
|
+
summary: 'Show the policy of the server, verified with the team key, with the rules and their members',
|
|
547
|
+
description: 'Reads GET /team/policy, verifies the signature with the team\'s signing key (compared with 1Password when it is there) and prints the version, the date, the hash and each rule with the members who may approve it. Exits 1 if the server\'s policy does not verify.',
|
|
548
|
+
options: itemOption,
|
|
549
|
+
run: show,
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
name: 'status',
|
|
553
|
+
summary: 'The policy of the server against the local file: verified, expiring, uncovered, in sync',
|
|
554
|
+
description: 'Says whether there is a policy, whether its signature verifies, when it expires, which rules lack members with a passkey, which app/profile combinations no rule covers, and whether ias.policy.json is the one of the server. Exits 1 only when the server\'s policy does not verify.',
|
|
555
|
+
options: itemOption,
|
|
556
|
+
run: status,
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
name: 'diff',
|
|
560
|
+
summary: 'Compare the local policy with the one of the server: rules and members added, changed or removed',
|
|
561
|
+
description: 'Verifies the policy of the server with the team key, strips the signature from the local file if it has one, and prints the rules that appear, change or go and the members that enter or leave. It writes nothing.',
|
|
562
|
+
positionals: [fileArgument],
|
|
563
|
+
options: itemOption,
|
|
564
|
+
run: diff,
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
name: 'simulate',
|
|
568
|
+
summary: 'What the local policy would decide for a job: the rule that matches, or the denial',
|
|
569
|
+
description: 'Matches app, platform, profile and type (and, for a submit, target and track) with the rules of the local file, choosing the most specific one, and prints the rule and who may approve it. A testflight matches as a submission to App Store Connect and a release as one to Google Play with its track. Exits 1 when no rule matches: with a policy, the server denies that job.',
|
|
570
|
+
positionals: [fileArgument],
|
|
571
|
+
options: {
|
|
572
|
+
file: { type: 'string', value: '<file>', description: `The policy file (${POLICY_FILE} in the project by default); the positional form is the same.` },
|
|
573
|
+
app: { type: 'string', value: '<code>', description: 'The application (app in ias.json by default).' },
|
|
574
|
+
platform: { type: 'string', value: 'ios|android', description: 'The platform of the job (needed, with --profile).' },
|
|
575
|
+
profile: { type: 'string', value: '<name>', description: 'The build profile of the job (needed, with --platform).' },
|
|
576
|
+
type: { type: 'string', value: 'build|submit|testflight|release', description: 'The job type (build by default).' },
|
|
577
|
+
target: { type: 'string', value: 'app_store_connect|google_play', description: 'The store of a submit (its platform\'s by default).' },
|
|
578
|
+
track: { type: 'string', value: '<track>', description: 'The Google Play track of a submit or a release (internal for a submit, production for a release, by default).' },
|
|
579
|
+
},
|
|
580
|
+
run: simulateCommand,
|
|
581
|
+
},
|
|
582
|
+
],
|
|
583
|
+
};
|