@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,174 @@
1
+ // ias migrate eas profiles: the build and submit profiles of eas.json into ias.json, with the conversion of ias init.
2
+ // A new ias.json is written whole; an existing one only gains the profiles it lacks (--force takes eas.json's for those
3
+ // that differ), so running it again changes nothing. It says what has no equivalent in IAS and what to do instead.
4
+ import { existsSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { isDeepStrictEqual } from 'node:util';
7
+ import { CONFIG_FILE, loadProject } from '../../config.mjs';
8
+ import { IasError, UsageError } from '../../errors.mjs';
9
+ import { validate } from '../../schema.mjs';
10
+ import { writtenSecrets } from '../../secrets.mjs';
11
+ import { initialConfig } from '../init/index.mjs';
12
+ import { iasCommand, projectDir, readApp } from './project.mjs';
13
+
14
+ const SERVER = /^https?:\/\/[^/?#\s]+\/?$/;
15
+ const TOOLCHAIN = 'the runners build with the tools of their Mac: there is no version per profile';
16
+ const VAULT_ASC = 'ias submit sends with the App Store Connect API key of the vault: ias credentials import --p8 AuthKey_<id>.p8 --issuer-id <uuid> --team-id <team>';
17
+ const METADATA = 'EAS Metadata has no equivalent: edit the listing in App Store Connect';
18
+
19
+ /** What IAS does instead of a field of eas.json, by its name. A function gets the value. */
20
+ const NOTES = {
21
+ env: (value) => `${Object.keys(value ?? {}).join(', ') || 'none'}: IAS keeps variables per environment (ias env set), not per profile`,
22
+ credentialsSource: () => 'IAS signs with the credentials of its vault (ias migrate eas import): there is no local credentials.json',
23
+ node: () => TOOLCHAIN,
24
+ yarn: () => TOOLCHAIN,
25
+ pnpm: () => TOOLCHAIN,
26
+ bun: () => TOOLCHAIN,
27
+ corepack: () => TOOLCHAIN,
28
+ cocoapods: () => TOOLCHAIN,
29
+ fastlane: () => TOOLCHAIN,
30
+ bundler: () => TOOLCHAIN,
31
+ ndk: () => TOOLCHAIN,
32
+ image: () => 'the runners are Macs of the team: there are no images or machine sizes',
33
+ resourceClass: () => 'the runners are Macs of the team: there are no images or machine sizes',
34
+ cache: () => 'IAS has no build cache',
35
+ prebuildCommand: () => 'the runner runs expo prebuild itself: there is no command of your own',
36
+ buildArtifactPaths: () => 'the runner keeps the IPA or AAB, the dSYMs or the mapping and a result file: there are no paths of your own',
37
+ applicationArchivePath: () => 'the runner keeps the IPA or AAB, the dSYMs or the mapping and a result file: there are no paths of your own',
38
+ config: () => 'EAS custom builds (.eas/build/*.yml) have no equivalent',
39
+ releaseChannel: () => 'classic updates: IAS uses channel',
40
+ withoutCredentials: () => 'an IAS build always signs: internal ones with ad hoc or development credentials',
41
+ buildConfiguration: () => 'the runner builds the Release configuration of the app\'s scheme',
42
+ scheme: () => 'the runner builds the Release configuration of the app\'s scheme',
43
+ enterpriseProvisioning: () => 'IAS has no enterprise distribution',
44
+ autoIncrement: () => 'autoIncrement goes per profile in ias.json, not per platform',
45
+ gradleCommand: () => 'the runner runs bundleRelease or assembleRelease after the format of the profile: there is no command of your own',
46
+ appleId: () => VAULT_ASC,
47
+ ascApiKeyPath: () => VAULT_ASC,
48
+ ascApiKeyId: () => VAULT_ASC,
49
+ ascApiKeyIssuerId: () => VAULT_ASC,
50
+ serviceAccountKeyPath: (value) => `ias submit sends with the Google service account of the vault: ias credentials import --service-account ${value ?? '<file>'}; then keep the file in 1Password and take it out of the project`,
51
+ sku: () => METADATA,
52
+ language: () => METADATA,
53
+ companyName: () => METADATA,
54
+ appName: () => METADATA,
55
+ metadataPath: () => METADATA,
56
+ bundleIdentifier: () => 'ias submit takes the identifier of the build',
57
+ applicationId: () => 'ias submit takes the identifier of the build',
58
+ };
59
+
60
+ /** What IAS does instead of each setting of eas.json › cli. */
61
+ const CLI_NOTES = {
62
+ appVersionSource: (value) => (value === 'remote' ? 'EAS kept the build numbers: ias migrate eas version brings them, and the server of IAS keeps them from then on (autoIncrement)' : 'the build numbers were in app.json: ias migrate eas version takes them to the server of IAS, which keeps them from then on (autoIncrement)'),
63
+ requireCommit: () => 'ias build always builds a commit of a clean tree',
64
+ version: () => 'the version of eas-cli that eas.json asks for: ias does not need it',
65
+ promptToConfigurePushNotifications: () => 'IAS asks nothing about push: ias push credentials loads the APNs key and the FCM account',
66
+ };
67
+
68
+ /** The build hooks EAS runs from package.json (EAS build lifecycle), which the runners of IAS do not run. */
69
+ const HOOKS = ['eas-build-pre-install', 'eas-build-post-install', 'eas-build-pre-upload-artifacts', 'eas-build-on-success', 'eas-build-on-error', 'eas-build-on-complete', 'eas-build-on-cancel'];
70
+
71
+ /** The value at `path` (build.preview.env) of eas.json. */
72
+ const valueAt = (eas, path) => path.split('.').reduce((value, key) => (value && typeof value === 'object' ? value[key] : undefined), eas);
73
+
74
+ /**
75
+ * What has no equivalent in IAS, as [{ path, why }]: what ias init left out of eas.json (in the words of a move from
76
+ * EAS), what it kept but IAS treats otherwise, and the EAS files and hooks of the app.
77
+ */
78
+ export function notesOf(app, leftOut) {
79
+ const notes = [];
80
+ for (const { path, why } of leftOut) {
81
+ if (path === 'eas.json › cli') {
82
+ for (const [key, value] of Object.entries(app.eas?.cli ?? {})) notes.push({ path: `eas.json › cli.${key}`, why: CLI_NOTES[key]?.(value) ?? 'eas-cli only' });
83
+ continue;
84
+ }
85
+ if (path === 'updates') {
86
+ notes.push({ path: 'app.json › updates.url', why: `${app.expo?.updates?.url ?? 'it'} is not an OTA project of IAS: changing it needs a new build (ias migrate eas checklist)` });
87
+ continue;
88
+ }
89
+ const key = path.split('.').at(-1);
90
+ const note = path.startsWith('build.') || path.startsWith('submit.') ? NOTES[key] : undefined;
91
+ notes.push({ path, why: note ? note(valueAt(app.eas, path)) : why });
92
+ }
93
+ for (const [name, profile] of Object.entries(app.eas?.submit ?? {})) {
94
+ const status = profile?.android?.releaseStatus;
95
+ if (status !== undefined && status !== 'draft') notes.push({ path: `submit.${name}.android.releaseStatus`, why: `IAS sends drafts only (decision D7): ias submit leaves «${status}» aside, and the release is sent for review in Play Console` });
96
+ }
97
+ for (const [name, profile] of Object.entries(app.eas?.build ?? {})) {
98
+ if (profile?.developmentClient || profile?.ios?.simulator) notes.push({ path: `build.${name}`, why: 'the runners of IAS do not build development clients or simulator builds yet: ias build --local does, on this Mac' });
99
+ }
100
+ for (const hook of HOOKS) {
101
+ if (app.packageJson?.scripts?.[hook] !== undefined) notes.push({ path: `package.json › scripts.${hook}`, why: 'the runners of IAS do not run the build hooks of EAS: move what it does to a config plugin of the app' });
102
+ }
103
+ if (existsSync(join(app.dir, '.easignore'))) notes.push({ path: '.easignore', why: 'ias build sends the commit with git archive: export-ignore in .gitattributes leaves files out instead' });
104
+ if (existsSync(join(app.dir, '.eas', 'build'))) notes.push({ path: '.eas/build', why: 'EAS custom builds have no equivalent' });
105
+ if (existsSync(join(app.dir, '.eas', 'workflows'))) notes.push({ path: '.eas/workflows', why: 'EAS Workflows have no equivalent yet (PLAN-APPLICATION-SERVICES.md, track 5C)' });
106
+ return notes;
107
+ }
108
+
109
+ export function profiles(context) {
110
+ const { values, output } = context;
111
+ const dir = projectDir(context);
112
+ const app = readApp(dir);
113
+ if (!app.eas) throw new IasError(`no eas.json in ${dir}`, { fix: 'there are no profiles to bring: write those of ias.json by hand (packages/cli/README.md §3), or run ias init' });
114
+ if (values.server !== undefined && !SERVER.test(values.server)) throw new UsageError(`--server takes the origin of IAS, not «${values.server}»`, { fix: 'such as https://sync.domibi.com, while ias.imagina.build does not answer' });
115
+ const project = loadProject(dir);
116
+ const current = project.config;
117
+ const { config: fresh, from, leftOut } = initialConfig(dir, { app: values.app ?? current?.app });
118
+ if (values.server !== undefined) {
119
+ fresh.server = new URL(values.server).origin;
120
+ from.server = '--server';
121
+ }
122
+ const added = [];
123
+ const same = [];
124
+ const kept = [];
125
+ let next;
126
+ if (!current) {
127
+ next = fresh;
128
+ for (const part of ['build', 'submit']) added.push(...Object.keys(fresh[part] ?? {}).map((name) => `${part}.${name}`));
129
+ } else {
130
+ next = structuredClone(current);
131
+ if (values.server !== undefined && next.server !== fresh.server) {
132
+ next.server = fresh.server;
133
+ added.push('server');
134
+ }
135
+ for (const part of ['build', 'submit']) {
136
+ for (const [name, profile] of Object.entries(fresh[part] ?? {})) {
137
+ const mine = next[part]?.[name];
138
+ if (mine === undefined || (values.force && !isDeepStrictEqual(mine, profile))) {
139
+ next[part] = { ...(next[part] ?? {}), [name]: profile };
140
+ added.push(`${part}.${name}${mine === undefined ? '' : ' (replaced)'}`);
141
+ } else if (isDeepStrictEqual(mine, profile)) {
142
+ same.push(`${part}.${name}`);
143
+ } else {
144
+ kept.push(`${part}.${name}`);
145
+ }
146
+ }
147
+ }
148
+ }
149
+ const problems = [...writtenSecrets(next), ...validate(next)];
150
+ if (problems.length > 0) throw new IasError(`the ias.json it would write is not valid (${problems.join('; ')})`, { fix: current ? 'fix ias.json (ias doctor checks it) and run it again' : 'this is a bug of ias: write ias.json by hand meanwhile' });
151
+ const notes = notesOf(app, leftOut);
152
+ const file = join(dir, CONFIG_FILE);
153
+ const write = added.length > 0 && !values['dry-run'];
154
+ if (write) writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`);
155
+ const verb = values['dry-run'] ? 'would write' : 'wrote';
156
+ if (!current) {
157
+ output.log(`${verb} ${file}, new:`);
158
+ output.log(` app ${next.app}, from ${from.app}`);
159
+ output.log(` server ${next.server}, from ${from.server}`);
160
+ for (const part of ['build', 'submit']) if (next[part]) output.log(` ${part.padEnd(7)} ${Object.keys(next[part]).join(', ')}, from eas.json`);
161
+ } else if (added.length > 0) {
162
+ output.log(`${verb} ${file}: ${added.join(', ')} from eas.json.`);
163
+ } else {
164
+ output.log(`${file} already has the profiles of eas.json: nothing to write.`);
165
+ }
166
+ if (same.length > 0 && added.length > 0) output.log(`already the same: ${same.join(', ')}`);
167
+ if (kept.length > 0) output.log(`kept those of ias.json, which differ from eas.json (--force takes eas.json's): ${kept.join(', ')}`);
168
+ if (notes.length > 0) {
169
+ output.log('without an equivalent in IAS:');
170
+ for (const { path, why } of notes) output.log(` ${path}: ${why}`);
171
+ }
172
+ output.log(`Next: commit ias.json; then ${iasCommand(context, 'prepare')}.`);
173
+ return { file, written: write, created: !current, added, same, kept, notes, config: next };
174
+ }
@@ -0,0 +1,249 @@
1
+ // What ias migrate eas reads from the app (app.json, eas.json, package.json) and the folders of prepare: outside the
2
+ // project, mode 700, with only what eas-cli needs to recognise the EAS project, so that eas credentials downloads there
3
+ // and never into the repository. Nothing here reads a secret; removeDownload deletes what eas wrote into such a folder.
4
+ import { chmodSync, existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, rmdirSync, symlinkSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { readExpo, readJson } from '../../config.mjs';
8
+ import { IasError } from '../../errors.mjs';
9
+
10
+ /** The file of a folder of prepare that says which app it is for. It holds no secret. */
11
+ export const MARKER = 'ias-migrate-eas.json';
12
+ const FORMAT = 'ias-migrate-eas-1';
13
+ const PREFIX = 'ias-eas-';
14
+ /** What prepare writes in its folder: anything else there came from eas. */
15
+ export const SCAFFOLD = Object.freeze(['package.json', 'app.json', 'eas.json', 'node_modules', MARKER]);
16
+ /** What eas credentials downloads: credentials.json and the files it names, by default in credentials/. */
17
+ export const DOWNLOAD = Object.freeze(['credentials.json', 'credentials']);
18
+
19
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
+ const ACCOUNT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
21
+ const EAS_UPDATE = /^https:\/\/u\.expo\.dev\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i;
22
+
23
+ /** The folder of the app: --project, or the current one. */
24
+ export const projectDir = ({ values, cwd }) => resolve(cwd, values.project ?? '.');
25
+
26
+ /** The app in `dir`: the expo object of app.json, eas.json and package.json, each undefined when it is missing. */
27
+ export function readApp(dir) {
28
+ const optional = (name) => (existsSync(join(dir, name)) ? readJson(join(dir, name), name, `fix ${name}: ias migrate eas reads it`) : undefined);
29
+ return { dir, expo: readExpo(dir), eas: optional('eas.json'), packageJson: optional('package.json') };
30
+ }
31
+
32
+ /** Whether app.json still points updates.url at EAS Update (u.expo.dev). */
33
+ export const usesEasUpdate = (expo) => EAS_UPDATE.test(expo?.updates?.url ?? '');
34
+
35
+ /**
36
+ * The EAS project of the app: { projectId, owner, slug }, from --eas-project and --owner, or from app.json
37
+ * (extra.eas.projectId, or the u.expo.dev URL of updates, and owner). Undefined if nothing names it. The owner may be
38
+ * null: eas-cli then takes the project's own.
39
+ */
40
+ export function easProjectOf(app, values = {}) {
41
+ const projectId = values['eas-project'] ?? app.expo?.extra?.eas?.projectId ?? EAS_UPDATE.exec(app.expo?.updates?.url ?? '')?.[1];
42
+ const owner = values.owner ?? app.expo?.owner ?? null;
43
+ if (projectId === undefined) return undefined;
44
+ if (typeof projectId !== 'string' || !UUID.test(projectId)) throw new IasError(`«${projectId}» is not the id of an EAS project`, { fix: 'it is a UUID: eas project:info, or expo.dev › the project, shows it' });
45
+ if (owner !== null && (typeof owner !== 'string' || !ACCOUNT.test(owner))) throw new IasError(`«${owner}» is not the name of an Expo account`, { fix: 'the account that owns the project, as expo.dev shows it (@account/project)' });
46
+ return { projectId: projectId.toLowerCase(), owner, slug: app.expo?.slug ?? null };
47
+ }
48
+
49
+ /** How the person calls the EAS project: @owner/slug, or the slug and its id. */
50
+ export const easProjectName = (easProject) => (easProject.owner ? `@${easProject.owner}/${easProject.slug}` : `${easProject.slug ?? 'the project'} (${easProject.projectId})`);
51
+
52
+ /** The platforms the app has: those whose identifier app.json gives, or both when it gives neither. */
53
+ export function platformsOf(expo) {
54
+ const platforms = [...(expo?.ios?.bundleIdentifier ? ['ios'] : []), ...(expo?.android?.package ? ['android'] : [])];
55
+ return platforms.length > 0 ? platforms : ['ios', 'android'];
56
+ }
57
+
58
+ /** The build profile of eas.json whose credentials go to the stores: production, or the first store one. */
59
+ export function storeProfile(eas) {
60
+ const profiles = eas?.build && typeof eas.build === 'object' ? eas.build : {};
61
+ if (profiles.production) return 'production';
62
+ return Object.entries(profiles).find(([, profile]) => profile?.distribution !== 'internal' && !profile?.developmentClient)?.[0] ?? 'production';
63
+ }
64
+
65
+ /** The build profiles of eas.json that distribute internally (ad hoc on iOS), which may have credentials of their own. */
66
+ export const internalProfiles = (eas) => Object.entries(eas?.build ?? {}).filter(([, profile]) => profile?.distribution === 'internal' && !profile?.developmentClient && !profile?.ios?.simulator).map(([name]) => name);
67
+
68
+ /** The real path of `path`, links resolved; for one that does not exist yet, that of its nearest folder that does. */
69
+ const realpath = (path) => {
70
+ const full = resolve(path);
71
+ try {
72
+ return realpathSync(full);
73
+ } catch {
74
+ const parent = dirname(full);
75
+ return parent === full ? full : join(realpath(parent), basename(full));
76
+ }
77
+ };
78
+
79
+ /** Whether `path` is `parent` or inside it, links resolved: a link into the project counts as inside. */
80
+ export function isInside(parent, path) {
81
+ const rest = relative(realpath(parent), realpath(path));
82
+ return rest === '' || (rest.split(sep)[0] !== '..' && !isAbsolute(rest));
83
+ }
84
+
85
+ /** Where prepare makes its folders: $TMPDIR, private to this user on macOS. */
86
+ export const temporaryBase = (env) => env.TMPDIR || tmpdir();
87
+
88
+ /** The marker of a folder of prepare, or undefined if it is not one. */
89
+ export function readMarker(folder) {
90
+ try {
91
+ const marker = JSON.parse(readFileSync(join(folder, MARKER), 'utf8'));
92
+ return marker?.format === FORMAT ? marker : undefined;
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ }
97
+
98
+ /** What eas credentials left in `folder` (credentials.json, credentials/). */
99
+ export const downloadIn = (folder) => DOWNLOAD.filter((name) => existsSync(join(folder, name)));
100
+
101
+ /** What `folder` has besides the scaffold of prepare: a download, a .env of eas env:pull, the files of .eas/… */
102
+ export function leftovers(folder) {
103
+ try {
104
+ return readdirSync(folder).filter((name) => !SCAFFOLD.includes(name)).sort();
105
+ } catch {
106
+ return [];
107
+ }
108
+ }
109
+
110
+ /** The folders prepare made for the app in `dir`, oldest first: [{ dir, marker, download, leftovers }]. */
111
+ export function preparedFolders(dir, env) {
112
+ const base = temporaryBase(env);
113
+ let names;
114
+ try {
115
+ names = readdirSync(base);
116
+ } catch {
117
+ return [];
118
+ }
119
+ const project = realpath(dir);
120
+ return names
121
+ .filter((name) => name.startsWith(PREFIX))
122
+ .map((name) => ({ dir: join(base, name), marker: readMarker(join(base, name)) }))
123
+ .filter(({ marker }) => marker?.project === project)
124
+ .map((folder) => ({ ...folder, download: downloadIn(folder.dir), leftovers: leftovers(folder.dir) }))
125
+ .sort((a, b) => String(a.marker.created).localeCompare(String(b.marker.created)));
126
+ }
127
+
128
+ /** app.json with only what eas-cli needs to know the project: names, identifiers and the EAS project. */
129
+ function minimalAppJson(expo, easProject) {
130
+ const ios = expo.ios?.bundleIdentifier ? { bundleIdentifier: expo.ios.bundleIdentifier, ...(expo.ios.appleTeamId ? { appleTeamId: expo.ios.appleTeamId } : {}) } : undefined;
131
+ const android = expo.android?.package ? { package: expo.android.package } : undefined;
132
+ return {
133
+ expo: {
134
+ name: expo.name,
135
+ slug: expo.slug,
136
+ ...(typeof expo.version === 'string' ? { version: expo.version } : {}),
137
+ ...(easProject.owner ? { owner: easProject.owner } : {}),
138
+ ...(ios ? { ios } : {}),
139
+ ...(android ? { android } : {}),
140
+ extra: { eas: { projectId: easProject.projectId } },
141
+ },
142
+ };
143
+ }
144
+
145
+ /** eas.json with its cli settings and build profiles, without the variables of each profile. */
146
+ function minimalEasJson(eas) {
147
+ const profiles = eas?.build && typeof eas.build === 'object' ? eas.build : { production: {} };
148
+ const build = Object.fromEntries(Object.entries(profiles).map(([name, profile]) => {
149
+ const { env: _variables, ...rest } = profile && typeof profile === 'object' ? profile : {};
150
+ return [name, rest];
151
+ }));
152
+ return { ...(eas?.cli ? { cli: eas.cli } : {}), build };
153
+ }
154
+
155
+ /**
156
+ * Makes (or, given `folder`, refreshes) the folder of prepare for `app`: mode 700, outside the project, with
157
+ * package.json, the minimal app.json and eas.json, node_modules linked to the app's, and the marker. A download already
158
+ * there stays. Returns its path.
159
+ */
160
+ export function writeFolder(app, easProject, { env, folder }) {
161
+ const base = temporaryBase(env);
162
+ if (!folder && isInside(app.dir, base)) throw new IasError(`the temporary folder (${base}) is inside the project`, { fix: 'set TMPDIR to a folder outside the app: the download must never be where git can pick it up' });
163
+ const target = folder ?? mkdtempSync(join(base, `${PREFIX}${String(app.expo.slug ?? 'app').replace(/[^A-Za-z0-9_-]+/g, '-').slice(0, 40)}-`));
164
+ chmodSync(target, 0o700);
165
+ const write = (name, value) => writeFileSync(join(target, name), `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
166
+ write('package.json', { name: `${String(app.expo.slug ?? 'app').toLowerCase().replace(/[^a-z0-9-]+/g, '-')}-eas-credentials`, private: true });
167
+ write('app.json', minimalAppJson(app.expo, easProject));
168
+ write('eas.json', minimalEasJson(app.eas));
169
+ const previous = readMarker(target);
170
+ write(MARKER, { format: FORMAT, project: realpath(app.dir), eas: easProject, created: previous?.created ?? new Date().toISOString(), note: 'Made by ias migrate eas prepare. eas credentials downloads here; ias migrate eas import seals the download into the vault of IAS and deletes it.' });
171
+ // eas-cli resolves expo from the project to read app.json; it only reads from there. A link is removed by rm -rf
172
+ // without touching what it points to.
173
+ const link = join(target, 'node_modules');
174
+ let linked = false;
175
+ try {
176
+ linked = lstatSync(link).isSymbolicLink();
177
+ } catch {
178
+ linked = false;
179
+ }
180
+ if (!linked && existsSync(join(app.dir, 'node_modules'))) symlinkSync(join(app.dir, 'node_modules'), link);
181
+ return target;
182
+ }
183
+
184
+ /** The paths credentials.json of `folder` names (certificates, profiles and keystores), resolved in the folder. */
185
+ function namedFiles(folder) {
186
+ let json;
187
+ try {
188
+ json = JSON.parse(readFileSync(join(folder, 'credentials.json'), 'utf8'));
189
+ } catch {
190
+ return [];
191
+ }
192
+ const paths = [];
193
+ const target = (ios) => {
194
+ if (typeof ios?.distributionCertificate?.path === 'string') paths.push(ios.distributionCertificate.path);
195
+ if (typeof ios?.provisioningProfilePath === 'string') paths.push(ios.provisioningProfilePath);
196
+ };
197
+ if (json?.ios && typeof json.ios === 'object') {
198
+ if (json.ios.distributionCertificate || json.ios.provisioningProfilePath) target(json.ios);
199
+ else Object.values(json.ios).forEach(target);
200
+ }
201
+ if (typeof json?.android?.keystore?.keystorePath === 'string') paths.push(json.android.keystore.keystorePath);
202
+ return paths.map((path) => resolve(folder, path));
203
+ }
204
+
205
+ /**
206
+ * Deletes what eas credentials downloaded into `folder`: the files its credentials.json names inside the folder (never
207
+ * through node_modules, which is a link to the app), credentials.json and credentials/. Never throws: returns
208
+ * { removed, failed, left }, `left` being what the folder still has besides the scaffold of prepare.
209
+ */
210
+ export function removeDownload(folder) {
211
+ const removed = [];
212
+ const failed = [];
213
+ const remove = (path) => {
214
+ let exists = false;
215
+ try {
216
+ lstatSync(path);
217
+ exists = true;
218
+ } catch {
219
+ exists = false;
220
+ }
221
+ if (!exists) return;
222
+ try {
223
+ rmSync(path, { recursive: true, force: true });
224
+ removed.push(relative(folder, path));
225
+ } catch {
226
+ failed.push(relative(folder, path));
227
+ }
228
+ };
229
+ for (const path of namedFiles(folder)) {
230
+ if (!existsSync(path) || !isInside(folder, path) || isInside(join(folder, 'node_modules'), path)) continue;
231
+ remove(path);
232
+ // The folders eas made for it, up to the folder of prepare, when they are left empty.
233
+ for (let parent = dirname(path); parent !== folder && isInside(folder, parent); parent = dirname(parent)) {
234
+ try {
235
+ rmdirSync(parent);
236
+ } catch {
237
+ break;
238
+ }
239
+ }
240
+ }
241
+ for (const name of DOWNLOAD) remove(join(folder, name));
242
+ return { removed, failed, left: leftovers(folder) };
243
+ }
244
+
245
+ /** `text` as one word of the shell: as it is when it is safe, in single quotes otherwise. */
246
+ export const shellWord = (text) => (/^[A-Za-z0-9_./:@%+=,-]+$/.test(text) ? text : `'${String(text).replace(/'/g, "'\\''")}'`);
247
+
248
+ /** A command of ias migrate eas to print, with --project when the person gave it. */
249
+ export const iasCommand = (context, ...words) => ['ias', 'migrate', 'eas', ...words, ...(context.values.project ? ['--project', shellWord(projectDir(context))] : [])].join(' ');
@@ -0,0 +1,205 @@
1
+ // Where a move from EAS is, from what can be looked at without touching anything: the app's files, the folders of
2
+ // prepare, and what the server of IAS has (credentials, build numbers and variables), if there is a token for it.
3
+ // ias migrate eas without a step says which steps are done and what comes next; checklist says what is left in
4
+ // expo.dev, the stores and the app, as jscheduler-app's docs/local-builds.md §7 did by hand, adapted to what it finds.
5
+ // Neither deletes nor changes anything.
6
+ import { existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { loadProject } from '../../config.mjs';
9
+ import { ApiError } from '../../errors.mjs';
10
+ import { applicationOf, connect } from '../../manage.mjs';
11
+ import { easProjectName, easProjectOf, iasCommand, platformsOf, preparedFolders, projectDir, readApp, shellWord, usesEasUpdate } from './project.mjs';
12
+
13
+ const DAY = 24 * 60 * 60 * 1000;
14
+ /** The names of profile ias.json takes: those of eas.json that ias init leaves out never count as missing. */
15
+ const PROFILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,49}$/;
16
+ const day = (date) => String(date).slice(0, 10);
17
+
18
+ /** What the server of IAS has for the application, or why it is not known: { known, application?, why? … }. */
19
+ async function serverState(context, project) {
20
+ let application;
21
+ try {
22
+ application = applicationOf(context, project);
23
+ } catch {
24
+ return { known: false, why: 'there is no ias.json (ias migrate eas profiles writes it) nor --app' };
25
+ }
26
+ let client;
27
+ try {
28
+ client = connect(context, project);
29
+ } catch (error) {
30
+ return { known: false, application, why: error.what ?? error.message };
31
+ }
32
+ try {
33
+ const credentials = (await client.request('GET', `/apps/${application}/credentials`)).data.data;
34
+ try {
35
+ credentials.push(...(await client.request('GET', '/team/credentials')).data.data);
36
+ } catch (error) {
37
+ if (!(error instanceof ApiError) || error.code !== 'role_forbidden') throw error;
38
+ }
39
+ const numbers = (await client.request('GET', `/apps/${application}/build-numbers`)).data;
40
+ const variables = (await client.request('GET', `/apps/${application}/env`)).data.data;
41
+ return { known: true, application, server: client.server, credentials: credentials.filter((credential) => !credential.retired_at), numbers, variables };
42
+ } catch (error) {
43
+ return { known: false, application, server: client.server, why: error.message };
44
+ }
45
+ }
46
+
47
+ /** Everything status and checklist look at. */
48
+ async function gather(context) {
49
+ const dir = projectDir(context);
50
+ const app = readApp(dir);
51
+ let project;
52
+ let configError;
53
+ try {
54
+ project = loadProject(dir);
55
+ } catch (error) {
56
+ project = { dir, source: 'legacy', config: undefined };
57
+ configError = error.message;
58
+ }
59
+ let easProject;
60
+ try {
61
+ easProject = easProjectOf(app, context.values);
62
+ } catch {
63
+ easProject = undefined;
64
+ }
65
+ return { dir, app, project, configError, easProject, folders: preparedFolders(dir, context.env), server: await serverState(context, project) };
66
+ }
67
+
68
+ const find = (state, type) => (state.server.credentials ?? []).filter((credential) => credential.type === type);
69
+
70
+ /** The steps of the move and their state: [{ step, state, detail }], state being done, pending or unknown. */
71
+ function steps(context, state) {
72
+ const { app, project, configError, folders, server } = state;
73
+ const platforms = platformsOf(app.expo);
74
+ const list = [];
75
+ const easProfiles = Object.keys(app.eas?.build ?? {}).filter((name) => PROFILE.test(name));
76
+ const missing = easProfiles.filter((name) => !project.config?.build?.[name]);
77
+ list.push(configError
78
+ ? { step: 'profiles', state: 'pending', detail: `ias.json is not valid: ${configError}` }
79
+ : !project.config
80
+ ? { step: 'profiles', state: 'pending', detail: 'there is no ias.json yet' }
81
+ : missing.length > 0
82
+ ? { step: 'profiles', state: 'pending', detail: `ias.json lacks ${missing.join(', ')} of eas.json` }
83
+ : { step: 'profiles', state: 'done', detail: `ias.json has ${Object.keys(project.config.build ?? {}).join(', ') || 'no build profiles'}` });
84
+ const waiting = folders.filter((folder) => folder.download.length > 0);
85
+ list.push(folders.length > 0
86
+ ? { step: 'prepare', state: 'done', detail: `${folders.map((folder) => folder.dir).join(', ')}${waiting.length > 0 ? `; a download waits in ${waiting.map((folder) => folder.dir).join(', ')}` : ''}` }
87
+ : server.known && hasSigning(state, platforms)
88
+ ? { step: 'prepare', state: 'done', detail: 'its folder is gone, and the credentials are in the vault' }
89
+ : { step: 'prepare', state: 'pending', detail: 'no folder for eas credentials on this Mac' });
90
+ if (!server.known) {
91
+ for (const step of ['import', 'version', 'env']) list.push({ step, state: 'unknown', detail: server.why });
92
+ } else {
93
+ const have = signing(state);
94
+ const lacking = [...(platforms.includes('ios') ? ['certificate', 'profile'] : []), ...(platforms.includes('android') ? ['keystore'] : [])].filter((what) => !have[what]);
95
+ const owned = Object.entries(have).filter(([, value]) => value).map(([what, value]) => `${what} ${value}`);
96
+ list.push({ step: 'import', state: lacking.length === 0 ? 'done' : 'pending', detail: `${owned.length > 0 ? `the vault has: ${owned.join('; ')}` : 'the vault has no signing credentials of the app'}${lacking.length > 0 ? `; lacks the ${lacking.join(', ')}` : ''}${waiting.length > 0 ? `; import ${waiting[0].dir}` : ''}` });
97
+ const numbers = platforms.map((platform) => `${platform === 'ios' ? 'iOS' : 'Android'} ${server.numbers?.[platform] ?? 'none yet'}`);
98
+ list.push({ step: 'version', state: platforms.every((platform) => server.numbers?.[platform]) ? 'done' : 'pending', detail: `IAS: ${numbers.join(', ')}` });
99
+ const counts = ['development', 'preview', 'production'].map((environment) => `${environment} ${server.variables.filter((variable) => variable.environment === environment).length}`);
100
+ list.push({ step: 'env', state: server.variables.length > 0 ? 'done' : 'pending', detail: `variables in IAS: ${counts.join(', ')}${server.variables.length === 0 ? ' (if EAS has none, there is nothing to do)' : ''}` });
101
+ }
102
+ list.push({ step: 'checklist', state: 'pending', detail: 'what is left in expo.dev, the stores and the app: yours to do' });
103
+ return list;
104
+ }
105
+
106
+ /** The signing credentials the vault has for the app: { certificate, profile, keystore }, each described or null. */
107
+ function signing(state) {
108
+ const profiles = find(state, 'apple_provisioning_profile');
109
+ const [certificate] = find(state, 'apple_distribution_certificate');
110
+ const [keystore] = find(state, 'android_upload_keystore');
111
+ return {
112
+ certificate: certificate ? `«${certificate.name}»` : null,
113
+ profile: profiles.length > 0 ? profiles.map((profile) => `«${profile.name}» (${profile.metadata?.type ?? 'profile'})`).join(', ') : null,
114
+ keystore: keystore ? `alias ${keystore.metadata?.alias ?? '?'}` : null,
115
+ };
116
+ }
117
+
118
+ const hasSigning = (state, platforms) => {
119
+ const have = signing(state);
120
+ return Boolean((!platforms.includes('ios') || (have.certificate && have.profile)) && (!platforms.includes('android') || have.keystore));
121
+ };
122
+
123
+ export async function status(context) {
124
+ const { output } = context;
125
+ const state = await gather(context);
126
+ const { app, easProject, server } = state;
127
+ const list = steps(context, state);
128
+ output.log(`Leaving EAS${easProject ? `, project ${easProjectName(easProject)},` : ''} for IAS${server.application ? `, application ${server.application}${server.server ? ` on ${server.server}` : ''}` : ''}:`);
129
+ list.forEach((entry, index) => output.log(` ${index + 1}. ${entry.step.padEnd(9)} ${entry.state.padEnd(7)} ${entry.detail}`));
130
+ if (!app.eas && !easProject) output.log('This app has neither eas.json nor an EAS project in app.json: there may be nothing to bring.');
131
+ const next = list.find((entry) => entry.state !== 'done');
132
+ const waiting = state.folders.find((folder) => folder.download.length > 0);
133
+ let command = iasCommand(context, next?.step ?? 'checklist');
134
+ if (next?.state === 'unknown') command = `${/not logged in/.test(server.why) ? 'ias login' : 'what stops it above'}, and ${iasCommand(context)} again`;
135
+ else if (next?.step === 'import' && waiting) command = iasCommand(context, 'import', shellWord(waiting.dir));
136
+ else if (next?.step === 'import' && state.folders.length > 0) command = `eas credentials in ${state.folders[0].dir} (${iasCommand(context, 'prepare')} says how), then import`;
137
+ output.log(`Next: ${command}`);
138
+ return { eas_project: easProject ?? null, application: server.application ?? null, steps: list, next: next?.step ?? null };
139
+ }
140
+
141
+ /** The items of the checklist, by section: [{ section, done, text }], done being true, false or null (not known). */
142
+ function items(context, state) {
143
+ const { dir, app, easProject, folders, server } = state;
144
+ const list = [];
145
+ const add = (section, done, text) => list.push({ section, done, text });
146
+ const platforms = platformsOf(app.expo);
147
+ const [certificate] = find(state, 'apple_distribution_certificate');
148
+ const [keystore] = find(state, 'android_upload_keystore');
149
+ const profiles = find(state, 'apple_provisioning_profile');
150
+ /** Whether an item of IAS is done, or null when the server could not be asked. */
151
+ const check = (value) => (server.known ? Boolean(value) : null);
152
+
153
+ // IAS.
154
+ const have = server.known ? signing(state) : {};
155
+ add('IAS', check(hasSigning(state, platforms)), `The signing credentials in the vault${server.known ? `: ${Object.entries(have).filter(([, value]) => value).map(([what, value]) => `${what} ${value}`).join('; ') || 'none yet'}` : ` (${server.why})`}: ${iasCommand(context, 'import', '<folder>')}.`);
156
+ if (profiles.length > 0 && !profiles.some((profile) => profile.metadata?.type === 'app-store')) add('IAS', false, 'Store builds need an App Store profile, and the vault has only internal ones: download the one of the store profile with eas credentials, or make one in Apple Developer, and import it.');
157
+ if (platforms.includes('android')) add('IAS', null, `Play Console › the app › Test and release › App integrity › App signing: the «Upload key certificate» must be ${keystore?.metadata?.sha256 ? `SHA-256 ${keystore.metadata.sha256}` : 'the SHA-256 of the upload keystore'}. Change nothing in EAS until it is: Google rejects an AAB signed with another upload key.`);
158
+ add('IAS', check(platforms.every((platform) => server.numbers?.[platform])), `The build numbers on the server${server.known ? `: iOS ${server.numbers?.ios ?? 'none yet'}, Android ${server.numbers?.android ?? 'none yet'}` : ''}: ${iasCommand(context, 'version')}.`);
159
+ add('IAS', server.variables?.length > 0 ? true : null, `The variables${server.known ? `: ${server.variables.length} in IAS` : ''}: ${iasCommand(context, 'env')}. Secret ones of EAS, which only its builders read, go with ias env set … --from op://…; account-wide ones (eas env:list --scope account) are not brought.`);
160
+ if (certificate?.expires_at && Date.parse(certificate.expires_at) - Date.now() < 90 * DAY) add('IAS', false, `The distribution certificate «${certificate.name}» expires on ${day(certificate.expires_at)}: renew it in Apple Developer before the first build after that date, and import it with its profile.`);
161
+ add('IAS', null, 'The first build with IAS, accepted by the stores: ias build --profile production, then ias submit (or Transporter and Play Console). Delete nothing in expo.dev before it is.');
162
+
163
+ // The app.
164
+ if (usesEasUpdate(app.expo)) add('The app', false, `updates.url is EAS Update's (${app.expo.updates.url}): the build after the move has to carry IAS's (${server.server ?? state.project.config?.server ?? 'https://ias.imagina.build'}/api/v1/ota/<project>/manifest, php artisan ota:project) and its code signing certificate. Take extra.eas and owner out of app.json in that same build: they enter the fingerprint.`);
165
+ for (const name of ['credentials.json', 'credentials']) if (existsSync(join(dir, name))) add('The app', false, `${name} is in the project: eas credentials ran there, and git does not ignore it. Move it to the folder of prepare (mv) and import it from there.`);
166
+ for (const [profileName, profile] of Object.entries(app.eas?.submit ?? {})) {
167
+ const path = profile?.android?.serviceAccountKeyPath;
168
+ if (typeof path === 'string' && existsSync(join(dir, path))) add('The app', false, `The Google service account of submit.${profileName} (${path}) is in the project folder: import it (ias credentials import --service-account ${shellWord(path)}), keep it in 1Password and delete it from the folder. Revoking its key in Google Cloud and its access in Play Console closes the uploads by API: a separate decision.`);
169
+ }
170
+ const hooks = Object.keys(app.packageJson?.scripts ?? {}).filter((name) => name.startsWith('eas-build-'));
171
+ if (hooks.length > 0) add('The app', false, `package.json has the EAS build hooks ${hooks.join(', ')}, which the runners of IAS do not run.`);
172
+ const easFiles = ['eas.json', '.easignore', '.eas'].filter((name) => existsSync(join(dir, name)));
173
+ if (easFiles.length > 0) add('The app', false, `Once the EAS project is deleted: remove ${easFiles.join(', ')} and the eas scripts of package.json (eas.json does not enter the fingerprint).`);
174
+
175
+ // expo.dev, in this order, after the first build with IAS.
176
+ const where = folders[0] ? ` (from ${folders[0].dir}, where eas knows the project, or expo.dev › Credentials)` : ' (expo.dev › Credentials, or eas credentials from a folder of ias migrate eas prepare)';
177
+ const submitProfiles = Object.values(app.eas?.submit ?? {});
178
+ const asc = submitProfiles.some((profile) => profile?.ios && Object.keys(profile.ios).some((key) => /^(ascApiKey|appleId)/.test(key)));
179
+ add('expo.dev', null, `Remove the submit keys from EAS${where}: eas credentials -p ios › App Store Connect: Manage your API Key › remove it${asc ? ' (eas.json names one)' : ''}; -p android › Google Service Account › remove it. With that, whoever enters the Expo account can no longer upload to TestFlight or Google Play. The key EAS made can be revoked in App Store Connect › Users and Access › Integrations: a separate decision.`);
180
+ const push = Boolean(app.packageJson?.dependencies?.['expo-notifications']);
181
+ if (push) add('expo.dev', null, `The app uses expo-notifications: EAS may hold its APNs key and its FCM account. Load them into IAS (ias push credentials ios AuthKey_<id>.p8, android <account>.json; the .p8 cannot be downloaded from EAS: use the team's, or make one) before removing them from EAS.`);
182
+ add('expo.dev', null, `Delete the EAS project: expo.dev › ${easProject ? easProjectName(easProject) : 'the project'} › Project settings › Delete project. Builds whose updates.url is u.expo.dev${easProject ? `/${easProject.projectId}` : ''} stop getting updates until they are updated from the store, nobody who enters the account can publish to them again, and the id cannot be made again.`);
183
+ add('expo.dev', null, `The credentials of the account: expo.dev › ${easProject?.owner ?? 'the account'} › Credentials. Distribution certificates${certificate?.metadata?.team ? ` (team ${certificate.metadata.team})` : ''}, App Store Connect keys, Google service accounts and push keys belong to the account and survive the project. Before deleting one, check that no other app of the account uses it: the same distribution certificate serves every app of the team. Deleting it in EAS only deletes EAS's copy: the certificate stays valid in Apple and the upload key stays Google Play's.`);
184
+
185
+ // This Mac.
186
+ for (const folder of folders) add('This Mac', false, `Delete the folder of prepare when you are done: rm -rf ${shellWord(folder.dir)}${folder.download.length > 0 ? ` (it still holds a download: ${folder.download.join(', ')})` : ''}. Its node_modules is a link: the app's is not touched.`);
187
+ return list;
188
+ }
189
+
190
+ export async function checklist(context) {
191
+ const { output } = context;
192
+ const state = await gather(context);
193
+ const list = items(context, state);
194
+ output.log(`What is left to leave EAS${state.easProject ? `, project ${easProjectName(state.easProject)}` : ''}. ias deletes nothing: each one is yours, in this order.`);
195
+ let section;
196
+ for (const item of list) {
197
+ if (item.section !== section) {
198
+ section = item.section;
199
+ output.log(`${section}${section === 'expo.dev' ? ', after the first build with IAS' : ''}`);
200
+ }
201
+ output.log(` [${item.done === true ? 'x' : item.done === false ? ' ' : '?'}] ${item.text}`);
202
+ }
203
+ output.log('[x] done, [ ] to do, [?] only you can check it.');
204
+ return { eas_project: state.easProject ?? null, application: state.server.application ?? null, items: list };
205
+ }