@imagina-build/ias-cli 0.0.0 → 0.1.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.
Files changed (78) hide show
  1. package/README.md +406 -2
  2. package/bin/ias.mjs +19 -0
  3. package/package.json +31 -12
  4. package/schema/ias.schema.json +535 -0
  5. package/shared/credentials/cms.mjs +105 -0
  6. package/shared/credentials/index.mjs +354 -0
  7. package/shared/credentials/plist.mjs +93 -0
  8. package/shared/vault/approvers-vectors.mjs +188 -0
  9. package/shared/vault/approvers.mjs +234 -0
  10. package/shared/vault/index.mjs +583 -0
  11. package/shared/vault/plaintext.mjs +115 -0
  12. package/shared/vault/test-vectors/ias-vault-2/approvers.json +2122 -0
  13. package/shared/vault/test-vectors/ias-vault-2/keys.json +43 -0
  14. package/shared/vault/test-vectors/ias-vault-2/malformed.json +1739 -0
  15. package/shared/vault/test-vectors/ias-vault-2/plaintexts.json +149 -0
  16. package/shared/vault/test-vectors/ias-vault-2/runners.json +580 -0
  17. package/shared/vault/test-vectors/ias-vault-2/sealed.json +567 -0
  18. package/shared/vault/test-vectors/ias-vault-2/tampered.json +989 -0
  19. package/shared/vault/test-vectors/keys.json +31 -0
  20. package/shared/vault/test-vectors/malformed.json +1341 -0
  21. package/shared/vault/test-vectors/sealed.json +384 -0
  22. package/shared/vault/test-vectors/tampered.json +610 -0
  23. package/shared/vault/vectors.mjs +720 -0
  24. package/src/cli.mjs +231 -0
  25. package/src/commands/apps/index.mjs +36 -0
  26. package/src/commands/auth/index.mjs +141 -0
  27. package/src/commands/build/follow.mjs +168 -0
  28. package/src/commands/build/index.mjs +110 -0
  29. package/src/commands/build/local.mjs +69 -0
  30. package/src/commands/build/profile.mjs +50 -0
  31. package/src/commands/build/source.mjs +71 -0
  32. package/src/commands/builds/index.mjs +148 -0
  33. package/src/commands/credentials/index.mjs +256 -0
  34. package/src/commands/credentials/material.mjs +159 -0
  35. package/src/commands/devices/asc.mjs +116 -0
  36. package/src/commands/devices/index.mjs +65 -0
  37. package/src/commands/devices/sync.mjs +223 -0
  38. package/src/commands/dist/index.mjs +108 -0
  39. package/src/commands/doctor/index.mjs +158 -0
  40. package/src/commands/env/index.mjs +163 -0
  41. package/src/commands/github/index.mjs +53 -0
  42. package/src/commands/init/index.mjs +224 -0
  43. package/src/commands/migrate/credentials.mjs +213 -0
  44. package/src/commands/migrate/eas-cli.mjs +153 -0
  45. package/src/commands/migrate/index.mjs +73 -0
  46. package/src/commands/migrate/numbers.mjs +85 -0
  47. package/src/commands/migrate/profiles.mjs +174 -0
  48. package/src/commands/migrate/project.mjs +249 -0
  49. package/src/commands/migrate/survey.mjs +205 -0
  50. package/src/commands/migrate/variables.mjs +183 -0
  51. package/src/commands/push/index.mjs +334 -0
  52. package/src/commands/sdk/index.mjs +81 -0
  53. package/src/commands/submit/index.mjs +85 -0
  54. package/src/commands/update/index.mjs +526 -0
  55. package/src/commands/usage/index.mjs +57 -0
  56. package/src/commands/vault/approvers.mjs +129 -0
  57. package/src/commands/vault/index.mjs +159 -0
  58. package/src/commands/vault/runners.mjs +115 -0
  59. package/src/commands/vault/team.mjs +111 -0
  60. package/src/commands/version/index.mjs +71 -0
  61. package/src/commands/webhooks/index.mjs +104 -0
  62. package/src/commands/workflows/index.mjs +88 -0
  63. package/src/config.mjs +261 -0
  64. package/src/dist.mjs +99 -0
  65. package/src/errors.mjs +158 -0
  66. package/src/http.mjs +45 -0
  67. package/src/legacy.mjs +73 -0
  68. package/src/manage.mjs +120 -0
  69. package/src/ota.mjs +122 -0
  70. package/src/output.mjs +36 -0
  71. package/src/process.mjs +45 -0
  72. package/src/schema.mjs +131 -0
  73. package/src/sdk.mjs +120 -0
  74. package/src/secret-input.mjs +27 -0
  75. package/src/secrets.mjs +37 -0
  76. package/src/shared.mjs +5 -0
  77. package/src/team-vault.mjs +152 -0
  78. package/src/token-store.mjs +93 -0
@@ -0,0 +1,213 @@
1
+ // ias migrate eas prepare and import: EAS's signing credentials into the sealed vault, as jscheduler-app's
2
+ // store-credentials.mjs did by hand into 1Password (its docs/local-builds.md §2). prepare makes the folder where the
3
+ // person runs eas credentials, which handles secrets and asks questions, so ias never runs it. import reads
4
+ // credentials.json and credentials/ there with inspectEasCredentials, shows what is not secret, imports each credential
5
+ // through ias credentials import (sealed and signed by the team on this Mac, its id made before sealing) and then
6
+ // deletes the download, also when something fails half way. Nothing here prints a password or a key.
7
+ import { existsSync, statSync } from 'node:fs';
8
+ import process from 'node:process';
9
+ import { join, resolve } from 'node:path';
10
+ import { loadProject } from '../../config.mjs';
11
+ import { IasError, UsageError } from '../../errors.mjs';
12
+ import { applicationOf, connect } from '../../manage.mjs';
13
+ import { cleanup } from '../../process.mjs';
14
+ import { credentials as inspect } from '../../shared.mjs';
15
+ import credentialsCommand from '../credentials/index.mjs';
16
+ import { downloadIn, easProjectName, easProjectOf, iasCommand, internalProfiles, isInside, platformsOf, preparedFolders, projectDir, readApp, readMarker, removeDownload, shellWord, storeProfile, writeFolder } from './project.mjs';
17
+
18
+ const credentialsImport = credentialsCommand.subcommands.find((subcommand) => subcommand.name === 'import');
19
+ const LABELS = { apple_distribution_certificate: 'iOS distribution certificate', apple_provisioning_profile: 'iOS provisioning profile', android_upload_keystore: 'Android upload keystore' };
20
+ const day = (date) => (date ? String(date).slice(0, 10) : 'never');
21
+
22
+ export function prepare(context) {
23
+ const { values, env, output } = context;
24
+ const dir = projectDir(context);
25
+ const app = readApp(dir);
26
+ if (!app.expo) throw new IasError(`no app.json in ${dir}`, { fix: 'eas credentials needs the app.json of the Expo app: run it in the folder of the app, or pass --project' });
27
+ const easProject = easProjectOf(app, values);
28
+ if (!easProject) throw new IasError('app.json does not name its EAS project (extra.eas.projectId)', { fix: 'pass --eas-project <id> and --owner <account>: eas project:info or expo.dev › the project shows them' });
29
+ const [existing] = preparedFolders(dir, env);
30
+ const folder = writeFolder(app, easProject, { env, folder: existing?.dir });
31
+ const download = downloadIn(folder);
32
+ const profile = storeProfile(app.eas);
33
+ const internal = internalProfiles(app.eas);
34
+ const platforms = platformsOf(app.expo);
35
+ const commands = platforms.map((platform) => `EAS_NO_VCS=1 eas credentials -p ${platform}`);
36
+ output.log(`${existing ? 'Refreshed' : 'Prepared'} ${folder} for eas credentials (mode 700, outside the project): its app.json names the EAS project ${easProjectName(easProject)}, its eas.json has the build profiles${existsSync(join(folder, 'node_modules')) ? ', and node_modules is a link to the app\'s' : ''}.`);
37
+ if (!existsSync(join(dir, 'node_modules'))) output.warn(`there is no node_modules in ${dir}: if eas credentials cannot read the project there, run npm install in the app and ias migrate eas prepare again`);
38
+ if (download.length > 0) output.log(`It already holds a download (${download.join(', ')}): ${iasCommand(context, 'import', shellWord(folder))} seals it into the vault and deletes it.`);
39
+ output.log(`
40
+ eas credentials writes credentials.json and credentials/ in the folder it runs in. It handles secrets and asks
41
+ questions, so you run it, from that folder (EAS_NO_VCS=1: it is not a git repository):
42
+ cd ${shellWord(folder)}
43
+ ${commands.map((command) => ` ${command}`).join('\n')}
44
+ In each: the profile ${profile} › «credentials.json: Upload/Download credentials between EAS servers and your local json» › «Download credentials from EAS to credentials.json».
45
+ - Never «Download existing keystore»: it prints the passwords.
46
+ - Before leaving, note what its summary shows besides: an App Store Connect API key, a push key, a Google service account (ias migrate eas checklist asks about them).${internal.length > 0 ? `\n- ${internal.length === 1 ? `The profile ${internal[0]} may have credentials of its own` : `The profiles ${internal.join(', ')} may have credentials of their own`} (ad hoc): each download replaces credentials.json, so import after each one.` : ''}
47
+ Then, from the app:
48
+ ${iasCommand(context, 'import', shellWord(folder), '--dry-run')}
49
+ ${iasCommand(context, 'import', shellWord(folder))}`);
50
+ return { folder, reused: Boolean(existing), eas_project: easProject, download, profile, commands: [`cd ${shellWord(folder)}`, ...commands] };
51
+ }
52
+
53
+ /** The metadata of the download, without its secrets, and the problems for the app of app.json. */
54
+ function inspectDownload(folder, expo) {
55
+ let found;
56
+ try {
57
+ found = inspect.inspectEasCredentials(folder);
58
+ } catch (error) {
59
+ if (error instanceof inspect.CredentialError) throw new IasError(`credentials.json: ${error.message}`, { fix: 'download it again with eas credentials, and check that the files it names are there', code: 'credential_unreadable' });
60
+ if (error instanceof TypeError) throw new IasError('credentials.json is not one eas credentials wrote', { fix: 'download it again with eas credentials › Download credentials from EAS to credentials.json', code: 'credential_unreadable' });
61
+ throw error;
62
+ }
63
+ const problems = [];
64
+ const notes = [];
65
+ if (found.ios) {
66
+ const { certificate, profile } = found.ios;
67
+ const teamId = expo?.ios?.appleTeamId;
68
+ const bundleId = expo?.ios?.bundleIdentifier;
69
+ problems.push(...inspect.checkIos({ certificate, profile }, { teamId, bundleId, type: profile.type }));
70
+ if (!teamId && bundleId && profile.bundleId !== bundleId) problems.push(`the profile is for ${profile.bundleId}, not ${bundleId}`);
71
+ if (profile.type !== 'app-store') notes.push(`the profile is ${profile.type}: it signs internal builds; store builds need an App Store profile (download the one of the store profile, or make one in Apple Developer and ias credentials import --profile it)`);
72
+ notes.push(`the certificate is the team's (${certificate.team}): other apps of the team may sign with it, so it goes to the team's credentials, and is skipped if it is already there`);
73
+ }
74
+ if (found.android) problems.push(...inspect.checkAndroid(found.android.keystore));
75
+ return { found, problems, notes };
76
+ }
77
+
78
+ function showDownload(folder, { found, problems, notes }, output) {
79
+ output.log(`credentials.json in ${folder}, without its secrets:`);
80
+ if (found.ios) {
81
+ const { certificate, profile } = found.ios;
82
+ output.log(` ${LABELS.apple_distribution_certificate} «${certificate.commonName}», team ${certificate.team}, serial ${certificate.serial}, SHA-1 ${certificate.sha1}, until ${day(certificate.expires)}`);
83
+ output.log(` ${LABELS.apple_provisioning_profile} «${profile.name}», ${profile.type}, UUID ${profile.uuid}, ${profile.applicationIdentifier}, until ${day(profile.expires)}${profile.devices > 0 ? `, ${profile.devices} devices` : ''}`);
84
+ output.log(` entitlements: ${Object.keys(profile.entitlements).sort().join(', ') || 'none'}`);
85
+ }
86
+ if (found.android) {
87
+ const { keystore } = found.android;
88
+ output.log(` ${LABELS.android_upload_keystore} alias ${keystore.alias}, SHA-1 ${keystore.sha1}, until ${day(keystore.expires)}`);
89
+ output.log(` SHA-256 ${keystore.sha256}`);
90
+ output.log(' Play Console › the app › Test and release › App integrity › App signing › «Upload key certificate» must show this SHA-256.');
91
+ }
92
+ for (const note of notes) output.log(` note: ${note}`);
93
+ for (const problem of problems) output.warn(`credentials.json: ${problem}`);
94
+ }
95
+
96
+ /** The types of credential a download holds. */
97
+ const typesOf = (found) => [...(found.ios ? ['apple_distribution_certificate', 'apple_provisioning_profile'] : []), ...(found.android ? ['android_upload_keystore'] : [])];
98
+
99
+ /** What ias credentials import said it did with each type, from its lines (when it stopped before returning). */
100
+ function fromLines(lines) {
101
+ const done = {};
102
+ for (const line of lines) {
103
+ const imported = /^imported ([a-z_]+) «.*» as (\S+),/.exec(line);
104
+ const skipped = /^([a-z_]+) «.*» is already in the vault as (\S+) /.exec(line);
105
+ if (imported) done[imported[1]] = { state: 'imported', id: imported[2] };
106
+ if (skipped) done[skipped[1]] = { state: 'already in the vault', id: skipped[2] };
107
+ }
108
+ return done;
109
+ }
110
+
111
+ /** What import did with each type, from the result of ias credentials import. */
112
+ function fromResult(result) {
113
+ const done = {};
114
+ for (const entry of result?.credentials ?? []) done[entry.type] = { state: entry.skipped ? 'already in the vault' : entry.dry_run ? 'sealed, not sent (dry run)' : 'imported', id: entry.id };
115
+ return done;
116
+ }
117
+
118
+ /**
119
+ * Runs `fn` if this process is interrupted, and then exits: the download must not stay because of Ctrl-C. It goes
120
+ * before the handler of bin/ias.mjs, which exits at once.
121
+ */
122
+ function onInterrupt(fn) {
123
+ const handler = (signal) => {
124
+ const said = fn();
125
+ process.stderr.write(`\nias: interrupted${said ? `: ${said}` : ''}\n`);
126
+ cleanup();
127
+ process.exit(signal === 'SIGINT' ? 130 : 143);
128
+ };
129
+ process.prependOnceListener('SIGINT', handler);
130
+ process.prependOnceListener('SIGTERM', handler);
131
+ return () => {
132
+ process.off('SIGINT', handler);
133
+ process.off('SIGTERM', handler);
134
+ };
135
+ }
136
+
137
+ export async function importDownload(context) {
138
+ const { values, positionals, env, output, cwd } = context;
139
+ const dir = projectDir(context);
140
+ const given = positionals[1];
141
+ if (!given) {
142
+ const waiting = preparedFolders(dir, env).filter((folder) => folder.download.length > 0).map((folder) => folder.dir);
143
+ throw new UsageError('import needs the folder of the download', { fix: waiting.length > 0 ? `the folders of ias migrate eas prepare with one: ${waiting.join(', ')}` : 'ias migrate eas prepare makes it and says how to download into it' });
144
+ }
145
+ const folder = resolve(cwd, given);
146
+ if (!existsSync(folder) || !statSync(folder).isDirectory()) throw new IasError(`there is no folder ${given}`, { fix: 'give the folder ias migrate eas prepare printed' });
147
+ if (isInside(dir, folder)) throw new IasError(`${given} is inside the project`, { fix: `nothing was read or deleted: move credentials.json and credentials/ to the folder of ias migrate eas prepare (mv) and import from there, so that git never sees them`, code: 'download_in_project' });
148
+ if (!existsSync(join(folder, 'credentials.json'))) throw new IasError(`there is no credentials.json in ${folder}`, { fix: 'download it there with eas credentials (ias migrate eas prepare says how)', code: 'download_missing' });
149
+ const marker = readMarker(folder);
150
+ const app = readApp(dir);
151
+ if (marker && !isInside(marker.project, dir)) throw new IasError(`${folder} was prepared for ${marker.project}, not for ${dir}`, { fix: `nothing was read or deleted: import it from that app (--project ${shellWord(marker.project)})`, code: 'download_of_another_app' });
152
+ if (statSync(folder).mode & 0o077) output.warn(`other users of this Mac can open ${folder}: the folders of ias migrate eas prepare are mode 700`);
153
+
154
+ // What is needed before reading the download: the application and a token. Stopping here leaves it as it is.
155
+ const project = loadProject(dir);
156
+ const application = applicationOf(context, project);
157
+ connect(context, project);
158
+ const dryRun = Boolean(values['dry-run']);
159
+ const lines = [];
160
+ const recorded = { ...output, log: (line) => (lines.push(line), output.log(line)) };
161
+ let inspected;
162
+ let result;
163
+ let failure;
164
+ const stop = onInterrupt(() => {
165
+ if (dryRun) return `the download stays in ${folder}`;
166
+ const { removed, failed } = removeDownload(folder);
167
+ return `deleted the download from ${folder}${removed.length > 0 ? ` (${removed.join(', ')})` : ''}${failed.length > 0 ? `; delete ${failed.join(', ')} yourself` : ''}`;
168
+ });
169
+ try {
170
+ inspected = inspectDownload(folder, app.expo);
171
+ showDownload(folder, inspected, output);
172
+ result = await credentialsImport.run({
173
+ ...context,
174
+ output: recorded,
175
+ positionals: [],
176
+ values: { project: values.project, app: application, eas: folder, force: values.force, 'dry-run': values['dry-run'], item: values.item, json: values.json },
177
+ });
178
+ } catch (error) {
179
+ failure = error;
180
+ } finally {
181
+ stop();
182
+ }
183
+
184
+ const types = inspected ? typesOf(inspected.found) : [];
185
+ const done = failure ? fromLines(lines) : fromResult(result);
186
+ const pending = types.filter((type) => !done[type]);
187
+ let cleaned = null;
188
+ if (dryRun) {
189
+ output.log(`dry run: nothing was sent, and the download stays in ${folder} for ${iasCommand(context, 'import', shellWord(folder))}.`);
190
+ } else {
191
+ cleaned = removeDownload(folder);
192
+ const say = failure ? output.warn : output.log;
193
+ say(`deleted the download from ${folder}${cleaned.removed.length > 0 ? ` (${cleaned.removed.join(', ')})` : ''}${failure ? ', although the import stopped' : ''}.`);
194
+ if (cleaned.failed.length > 0) output.warn(`could not delete ${cleaned.failed.join(', ')} in ${folder}: delete ${cleaned.failed.length === 1 ? 'it' : 'them'} yourself (rm -rf), since ${cleaned.failed.length === 1 ? 'it holds' : 'they hold'} secrets`);
195
+ if (cleaned.left.length > 0) output.warn(`${folder} still has ${cleaned.left.join(', ')}, which ias did not write: delete what holds a secret`);
196
+ }
197
+ if (failure) {
198
+ const imported = Object.entries(done).map(([type, { state, id }]) => `${LABELS[type] ?? type} ${state} (${id})`);
199
+ output.warn(`import stopped: ${imported.length > 0 ? `${imported.join('; ')}; ` : 'nothing was imported; '}${pending.length > 0 ? `left: ${pending.map((type) => LABELS[type] ?? type).join(', ')}. ` : ''}${dryRun ? '' : 'Download again with eas credentials and import again: what is in the vault already is skipped.'}`);
200
+ throw failure;
201
+ }
202
+ if (!dryRun) output.log(`Next: ${iasCommand(context, 'version')} brings the build numbers of EAS (${iasCommand(context)} says where the move is).`);
203
+ return {
204
+ folder,
205
+ application,
206
+ dry_run: dryRun,
207
+ found: Object.fromEntries(Object.entries(inspected.found).map(([platform, value]) => [platform, platform === 'ios' ? { certificate: value.certificate, profile: value.profile } : { keystore: value.keystore }])),
208
+ problems: inspected.problems,
209
+ credentials: result?.credentials ?? [],
210
+ deleted: cleaned?.removed ?? [],
211
+ left: cleaned?.left ?? null,
212
+ };
213
+ }
@@ -0,0 +1,153 @@
1
+ // eas-cli for ias migrate eas. ias runs it only where it reads nothing secret into a terminal and asks nothing:
2
+ // build:version:get and env:list, without a terminal (eas-cli is then non-interactive), their output read here and
3
+ // never printed. eas credentials and eas env:pull handle secrets and ask questions, so the person runs them; this reads
4
+ // the .env that env:pull writes. What is read was checked against eas-cli 24.14.
5
+ import { spawnSync } from 'node:child_process';
6
+ import { IasError } from '../../errors.mjs';
7
+
8
+ const ANSI = /\u001b\[[0-9;]*[A-Za-z]/g;
9
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
10
+ const BUILD_NUMBER = /^[1-9][0-9]{0,9}$/;
11
+
12
+ /** The environment of eas: this one without IAS's variables (an IAS token is none of eas's business) and without colours. */
13
+ export function easEnvironment(env, { noVcs = false } = {}) {
14
+ const clean = Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith('IAS_')));
15
+ return { ...clean, NO_COLOR: '1', FORCE_COLOR: '0', ...(noVcs ? { EAS_NO_VCS: '1' } : {}) };
16
+ }
17
+
18
+ /** Why eas failed: the last line it wrote that says more than «Error: <command> command failed.». */
19
+ function reasonOf(text) {
20
+ const lines = String(text ?? '').replace(ANSI, '').split('\n').map((line) => line.trim()).filter(Boolean);
21
+ return (lines.filter((line) => !/^(Error: )?\S+ command failed\.?$/.test(line)).at(-1) ?? lines.at(-1))?.slice(0, 300);
22
+ }
23
+
24
+ /**
25
+ * Runs eas with `args` in `cwd`, with no terminal: stdin closed and its output read here. Returns stdout. Fails with
26
+ * the last line eas wrote to stderr, never with its stdout, which may hold values.
27
+ */
28
+ export function runEas(args, { cwd, env, noVcs = false, fix }) {
29
+ const command = `eas ${args[0]}`;
30
+ const result = spawnSync('eas', args, { cwd, env: easEnvironment(env, { noVcs }), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024, timeout: 180_000 });
31
+ if (result.error?.code === 'ENOENT') throw new IasError(`${command}: eas-cli is not installed here`, { fix, code: 'eas_missing' });
32
+ if (result.error) throw new IasError(`${command}: ${result.error.message}`, { fix, code: 'eas_failed' });
33
+ if (result.status !== 0) throw new IasError(`${command} failed: ${reasonOf(result.stderr) ?? `exit ${result.status}`}`, { fix, code: 'eas_failed' });
34
+ return result.stdout.replace(ANSI, '');
35
+ }
36
+
37
+ /** The JSON document in `text`, or undefined. */
38
+ function jsonIn(text) {
39
+ const trimmed = text.trim();
40
+ for (const start of [0, trimmed.search(/[[{]/)]) {
41
+ if (start < 0) continue;
42
+ try {
43
+ return JSON.parse(trimmed.slice(start));
44
+ } catch {
45
+ // not from here
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ /** A build number as IAS keeps it (a whole number from 1), or null. */
52
+ export function buildNumber(value) {
53
+ const number = typeof value === 'string' && BUILD_NUMBER.test(value.trim()) ? Number(value) : value;
54
+ return Number.isInteger(number) && number >= 1 && number <= 2147483647 ? number : null;
55
+ }
56
+
57
+ /**
58
+ * The numbers of `eas build:version:get --json`: { buildNumber: "3", versionCode: "3" }, each only when EAS has one.
59
+ * Returns { ios, android } (numbers or null) and `raw`, what EAS said, for a number IAS cannot take (iOS allows 1.0.3).
60
+ */
61
+ export function parseVersions(text) {
62
+ const json = jsonIn(text);
63
+ if (!json || typeof json !== 'object' || Array.isArray(json)) throw new IasError('eas build:version:get did not print the JSON of --json', { fix: 'update eas-cli, or run eas build:version:get -p all yourself and give the numbers: ias migrate eas version --ios <n> --android <n>', code: 'eas_output_unknown' });
64
+ const raw = { ios: json.buildNumber ?? json.ios ?? null, android: json.versionCode ?? json.android ?? null };
65
+ return { ios: buildNumber(raw.ios), android: buildNumber(raw.android), raw };
66
+ }
67
+
68
+ /** The visibility of a variable in EAS, in lowercase: public, sensitive or secret. */
69
+ const visibilityOf = (value) => String(value ?? '').toLowerCase() || 'public';
70
+
71
+ /** A variable of eas env:list as ias migrate eas uses it. */
72
+ function variable({ name, value, visibility, type, scope }) {
73
+ const file = /file/i.test(String(type ?? ''));
74
+ const shown = typeof value === 'string' && !/^\*{5}( \(This is a .*\))?$/.test(value) ? value : null;
75
+ const secret = visibilityOf(visibility) === 'secret';
76
+ return { name, value: file || secret ? null : shown, visibility: visibilityOf(visibility), file, scope: scope ? String(scope).toLowerCase() : 'project' };
77
+ }
78
+
79
+ /** The fields of a variable in `eas env:list --format long`, in their order. */
80
+ const LONG_FIELDS = ['ID', 'Name', 'Value', 'Scope', 'Visibility', 'Environments', 'type', 'Created at', 'Updated at'];
81
+
82
+ /** One variable of the long format: its fields in their order, and a value that may take several lines. */
83
+ function longBlock(block) {
84
+ const lines = block.split('\n');
85
+ const start = lines.findIndex((line) => /^ID +\S/.test(line));
86
+ if (start === -1) return undefined;
87
+ // Every label is padded to the longest one and followed by two spaces: the values start at the same column.
88
+ const column = /^ID +/.exec(lines[start])[0].length;
89
+ const fields = {};
90
+ let at = -1;
91
+ for (const line of lines.slice(start)) {
92
+ const next = LONG_FIELDS.slice(at + 1).findIndex((label) => line.startsWith(`${label} `) || line === label);
93
+ if (next !== -1) {
94
+ at += next + 1;
95
+ fields[LONG_FIELDS[at]] = line.slice(column);
96
+ } else if (LONG_FIELDS[at] === 'Value') {
97
+ fields.Value += `\n${line}`;
98
+ }
99
+ }
100
+ if (!fields.Name) return undefined;
101
+ return variable({ name: fields.Name.trim(), value: fields.Value, visibility: fields.Visibility?.trim(), type: fields.type?.trim(), scope: fields.Scope?.trim() });
102
+ }
103
+
104
+ /**
105
+ * The variables of one environment as `eas env:list --format long` prints them (eas-cli 24 has no --json there):
106
+ * blocks separated by ———, with the fields ID, Name, Value, Scope, Visibility, Environments, type… A later eas-cli that
107
+ * prints JSON is read too. Returns [{ name, value, visibility, file, scope }]; value is null when eas could not show
108
+ * it: a secret (only EAS's builders read those), or a file.
109
+ */
110
+ export function parseVariableList(text) {
111
+ const trimmed = text.trim();
112
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
113
+ const json = jsonIn(trimmed);
114
+ const list = Array.isArray(json) ? json : (json?.variables ?? json?.data ?? []);
115
+ return list.filter((entry) => typeof entry?.name === 'string').map((entry) => variable(entry));
116
+ }
117
+ if (/No variables found/i.test(trimmed)) return [];
118
+ return trimmed.split(/\n\s*———\s*\n/).map(longBlock).filter(Boolean);
119
+ }
120
+
121
+ /** A value of a .env line: in double quotes with their escapes, in single quotes as it is, or bare and trimmed. */
122
+ function unquote(text) {
123
+ const value = text.trim();
124
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) return value.slice(1, -1).replace(/\\([nrt"\\])/g, (_, char) => ({ n: '\n', r: '\r', t: '\t' })[char] ?? char);
125
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
126
+ return value;
127
+ }
128
+
129
+ /**
130
+ * The .env that `eas env:pull` writes: «# Environment: production», then NAME=value lines as they are, a secret as
131
+ * «# NAME=***** (secret)» (only EAS's builders read it), and a file variable as the path of the file it wrote in
132
+ * .eas/.env/. Returns { environment, variables: [{ name, value }], secret: [names], files: [{ name, path }], unread }.
133
+ */
134
+ export function parseEnvFile(text) {
135
+ const result = { environment: /^# Environment: (\S+)/m.exec(text)?.[1]?.toLowerCase() ?? null, variables: [], secret: [], files: [], unread: 0 };
136
+ for (const line of text.split(/\r?\n/)) {
137
+ const hidden = /^\s*#\s*([A-Za-z_][A-Za-z0-9_]*)=\*{5} \(secret\)\s*$/.exec(line);
138
+ if (hidden) {
139
+ result.secret.push(hidden[1]);
140
+ continue;
141
+ }
142
+ if (/^\s*(#|$)/.test(line)) continue;
143
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line);
144
+ if (!match || !NAME.test(match[1])) {
145
+ result.unread += 1;
146
+ continue;
147
+ }
148
+ const value = unquote(match[2]);
149
+ if (/(^|[\\/])\.eas[\\/]\.env[\\/]/.test(value)) result.files.push({ name: match[1], path: value });
150
+ else result.variables.push({ name: match[1], value });
151
+ }
152
+ return result;
153
+ }
@@ -0,0 +1,73 @@
1
+ // ias migrate eas: leave EAS for IAS step by step (track 4D of PLAN-APPLICATION-SERVICES.md), as jscheduler-app did by
2
+ // hand on 19 October 2026 (its docs/local-builds.md): ias.json from eas.json, a folder outside the project for
3
+ // eas credentials, the credentials sealed into the vault, the build numbers and the variables of EAS on the server, and
4
+ // what is left to do in expo.dev and the stores. Each step can run again without harm and says what it did and what is
5
+ // left; without a step, it says where the move is. The router has two levels, so the step is an argument.
6
+ import { UsageError } from '../../errors.mjs';
7
+ import { importDownload, prepare } from './credentials.mjs';
8
+ import { version } from './numbers.mjs';
9
+ import { profiles } from './profiles.mjs';
10
+ import { checklist, status } from './survey.mjs';
11
+ import { variables } from './variables.mjs';
12
+
13
+ /** Each step, with the options it takes besides --project, --json and --help. */
14
+ const STEPS = {
15
+ status: { run: status, options: ['app'] },
16
+ profiles: { run: profiles, options: ['app', 'server', 'force', 'dry-run'] },
17
+ prepare: { run: prepare, options: ['eas-project', 'owner'] },
18
+ import: { run: importDownload, options: ['app', 'force', 'dry-run', 'item'], folder: true },
19
+ version: { run: version, options: ['app', 'profile', 'ios', 'android', 'dry-run'] },
20
+ env: { run: variables, options: ['app', 'environment', 'env-file', 'force', 'dry-run', 'item'] },
21
+ checklist: { run: checklist, options: ['app'] },
22
+ };
23
+ const GLOBAL = ['project', 'json', 'help'];
24
+
25
+ function run(context) {
26
+ const name = context.positionals[0] ?? 'status';
27
+ const step = STEPS[name];
28
+ const foreign = Object.keys(context.values).filter((option) => context.values[option] !== undefined && !GLOBAL.includes(option) && !step.options.includes(option));
29
+ if (foreign.length > 0) throw new UsageError(`--${foreign[0]} does not go with ias migrate eas ${name}`, { fix: `it takes ${step.options.map((option) => `--${option}`).join(', ') || 'no options'}; ias migrate eas --help says which step takes which` });
30
+ if (context.positionals[1] !== undefined && !step.folder) throw new UsageError(`ias migrate eas ${name} takes no folder`, { fix: 'only import does: ias migrate eas import <folder>' });
31
+ return step.run(context);
32
+ }
33
+
34
+ export default {
35
+ name: 'migrate',
36
+ summary: 'Leave another service for IAS, step by step: eas',
37
+ subcommands: [
38
+ {
39
+ name: 'eas',
40
+ summary: 'Leave EAS: ias.json, the signing credentials, the build numbers, the variables and what is left in expo.dev',
41
+ description: [
42
+ 'Steps, in their order (each can run again without harm, and says what it did and what is left):',
43
+ ' (none) where the move is: what each step finds done, and the next one',
44
+ ' profiles the build and submit profiles of eas.json into ias.json, and what has no equivalent [--app, --server, --force, --dry-run]',
45
+ ' prepare a folder outside the project (mode 700) where you run eas credentials, and how [--eas-project, --owner]',
46
+ ' import <folder>: credentials.json of eas credentials sealed into the vault, as ias credentials import --eas does; the download is then deleted, also when it fails half way (not with --dry-run) [--force, --dry-run, --item]',
47
+ ' version the last build numbers of EAS (eas build:version:get, run here without a terminal) to the server; they never go down [--ios, --android, --profile, --dry-run]',
48
+ ' env the variables of EAS (eas env:list, or the .env of eas env:pull with --env-file): EXPO_PUBLIC_* plain, the rest sealed [--environment, --env-file, --force, --dry-run, --item]',
49
+ ' checklist what is left in expo.dev, the stores and the app; ias deletes nothing',
50
+ 'eas credentials and eas env:pull handle secrets and ask questions: you run them. Nothing prints a password or a key.',
51
+ ].join('\n'),
52
+ positionals: [
53
+ { name: 'step', optional: true, description: `One of ${Object.keys(STEPS).join(', ')} (status when left out).`, pattern: new RegExp(`^(${Object.keys(STEPS).join('|')})$`) },
54
+ { name: 'folder', optional: true, description: 'import: the folder of ias migrate eas prepare, with the download of eas credentials.' },
55
+ ],
56
+ options: {
57
+ app: { type: 'string', value: '<code>', description: 'The application in IAS (app of ias.json by default; profiles writes it in a new ias.json).' },
58
+ server: { type: 'string', value: '<origin>', description: 'profiles: the server of IAS in ias.json, such as https://sync.domibi.com.' },
59
+ 'eas-project': { type: 'string', value: '<id>', description: 'prepare: the id of the EAS project, when app.json no longer names it (extra.eas.projectId).' },
60
+ owner: { type: 'string', value: '<account>', description: 'prepare: the Expo account of the project, when app.json does not say it.' },
61
+ profile: { type: 'string', value: '<profile>', description: 'version: the build profile of eas.json whose numbers to bring (production by default).' },
62
+ ios: { type: 'string', value: '<number>', description: 'version: the last iOS build number, instead of asking eas.' },
63
+ android: { type: 'string', value: '<number>', description: 'version: the last Android version code, instead of asking eas.' },
64
+ environment: { type: 'string', value: '<environment>', description: 'env: development, preview or production (all three from eas env:list by default; required with --env-file).' },
65
+ 'env-file': { type: 'string', value: '<file>', description: 'env: the .env of eas env:pull, deleted afterwards when it is in the folder of prepare.' },
66
+ force: { type: 'boolean', description: 'profiles: take eas.json\'s profiles over those of ias.json; import: import credentials already in the vault or with problems; env: replace variables already in IAS.' },
67
+ 'dry-run': { type: 'boolean', description: 'Read and check everything, and send or write nothing; import keeps the download.' },
68
+ item: { type: 'string', value: '<op://vault/item>', description: 'import and env: the 1Password item of the vault, to seal.' },
69
+ },
70
+ run,
71
+ },
72
+ ],
73
+ };
@@ -0,0 +1,85 @@
1
+ // ias migrate eas version: the last build numbers of EAS to the server of IAS (PUT /apps/{code}/build-numbers,
2
+ // docs/technical/runner-api.md §4.1), so that the next build with autoIncrement goes above every number a store has seen. They
3
+ // come from --ios and --android, or from eas build:version:get, which ias runs without a terminal (it reads no secret)
4
+ // when eas.json says appVersionSource remote; app.json's count too, the highest wins, and a number on the server never
5
+ // goes down.
6
+ import { existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { loadProject } from '../../config.mjs';
9
+ import { IasError } from '../../errors.mjs';
10
+ import { applicationOf, connect } from '../../manage.mjs';
11
+ import { buildNumberOf } from '../build/source.mjs';
12
+ import { buildNumber, parseVersions, runEas } from './eas-cli.mjs';
13
+ import { iasCommand, platformsOf, preparedFolders, projectDir, readApp, storeProfile } from './project.mjs';
14
+
15
+ const NAMES = { ios: 'iOS', android: 'Android' };
16
+
17
+ /**
18
+ * Where eas can run for the app: its own folder if app.json names the EAS project, or else the folder of prepare,
19
+ * whose app.json does (EAS_NO_VCS there: it is not a repository). Undefined if neither.
20
+ */
21
+ export function easFolder(app, env) {
22
+ if (app.expo?.extra?.eas?.projectId) return { dir: app.dir, noVcs: !existsSync(join(app.dir, '.git')) };
23
+ const [prepared] = preparedFolders(app.dir, env);
24
+ return prepared ? { dir: prepared.dir, noVcs: true } : undefined;
25
+ }
26
+
27
+ /** The numbers of EAS for the store profile, with eas build:version:get. */
28
+ function easNumbers(app, { env, profile }) {
29
+ const fix = 'run eas build:version:get -p all yourself (logged in to Expo) and give its numbers: ias migrate eas version --ios <n> --android <n>';
30
+ const folder = easFolder(app, env);
31
+ if (!folder) throw new IasError('app.json does not name its EAS project, so eas cannot read its numbers here', { fix: `ias migrate eas prepare --eas-project <id> makes a folder where it can; or ${fix}` });
32
+ return parseVersions(runEas(['build:version:get', '--platform', 'all', '--profile', profile, '--json', '--non-interactive'], { cwd: folder.dir, env, noVcs: folder.noVcs, fix }));
33
+ }
34
+
35
+ export async function version(context) {
36
+ const { values, env, output } = context;
37
+ const dir = projectDir(context);
38
+ const app = readApp(dir);
39
+ const project = loadProject(dir);
40
+ const application = applicationOf(context, project);
41
+ const platforms = platformsOf(app.expo);
42
+ const given = Object.fromEntries(['ios', 'android'].filter((platform) => values[platform] !== undefined).map((platform) => {
43
+ const number = buildNumber(values[platform]);
44
+ if (number === null) throw new IasError(`--${platform} takes a whole number from 1, not «${values[platform]}»`, { fix: 'the last build number EAS gave, as eas build:version:get shows it' });
45
+ return [platform, number];
46
+ }));
47
+ const explicit = Object.keys(given).length > 0;
48
+ const profile = values.profile ?? storeProfile(app.eas);
49
+ // With appVersionSource local, EAS took the numbers from app.json, and eas build:version:get refuses to answer.
50
+ const remote = app.eas?.cli?.appVersionSource === 'remote';
51
+ const eas = explicit || !remote ? null : easNumbers(app, { env, profile });
52
+ const appJson = Object.fromEntries(platforms.map((platform) => [platform, app.expo ? buildNumberOf(app.expo, platform) : null]));
53
+ const client = connect(context, project);
54
+ const { data: current } = await client.request('GET', `/apps/${application}/build-numbers`);
55
+ const raise = {};
56
+ const rows = [];
57
+ output.log(`Build numbers of ${application}${explicit ? '' : remote ? ` (EAS: profile ${profile})` : ' (appVersionSource is not remote in eas.json: EAS took them from app.json)'}:`);
58
+ for (const platform of explicit ? Object.keys(given) : platforms) {
59
+ const sources = explicit ? [given[platform]] : [eas?.[platform] ?? null, appJson[platform]];
60
+ const wanted = Math.max(0, ...sources.filter((number) => number !== null)) || null;
61
+ const now = current?.[platform] ?? null;
62
+ const said = explicit ? `--${platform} ${given[platform]}` : `${eas ? `EAS ${eas.raw[platform] ?? 'none'}, ` : ''}app.json ${appJson[platform] ?? 'none'}`;
63
+ let outcome;
64
+ if (eas && eas.raw[platform] !== null && eas[platform] === null) output.warn(`the ${NAMES[platform]} build number of EAS («${eas.raw[platform]}») is not a whole number, which IAS needs: give the last one with --${platform} <n>`);
65
+ if (wanted === null) outcome = `nothing to bring (--${platform} <n> gives it); IAS ${now ?? 'none yet'}`;
66
+ else if (now !== null && now >= wanted) outcome = `IAS already ${now}: left as it is (it never goes down)`;
67
+ else {
68
+ raise[platform] = wanted;
69
+ outcome = `IAS ${now ?? 'none yet'} → ${wanted}`;
70
+ }
71
+ rows.push({ platform, eas: eas?.[platform] ?? null, app_json: explicit ? null : appJson[platform], given: given[platform] ?? null, server: now, set: raise[platform] ?? null });
72
+ output.log(` ${NAMES[platform].padEnd(8)} ${said}: ${outcome}`);
73
+ }
74
+ let after = current;
75
+ if (Object.keys(raise).length > 0 && !values['dry-run']) {
76
+ after = (await client.request('PUT', `/apps/${application}/build-numbers`, { json: raise })).data;
77
+ output.log(`${application}: the last build numbers are now iOS ${after.ios ?? 'none yet'}, Android ${after.android ?? 'none yet'}; a build with autoIncrement takes the next one.`);
78
+ } else if (Object.keys(raise).length > 0) {
79
+ output.log(`dry run: would set ${Object.entries(raise).map(([platform, number]) => `${NAMES[platform]} ${number}`).join(' and ')}; nothing sent.`);
80
+ } else {
81
+ output.log(`${application}: nothing to raise.`);
82
+ }
83
+ if (!explicit && !values['dry-run']) output.log(`Next: ${iasCommand(context, 'env')} brings the variables of EAS.`);
84
+ return { application, profile: eas ? profile : null, platforms: rows, build_numbers: after, dry_run: Boolean(values['dry-run']) };
85
+ }