@imagina-build/ias-cli 0.2.2 → 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 +26 -5
- 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
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
// ias setup: the checklist that takes an Expo app from nothing to its first build and its first update, one step at a
|
|
2
|
+
// time (docs/plans/PLAN-ONBOARDING.md). Every step is checked against the files and the server, so it says what is done
|
|
3
|
+
// and not what someone remembers. The steps that can be done here (ias.json, the SDK, the login) are done here; the
|
|
4
|
+
// ones that need a person (the app in the panel, the credentials, the runner) say exactly what to do and stay locked
|
|
5
|
+
// until the check passes. A step cannot run while the one it needs is not done.
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { CONFIG_FILE, loadProject, readExpo, serverOf } from './config.mjs';
|
|
10
|
+
import { IasError } from './errors.mjs';
|
|
11
|
+
import { ensurePush, applicationPlan, createApplication, printApplicationPlan } from './applications.mjs';
|
|
12
|
+
import { connect, manageToken, serverOrigin, sleep as manageSleep } from './manage.mjs';
|
|
13
|
+
import { createRunnerCode, provisionDistribution, provisionOta } from './provisioning.mjs';
|
|
14
|
+
import { probe as systemProbe } from './process.mjs';
|
|
15
|
+
import { SDK_PACKAGE, sdkState } from './sdk.mjs';
|
|
16
|
+
import { SERVICES, inferServices } from './setup-state.mjs';
|
|
17
|
+
import { diagnose } from './commands/doctor/index.mjs';
|
|
18
|
+
import { init } from './commands/init/index.mjs';
|
|
19
|
+
import { expoModulesFor, installSdk } from './commands/sdk/index.mjs';
|
|
20
|
+
|
|
21
|
+
const CODE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
22
|
+
const PLATFORMS = ['ios', 'android'];
|
|
23
|
+
const STATUS = Object.freeze({ DONE: 'done', READY: 'ready', LOCKED: 'locked', OPTIONAL: 'optional', FAILED: 'failed' });
|
|
24
|
+
|
|
25
|
+
const ok = (detail, extra = {}) => ({ ok: true, detail, ...extra });
|
|
26
|
+
const no = (detail, fix, extra = {}) => ({ ok: false, detail, fix, ...extra });
|
|
27
|
+
|
|
28
|
+
function projectSafe(dir) {
|
|
29
|
+
try {
|
|
30
|
+
return loadProject(dir);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
return { dir, source: 'error', config: undefined, error };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function serverOfDir(dir, env) {
|
|
37
|
+
const project = projectSafe(dir);
|
|
38
|
+
return project.error ? serverOf({ config: undefined }, env) : serverOf(project, env);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function originOf(dir, env) {
|
|
42
|
+
try {
|
|
43
|
+
return serverOrigin(projectSafe(dir), env);
|
|
44
|
+
} catch {
|
|
45
|
+
return serverOfDir(dir, env);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The management API, with the token of this computer. Throws not_logged_in, which the checks turn into a fix. */
|
|
50
|
+
async function api(ctx) {
|
|
51
|
+
if (ctx.client) return ctx.client;
|
|
52
|
+
if (!ctx.session) {
|
|
53
|
+
const project = projectSafe(ctx.dir);
|
|
54
|
+
if (project.error) throw project.error;
|
|
55
|
+
ctx.session = connect({ env: ctx.env, sleep: ctx.sleep ?? manageSleep }, project);
|
|
56
|
+
}
|
|
57
|
+
return ctx.session;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** GET, with the app and the vault kept for the checks that need them again. */
|
|
61
|
+
async function getApp(ctx) {
|
|
62
|
+
if (ctx.appData) return ctx.appData;
|
|
63
|
+
const client = await api(ctx);
|
|
64
|
+
const code = ctx.config?.app;
|
|
65
|
+
if (!code) throw new IasError('ias.json has no app', { fix: 'run ias init, or write app in ias.json' });
|
|
66
|
+
const { data } = await client.request('GET', `/apps/${encodeURIComponent(code)}`);
|
|
67
|
+
ctx.appData = data;
|
|
68
|
+
return data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function getVault(ctx) {
|
|
72
|
+
if (ctx.vaultData) return ctx.vaultData;
|
|
73
|
+
const client = await api(ctx);
|
|
74
|
+
const { data } = await client.request('GET', '/vault');
|
|
75
|
+
ctx.vaultData = data;
|
|
76
|
+
return data;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function expoOf(dir) {
|
|
80
|
+
try {
|
|
81
|
+
return readExpo(dir) ?? {};
|
|
82
|
+
} catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function platformsOf(ctx) {
|
|
88
|
+
const app = ctx.appData ?? {};
|
|
89
|
+
const expo = expoOf(ctx.dir);
|
|
90
|
+
const platforms = [];
|
|
91
|
+
if (app.ios || expo.ios?.bundleIdentifier) platforms.push('ios');
|
|
92
|
+
if (app.android || expo.android?.package) platforms.push('android');
|
|
93
|
+
return platforms.length > 0 ? platforms : PLATFORMS;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const panel = (ctx, path) => `${originOf(ctx.dir, ctx.env)}/admin${path}`;
|
|
97
|
+
|
|
98
|
+
/** The commands that import (or make) the signing credentials, per platform, for the fix of a check. */
|
|
99
|
+
const CREDENTIAL_COMMANDS = {
|
|
100
|
+
ios: [
|
|
101
|
+
'ias credentials generate ios (the certificate and the App Store profile, reusing what is valid)',
|
|
102
|
+
'ias credentials import apple_distribution_certificate --p12 <certificate.p12> --password op://…',
|
|
103
|
+
'ias credentials import apple_provisioning_profile --profile <profile.mobileprovision>',
|
|
104
|
+
],
|
|
105
|
+
android: ['ias credentials import android_upload_keystore --keystore <upload.jks> --store-password op://… --key-alias <alias>'],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const REQUIRED_CREDENTIALS = {
|
|
109
|
+
ios: ['apple_distribution_certificate', 'apple_provisioning_profile'],
|
|
110
|
+
android: ['android_upload_keystore'],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** The steps, in order. `service` means the step belongs to that service; `optional` means it does not block. */
|
|
114
|
+
function buildSteps(ctx) {
|
|
115
|
+
const selected = (service) => ctx.services.includes(service);
|
|
116
|
+
|
|
117
|
+
return [
|
|
118
|
+
{
|
|
119
|
+
id: 'environment',
|
|
120
|
+
title: 'This computer has what ias needs',
|
|
121
|
+
description: 'Node 22, git and the 1Password CLI (op). Xcode and the Android SDK are warnings until a local build.',
|
|
122
|
+
check: () => {
|
|
123
|
+
const checks = diagnose({ dir: ctx.dir, env: ctx.env, platform: ctx.platform, nodeVersion: ctx.nodeVersion, probe: ctx.probe, aapt2: ctx.aapt2 });
|
|
124
|
+
const byName = Object.fromEntries(checks.map((check) => [check.name, check]));
|
|
125
|
+
const required = ['node', 'git', 'op'].map((name) => byName[name]).filter(Boolean);
|
|
126
|
+
const failing = required.filter((check) => check.status === 'fail');
|
|
127
|
+
const warnings = ['xcode', 'aapt2', 'macos-tools'].map((name) => byName[name]).filter((check) => check?.status === 'fail' || check?.status === 'warn');
|
|
128
|
+
if (failing.length > 0) return no(failing.map((check) => `${check.name}: ${check.detail}`).join('; '), failing.map((check) => check.fix).join('; '));
|
|
129
|
+
return ok([...required.map((check) => check.detail), ...warnings.map((check) => `${check.name}: ${check.detail}`)].join(', '));
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: 'project',
|
|
134
|
+
title: 'The app has an ias.json',
|
|
135
|
+
description: 'ias.json goes in git and holds no secrets: the tokens and keys are op:// references.',
|
|
136
|
+
check: () => {
|
|
137
|
+
const project = projectSafe(ctx.dir);
|
|
138
|
+
if (project.error) return no(project.error.message, project.error.fix, { code: project.error.code });
|
|
139
|
+
if (!project.config) return no(`no ${CONFIG_FILE} in ${ctx.dir}`, 'ias setup next writes it from app.json (ias init)', { command: 'ias init' });
|
|
140
|
+
const config = project.config;
|
|
141
|
+
if (!CODE.test(config.app ?? '')) return no(`${CONFIG_FILE} has no valid app (${config.app ?? 'none'})`, 'write the code of the application in app, or run ias init --app <code>');
|
|
142
|
+
ctx.config = config;
|
|
143
|
+
return ok(`${CONFIG_FILE}: app ${config.app} on ${config.server ?? 'https://ias.imagina.build (the default)'}`);
|
|
144
|
+
},
|
|
145
|
+
async run() {
|
|
146
|
+
const project = projectSafe(ctx.dir);
|
|
147
|
+
if (project.error) throw project.error;
|
|
148
|
+
if (project.config) return { changed: false };
|
|
149
|
+
init(ctx.dir, { app: ctx.values.app, dryRun: ctx.values['dry-run'] === true }, ctx.output);
|
|
150
|
+
ctx.config = projectSafe(ctx.dir).config;
|
|
151
|
+
return { changed: true };
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: 'login',
|
|
156
|
+
title: 'This computer is authorized',
|
|
157
|
+
description: 'A passkey approves this computer and keeps a personal token (iasp_…). On CI, IAS_TOKEN is the token and this step is done.',
|
|
158
|
+
requires: ['project'],
|
|
159
|
+
check: () => {
|
|
160
|
+
const server = originOf(ctx.dir, ctx.env);
|
|
161
|
+
try {
|
|
162
|
+
const { token, source } = manageToken(server, ctx.env);
|
|
163
|
+
return ok(`token from ${source}`);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return no(`not logged in to ${server}`, 'ias setup next runs ias login (a passkey approves the code it shows)', { code: error.code });
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
async run() {
|
|
169
|
+
if (ctx.values['dry-run']) {
|
|
170
|
+
ctx.output.progress(`${ctx.output.prefix}: would run ias login`);
|
|
171
|
+
return { dry_run: true };
|
|
172
|
+
}
|
|
173
|
+
const { login } = await import('./commands/auth/index.mjs');
|
|
174
|
+
return login({ values: { project: ctx.dir, team: ctx.values.team }, cwd: ctx.dir, env: ctx.env, output: ctx.output, sleep: ctx.sleep, openUrl: ctx.openUrl });
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: 'application',
|
|
179
|
+
title: 'The application exists in IAS',
|
|
180
|
+
description: 'An owner creates the application here with ias setup --create-app (or ias apps --create), or in the panel. Its code is the one of ias.json, and its bundle id and package must be the ones of app.json.',
|
|
181
|
+
requires: ['login'],
|
|
182
|
+
check: async () => {
|
|
183
|
+
const project = projectSafe(ctx.dir);
|
|
184
|
+
if (project.config) ctx.config = project.config;
|
|
185
|
+
if (!ctx.config) return no('there is no ias.json', 'ias setup next writes it first', { command: 'ias init' });
|
|
186
|
+
let app;
|
|
187
|
+
try {
|
|
188
|
+
app = await getApp(ctx);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error.code === 'application_not_found' || error.status === 404) {
|
|
191
|
+
return no(`the application ${ctx.config.app} is not in IAS`, `create it with ias setup --create-app (an owner with a personal token), or in the panel: ${panel(ctx, '/applications')}`, { url: panel(ctx, '/applications'), command: 'ias setup --create-app' });
|
|
192
|
+
}
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
const expo = expoOf(ctx.dir);
|
|
196
|
+
const mismatches = [];
|
|
197
|
+
const pairs = { ios: ['bundle_id', expo.ios?.bundleIdentifier], android: ['package', expo.android?.package] };
|
|
198
|
+
for (const [platform, [field, expected]] of Object.entries(pairs)) {
|
|
199
|
+
const actual = app[platform]?.[field];
|
|
200
|
+
if (expected && actual && actual !== expected) mismatches.push(`${platform}: IAS has ${actual}, app.json has ${expected}`);
|
|
201
|
+
}
|
|
202
|
+
if (mismatches.length > 0) return no(`the application ${app.code} does not match app.json (${mismatches.join('; ')})`, `fix it in the panel: ${panel(ctx, `/applications/${app.code}/settings`)}`, { url: panel(ctx, `/applications/${app.code}/settings`) });
|
|
203
|
+
return ok(`application ${app.code} (${(app.services ?? []).length > 0 ? app.services.join(', ') : 'no services yet'})`);
|
|
204
|
+
},
|
|
205
|
+
...(ctx.values['create-app'] ? {
|
|
206
|
+
async run() {
|
|
207
|
+
const expo = expoOf(ctx.dir);
|
|
208
|
+
const details = {
|
|
209
|
+
code: ctx.config.app,
|
|
210
|
+
name: ctx.values.name ?? expo.name ?? ctx.config.app,
|
|
211
|
+
iosBundleId: ctx.values['ios-bundle-id'] ?? expo.ios?.bundleIdentifier,
|
|
212
|
+
androidPackage: ctx.values['android-package'] ?? expo.android?.package,
|
|
213
|
+
appleTeamId: ctx.values['apple-team-id'] ?? expo.ios?.appleTeamId,
|
|
214
|
+
services: ctx.services,
|
|
215
|
+
};
|
|
216
|
+
if (ctx.values['dry-run']) {
|
|
217
|
+
const plan = applicationPlan(details);
|
|
218
|
+
printApplicationPlan(plan, ctx.output);
|
|
219
|
+
return plan;
|
|
220
|
+
}
|
|
221
|
+
return createApplication(await api(ctx), details);
|
|
222
|
+
},
|
|
223
|
+
} : {}),
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: 'services',
|
|
227
|
+
title: 'The services the app uses are on',
|
|
228
|
+
description: 'Updates, distribution and push are optional per application. ias setup next makes the OTA and distribution projects here (an owner with a personal token) and turns push on with its install key.',
|
|
229
|
+
requires: ['application'],
|
|
230
|
+
optional: () => ctx.services.length === 0,
|
|
231
|
+
check: async () => {
|
|
232
|
+
const app = await getApp(ctx);
|
|
233
|
+
const missing = [];
|
|
234
|
+
if (selected('updates') && !app.updates) missing.push('updates');
|
|
235
|
+
if (selected('distribution') && !app.distribution) missing.push('distribution');
|
|
236
|
+
if (selected('push') && !(app.services ?? []).includes('push')) missing.push('push');
|
|
237
|
+
if (missing.length > 0) {
|
|
238
|
+
return no(`missing in ${app.code}: ${missing.join(', ')}`, `ias setup next makes ${missing.join(' and ')} here (an owner with a personal token), or turn them on in the panel: ${panel(ctx, `/applications/${app.code}/settings`)}`, { url: panel(ctx, `/applications/${app.code}/settings`), command: 'ias setup next' });
|
|
239
|
+
}
|
|
240
|
+
if (selected('push') && !projectSafe(ctx.dir).config?.push?.installKey) {
|
|
241
|
+
return no(`${app.code} has push on but ias.json has no push.installKey`, 'ias setup next reads it from the API or makes one; the install key is not a secret', { command: 'ias setup next' });
|
|
242
|
+
}
|
|
243
|
+
const parts = [];
|
|
244
|
+
if (app.updates) parts.push(`updates ${app.updates.project} (${app.updates.manifest_url})`);
|
|
245
|
+
if (app.distribution) parts.push(`distribution ${app.distribution.project} (${app.distribution.install_url})`);
|
|
246
|
+
if ((app.services ?? []).includes('push')) parts.push('push');
|
|
247
|
+
return ok(parts.length > 0 ? parts.join(', ') : 'no service');
|
|
248
|
+
},
|
|
249
|
+
async run() {
|
|
250
|
+
const app = await getApp(ctx);
|
|
251
|
+
const client = await api(ctx);
|
|
252
|
+
const made = [];
|
|
253
|
+
if (selected('updates') && !app.updates) {
|
|
254
|
+
made.push(await provisionOta({ dir: ctx.dir, env: ctx.env, output: ctx.output, values: ctx.values, client, sleep: ctx.sleep, dryRun: ctx.values['dry-run'] === true }));
|
|
255
|
+
}
|
|
256
|
+
if (selected('distribution') && !app.distribution) {
|
|
257
|
+
made.push(await provisionDistribution({ dir: ctx.dir, env: ctx.env, output: ctx.output, values: ctx.values, client, sleep: ctx.sleep, dryRun: ctx.values['dry-run'] === true }));
|
|
258
|
+
}
|
|
259
|
+
if (selected('push') && (!(app.services ?? []).includes('push') || !projectSafe(ctx.dir).config?.push?.installKey)) {
|
|
260
|
+
made.push(await ensurePush({ client, dir: ctx.dir, app: app.code, output: ctx.output, dryRun: ctx.values['dry-run'] === true }));
|
|
261
|
+
}
|
|
262
|
+
return { made };
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: 'stores',
|
|
267
|
+
title: 'The app exists in the stores',
|
|
268
|
+
description: 'The bundle id and the App Store Connect app record, and the Google Play package. IAS creates neither the app record nor the Play app: a person does it on the web, and this checks what IAS keeps. It blocks neither builds nor updates.',
|
|
269
|
+
requires: ['application'],
|
|
270
|
+
optional: () => true,
|
|
271
|
+
check: async () => {
|
|
272
|
+
const app = await getApp(ctx);
|
|
273
|
+
const panels = panel(ctx, `/applications/${app.code}/settings`);
|
|
274
|
+
if (!app.ios?.bundle_id && !app.android?.package) {
|
|
275
|
+
return no(`the application ${app.code} has no iOS bundle id and no Android package`, `set them in the panel: ${panels}`, { url: panels });
|
|
276
|
+
}
|
|
277
|
+
if (app.ios?.bundle_id && !app.ios.asc_app_id) {
|
|
278
|
+
const sku = `IAS-${app.code.toUpperCase()}`;
|
|
279
|
+
return no(
|
|
280
|
+
`iOS: the bundle id ${app.ios.bundle_id} has no app record in IAS; once it exists: ias apps store ${app.code} --check`,
|
|
281
|
+
`a person creates it in https://appstoreconnect.apple.com/apps (name ${app.name}, bundle id ${app.ios.bundle_id}, SKU ${sku}; the SKU cannot change afterwards) and then runs: ias apps store ${app.code} --check`,
|
|
282
|
+
{ command: `ias apps store ${app.code} --check`, code: 'asc_app_missing' },
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const parts = [];
|
|
286
|
+
if (app.ios?.bundle_id) parts.push(`iOS: ${app.ios.bundle_id}, app record ${app.ios.asc_app_id}${app.ios.asc_app_checked_at ? ` (checked ${app.ios.asc_app_checked_at.slice(0, 10)})` : ' (not checked against Apple)'}`);
|
|
287
|
+
if (app.android?.package) parts.push(`Android: ${app.android.package} (in Play: confirmed by the first release)`);
|
|
288
|
+
return ok(parts.join(', '));
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
id: 'sdk',
|
|
293
|
+
title: `The SDK ${SDK_PACKAGE} is in the app`,
|
|
294
|
+
description: 'It brings ias.sync and ias.push, and its config plugin writes the updates URL, the certificate and the push configuration from ias.json into the builds.',
|
|
295
|
+
requires: ['application'],
|
|
296
|
+
optional: () => !selected('updates') && !selected('push'),
|
|
297
|
+
check: () => {
|
|
298
|
+
let state;
|
|
299
|
+
try {
|
|
300
|
+
state = sdkState(ctx.dir);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
return no(error.message, error.fix);
|
|
303
|
+
}
|
|
304
|
+
const wanted = expoModulesFor({ updates: selected('updates'), push: selected('push') });
|
|
305
|
+
const missing = wanted.filter((name) => !state.dependencies[name]);
|
|
306
|
+
if (!state.declared && !state.installed) return no(`${SDK_PACKAGE} is not installed`, 'ias setup next runs ias sdk install, with its plugin and the Expo modules of the services');
|
|
307
|
+
if (!state.installed) return no(`${SDK_PACKAGE} ${state.declared} is in package.json, but not installed`, 'ias setup next runs ias sdk install');
|
|
308
|
+
if (state.plugin !== 'yes') {
|
|
309
|
+
return no(`app.json does not have the plugin of ${SDK_PACKAGE}${state.plugin === 'dynamic' ? ` (${state.dynamic} is code)` : ''}`, state.plugin === 'dynamic' ? `add "${SDK_PACKAGE}" to the plugins of ${state.dynamic}, for a new build` : 'ias setup next runs ias sdk install, which adds it');
|
|
310
|
+
}
|
|
311
|
+
if (missing.length > 0) return no(`${SDK_PACKAGE} ${state.installed}; missing ${missing.join(', ')}`, `ias setup next runs npx expo install ${missing.join(' ')}`);
|
|
312
|
+
return ok(`${SDK_PACKAGE} ${state.installed}, plugin in app.json${wanted.length > 0 ? `, ${wanted.join(', ')}` : ''}`);
|
|
313
|
+
},
|
|
314
|
+
async run() {
|
|
315
|
+
const wanted = expoModulesFor({ updates: selected('updates'), push: selected('push') });
|
|
316
|
+
installSdk(ctx.dir, { dryRun: ctx.values['dry-run'], modules: wanted }, { output: ctx.output, env: ctx.env });
|
|
317
|
+
return { installed: true };
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
id: 'vault',
|
|
322
|
+
title: 'The team vault has its keys',
|
|
323
|
+
description: 'The recovery key opens every sealed credential and the signing key signs its envelope. Their private halves live only in 1Password.',
|
|
324
|
+
requires: ['login'],
|
|
325
|
+
check: async () => {
|
|
326
|
+
let vault;
|
|
327
|
+
try {
|
|
328
|
+
vault = await getVault(ctx);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
return no(error.message, error.fix, { code: error.code });
|
|
331
|
+
}
|
|
332
|
+
if (!vault.recovery_key || !vault.signing_key) {
|
|
333
|
+
return no(`the vault of ${originOf(ctx.dir, ctx.env)} has no keys`, 'an owner runs ias setup next (ias vault init) and approves it with a passkey in the panel', { command: 'ias vault init' });
|
|
334
|
+
}
|
|
335
|
+
return ok(`recovery ${vault.recovery_key.kid.slice(0, 12)}…, signing ${vault.signing_key.kid.slice(0, 12)}…`);
|
|
336
|
+
},
|
|
337
|
+
async run() {
|
|
338
|
+
if (ctx.values['dry-run']) {
|
|
339
|
+
ctx.output.progress(`${ctx.output.prefix}: would run ias vault init`);
|
|
340
|
+
return { dry_run: true };
|
|
341
|
+
}
|
|
342
|
+
const { init: vaultInit } = await import('./commands/vault/index.mjs');
|
|
343
|
+
return vaultInit({ values: { project: ctx.dir, item: ctx.values.item }, cwd: ctx.dir, env: ctx.env, output: ctx.output, sleep: ctx.sleep, openUrl: ctx.openUrl });
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
id: 'credentials',
|
|
348
|
+
title: 'The signing credentials are in the vault',
|
|
349
|
+
description: 'The certificates, profiles and keystores of the app, sealed on this computer. The files come from Apple and Google; ias never prints a password.',
|
|
350
|
+
requires: ['vault', 'services'],
|
|
351
|
+
optional: () => !selected('updates') && !selected('distribution') && !selected('push'),
|
|
352
|
+
check: async () => {
|
|
353
|
+
const app = await getApp(ctx);
|
|
354
|
+
const client = await api(ctx);
|
|
355
|
+
const found = new Set((await client.request('GET', `/apps/${app.code}/credentials`)).data.data.map((credential) => credential.type));
|
|
356
|
+
try {
|
|
357
|
+
for (const credential of (await client.request('GET', '/team/credentials')).data.data) found.add(credential.type);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (error.code !== 'role_forbidden') throw error;
|
|
360
|
+
}
|
|
361
|
+
const needed = platformsOf(ctx).flatMap((platform) => REQUIRED_CREDENTIALS[platform] ?? []);
|
|
362
|
+
const missing = needed.filter((type) => !found.has(type));
|
|
363
|
+
if (missing.length > 0) {
|
|
364
|
+
const commands = platformsOf(ctx).flatMap((platform) => CREDENTIAL_COMMANDS[platform] ?? []).join('\n ');
|
|
365
|
+
return no(`missing ${missing.join(', ')}`, `make or import them with ias credentials (the short path for iOS is ias credentials generate ios), or with ias migrate eas if the app comes from EAS:
|
|
366
|
+
${commands}`, { command: missing.some((type) => type.startsWith('apple_')) ? 'ias credentials generate ios' : 'ias credentials list' });
|
|
367
|
+
}
|
|
368
|
+
return ok(`${needed.length} credential types in the app and the team`);
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
id: 'signing',
|
|
373
|
+
title: 'The iOS signing material is generated or reused',
|
|
374
|
+
description: 'The team\'s distribution certificate and the App Store profile of the bundle id, in the vault. The short path is ias credentials generate ios: it reuses what is valid and makes only what is missing. This check is read-only and offline (the server\'s credentials only): whether Apple still lists the certificate is what the command says when it runs.',
|
|
375
|
+
requires: ['credentials'],
|
|
376
|
+
optional: () => true,
|
|
377
|
+
check: async () => {
|
|
378
|
+
const app = await getApp(ctx);
|
|
379
|
+
const command = 'ias credentials generate ios';
|
|
380
|
+
if (!platformsOf(ctx).includes('ios')) return ok('the app does not use iOS: there is no iOS signing material to prepare');
|
|
381
|
+
if (!app.ios?.bundle_id) {
|
|
382
|
+
return no(`the application ${app.code} has no iOS bundle id (${command})`, `set it in the panel: ${panel(ctx, `/applications/${app.code}/settings`)}`, { url: panel(ctx, `/applications/${app.code}/settings`), code: 'application_without_ios' });
|
|
383
|
+
}
|
|
384
|
+
const client = await api(ctx);
|
|
385
|
+
const own = (await client.request('GET', `/apps/${app.code}/credentials`)).data.data;
|
|
386
|
+
let team = [];
|
|
387
|
+
try {
|
|
388
|
+
team = (await client.request('GET', '/team/credentials')).data.data;
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (error.code !== 'role_forbidden') throw error;
|
|
391
|
+
}
|
|
392
|
+
const now = Date.now();
|
|
393
|
+
const live = (credential) => !credential.retired_at && (!credential.expires_at || Date.parse(credential.expires_at) > now);
|
|
394
|
+
const latest = (list) => [...list].sort((a, b) => (Date.parse(b.expires_at ?? '9999') || 0) - (Date.parse(a.expires_at ?? '9999') || 0))[0];
|
|
395
|
+
|
|
396
|
+
if (!team.some((credential) => credential.type === 'asc_api_key' && live(credential))) {
|
|
397
|
+
return no(`the vault has no App Store Connect key (${command})`, `import it with ias credentials import asc_api_key --p8 <AuthKey_….p8> --key-id <id> --issuer-id <uuid>; then: ${command}`, { command, code: 'credentials_missing' });
|
|
398
|
+
}
|
|
399
|
+
const certificates = team.filter((credential) => credential.type === 'apple_distribution_certificate' && credential.metadata?.kind === 'distribution' && (!app.ios.team_id || credential.metadata?.team === app.ios.team_id));
|
|
400
|
+
const usable = certificates.filter(live);
|
|
401
|
+
if (usable.length === 0) {
|
|
402
|
+
const expired = certificates.filter((credential) => !credential.retired_at && credential.expires_at).sort((a, b) => Date.parse(b.expires_at) - Date.parse(a.expires_at))[0];
|
|
403
|
+
if (expired && Date.parse(expired.expires_at) <= now) return no(`the distribution certificate expired on ${expired.expires_at.slice(0, 10)} (${command})`, `${command} makes a new one and regenerates the App Store profile; the ad hoc one, afterwards, with ias devices sync --force`, { command, code: 'certificate_expired' });
|
|
404
|
+
const retired = certificates.find((credential) => credential.retired_at);
|
|
405
|
+
if (retired) return no(`the distribution certificate was retired on ${retired.retired_at.slice(0, 10)} (${command})`, `${command} makes a new one and regenerates the App Store profile`, { command, code: 'certificate_retired' });
|
|
406
|
+
return no(`the vault has no distribution certificate of the team ${app.ios.team_id ?? ''} (${command})`.trim(), `${command} makes it on this Mac with the App Store Connect key of the vault, without the server ever seeing the private key`, { command, code: 'certificate_missing' });
|
|
407
|
+
}
|
|
408
|
+
const certificate = latest(usable);
|
|
409
|
+
const sha1 = String(certificate.metadata?.sha1 ?? '').toUpperCase();
|
|
410
|
+
const profiles = own.filter((credential) => live(credential) && credential.type === 'apple_provisioning_profile' && credential.metadata?.type === 'app-store' && credential.metadata?.bundleId === app.ios.bundle_id);
|
|
411
|
+
const valid = profiles.filter((credential) => (credential.metadata?.certificates ?? []).map((value) => String(value).toUpperCase()).includes(sha1));
|
|
412
|
+
const profile = latest(valid);
|
|
413
|
+
if (!profile) {
|
|
414
|
+
const expired = profiles.filter((credential) => credential.expires_at && Date.parse(credential.expires_at) <= now).sort((a, b) => Date.parse(b.expires_at) - Date.parse(a.expires_at))[0];
|
|
415
|
+
if (expired) return no(`the App Store profile expired on ${expired.expires_at.slice(0, 10)} (${command})`, `${command} makes another one with the current certificate and seals it; the ad hoc one is untouched`, { command, code: 'profile_expired' });
|
|
416
|
+
return no(`the vault has no App Store profile of ${app.ios.bundle_id}, or it does not contain the current certificate (${sha1}) (${command})`, `${command} makes it and seals it as the next version; the ad hoc profile of ias devices sync is untouched`, { command, code: 'profile_missing' });
|
|
417
|
+
}
|
|
418
|
+
const origin = certificate.metadata?.appleCertificateId ? 'generated by ias' : 'imported';
|
|
419
|
+
const note = certificate.expires_at && Date.parse(certificate.expires_at) - now < 30 * 86400000 ? ' · the certificate expires within 30 days: the command renews it if Apple allows' : '';
|
|
420
|
+
return ok(`certificate ${sha1} until ${String(certificate.expires_at ?? '').slice(0, 10)} (${origin}), App Store profile ${profile.metadata.uuid} until ${String(profile.expires_at ?? '').slice(0, 10)}${note}; this check does not call Apple: ${command} checks that Apple still has both and reuses them`);
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
id: 'runner',
|
|
425
|
+
title: 'A runner compiles the app',
|
|
426
|
+
description: 'A runner is a machine of the team with the toolchain. ias setup next asks for a one-time code, prints the ias-runner register command for that Mac and leaves it pending until a member authorizes it with ias vault grant.',
|
|
427
|
+
requires: ['vault'],
|
|
428
|
+
optional: () => ctx.values['local-builds'] === true,
|
|
429
|
+
check: async () => {
|
|
430
|
+
let vault;
|
|
431
|
+
try {
|
|
432
|
+
vault = await getVault(ctx);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
return no(error.message, error.fix, { code: error.code });
|
|
435
|
+
}
|
|
436
|
+
const runners = (vault.runner_list?.runners ?? vault.runners ?? []).filter((runner) => runner.status !== 'revoked');
|
|
437
|
+
if (!vault.runner_list || vault.runner_list.serial === null || runners.length === 0) {
|
|
438
|
+
return no('the team has no runner in the list it signed', `ias setup next asks for a one-time code (or ias runners code) and prints the ias-runner register command for the runner Mac; then authorize it here with ias vault grant "<its name>" (the panel also makes codes: ${panel(ctx, '/runners')})`, { url: panel(ctx, '/runners'), command: 'ias setup next' });
|
|
439
|
+
}
|
|
440
|
+
return ok(`${runners.length} runner${runners.length === 1 ? '' : 's'} in the signed list (serial ${vault.runner_list.serial})`);
|
|
441
|
+
},
|
|
442
|
+
async run() {
|
|
443
|
+
return createRunnerCode({ dir: ctx.dir, env: ctx.env, output: ctx.output, client: await api(ctx), sleep: ctx.sleep, dryRun: ctx.values['dry-run'] === true });
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
id: 'build',
|
|
448
|
+
title: 'The first build works',
|
|
449
|
+
description: 'A build is made from a clean commit. An internal build (preview) needs no approval; a store build waits for an owner with a passkey.',
|
|
450
|
+
requires: ['credentials', 'runner'],
|
|
451
|
+
optional: () => ctx.services.length === 0,
|
|
452
|
+
check: async () => {
|
|
453
|
+
if (ctx.values['local-builds']) {
|
|
454
|
+
const folder = join(ctx.env.HOME ?? '', 'ias-builds', ctx.config?.app ?? '');
|
|
455
|
+
if (existsSync(folder)) return ok(`local builds in ${folder}`);
|
|
456
|
+
return no(`no local builds in ${folder}`, 'ias build --local --profile preview --platform all', { command: 'ias build --local --profile preview --platform all' });
|
|
457
|
+
}
|
|
458
|
+
const app = await getApp(ctx);
|
|
459
|
+
const client = await api(ctx);
|
|
460
|
+
const { data } = await client.request('GET', `/apps/${app.code}/builds`);
|
|
461
|
+
const builds = data.data ?? [];
|
|
462
|
+
const succeeded = builds.filter((build) => build.status === 'succeeded');
|
|
463
|
+
if (succeeded.length === 0) {
|
|
464
|
+
const latest = builds[0];
|
|
465
|
+
return no(latest ? `the latest build is ${latest.status}` : 'the application has no builds', 'ias build --profile preview --platform all', { command: 'ias build --profile preview --platform all' });
|
|
466
|
+
}
|
|
467
|
+
return ok(`${succeeded.length} build${succeeded.length === 1 ? '' : 's'} succeeded; the latest is ${succeeded[0].platform} ${succeeded[0].version} (${succeeded[0].build_number ?? '?'})`);
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
id: 'testflight',
|
|
472
|
+
title: 'TestFlight is optional: the build can reach its testers',
|
|
473
|
+
description: 'A store iOS build that ias submit sent to App Store Connect is distributed to TestFlight groups with ias testflight, and only if external testers need it, sent to Beta App Review with ias testflight review: two commands and two passkeys. This step never runs in ias setup next --all.',
|
|
474
|
+
requires: ['build'],
|
|
475
|
+
optional: () => true,
|
|
476
|
+
check: async () => {
|
|
477
|
+
if (!platformsOf(ctx).includes('ios')) return ok('the app does not use iOS: TestFlight does not apply');
|
|
478
|
+
const app = await getApp(ctx);
|
|
479
|
+
const client = await api(ctx);
|
|
480
|
+
const builds = (await client.request('GET', `/apps/${app.code}/builds?platform=ios`)).data.data ?? [];
|
|
481
|
+
const sent = builds.find((build) => build.status === 'succeeded' && build.distribution === 'store' && (build.submissions ?? []).some((submission) => submission.target === 'app_store_connect' && submission.status === 'succeeded'));
|
|
482
|
+
if (!sent) {
|
|
483
|
+
return no('no iOS store build is in App Store Connect yet', 'ias build --platform ios --profile production, then ias submit <build> --wait', { command: 'ias build --platform ios --profile production' });
|
|
484
|
+
}
|
|
485
|
+
return ok(`build ${sent.version} (${sent.build_number ?? '?'}) is in TestFlight; ias testflight ${sent.id} distributes it`, { command: `ias testflight ${sent.id}` });
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
id: 'updates',
|
|
490
|
+
title: 'The app is ready to publish OTA updates',
|
|
491
|
+
description: 'The project, the signing key and the token are in place. The first update is signed on this Mac with the key of 1Password; the server only checks the signature.',
|
|
492
|
+
requires: ['build', 'sdk', 'services'],
|
|
493
|
+
service: 'updates',
|
|
494
|
+
check: async () => {
|
|
495
|
+
const app = await getApp(ctx);
|
|
496
|
+
const project = projectSafe(ctx.dir);
|
|
497
|
+
const signing = project.config?.updates?.signing;
|
|
498
|
+
if (!app.updates) return no(`the application ${app.code} has no OTA project`, `run ias update init (an owner with a personal token); the guide (docs/guide/actualizaciones.md#alta-del-proyecto-una-vez) has the artisan command if the API is not available`);
|
|
499
|
+
if (!signing?.keyId || !signing?.privateKey || !signing?.certificate) {
|
|
500
|
+
return no(`ias.json has no updates.signing (keyId, privateKey and certificate)`, 'keep the private key in 1Password and set its op:// reference, the keyid and the certificate in ias.json; docs/guide/actualizaciones.md#alta-del-proyecto-una-vez has the openssl command that makes them');
|
|
501
|
+
}
|
|
502
|
+
const certificate = join(ctx.dir, signing.certificate);
|
|
503
|
+
if (!existsSync(certificate)) return no(`no certificate at ${signing.certificate}`, 'restore it from git: it is the public half of the key, and every build carries it');
|
|
504
|
+
return ok(`project ${app.updates.project}, keyid ${signing.keyId}, certificate ${signing.certificate}; publish with ias update publish --message "…"`);
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
id: 'distribution',
|
|
509
|
+
title: 'The install page is ready',
|
|
510
|
+
description: 'A preview build (an ad hoc IPA or an APK) is published by itself, with an install page and a stable link.',
|
|
511
|
+
requires: ['build', 'services'],
|
|
512
|
+
service: 'distribution',
|
|
513
|
+
check: async () => {
|
|
514
|
+
const app = await getApp(ctx);
|
|
515
|
+
if (!app.distribution) return no(`the application ${app.code} has no distribution project`, `run ias dist init (an owner with a personal token), or turn distribution on in the panel: ${panel(ctx, `/applications/${app.code}/settings`)}`, { url: panel(ctx, `/applications/${app.code}/settings`) });
|
|
516
|
+
return ok(`project ${app.distribution.project}, stable link ${app.distribution.install_url}; ias dist list shows the builds`);
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
id: 'push',
|
|
521
|
+
title: 'The app sends push notifications',
|
|
522
|
+
description: 'ias.push registers the device and receives the sync notices. The install key is not a secret; the APNs and FCM credentials are, and a member approves them.',
|
|
523
|
+
requires: ['build', 'sdk', 'services'],
|
|
524
|
+
service: 'push',
|
|
525
|
+
check: async () => {
|
|
526
|
+
const app = await getApp(ctx);
|
|
527
|
+
const config = projectSafe(ctx.dir).config;
|
|
528
|
+
if (!(app.services ?? []).includes('push')) return no(`push is not on for ${app.code}`, `ias setup next turns it on; or in the panel: ${panel(ctx, `/applications/${app.code}/settings`)}`, { url: panel(ctx, `/applications/${app.code}/settings`) });
|
|
529
|
+
if (!config?.push?.installKey) return no('ias.json has no push.installKey', 'ias setup next reads it from the API and writes it; it is not a secret');
|
|
530
|
+
const googleServices = config.push?.android?.googleServicesFile;
|
|
531
|
+
if (googleServices && !existsSync(join(ctx.dir, googleServices))) return no(`no ${googleServices}`, 'download it from the Firebase project of the app, or remove push.android.googleServicesFile');
|
|
532
|
+
return ok('push is on and ias.json has its install key; ias push credentials loads the APNs and FCM keys');
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
id: 'ci',
|
|
537
|
+
title: 'CI has its robot token',
|
|
538
|
+
description: 'Optional: a robot token of the application (iasr_…) in IAS_TOKEN, made by an owner with ias tokens create (or in the panel). It is what CI uses instead of a person.',
|
|
539
|
+
requires: ['build'],
|
|
540
|
+
optional: () => true,
|
|
541
|
+
check: () => (ctx.env.IAS_TOKEN ? ok('IAS_TOKEN is set') : no('IAS_TOKEN is not set', `make one with ias tokens create --app ${ctx.config?.app ?? '<app>'} (an owner with a personal token), or in the panel (${panel(ctx, '/applications/' + (ctx.config?.app ?? '<app>') + '/access')}), and set IAS_TOKEN in CI`, { url: panel(ctx, `/applications/${ctx.config?.app ?? ''}/access`), command: `ias tokens create --app ${ctx.config?.app ?? '<app>'}` })),
|
|
542
|
+
},
|
|
543
|
+
];
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** A context with what the checks read; the tests pass their own client, probe and sleep. */
|
|
547
|
+
export function createSetupContext({ dir, env = process.env, output, values = {}, state = {}, services, probe = systemProbe, platform = process.platform, nodeVersion = process.versions.node, sleep, openUrl, client, aapt2 }) {
|
|
548
|
+
const project = projectSafe(resolve(dir));
|
|
549
|
+
const config = project.config;
|
|
550
|
+
const chosen = services ?? state.services ?? inferServices({ config, expo: expoOf(resolve(dir)), dependencies: sdkDependencies(resolve(dir)) });
|
|
551
|
+
return { dir: resolve(dir), env, output, values, state, services: chosen.filter((service) => SERVICES.includes(service)), probe, platform, nodeVersion, sleep, openUrl, client, aapt2, config, appData: undefined, vaultData: undefined, session: undefined };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function sdkDependencies(dir) {
|
|
555
|
+
try {
|
|
556
|
+
return sdkState(dir).dependencies;
|
|
557
|
+
} catch {
|
|
558
|
+
return {};
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** One check, never a throw: an error becomes a step that says what happened and how to fix it. */
|
|
563
|
+
async function checkStep(step, ctx) {
|
|
564
|
+
try {
|
|
565
|
+
const result = await step.check(ctx);
|
|
566
|
+
return result ?? ok('done');
|
|
567
|
+
} catch (error) {
|
|
568
|
+
if (error.code === 'not_logged_in') return no('not logged in', 'ias setup next runs ias login', { code: error.code });
|
|
569
|
+
if (error instanceof IasError) return no(error.message, error.fix, { code: error.code });
|
|
570
|
+
return no(error.message ?? String(error), 'run ias doctor to check this computer and the app');
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** The checklist: every step with its status, detail and what is next. */
|
|
575
|
+
export async function setupPlan(ctx) {
|
|
576
|
+
const steps = buildSteps(ctx);
|
|
577
|
+
const results = [];
|
|
578
|
+
const byId = new Map();
|
|
579
|
+
ctx.appData = undefined;
|
|
580
|
+
ctx.vaultData = undefined;
|
|
581
|
+
for (const step of steps) {
|
|
582
|
+
const selected = !step.service || ctx.services.includes(step.service);
|
|
583
|
+
const optional = step.optional?.(ctx) === true || !selected;
|
|
584
|
+
let result;
|
|
585
|
+
if (!selected) {
|
|
586
|
+
result = { ok: false, optional: true, detail: `not part of this setup (${step.service} is off)`, fix: null };
|
|
587
|
+
} else {
|
|
588
|
+
const missing = (step.requires ?? []).filter((id) => {
|
|
589
|
+
const required = byId.get(id);
|
|
590
|
+
return required && !required.optional && required.status !== STATUS.DONE;
|
|
591
|
+
});
|
|
592
|
+
if (missing.length > 0) result = { ok: false, optional, detail: optional ? `optional, after ${missing.join(', ')}` : `finish ${missing.join(', ')} first`, fix: null };
|
|
593
|
+
else result = { ...(await checkStep(step, ctx)), optional };
|
|
594
|
+
}
|
|
595
|
+
const entry = {
|
|
596
|
+
id: step.id,
|
|
597
|
+
title: step.title,
|
|
598
|
+
description: step.description,
|
|
599
|
+
requires: step.requires ?? [],
|
|
600
|
+
kind: step.run ? 'auto' : 'manual',
|
|
601
|
+
optional: result.optional,
|
|
602
|
+
status: !selected ? STATUS.OPTIONAL : result.optional && !result.ok ? STATUS.OPTIONAL : result.ok ? STATUS.DONE : result.detail?.startsWith('finish ') ? STATUS.LOCKED : STATUS.READY,
|
|
603
|
+
detail: result.detail,
|
|
604
|
+
...(result.fix ? { fix: result.fix } : {}),
|
|
605
|
+
...(result.command ? { command: result.command } : {}),
|
|
606
|
+
...(result.url ? { url: result.url } : {}),
|
|
607
|
+
...(result.code ? { code: result.code } : {}),
|
|
608
|
+
};
|
|
609
|
+
byId.set(step.id, entry);
|
|
610
|
+
results.push(entry);
|
|
611
|
+
}
|
|
612
|
+
const required = results.filter((step) => !step.optional);
|
|
613
|
+
const done = required.filter((step) => step.status === STATUS.DONE);
|
|
614
|
+
const next = results.find((step) => !step.optional && step.status !== STATUS.DONE) ?? null;
|
|
615
|
+
return { steps: results, complete: required.every((step) => step.status === STATUS.DONE), counts: { done: done.length, required: required.length }, next: next?.id ?? null };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Runs the step `id` (the first one not done by default) if it is its turn, and returns what it did. */
|
|
619
|
+
export async function setupNext(ctx, { id, all = false, plan } = {}) {
|
|
620
|
+
const current = plan ?? (await setupPlan(ctx));
|
|
621
|
+
const step = id ? current.steps.find((candidate) => candidate.id === id) : current.steps.find((candidate) => !candidate.optional && candidate.status !== STATUS.DONE);
|
|
622
|
+
if (!step) return { plan: current, ran: null, complete: current.complete };
|
|
623
|
+
const definition = buildSteps(ctx).find((candidate) => candidate.id === step.id);
|
|
624
|
+
if (step.status === STATUS.DONE) return { plan: current, ran: null, complete: current.complete };
|
|
625
|
+
if (step.status === STATUS.LOCKED) {
|
|
626
|
+
throw new IasError(`ias setup cannot run ${step.id} yet: ${step.detail}`, { fix: `run ias setup next first` });
|
|
627
|
+
}
|
|
628
|
+
if (!definition.run) return { plan: current, ran: null, complete: false, waiting: step };
|
|
629
|
+
const run = await definition.run(ctx);
|
|
630
|
+
const after = await setupPlan(ctx);
|
|
631
|
+
if (!all) return { plan: after, ran: step.id, result: run, complete: after.complete, waiting: after.steps.find((candidate) => !candidate.optional && candidate.status !== STATUS.DONE && candidate.kind === 'manual') ?? null };
|
|
632
|
+
const last = after.steps.find((candidate) => candidate.id === step.id);
|
|
633
|
+
if (last?.status !== STATUS.DONE) return { plan: after, ran: step.id, result: run, complete: after.complete, waiting: last };
|
|
634
|
+
if (after.complete || after.next === null) return { plan: after, ran: step.id, result: run, complete: after.complete };
|
|
635
|
+
return setupNext(ctx, { all, plan: after });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const SYMBOL = { [STATUS.DONE]: '[x]', [STATUS.READY]: '[>]', [STATUS.LOCKED]: '[ ]', [STATUS.OPTIONAL]: '[-]' };
|
|
639
|
+
|
|
640
|
+
/** The checklist for people, the lines of `ias setup`. */
|
|
641
|
+
export function printPlan(plan, ctx, output) {
|
|
642
|
+
const app = ctx.config?.app ?? '(no ias.json)';
|
|
643
|
+
const server = originOf(ctx.dir, ctx.env);
|
|
644
|
+
output.log(`${output.prefix} setup: ${app} on ${server} — ${plan.counts.done} of ${plan.counts.required} steps${plan.complete ? ', complete' : ''}`);
|
|
645
|
+
output.log('');
|
|
646
|
+
const width = Math.max(...plan.steps.map((step) => step.id.length));
|
|
647
|
+
for (const step of plan.steps) {
|
|
648
|
+
output.log(` ${SYMBOL[step.status]} ${step.id.padEnd(width)} ${step.detail}`);
|
|
649
|
+
if (step.fix && step.status !== STATUS.DONE && step.status !== STATUS.LOCKED && (step.status !== STATUS.OPTIONAL || step.command)) {
|
|
650
|
+
const indent = ` ${' '.repeat(width)} → `;
|
|
651
|
+
output.log(step.fix.split('\n').map((line, index) => (index === 0 ? `${indent}${line}` : ` ${' '.repeat(width)} ${line}`)).join('\n'));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const waiting = plan.steps.find((step) => !step.optional && step.status !== STATUS.DONE);
|
|
655
|
+
if (waiting) output.log(`\nnext: ias setup next (${waiting.id}${waiting.kind === 'manual' ? ': a person' : ''})`);
|
|
656
|
+
else output.log('\nthe checklist is complete: ias build, ias update publish and ias push are next.');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
/** The first build, opt-in with `ias setup next --build <profile>`: it runs ias build on the runner or this Mac. */
|
|
662
|
+
export async function runSetupBuild(ctx) {
|
|
663
|
+
const plan = await setupPlan(ctx);
|
|
664
|
+
const step = plan.steps.find((candidate) => candidate.id === 'build');
|
|
665
|
+
if (!step || step.status === STATUS.LOCKED) throw new IasError(`ias setup cannot build yet: ${step?.detail ?? 'the checklist has no build step'}`, { fix: 'finish the steps before it, or run ias setup next first' });
|
|
666
|
+
const { default: buildCommand } = await import('./commands/build/index.mjs');
|
|
667
|
+
const profile = typeof ctx.values.build === 'string' ? ctx.values.build : 'preview';
|
|
668
|
+
return buildCommand.run({
|
|
669
|
+
values: {
|
|
670
|
+
project: ctx.dir,
|
|
671
|
+
profile,
|
|
672
|
+
platform: 'all',
|
|
673
|
+
...(ctx.values['local-builds'] ? { local: true } : {}),
|
|
674
|
+
...(ctx.values.message ? { message: ctx.values.message } : {}),
|
|
675
|
+
...(ctx.values.wait ? { wait: true } : {}),
|
|
676
|
+
},
|
|
677
|
+
cwd: ctx.dir,
|
|
678
|
+
env: ctx.env,
|
|
679
|
+
output: ctx.output,
|
|
680
|
+
openUrl: ctx.openUrl,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** The first OTA update, opt-in with `ias setup next --publish`: it signs with the key of 1Password. */
|
|
685
|
+
export async function runSetupPublish(ctx) {
|
|
686
|
+
const plan = await setupPlan(ctx);
|
|
687
|
+
const build = plan.steps.find((candidate) => candidate.id === 'build');
|
|
688
|
+
const updates = plan.steps.find((candidate) => candidate.id === 'updates');
|
|
689
|
+
if (!build || build.status !== STATUS.DONE) throw new IasError('there is no build to publish: run ias setup next --build preview first', { fix: 'a build has to carry the updates URL and certificate, and be built before the update' });
|
|
690
|
+
if (!updates || updates.status !== STATUS.DONE) throw new IasError(`the app is not ready to publish: ${updates?.detail ?? 'there is no updates step'}`, { fix: updates?.fix ?? 'finish the updates step first' });
|
|
691
|
+
const { projectFor, updateSettings } = await import('./config.mjs');
|
|
692
|
+
const { publish, withChannels } = await import('./commands/update/index.mjs');
|
|
693
|
+
const project = projectFor(ctx.dir, 'updates');
|
|
694
|
+
const settings = withChannels(updateSettings(project, ctx.env), project);
|
|
695
|
+
return publish(settings, { message: ctx.values.message ?? 'First update', platforms: ['ios', 'android'], rollout: 100, dryRun: ctx.values['dry-run'] === true }, ctx.output);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export { STATUS };
|