@imagina-build/ias-cli 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +188 -22
- 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/doctor/index.mjs +3 -0
- package/src/commands/github/index.mjs +13 -3
- package/src/commands/init/index.mjs +6 -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/skill/index.mjs +43 -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 +701 -0
- package/src/skill-files.generated.mjs +10 -0
- package/src/skill.mjs +256 -0
- package/src/team-vault.mjs +2 -2
- package/src/token-store.mjs +1 -1
package/shared/vault/vectors.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
PLAINTEXT_TYPES,
|
|
38
38
|
} from './index.mjs';
|
|
39
39
|
import { approverVectors } from './approvers-vectors.mjs';
|
|
40
|
+
import { policyVectors } from './policy-vectors.mjs';
|
|
40
41
|
|
|
41
42
|
export const VECTORS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'test-vectors');
|
|
42
43
|
|
|
@@ -695,6 +696,7 @@ function vectors2(all) {
|
|
|
695
696
|
...runners,
|
|
696
697
|
},
|
|
697
698
|
'ias-vault-2/approvers.json': approverVectors(signing),
|
|
699
|
+
'ias-policy-1/policy.json': policyVectors(signing),
|
|
698
700
|
'ias-vault-2/plaintexts.json': {
|
|
699
701
|
...header('What an envelope holds for each credential type (docs/technical/vault.md §3.5): good has one plaintext per type, with fake files; bad must be refused with code by validatePlaintext(type, bytes). text is the UTF-8 of the plaintext; plaintext, its bytes in base64url.'),
|
|
700
702
|
...plain,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The capabilities of an App ID and the entitlements of the app that ask for them (docs/plans/gaps/02-perfil-app-store.md
|
|
2
|
+
// §3.4 and Anexo B), read from app.json › expo › ios › entitlements. The App Store Connect API only enables some
|
|
3
|
+
// CapabilityTypes: push notifications, associated domains, App Groups, iCloud containers, merchant ids and the rest that
|
|
4
|
+
// need settings of their own (a group, a container, a merchant) are left to Apple Developer › Identifiers, because the
|
|
5
|
+
// public API does not create them; ias says which ones and where, and never touches the App ID for them. Nothing here
|
|
6
|
+
// disables a capability: turning one off can break another app or extension of the same App ID, and signing does not need
|
|
7
|
+
// it. Only one direction: what the entitlements ask for and the App ID lacks is enabled, one by one, with the API.
|
|
8
|
+
|
|
9
|
+
/** Where the capabilities of an App ID are enabled by hand. `bundleIdId` is Apple's id of the /v1/bundleIds resource. */
|
|
10
|
+
export const portalUrl = (bundleIdId) =>
|
|
11
|
+
bundleIdId ? `https://developer.apple.com/account/resources/identifiers/edit/${encodeURIComponent(bundleIdId)}` : 'https://developer.apple.com/account/resources/identifiers/list';
|
|
12
|
+
|
|
13
|
+
/** The entitlements that map to a CapabilityType the public API can enable. */
|
|
14
|
+
export const CAPABILITIES = Object.freeze({
|
|
15
|
+
'com.apple.developer.applesignin': 'APPLE_ID_AUTH',
|
|
16
|
+
'com.apple.developer.in-app-payments': 'APPLE_PAY',
|
|
17
|
+
'com.apple.developer.healthkit': 'HEALTHKIT',
|
|
18
|
+
'com.apple.developer.homekit': 'HOMEKIT',
|
|
19
|
+
'com.apple.developer.networking.wifi-info': 'ACCESS_WIFI_INFORMATION',
|
|
20
|
+
'com.apple.developer.networking.vpn.api': 'PERSONAL_VPN',
|
|
21
|
+
'com.apple.developer.networking.networkextension': 'NETWORK_EXTENSIONS',
|
|
22
|
+
'com.apple.developer.nfc.readersession.formats': 'NFC_TAG_READING',
|
|
23
|
+
'com.apple.developer.siri': 'SIRIKIT',
|
|
24
|
+
'com.apple.developer.pass-type-identifiers': 'WALLET',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The entitlements Apple writes into the App ID and the profile itself: there is no capability to enable for them, and
|
|
29
|
+
* a profile carries them whatever the App ID says.
|
|
30
|
+
*/
|
|
31
|
+
export const AUTOMATIC = Object.freeze([
|
|
32
|
+
'application-identifier',
|
|
33
|
+
'com.apple.developer.team-identifier',
|
|
34
|
+
'keychain-access-groups',
|
|
35
|
+
'get-task-allow',
|
|
36
|
+
'beta-reports-active',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The entitlements whose capability the public API cannot enable, with why: the endpoint takes only a CapabilityType,
|
|
41
|
+
* and these need a value (a service, a domain, a group, a container, a merchant). They are never skipped silently.
|
|
42
|
+
*/
|
|
43
|
+
export const UNSUPPORTED = Object.freeze({
|
|
44
|
+
'aps-environment': 'PUSH_NOTIFICATIONS needs the push settings of the App ID, which the API does not write',
|
|
45
|
+
'com.apple.developer.associated-domains': 'ASSOCIATED_DOMAINS needs the list of domains of the App ID',
|
|
46
|
+
'com.apple.security.application-groups': 'APP_GROUPS needs the group identifiers, and creating them is not in the public API',
|
|
47
|
+
'com.apple.developer.icloud-container-identifiers': 'ICLOUD needs the containers, and creating them is not in the public API',
|
|
48
|
+
'com.apple.developer.icloud-services': 'ICLOUD needs the containers, and creating them is not in the public API',
|
|
49
|
+
'com.apple.developer.ubiquity-kvstore-identifier': 'ICLOUD needs the containers, and creating them is not in the public API',
|
|
50
|
+
'com.apple.developer.merchant-id': 'APPLE_PAY merchant ids are created in Apple Developer, not with the API',
|
|
51
|
+
'com.apple.developer.usernotifications.communication': 'the communication notifications need PUSH_NOTIFICATIONS, which the API does not enable for this',
|
|
52
|
+
'com.apple.developer.push-to-talk': 'PUSH_TO_TALK needs the push settings of the App ID, which the API does not write',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What the entitlements of the app ask of its App ID: { requested, automatic, unsupported }. `requested` is one entry per
|
|
59
|
+
* CapabilityType ({ capabilityType, entitlements }), in the order of the table; `automatic` are the keys Apple writes
|
|
60
|
+
* itself; `unsupported` names what has to be enabled in the portal ({ entitlement, why }). A false or empty value means
|
|
61
|
+
* the app does not ask for it.
|
|
62
|
+
*/
|
|
63
|
+
export function capabilityPlan(entitlements) {
|
|
64
|
+
const requested = new Map();
|
|
65
|
+
const automatic = [];
|
|
66
|
+
const unsupported = [];
|
|
67
|
+
for (const [entitlement, value] of Object.entries(isRecord(entitlements) ? entitlements : {})) {
|
|
68
|
+
if (value === false || value === null || value === undefined) continue;
|
|
69
|
+
if (CAPABILITIES[entitlement]) {
|
|
70
|
+
const capabilityType = CAPABILITIES[entitlement];
|
|
71
|
+
const entry = requested.get(capabilityType) ?? { capabilityType, entitlements: [] };
|
|
72
|
+
entry.entitlements.push(entitlement);
|
|
73
|
+
requested.set(capabilityType, entry);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (AUTOMATIC.includes(entitlement)) {
|
|
77
|
+
automatic.push(entitlement);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
unsupported.push({
|
|
81
|
+
entitlement,
|
|
82
|
+
why: UNSUPPORTED[entitlement] ?? 'the App Store Connect API has no capability for it',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return { requested: [...requested.values()], automatic, unsupported };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The CapabilityTypes of an App ID as Apple lists them, as a Set of names. */
|
|
89
|
+
export function currentCapabilities(resources) {
|
|
90
|
+
return new Set((Array.isArray(resources) ? resources : []).map((resource) => resource?.attributes?.capabilityType).filter(Boolean));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The entries of `requested` whose CapabilityType the App ID does not have yet. */
|
|
94
|
+
export function missingCapabilities(requested, current) {
|
|
95
|
+
return (Array.isArray(requested) ? requested : []).filter((entry) => !current.has(entry.capabilityType));
|
|
96
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// What Apple has and the vault can sign with, for ias credentials generate (docs/plans/gaps/00-estados-cuenta.md §1):
|
|
2
|
+
// the distribution certificates with their SHA-1 and expiry, and the profiles of a bundle id with their state, the
|
|
3
|
+
// certificate they hold and how many devices they list. Reading only: nothing here writes in Apple or the vault. The
|
|
4
|
+
// SHA-1 of a certificate is what codesign and checkIos compare, and what ties a .p12 of the vault to Apple.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { credentials as inspect } from '../shared.mjs';
|
|
7
|
+
|
|
8
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
/** The SHA-1 of a certificate as Apple gives it: certificateContent is the DER in base64. */
|
|
11
|
+
export const sha1OfDer = (base64) => createHash('sha1').update(Buffer.from(String(base64 ?? ''), 'base64')).digest('hex').toUpperCase();
|
|
12
|
+
|
|
13
|
+
/** Whether a date is past; an absent date is not. */
|
|
14
|
+
export const expiredAt = (date, now = Date.now()) => Boolean(date) && !(Date.parse(date) > now);
|
|
15
|
+
|
|
16
|
+
/** The whole days until a date, negative when it is past. */
|
|
17
|
+
export const daysUntil = (date, now = Date.now()) => Math.floor((Date.parse(date) - now) / DAY);
|
|
18
|
+
|
|
19
|
+
/** The profile type of IAS (app-store, ad-hoc…) from Apple's ProfileType. */
|
|
20
|
+
export function localProfileType(profileType) {
|
|
21
|
+
const types = { IOS_APP_STORE: 'app-store', IOS_APP_ADHOC: 'ad-hoc', IOS_APP_DEVELOPMENT: 'development', IOS_APP_INHOUSE: 'enterprise' };
|
|
22
|
+
return types[profileType] ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A certificate of Apple as the inventory shows it: id, name, serial, SHA-1, expiry, type, and whether it is valid,
|
|
27
|
+
* expired or inactive (Apple's `activated`). The SHA-1 is null when Apple gave no content (nothing to compare with).
|
|
28
|
+
*/
|
|
29
|
+
export function certificateSummary(certificate, { now = Date.now() } = {}) {
|
|
30
|
+
const attributes = certificate.attributes ?? {};
|
|
31
|
+
const sha1 = attributes.certificateContent ? sha1OfDer(attributes.certificateContent) : null;
|
|
32
|
+
const status = attributes.activated === false ? 'inactive' : expiredAt(attributes.expirationDate, now) ? 'expired' : 'valid';
|
|
33
|
+
return {
|
|
34
|
+
id: certificate.id,
|
|
35
|
+
name: attributes.displayName ?? attributes.name ?? null,
|
|
36
|
+
serial: attributes.serialNumber ?? null,
|
|
37
|
+
sha1,
|
|
38
|
+
expires: attributes.expirationDate ?? null,
|
|
39
|
+
type: attributes.certificateType ?? null,
|
|
40
|
+
status,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A profile of Apple as the inventory shows it: uuid, name, type, state, expiry, the SHA-1 of the certificates it holds,
|
|
46
|
+
* its bundle id and how many devices it lists. Both come from its CMS when Apple sends the content, which is what the
|
|
47
|
+
* vault seals; the attributes are the fallback.
|
|
48
|
+
*/
|
|
49
|
+
export function profileSummary(profile, { now = Date.now() } = {}) {
|
|
50
|
+
const attributes = profile.attributes ?? {};
|
|
51
|
+
let parsed = null;
|
|
52
|
+
if (attributes.profileContent) {
|
|
53
|
+
try {
|
|
54
|
+
parsed = inspect.inspectProfile(Buffer.from(attributes.profileContent, 'base64'));
|
|
55
|
+
} catch {
|
|
56
|
+
parsed = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const expires = attributes.expirationDate ?? parsed?.expires ?? null;
|
|
60
|
+
const state = attributes.profileState ?? null;
|
|
61
|
+
const status = state !== null && state !== 'ACTIVE' ? 'invalid' : expiredAt(expires, now) ? 'expired' : 'valid';
|
|
62
|
+
return {
|
|
63
|
+
id: profile.id,
|
|
64
|
+
uuid: attributes.uuid ?? parsed?.uuid ?? null,
|
|
65
|
+
name: attributes.name ?? parsed?.name ?? null,
|
|
66
|
+
type: localProfileType(attributes.profileType) ?? parsed?.type ?? null,
|
|
67
|
+
state,
|
|
68
|
+
team: parsed?.team ?? null,
|
|
69
|
+
expires,
|
|
70
|
+
certificates: parsed?.certificates ?? [],
|
|
71
|
+
bundleId: parsed?.bundleId ?? null,
|
|
72
|
+
devices: parsed?.devices ?? null,
|
|
73
|
+
status,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The local half of an Apple distribution certificate (docs/plans/gaps/01-certificado-ios.md §3.4): an RSA-2048 key and a
|
|
2
|
+
// SHA-256 CSR made here, with the LibreSSL of macOS (the .p12 format macOS and the runner expect), and the .p12 that
|
|
3
|
+
// holds the certificate Apple issues and that key — one identity, no WWDR intermediate, as inspectP12 and the runner
|
|
4
|
+
// need. Everything lives in a temporary folder (0700, files 0600) that is removed at the end; the password of the .p12
|
|
5
|
+
// is random and is never printed, written outside that folder, nor an argument of a process (openssl gets it through the
|
|
6
|
+
// environment). The private key never leaves this computer: only the CSR travels to Apple.
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { createHash, createPublicKey, randomBytes } from 'node:crypto';
|
|
9
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import process from 'node:process';
|
|
13
|
+
import { IasError } from '../errors.mjs';
|
|
14
|
+
import { credentials as inspect } from '../shared.mjs';
|
|
15
|
+
|
|
16
|
+
/** The openssl of macOS (LibreSSL): the one that makes the .p12 the runner imports. */
|
|
17
|
+
export const OPENSSL = '/usr/bin/openssl';
|
|
18
|
+
|
|
19
|
+
/** The subject Apple expects in the CSR: the CN, the team in OU, and no locale surprises. */
|
|
20
|
+
export const certificateSubject = ({ name, team }) => `/CN=Apple Distribution: ${name} (${team})/OU=${team}/O=${name}/C=ES`;
|
|
21
|
+
|
|
22
|
+
/** The CN of the certificate: the name Apple gives it, and the name of the .p12. */
|
|
23
|
+
export const certificateCommonName = ({ name, team }) => `Apple Distribution: ${name} (${team})`;
|
|
24
|
+
|
|
25
|
+
/** Whether `openssl` answers (ias doctor style): { ok, output }. Never throws. */
|
|
26
|
+
export function opensslState(openssl = OPENSSL) {
|
|
27
|
+
const result = spawnSync(openssl, ['version'], { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
28
|
+
return { ok: !result.error && result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The error of a missing or broken openssl, with the doctor-style fix. */
|
|
32
|
+
function missingOpenSSL(openssl, error) {
|
|
33
|
+
return new IasError(`openssl does not answer (${error.message})`, {
|
|
34
|
+
fix: 'the key, the CSR and the .p12 are made with the openssl of macOS (/usr/bin/openssl, which comes with the command line tools): run xcode-select --install, or set IAS_OPENSSL to another openssl',
|
|
35
|
+
code: 'openssl_missing',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The local material of a certificate, in a new temporary folder: the RSA-2048 key, the CSR (as text and its SHA-256),
|
|
41
|
+
* the subject, and `complete(certificateContent)` to turn the DER Apple answers (base64) into the .p12 of one identity,
|
|
42
|
+
* validated with inspectP12. `cleanup()` removes the folder; `preserve()` keeps it, with the password in a 0600 file,
|
|
43
|
+
* only for the case in which Apple issued the certificate and it could not be stored anywhere else.
|
|
44
|
+
*/
|
|
45
|
+
export function appleSigningMaterial({ name, team, openssl = OPENSSL } = {}) {
|
|
46
|
+
if (!name || !team) throw new IasError('the name and the team of the certificate are missing', { fix: 'pass --name and --team-id, or give the app its Apple team in the panel' });
|
|
47
|
+
const state = opensslState(openssl);
|
|
48
|
+
if (!state.ok) throw missingOpenSSL(openssl, new Error(state.output || 'no version'));
|
|
49
|
+
|
|
50
|
+
const subject = certificateSubject({ name, team });
|
|
51
|
+
const commonName = certificateCommonName({ name, team });
|
|
52
|
+
const directory = mkdtempSync(join(tmpdir(), 'ias-certificate-'));
|
|
53
|
+
chmodSync(directory, 0o700);
|
|
54
|
+
const key = join(directory, 'key.pem');
|
|
55
|
+
const csr = join(directory, 'request.certSigningRequest');
|
|
56
|
+
const der = join(directory, 'certificate.der');
|
|
57
|
+
const pem = join(directory, 'certificate.pem');
|
|
58
|
+
const p12 = join(directory, 'certificate.p12');
|
|
59
|
+
const secrets = {};
|
|
60
|
+
let kept = false;
|
|
61
|
+
|
|
62
|
+
const run = (args, why, env = {}) => {
|
|
63
|
+
const result = spawnSync(openssl, args, { encoding: 'utf8', env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024 });
|
|
64
|
+
if (result.error) throw missingOpenSSL(openssl, result.error);
|
|
65
|
+
if (result.status !== 0) {
|
|
66
|
+
const reason = `${result.stderr}\n${result.stdout}`.split('\n').map((line) => line.trim()).find(Boolean) ?? `exit ${result.status}`;
|
|
67
|
+
throw new IasError(`${why}: ${reason}`, { fix: 'nothing was sent to Apple: run it again, and if it goes on check openssl with ias doctor', code: 'openssl_failed' });
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// An RSA-2048 key, 0600 from the start, and the CSR with SHA-256 (Apple signs it and keeps only the public half).
|
|
73
|
+
run(['genrsa', '-out', key, '2048'], 'openssl could not make the RSA-2048 key');
|
|
74
|
+
chmodSync(key, 0o600);
|
|
75
|
+
run(['req', '-new', '-sha256', '-key', key, '-subj', subject, '-out', csr], 'openssl could not make the CSR');
|
|
76
|
+
chmodSync(csr, 0o600);
|
|
77
|
+
const csrBytes = readFileSync(csr);
|
|
78
|
+
const csrPem = csrBytes.toString('utf8');
|
|
79
|
+
const csrSha256 = createHash('sha256').update(csrBytes).digest('hex');
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
directory,
|
|
83
|
+
subject,
|
|
84
|
+
commonName,
|
|
85
|
+
key,
|
|
86
|
+
csr,
|
|
87
|
+
csrPem,
|
|
88
|
+
csrSha256,
|
|
89
|
+
get password() {
|
|
90
|
+
return secrets.IAS_P12_PASSWORD ?? null;
|
|
91
|
+
},
|
|
92
|
+
/** The .p12 of one identity from the certificate Apple issued (its DER in base64), validated here. */
|
|
93
|
+
complete(certificateContent) {
|
|
94
|
+
writeFileSync(der, Buffer.from(String(certificateContent ?? ''), 'base64'), { mode: 0o600 });
|
|
95
|
+
run(['x509', '-inform', 'DER', '-in', der, '-out', pem], 'the certificate Apple gave does not read');
|
|
96
|
+
chmodSync(pem, 0o600);
|
|
97
|
+
// The certificate has to be the one of this key: otherwise the .p12 would never sign with it.
|
|
98
|
+
if (!createPublicKey(readFileSync(pem)).equals(createPublicKey(readFileSync(key)))) {
|
|
99
|
+
throw new IasError('the certificate Apple gave is not the one of the key made here', { fix: 'nothing was stored: run it again (the certificate may stay in Apple Developer until someone revokes it)', code: 'certificate_key_mismatch' });
|
|
100
|
+
}
|
|
101
|
+
// One identity (leaf + key) and no WWDR intermediate: what inspectP12 accepts and the runner imports.
|
|
102
|
+
const password = randomBytes(24).toString('base64url');
|
|
103
|
+
secrets.IAS_P12_PASSWORD = password;
|
|
104
|
+
run(['pkcs12', '-export', '-inkey', key, '-in', pem, '-name', commonName, '-passout', 'env:IAS_P12_PASSWORD', '-out', p12], 'openssl could not export the .p12', secrets);
|
|
105
|
+
chmodSync(p12, 0o600);
|
|
106
|
+
let metadata;
|
|
107
|
+
try {
|
|
108
|
+
metadata = inspect.inspectP12(p12, password, { openssl });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new IasError(`the .p12 made here does not read (${error.message})`, { fix: 'nothing was stored: run it again, and if it goes on report it', code: 'p12_invalid' });
|
|
111
|
+
}
|
|
112
|
+
return { p12, password, metadata, pem, der };
|
|
113
|
+
},
|
|
114
|
+
/** Keeps the folder (0700) with the password in a 0600 file, only when nothing else could store the key. */
|
|
115
|
+
preserve() {
|
|
116
|
+
kept = true;
|
|
117
|
+
if (secrets.IAS_P12_PASSWORD) writeFileSync(join(directory, 'password'), `${secrets.IAS_P12_PASSWORD}\n`, { mode: 0o600 });
|
|
118
|
+
return directory;
|
|
119
|
+
},
|
|
120
|
+
cleanup() {
|
|
121
|
+
if (!kept) rmSync(directory, { recursive: true, force: true });
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Runs `work` with the material and removes its folder however it ends, unless work kept it. */
|
|
127
|
+
export async function withAppleSigningMaterial(options, work) {
|
|
128
|
+
const material = appleSigningMaterial(options);
|
|
129
|
+
try {
|
|
130
|
+
return await work(material);
|
|
131
|
+
} finally {
|
|
132
|
+
material.cleanup();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Applications from the management API: create one with its identity and services, change its services, and make sure
|
|
2
|
+
// push has its install key (which goes to push.installKey of ias.json). `ias apps` and `ias setup` share it.
|
|
3
|
+
import { loadProject, saveProjectConfig } from './config.mjs';
|
|
4
|
+
import { IasError } from './errors.mjs';
|
|
5
|
+
|
|
6
|
+
export const APPLICATION_SERVICES = ['credentials', 'builds', 'distribution', 'updates', 'sync', 'push'];
|
|
7
|
+
|
|
8
|
+
/** The list of services of --services, in the order the API keeps them. */
|
|
9
|
+
export function parseApplicationServices(text) {
|
|
10
|
+
const names = String(text).split(',').map((name) => name.trim()).filter(Boolean);
|
|
11
|
+
const unknown = names.filter((name) => !APPLICATION_SERVICES.includes(name));
|
|
12
|
+
if (unknown.length > 0) throw new IasError(`unknown service ${unknown.join(', ')}`, { fix: `ias.json takes ${APPLICATION_SERVICES.join(', ')}` });
|
|
13
|
+
return [...new Set(names)];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** POST /apps: the application, with its code, identity and services. */
|
|
17
|
+
export async function createApplication(client, { code, name, iosBundleId, androidPackage, appleTeamId, services = [] }) {
|
|
18
|
+
const { data } = await client.request('POST', '/apps', {
|
|
19
|
+
json: {
|
|
20
|
+
code,
|
|
21
|
+
...(name ? { name } : {}),
|
|
22
|
+
...(iosBundleId ? { ios_bundle_id: iosBundleId } : {}),
|
|
23
|
+
...(androidPackage ? { android_package: androidPackage } : {}),
|
|
24
|
+
...(appleTeamId ? { apple_team_id: appleTeamId } : {}),
|
|
25
|
+
services,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
return data;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What `ias apps --create` would send, for --dry-run: the same plan `ias setup --create-app --dry-run` prints and
|
|
33
|
+
* returns, so the two commands cannot drift.
|
|
34
|
+
*/
|
|
35
|
+
export function applicationPlan({ code, name, iosBundleId, androidPackage, appleTeamId, services = [] }) {
|
|
36
|
+
return {
|
|
37
|
+
dry_run: true,
|
|
38
|
+
code,
|
|
39
|
+
name: name ?? null,
|
|
40
|
+
ios_bundle_id: iosBundleId ?? null,
|
|
41
|
+
android_package: androidPackage ?? null,
|
|
42
|
+
apple_team_id: appleTeamId ?? null,
|
|
43
|
+
services,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The lines of a plan: what would be created, with what, and that nothing was. */
|
|
48
|
+
export function printApplicationPlan(plan, output) {
|
|
49
|
+
output.progress(`${output.prefix}: would create the application ${plan.code} in IAS (${plan.services.join(', ') || 'no service'})`);
|
|
50
|
+
if (plan.name) output.progress(` name ${plan.name}`);
|
|
51
|
+
if (plan.ios_bundle_id) output.progress(` ios ${plan.ios_bundle_id}${plan.apple_team_id ? ` (team ${plan.apple_team_id})` : ''}`);
|
|
52
|
+
if (plan.android_package) output.progress(` android ${plan.android_package}`);
|
|
53
|
+
output.progress(` services ${plan.services.join(', ') || 'none'}`);
|
|
54
|
+
output.progress('nothing was created: an owner with a personal token runs it again without --dry-run');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** PUT /apps/{code}/services: the whole list; a service with pieces cannot be dropped. */
|
|
58
|
+
export async function setApplicationServices(client, app, services) {
|
|
59
|
+
const { data } = await client.request('PUT', `/apps/${app}/services`, { json: { services } });
|
|
60
|
+
return data;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Push on and an install key: reads the application, turns push on if it is
|
|
65
|
+
* not, reads the key or makes one if there is none, and writes it to
|
|
66
|
+
* push.installKey of ias.json. The key is not a secret: every copy of the
|
|
67
|
+
* app carries it. It never replaces an existing key unless `replace` says so.
|
|
68
|
+
*/
|
|
69
|
+
export async function ensurePush({ client, dir, app, output, dryRun = false, replace = false }) {
|
|
70
|
+
const { data: current } = await client.request('GET', `/apps/${app}`);
|
|
71
|
+
let services = current.services ?? [];
|
|
72
|
+
|
|
73
|
+
if (!services.includes('push')) {
|
|
74
|
+
if (dryRun) {
|
|
75
|
+
output?.progress(`${output.prefix}: would turn push on for ${app}`);
|
|
76
|
+
return { app, services: [...services, 'push'], install_key: null, dry_run: true };
|
|
77
|
+
}
|
|
78
|
+
const updated = await setApplicationServices(client, app, [...services, 'push']);
|
|
79
|
+
services = updated.services;
|
|
80
|
+
output?.log(`${output.prefix}: push is on for ${app}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let key;
|
|
84
|
+
if (dryRun) {
|
|
85
|
+
try {
|
|
86
|
+
key = (await client.request('GET', `/apps/${app}/push/install-key`)).data.install_key;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error.code !== 'install_key_missing' && error.status !== 404) throw error;
|
|
89
|
+
key = null;
|
|
90
|
+
}
|
|
91
|
+
return { app, services, install_key: key, dry_run: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (replace) {
|
|
95
|
+
key = (await client.request('POST', `/apps/${app}/push/install-key`, {})).data.install_key;
|
|
96
|
+
output?.log(`${output.prefix}: made a new install key for ${app} (the old one stops working)`);
|
|
97
|
+
} else {
|
|
98
|
+
try {
|
|
99
|
+
key = (await client.request('GET', `/apps/${app}/push/install-key`)).data.install_key;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error.code !== 'install_key_missing' && error.status !== 404) throw error;
|
|
102
|
+
key = (await client.request('POST', `/apps/${app}/push/install-key`, {})).data.install_key;
|
|
103
|
+
output?.log(`${output.prefix}: made the install key of ${app}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const project = loadProject(dir);
|
|
108
|
+
if (!project.config) throw new IasError(`no ias.json in ${dir}`, { fix: 'run ias init there first' });
|
|
109
|
+
const config = project.config;
|
|
110
|
+
config.push = { ...(config.push ?? {}), installKey: key };
|
|
111
|
+
saveProjectConfig(dir, config);
|
|
112
|
+
output?.log(`${output.prefix}: push.installKey of ias.json is the key of ${app}`);
|
|
113
|
+
return { app, services, install_key: key };
|
|
114
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export { EXIT_CODE };
|
|
|
17
17
|
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
18
18
|
|
|
19
19
|
/** The order of the commands in the help; the rest follow by name. */
|
|
20
|
-
const ORDER = ['login', 'logout', 'whoami', 'apps', 'init', 'doctor', 'sdk', 'update', 'dist', 'credentials', 'env', 'vault'];
|
|
20
|
+
const ORDER = ['login', 'logout', 'whoami', 'apps', 'setup', 'init', 'doctor', 'skill', 'sdk', 'update', 'dist', 'credentials', 'env', 'vault', 'policy', 'tokens', 'runners'];
|
|
21
21
|
|
|
22
22
|
/** The options of every command. */
|
|
23
23
|
export const GLOBAL_OPTIONS = {
|
|
@@ -90,13 +90,22 @@ export function generalHelp(commands) {
|
|
|
90
90
|
].join('\n');
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** The help of a command with subcommands; one that also runs itself (ias testflight <build>) shows its own form too. */
|
|
93
94
|
export function commandHelp(command) {
|
|
94
95
|
if (!command.subcommands) return leafHelp([command.name], command);
|
|
96
|
+
const own = command.run ? command : null;
|
|
97
|
+
const positionals = own?.positionals ?? [];
|
|
98
|
+
const options = { ...(own?.options ?? {}), ...GLOBAL_OPTIONS };
|
|
99
|
+
const argumentRows = positionals.map((positional) => [`<${positional.name}>`, positional.description ?? '']);
|
|
95
100
|
return [
|
|
96
101
|
`ias ${command.name}: ${command.summary}`,
|
|
97
102
|
...(command.description ? ['', command.description] : []),
|
|
98
103
|
'',
|
|
99
|
-
|
|
104
|
+
...(own
|
|
105
|
+
? [`usage: ${usageLine([command.name], own)}`, ` or: ias ${command.name} <subcommand> [options]`]
|
|
106
|
+
: [`usage: ias ${command.name} <subcommand> [options]`]),
|
|
107
|
+
...(argumentRows.length > 0 ? ['', 'arguments:', table(argumentRows)] : []),
|
|
108
|
+
...(own ? ['', 'options:', table(Object.entries(options).map(([name, option]) => [optionLabel(name, option), option.description ?? '']))] : []),
|
|
100
109
|
'',
|
|
101
110
|
'subcommands:',
|
|
102
111
|
table(command.subcommands.map((subcommand) => [[subcommand.name, ...(subcommand.aliases ?? [])].join(', '), subcommand.summary])),
|
|
@@ -199,20 +208,29 @@ export async function main(argv = process.argv.slice(2), { env = process.env, cw
|
|
|
199
208
|
const path = [command.name];
|
|
200
209
|
if (command.subcommands) {
|
|
201
210
|
const word = args[0];
|
|
202
|
-
|
|
203
|
-
|
|
211
|
+
const subcommand = word === undefined ? undefined : named(command.subcommands, word);
|
|
212
|
+
// A command can also have a positional form, such as `ias apps <code>`: without a subcommand, it runs itself.
|
|
213
|
+
if (subcommand) {
|
|
214
|
+
leaf = subcommand;
|
|
215
|
+
path.push(leaf.name);
|
|
216
|
+
args = args.slice(1);
|
|
217
|
+
} else if (command.run) {
|
|
218
|
+
// It runs itself: its options, or its positional form (ias apps <code>).
|
|
219
|
+
if ((word === undefined || word.startsWith('-')) && (args.includes('--help') || args.includes('-h'))) {
|
|
204
220
|
print(commandHelp(command));
|
|
205
221
|
return 0;
|
|
206
222
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
223
|
+
} else {
|
|
224
|
+
if (word === undefined || word.startsWith('-')) {
|
|
225
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
226
|
+
print(commandHelp(command));
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
throw new UsageError(`ias ${command.name} needs a subcommand`, { fix: `one of ${command.subcommands.map((subcommand) => subcommand.name).join(', ')}; ias ${command.name} --help says what each does` });
|
|
230
|
+
}
|
|
211
231
|
const guess = closest(word, command.subcommands.map((subcommand) => subcommand.name));
|
|
212
232
|
throw new UsageError(`ias ${command.name} has no subcommand «${word}»`, { fix: guess ? `did you mean ias ${command.name} ${guess}? ias ${command.name} --help lists them` : `ias ${command.name} --help lists them` });
|
|
213
233
|
}
|
|
214
|
-
path.push(leaf.name);
|
|
215
|
-
args = args.slice(1);
|
|
216
234
|
}
|
|
217
235
|
const { values, positionals } = parseCommandLine(path, leaf, args);
|
|
218
236
|
if (values.help) {
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
// ias apps: the applications the token can see (docs/technical/manage-api.md §4),
|
|
1
|
+
// ias apps: the applications the token can see (docs/technical/manage-api.md §4), one of them, a new one, or its
|
|
2
|
+
// services. A robot token sees only its own and cannot create or change them.
|
|
2
3
|
import { resolve } from 'node:path';
|
|
4
|
+
import { applicationPlan, createApplication, parseApplicationServices, printApplicationPlan, setApplicationServices } from '../../applications.mjs';
|
|
3
5
|
import { loadProject } from '../../config.mjs';
|
|
6
|
+
import { IasError } from '../../errors.mjs';
|
|
4
7
|
import { connect } from '../../manage.mjs';
|
|
8
|
+
import storeCommand, { appRecordLine } from './store.mjs';
|
|
5
9
|
|
|
6
10
|
/** The lines that describe an application for people. */
|
|
7
11
|
export function describeApplication(app) {
|
|
8
|
-
const lines = [`${app.code} ${app.name}`, ` services ${app.services.length > 0 ? app.services.join(', ') : 'none'}`];
|
|
12
|
+
const lines = [`${app.code} ${app.name}`, ` services ${(app.services ?? []).length > 0 ? app.services.join(', ') : 'none'}`];
|
|
9
13
|
if (app.ios) lines.push(` ios ${app.ios.bundle_id ?? '—'} (team ${app.ios.team_id ?? '—'})`);
|
|
14
|
+
const record = appRecordLine(app);
|
|
15
|
+
if (record) lines.push(record);
|
|
10
16
|
if (app.android) lines.push(` android ${app.android.package ?? '—'}`);
|
|
11
17
|
if (app.updates) lines.push(` updates project ${app.updates.project}, channel ${app.updates.default_channel}, ${app.updates.manifest_url}`);
|
|
12
18
|
if (app.distribution) lines.push(` distribution project ${app.distribution.project}, ${app.distribution.install_url}`);
|
|
@@ -14,23 +20,67 @@ export function describeApplication(app) {
|
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
async function apps(context) {
|
|
17
|
-
const
|
|
18
|
-
const
|
|
23
|
+
const { values, output, positionals } = context;
|
|
24
|
+
const project = loadProject(resolve(context.cwd, values.project ?? '.'));
|
|
25
|
+
const clientOf = () => connect(context, project);
|
|
26
|
+
let code = positionals[0] ?? values.code;
|
|
27
|
+
|
|
28
|
+
if (values.create) {
|
|
29
|
+
if (!code) throw new IasError('which code for the application?', { fix: 'ias apps --create --code <code> --name "…" --services updates,distribution' });
|
|
30
|
+
const services = values.services === undefined ? [] : parseApplicationServices(values.services);
|
|
31
|
+
const details = {
|
|
32
|
+
code,
|
|
33
|
+
name: values.name,
|
|
34
|
+
iosBundleId: values['ios-bundle-id'],
|
|
35
|
+
androidPackage: values['android-package'],
|
|
36
|
+
appleTeamId: values['apple-team-id'],
|
|
37
|
+
services,
|
|
38
|
+
};
|
|
39
|
+
if (values['dry-run']) {
|
|
40
|
+
const plan = applicationPlan(details);
|
|
41
|
+
printApplicationPlan(plan, output);
|
|
42
|
+
return plan;
|
|
43
|
+
}
|
|
44
|
+
const app = await createApplication(clientOf(), details);
|
|
45
|
+
for (const line of describeApplication(app)) output.log(line);
|
|
46
|
+
return app;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (values.services !== undefined) {
|
|
50
|
+
if (!code) throw new IasError('which application?', { fix: 'pass its code: ias apps <code> --services updates,distribution,push' });
|
|
51
|
+
const app = await setApplicationServices(clientOf(), code, parseApplicationServices(values.services));
|
|
52
|
+
output.log(`${output.prefix}: ${app.code}: services ${app.services.length > 0 ? app.services.join(', ') : 'none'}`);
|
|
53
|
+
return app;
|
|
54
|
+
}
|
|
55
|
+
|
|
19
56
|
if (code) {
|
|
20
|
-
const { data } = await
|
|
21
|
-
for (const line of describeApplication(data))
|
|
57
|
+
const { data } = await clientOf().request('GET', `/apps/${encodeURIComponent(code)}`);
|
|
58
|
+
for (const line of describeApplication(data)) output.log(line);
|
|
22
59
|
return data;
|
|
23
60
|
}
|
|
24
|
-
|
|
25
|
-
|
|
61
|
+
|
|
62
|
+
const { data } = await clientOf().request('GET', '/apps');
|
|
63
|
+
if (data.data.length === 0) output.log('No applications: ias apps --create --code <code> --name "…" --services updates,distribution');
|
|
26
64
|
const width = Math.max(0, ...data.data.map((app) => app.code.length));
|
|
27
|
-
for (const app of data.data)
|
|
65
|
+
for (const app of data.data) output.log(`${app.code.padEnd(width)} ${app.name} (${app.services.join(', ') || 'no services'})`);
|
|
28
66
|
return data;
|
|
29
67
|
}
|
|
30
68
|
|
|
31
69
|
export default {
|
|
32
70
|
name: 'apps',
|
|
33
|
-
summary: 'The applications the token can see,
|
|
34
|
-
|
|
71
|
+
summary: 'The applications the token can see: the list, one of them, a new one (--create), its services or its store identity',
|
|
72
|
+
description: 'Without arguments, the list. With a code, one in detail. --create makes one (an owner with a personal token) with its identity and services; --services replaces the services of one (a service with pieces cannot be turned off). ias apps store <code> shows and settles its identity in App Store Connect (see ias apps store --help).',
|
|
73
|
+
positionals: [{ name: 'code', optional: true, description: 'The code of one application, to see it in detail or to change its services.', pattern: /^[a-z0-9][a-z0-9-]{0,31}$/ }],
|
|
74
|
+
subcommands: [storeCommand],
|
|
75
|
+
options: {
|
|
76
|
+
create: { type: 'boolean', description: 'Make a new application with --code, --name and the identity flags; an owner with a personal token.' },
|
|
77
|
+
code: { type: 'string', value: '<code>', description: 'The code of the new application, or of the one whose services change.' },
|
|
78
|
+
name: { type: 'string', value: '"…"', description: 'The name in the panel (the code by default).' },
|
|
79
|
+
'ios-bundle-id': { type: 'string', value: '<id>', description: 'The bundle id of iOS, such as com.imagina.jscheduler.' },
|
|
80
|
+
'android-package': { type: 'string', value: '<id>', description: 'The package of Android, such as com.imagina.jscheduler.' },
|
|
81
|
+
'apple-team-id': { type: 'string', value: '<id>', description: 'The Apple team id (10 letters and digits).' },
|
|
82
|
+
services: { type: 'string', value: '<list>', description: `The services, comma-separated: ${['credentials', 'builds', 'distribution', 'updates', 'sync', 'push'].join(', ')}.` },
|
|
83
|
+
'dry-run': { type: 'boolean', description: '--create: say what it would create and create nothing (the same plan as ias setup --create-app --dry-run).' },
|
|
84
|
+
},
|
|
35
85
|
run: apps,
|
|
36
86
|
};
|