@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
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// ias apps store <code> (docs/plans/gaps/05-bundle-id-app-record.md §3.5): the identity of an application in App
|
|
2
|
+
// Store Connect, and the way to close the gap that the store APIs cannot. Read-only by default: it shows the bundle
|
|
3
|
+
// id, the app record IAS knows (and when a Mac checked it) and what a person has to do. --register-bundle-id creates
|
|
4
|
+
// the App ID when it does not exist (idempotent), --check asks Apple for the app record and saves its Apple ID with
|
|
5
|
+
// PATCH /apps/{code}, and --set-asc-app-id saves one by hand. The App Store Connect key is the team's sealed one,
|
|
6
|
+
// opened in memory on this computer, as ias devices sync does: the server never sees it, and neither the key nor a
|
|
7
|
+
// token is printed or written.
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { loadProject } from '../../config.mjs';
|
|
10
|
+
import { IasError } from '../../errors.mjs';
|
|
11
|
+
import { applicationOf, connect } from '../../manage.mjs';
|
|
12
|
+
import { vaultItem } from '../../team-vault.mjs';
|
|
13
|
+
import { ascClient } from '../devices/asc.mjs';
|
|
14
|
+
import { openAscKey } from '../devices/sync.mjs';
|
|
15
|
+
import { confirm, teamKeys } from '../vault/team.mjs';
|
|
16
|
+
|
|
17
|
+
/** The Apple ID of an app record: up to 20 digits, as the API validates it. */
|
|
18
|
+
const ASC_APP_ID = /^[0-9]{1,20}$/;
|
|
19
|
+
|
|
20
|
+
/** Where a person creates the app record: the public API cannot. */
|
|
21
|
+
export const ASC_APPS_URL = 'https://appstoreconnect.apple.com/apps';
|
|
22
|
+
|
|
23
|
+
/** The SKU IAS proposes for an application: deterministic, and it cannot change once the app exists. */
|
|
24
|
+
export const skuOf = (code) => `IAS-${String(code).toUpperCase()}`;
|
|
25
|
+
|
|
26
|
+
/** The line of the stored app record, for ias apps <code> (no network). */
|
|
27
|
+
export function appRecordLine(app) {
|
|
28
|
+
if (!app.ios?.asc_app_id) return null;
|
|
29
|
+
const checked = app.ios.asc_app_checked_at ? ` · checked ${app.ios.asc_app_checked_at.slice(0, 10)}` : ' · not checked against Apple';
|
|
30
|
+
return ` app record Apple ID ${app.ios.asc_app_id} · SKU ${skuOf(app.code)}${checked}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The team's App Store Connect API key in the vault, or credentials_missing. */
|
|
34
|
+
async function teamAscKey(client) {
|
|
35
|
+
const { data } = await client.request('GET', '/team/credentials');
|
|
36
|
+
const key = data.data
|
|
37
|
+
.filter((credential) => !credential.retired_at && credential.type === 'asc_api_key' && (!credential.expires_at || Date.parse(credential.expires_at) > Date.now()))
|
|
38
|
+
.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''))[0];
|
|
39
|
+
|
|
40
|
+
if (!key) {
|
|
41
|
+
throw new IasError('the vault has no App Store Connect key', {
|
|
42
|
+
fix: 'import it with ias credentials import asc_api_key --p8 <AuthKey_….p8> --key-id <id> --issuer-id <uuid>',
|
|
43
|
+
code: 'credentials_missing',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return key;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Opens the team's key in memory, makes the ASC client and runs `work` with it; the .p8 is wiped however it ends.
|
|
52
|
+
* The key and a token are never printed.
|
|
53
|
+
*/
|
|
54
|
+
async function withApple(context, project, client, work) {
|
|
55
|
+
const item = vaultItem(context, project, client.server);
|
|
56
|
+
const { keys, recoveryKey } = await teamKeys(client, item, context.env, { recovery: true });
|
|
57
|
+
const credential = await teamAscKey(client);
|
|
58
|
+
const key = await openAscKey(client, credential, { recoveryKey, keys });
|
|
59
|
+
try {
|
|
60
|
+
return await work(ascClient(key, { env: context.env, fetch: context.fetch ?? fetch }), { credential, key });
|
|
61
|
+
} finally {
|
|
62
|
+
key.p8?.fill?.(0);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What Apple answered, as an IAS error of its own: the role of the key, or a bundle id taken. */
|
|
67
|
+
function appleErrorOf(error, bundleId) {
|
|
68
|
+
if (error.code === 'apple_api_error' && error.details?.status === 403) {
|
|
69
|
+
return new IasError('the App Store Connect key cannot touch identifiers', {
|
|
70
|
+
fix: 'a team key needs the Admin or App Manager role in App Store Connect; an Individual Key does not work here',
|
|
71
|
+
code: 'asc_key_forbidden',
|
|
72
|
+
details: error.details,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (error.code === 'apple_api_error' && error.details?.status === 409) {
|
|
76
|
+
return new IasError(`Apple does not let the bundle id ${bundleId} be registered: it already exists in this team or another`, {
|
|
77
|
+
fix: 'use the existing App ID, or choose another bundle id; the API only sees the App IDs of this team',
|
|
78
|
+
code: 'bundle_id_taken',
|
|
79
|
+
details: error.details,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return error;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The block a person follows to create the app record, already filled with what the form asks. */
|
|
86
|
+
function printGuided(app, output) {
|
|
87
|
+
output.log(' app record missing in App Store Connect');
|
|
88
|
+
output.log(` → a person with the Account Holder, App Manager or Admin role creates it in ${ASC_APPS_URL}`);
|
|
89
|
+
output.log(` platform iOS · name ${app.name} · language es-ES (Spanish)`);
|
|
90
|
+
output.log(` bundle id ${app.ios.bundle_id} · SKU ${skuOf(app.code)} · full access`);
|
|
91
|
+
output.log(' the SKU cannot change afterwards; the name has to be free in the account');
|
|
92
|
+
output.log(` when it is there: ias apps store ${app.code} --check`);
|
|
93
|
+
output.log(' and the iOS signing material: ias credentials generate ios (certificate and App Store profile)');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The Apple state without saving anything: { bundle, record }, or null when this Mac cannot ask (no vault, no op). */
|
|
97
|
+
async function inspectApple(context, project, client, app) {
|
|
98
|
+
try {
|
|
99
|
+
return await withApple(context, project, client, async (asc, material) => {
|
|
100
|
+
context.output.progress(`App Store Connect: key ${material.key.key_id}, from the vault (${material.credential.id} v${material.credential.version})`);
|
|
101
|
+
const bundle = await asc.bundleId(app.ios.bundle_id);
|
|
102
|
+
const record = bundle ? await asc.appByBundleId(app.ios.bundle_id) : null;
|
|
103
|
+
return { bundle, record };
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
context.output.warn(`Apple could not be asked (${error.message}); this is the state IAS keeps`);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The line of what Apple answered, for the read-only inventory. */
|
|
112
|
+
function printAppleState(app, state, output) {
|
|
113
|
+
if (state === null) {
|
|
114
|
+
output.log(' Apple not checked: --check asks Apple for the App ID and the app record');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (!state.bundle) {
|
|
118
|
+
output.log(` Apple the App ID ${app.ios.bundle_id} is not in Apple: ias apps store ${app.code} --register-bundle-id registers it`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (!state.record) {
|
|
122
|
+
output.log(` Apple App ID registered (${state.bundle.id}) · no app record`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const name = state.record.attributes?.name ?? app.name;
|
|
126
|
+
const sku = state.record.attributes?.sku ?? skuOf(app.code);
|
|
127
|
+
output.log(` Apple App ID registered (${state.bundle.id}) · app record ${name} · Apple ID ${state.record.id} · SKU ${sku}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The block and the error when Apple (or IAS) has no app record for the bundle id. */
|
|
131
|
+
function missingAppRecord(app, output) {
|
|
132
|
+
printGuided(app, output);
|
|
133
|
+
throw new IasError(`App Store Connect has no app record for ${app.ios.bundle_id}`, {
|
|
134
|
+
fix: `a person creates it in ${ASC_APPS_URL} (name ${app.name}, bundle id ${app.ios.bundle_id}, SKU ${skuOf(app.code)}); when it is there: ias apps store ${app.code} --check`,
|
|
135
|
+
code: 'asc_app_missing',
|
|
136
|
+
details: { bundle_id: app.ios.bundle_id, sku: skuOf(app.code) },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The state for --json: stable keys, never the key or a token. */
|
|
141
|
+
function reportOf(app, { apple = null, changed = false, action = 'none' } = {}) {
|
|
142
|
+
return {
|
|
143
|
+
app: app.code,
|
|
144
|
+
name: app.name,
|
|
145
|
+
ios: app.ios ?? null,
|
|
146
|
+
sku: app.ios ? skuOf(app.code) : null,
|
|
147
|
+
apple,
|
|
148
|
+
android: app.android ? { package: app.android.package, checked: false } : null,
|
|
149
|
+
changed,
|
|
150
|
+
action,
|
|
151
|
+
next: app.ios && !app.ios.asc_app_id ? `ias apps store ${app.code} --check` : null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Creates the App ID when Apple does not have it; { registered, bundle }. */
|
|
156
|
+
async function registerBundleId(context, project, client, app) {
|
|
157
|
+
const { values, output } = context;
|
|
158
|
+
|
|
159
|
+
return withApple(context, project, client, async (asc, material) => {
|
|
160
|
+
output.progress(`App Store Connect: key ${material.key.key_id}, from the vault (${material.credential.id} v${material.credential.version})`);
|
|
161
|
+
let bundle;
|
|
162
|
+
try {
|
|
163
|
+
bundle = await asc.bundleId(app.ios.bundle_id);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
throw appleErrorOf(error, app.ios.bundle_id);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (bundle) {
|
|
169
|
+
output.log(` Apple already has the App ID ${app.ios.bundle_id} (${bundle.id}): nothing to register.`);
|
|
170
|
+
return { registered: false, bundle };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
output.log(` It would register in Apple the App ID ${app.ios.bundle_id} as «${app.name}» (IOS).`);
|
|
174
|
+
if (values['dry-run']) {
|
|
175
|
+
output.log(' --dry-run: nothing was registered.');
|
|
176
|
+
return { registered: false, bundle: null };
|
|
177
|
+
}
|
|
178
|
+
await confirm(context, `Register the App ID ${app.ios.bundle_id} in the Apple team?`);
|
|
179
|
+
let created;
|
|
180
|
+
try {
|
|
181
|
+
created = await asc.createBundleId({ identifier: app.ios.bundle_id, name: app.name });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
throw appleErrorOf(error, app.ios.bundle_id);
|
|
184
|
+
}
|
|
185
|
+
output.log(` App ID ${created.attributes?.identifier ?? app.ios.bundle_id} registered in Apple (${created.id}).`);
|
|
186
|
+
output.log(` The app record is missing: ${ASC_APPS_URL} (name ${app.name}, bundle id ${app.ios.bundle_id}, SKU ${skuOf(app.code)}).`);
|
|
187
|
+
output.log(` when it is there: ias apps store ${app.code} --check`);
|
|
188
|
+
return { registered: true, bundle: created };
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Asks Apple for the app record and saves its Apple ID; throws asc_app_missing when it does not exist. */
|
|
193
|
+
async function checkAppRecord(context, project, client, app) {
|
|
194
|
+
const { values, output } = context;
|
|
195
|
+
|
|
196
|
+
return withApple(context, project, client, async (asc, material) => {
|
|
197
|
+
output.progress(`App Store Connect: key ${material.key.key_id}, from the vault (${material.credential.id} v${material.credential.version})`);
|
|
198
|
+
let bundle;
|
|
199
|
+
try {
|
|
200
|
+
bundle = await asc.bundleId(app.ios.bundle_id);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
throw appleErrorOf(error, app.ios.bundle_id);
|
|
203
|
+
}
|
|
204
|
+
output.log(` App ID ${bundle ? `registered in Apple (${bundle.id})` : 'not in Apple: ias apps store --register-bundle-id registers it'}`);
|
|
205
|
+
|
|
206
|
+
let record = null;
|
|
207
|
+
if (bundle) {
|
|
208
|
+
try {
|
|
209
|
+
record = await asc.appByBundleId(app.ios.bundle_id);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
throw appleErrorOf(error, app.ios.bundle_id);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!record) {
|
|
216
|
+
missingAppRecord(app, output);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const name = record.attributes?.name ?? app.name;
|
|
220
|
+
const sku = record.attributes?.sku ?? skuOf(app.code);
|
|
221
|
+
if (name !== app.name) output.warn(`the name in App Store Connect is «${name}», not «${app.name}»: it does not stop a submission, but they should match`);
|
|
222
|
+
|
|
223
|
+
if (values['dry-run']) {
|
|
224
|
+
output.log(` app record ${name} · Apple ID ${record.id} · SKU ${sku} (--dry-run: nothing is saved)`);
|
|
225
|
+
return { saved: null, record };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const { data: saved } = await client.request('PATCH', `/apps/${encodeURIComponent(app.code)}`, { json: { asc_app_id: record.id, asc_app_checked_at: new Date().toISOString() } });
|
|
229
|
+
output.log(` app record ${name} · Apple ID ${record.id} · SKU ${sku} · checked now`);
|
|
230
|
+
output.log(` Saved in IAS (${saved.code}): ias submit uses the Apple ID ${record.id} without --asc-app-id.`);
|
|
231
|
+
return { saved, record };
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function store(context) {
|
|
236
|
+
const { values, output } = context;
|
|
237
|
+
const project = loadProject(resolve(context.cwd, values.project ?? '.'));
|
|
238
|
+
const code = context.positionals[0] ?? applicationOf(context, project);
|
|
239
|
+
|
|
240
|
+
if (values.check && values['set-asc-app-id'] !== undefined) {
|
|
241
|
+
throw new IasError('--check and --set-asc-app-id do not go together', { fix: 'use --check to ask Apple, or --set-asc-app-id <id> to paste it by hand' });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const client = connect(context, project);
|
|
245
|
+
const { data: initial } = await client.request('GET', `/apps/${encodeURIComponent(code)}`);
|
|
246
|
+
let app = initial;
|
|
247
|
+
|
|
248
|
+
// A person pasted the Apple ID: no Apple call, and it stays unchecked until --check sees it.
|
|
249
|
+
if (values['set-asc-app-id'] !== undefined) {
|
|
250
|
+
const ascAppId = String(values['set-asc-app-id']).trim();
|
|
251
|
+
if (!ASC_APP_ID.test(ascAppId)) {
|
|
252
|
+
throw new IasError(`«${ascAppId}» is not an Apple ID`, { fix: 'it is the number App Store Connect shows in App Information, such as 6822051274' });
|
|
253
|
+
}
|
|
254
|
+
if (values['dry-run']) {
|
|
255
|
+
output.log(`It would save the Apple ID ${ascAppId} of ${code}, without checking it against Apple.`);
|
|
256
|
+
return reportOf(app, { action: 'save_app_record' });
|
|
257
|
+
}
|
|
258
|
+
const { data: saved } = await client.request('PATCH', `/apps/${encodeURIComponent(code)}`, { json: { asc_app_id: ascAppId } });
|
|
259
|
+
output.log(`${saved.code}: Apple ID ${ascAppId} saved (not checked against Apple yet: ias apps store ${code} --check).`);
|
|
260
|
+
return reportOf(saved, { changed: true, action: 'save_app_record' });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Everything else starts from the inventory, as the command does without flags.
|
|
264
|
+
output.log(`${app.code} ${app.name}`);
|
|
265
|
+
if (app.ios?.bundle_id) {
|
|
266
|
+
output.log(` ios ${app.ios.bundle_id} (team ${app.ios.team_id ?? '—'})`);
|
|
267
|
+
if (app.ios.asc_app_id) {
|
|
268
|
+
const checked = app.ios.asc_app_checked_at ? `checked ${app.ios.asc_app_checked_at.slice(0, 10)}` : 'not checked against Apple';
|
|
269
|
+
output.log(` app record Apple ID ${app.ios.asc_app_id} · SKU ${skuOf(app.code)} · ${checked}`);
|
|
270
|
+
} else {
|
|
271
|
+
output.log(' app record missing in App Store Connect');
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
output.log(' no iOS bundle id: there is nothing to check in Apple (add it in the panel › Ajustes)');
|
|
275
|
+
}
|
|
276
|
+
if (app.android) output.log(` android ${app.android.package} (in Play: to be confirmed)`);
|
|
277
|
+
|
|
278
|
+
// Without a bundle id there is nothing in Apple to look at, whatever else was asked.
|
|
279
|
+
if (!app.ios?.bundle_id) {
|
|
280
|
+
if (values['register-bundle-id'] || values.check) {
|
|
281
|
+
throw new IasError(`${app.code} has no iOS bundle id`, { fix: 'set it in the panel (the application › Ajustes) and run the command again', code: 'application_without_ios' });
|
|
282
|
+
}
|
|
283
|
+
return reportOf(app);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Without flags it is read-only, but it still asks Apple when this Mac has the key; if it cannot, the stored state counts.
|
|
287
|
+
if (!values.check && !values['register-bundle-id']) {
|
|
288
|
+
const state = await inspectApple(context, project, client, app);
|
|
289
|
+
printAppleState(app, state, output);
|
|
290
|
+
|
|
291
|
+
if (state !== null) {
|
|
292
|
+
if (!state.record) missingAppRecord(app, output);
|
|
293
|
+
const appleReport = {
|
|
294
|
+
bundle_id_registered: true,
|
|
295
|
+
app_record: {
|
|
296
|
+
id: state.record.id,
|
|
297
|
+
name: state.record.attributes?.name ?? null,
|
|
298
|
+
sku: state.record.attributes?.sku ?? null,
|
|
299
|
+
primary_locale: state.record.attributes?.primaryLocale ?? null,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
if (!app.ios.asc_app_id) {
|
|
303
|
+
output.log(` IAS does not have it saved: ias apps store ${app.code} --check saves it.`);
|
|
304
|
+
} else if (app.ios.asc_app_id !== state.record.id) {
|
|
305
|
+
output.warn(`the saved Apple ID (${app.ios.asc_app_id}) is not the one Apple has (${state.record.id}): ias apps store ${app.code} --check fixes it`);
|
|
306
|
+
}
|
|
307
|
+
return reportOf(app, { apple: appleReport });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let registered = false;
|
|
312
|
+
if (values['register-bundle-id']) {
|
|
313
|
+
const outcome = await registerBundleId(context, project, client, app);
|
|
314
|
+
registered = outcome.registered;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let apple = null;
|
|
318
|
+
let saved = null;
|
|
319
|
+
if (values.check) {
|
|
320
|
+
const outcome = await checkAppRecord(context, project, client, app);
|
|
321
|
+
saved = outcome.saved;
|
|
322
|
+
apple = {
|
|
323
|
+
bundle_id_registered: true,
|
|
324
|
+
app_record: {
|
|
325
|
+
id: outcome.record.id,
|
|
326
|
+
name: outcome.record.attributes?.name ?? null,
|
|
327
|
+
sku: outcome.record.attributes?.sku ?? null,
|
|
328
|
+
primary_locale: outcome.record.attributes?.primaryLocale ?? null,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
if (saved) {
|
|
332
|
+
app = saved;
|
|
333
|
+
output.log(` ${app.code}: ias apps store ${app.code} has nothing pending in Apple any more.`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const report = reportOf(app, {
|
|
338
|
+
apple,
|
|
339
|
+
changed: registered || saved !== null,
|
|
340
|
+
action: saved ? 'save_app_record' : registered ? 'register_bundle_id' : 'none',
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
if (!app.ios.asc_app_id && !values.check) {
|
|
344
|
+
missingAppRecord(app, output);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return report;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export default {
|
|
351
|
+
name: 'store',
|
|
352
|
+
summary: 'The identity of the app in App Store Connect: bundle id, app record and the next step',
|
|
353
|
+
description: [
|
|
354
|
+
'Without flags it only reads: it shows the bundle id, the app record IAS knows (Apple ID and when it was checked) and what is missing, with the creation form already filled in (name, bundle id, language and SKU).',
|
|
355
|
+
'--register-bundle-id registers the App ID in Apple only when it does not exist (idempotent, asks for confirmation unless --yes); --check asks Apple for the app record and saves its Apple ID with PATCH /apps/{code}; --set-asc-app-id <id> saves one by hand, without calling Apple. The App Store Connect key comes from the vault and is opened in memory on this Mac: it is never printed, and the server never sees it.',
|
|
356
|
+
].join('\n'),
|
|
357
|
+
positionals: [{ name: 'code', optional: true, description: 'The code of the application (app of ias.json by default).', pattern: /^[a-z0-9][a-z0-9-]{0,31}$/ }],
|
|
358
|
+
options: {
|
|
359
|
+
check: { type: 'boolean', description: 'Ask Apple for the app record and, when it exists, save its Apple ID in IAS.' },
|
|
360
|
+
'register-bundle-id': { type: 'boolean', description: 'Register the App ID in Apple when it does not exist yet (idempotent; asks for confirmation).' },
|
|
361
|
+
'set-asc-app-id': { type: 'string', value: '<id>', description: 'Save this Apple ID by hand, without calling Apple.' },
|
|
362
|
+
yes: { type: 'boolean', description: 'Do not ask before registering the App ID in Apple.' },
|
|
363
|
+
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>).' },
|
|
364
|
+
'dry-run': { type: 'boolean', description: 'Register nothing in Apple and save nothing: say what it would do.' },
|
|
365
|
+
},
|
|
366
|
+
run: store,
|
|
367
|
+
};
|
|
@@ -59,13 +59,13 @@ export async function authorize(client, { name, team, wait = sleep, open = () =>
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
async function login(context) {
|
|
62
|
+
export async function login(context) {
|
|
63
63
|
const { env, output } = context;
|
|
64
64
|
const server = serverOrigin(projectOf(context), env);
|
|
65
65
|
const kind = storeKind(env);
|
|
66
66
|
const team = context.values.team;
|
|
67
67
|
if (team !== undefined && !TEAM_SLUG.test(team)) throw new IasError(`«${team}» is not the code of a team`, { fix: 'use the code the panel shows (Plataforma › Equipos, or ias teams): lowercase letters, digits and hyphens', code: 'usage' });
|
|
68
|
-
if (kind === 'none') throw new IasError('ias login keeps no token on CI', { fix: 'set IAS_TOKEN to a robot token of the application (iasr_
|
|
68
|
+
if (kind === 'none') throw new IasError('ias login keeps no token on CI', { fix: 'set IAS_TOKEN to a robot token of the application (iasr_…): ias tokens create makes one, or the panel (the application › Settings › Access)', code: 'no_token_store' });
|
|
69
69
|
if (env.IAS_TOKEN) output.warn('IAS_TOKEN is set and wins over the token of ias login: unset it to use this one');
|
|
70
70
|
const client = manageClient(server, undefined, { wait: context.sleep ?? sleep });
|
|
71
71
|
const granted = await authorize(client, { name: context.values.name ?? deviceName(env), team, wait: context.sleep ?? sleep, open: context.values['no-browser'] ? () => false : (context.openUrl ?? ((url) => openUrl(url, env))), output });
|
|
@@ -127,7 +127,7 @@ export default [
|
|
|
127
127
|
{
|
|
128
128
|
name: 'login',
|
|
129
129
|
summary: 'Authorize this computer with a passkey and keep its personal token',
|
|
130
|
-
description: 'Shows a code and opens the panel (Authorize the CLI), where a member of the team approves it with a passkey. The token (iasp_…, 90 days) goes to the macOS keychain, or to a file with mode 600 ($XDG_CONFIG_HOME/ias/tokens.json) elsewhere; IAS_TOKEN_STORE (keychain, file) chooses. A token is of one team: --team preselects it in the panel, where you choose among yours (ias teams lists them); logging in for another team replaces the token of this server.
|
|
130
|
+
description: 'Shows a code and opens the panel (Authorize the CLI), where a member of the team approves it with a passkey. The token (iasp_…, 90 days) goes to the macOS keychain, or to a file with mode 600 ($XDG_CONFIG_HOME/ias/tokens.json) elsewhere; IAS_TOKEN_STORE (keychain, file, none) chooses; none is the default on CI, where IAS_TOKEN holds a robot token. A token is of one team: --team preselects it in the panel, where you choose among yours (ias teams lists them); logging in for another team replaces the token of this server. The server is IAS_SERVER, server of ias.json or ias.imagina.build.',
|
|
131
131
|
options: {
|
|
132
132
|
name: { type: 'string', value: '"…"', description: 'The name the panel shows for this computer (its name by default).' },
|
|
133
133
|
team: { type: 'string', value: '<code>', description: 'The code of the team the token is for (the panel asks if you have several and it is not given).' },
|
|
@@ -13,6 +13,7 @@ import { buildLine, buildsClient, downloadArtifacts, follow, uploadSource } from
|
|
|
13
13
|
import { localBuilds } from './local.mjs';
|
|
14
14
|
import { platformsOf, profileConfig, remoteProblem } from './profile.mjs';
|
|
15
15
|
import { appConfigOf, archive, buildNumberOf, repository } from './source.mjs';
|
|
16
|
+
import { sayPolicy } from '../policy/progress.mjs';
|
|
16
17
|
|
|
17
18
|
const MESSAGE_LENGTH = 1000;
|
|
18
19
|
|
|
@@ -66,6 +67,7 @@ async function build(context) {
|
|
|
66
67
|
output.progress(`${platform}: build ${made.id}, number ${made.build_number ?? 'of the runner'}; uploading the code…`);
|
|
67
68
|
await uploadSource(upload, source);
|
|
68
69
|
const { data: completed } = await client.request('POST', `/builds/${made.id}/source/complete`);
|
|
70
|
+
sayPolicy(output, completed);
|
|
69
71
|
if (completed.status === 'awaiting_approval') {
|
|
70
72
|
const opened = (context.openUrl ?? ((url) => openUrl(url, env)))(completed.approval_url);
|
|
71
73
|
output.progress(`${platform}: ${opened ? 'opened' : 'open'} ${completed.approval_url}`);
|
|
@@ -49,9 +49,29 @@ async function view(context) {
|
|
|
49
49
|
if (build.error) output.log(` error ${build.error.code}${build.error.step ? ` at ${build.error.step}` : ''}${build.error.message ? `: ${build.error.message}` : ''}`);
|
|
50
50
|
for (const artifact of build.artifacts ?? []) output.log(` artifact ${artifact.kind.padEnd(8)} ${artifact.file} (${artifact.bytes} bytes)`);
|
|
51
51
|
for (const submission of build.submissions ?? []) output.log(` submission ${submission.id} ${submission.target}${submission.track ? ` ${submission.track}` : ''}: ${submission.status}${submission.approval_url ? ` (approve at ${submission.approval_url})` : ''}${submission.error ? ` — ${submission.error.code}` : ''}`);
|
|
52
|
+
if (build.platform === 'ios' && build.distribution === 'store') {
|
|
53
|
+
const testflight = await testflightLine(client, build);
|
|
54
|
+
if (testflight) output.log(` testflight ${testflight}`);
|
|
55
|
+
}
|
|
52
56
|
return build;
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
/**
|
|
60
|
+
* What TestFlight says of an iOS store build, if it knows anything: the processing, its groups and Beta App Review, with
|
|
61
|
+
* the command that goes on. Read-only, and a failure never hides the build (GET /builds/{id}/testflight is optional here).
|
|
62
|
+
*/
|
|
63
|
+
async function testflightLine(client, build) {
|
|
64
|
+
try {
|
|
65
|
+
const { data: state } = await client.request('GET', `/builds/${build.id}/testflight`);
|
|
66
|
+
const groups = (state.groups ?? []).map((group) => group.name).filter(Boolean);
|
|
67
|
+
const parts = [state.processing_state ?? 'not read yet', groups.length > 0 ? `groups ${groups.join(', ')}` : 'no groups'];
|
|
68
|
+
if (state.review?.state) parts.push(`Beta App Review ${state.review.state}`);
|
|
69
|
+
return `${parts.join('; ')} — ias testflight status ${build.id}`;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
async function logs(context) {
|
|
56
76
|
const { values, output } = context;
|
|
57
77
|
const client = clientOf(context);
|