@hot-updater/android-helper 1.2.0-rc.7 → 1.2.0-rc.8

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/dist/index.mjs CHANGED
@@ -7,118 +7,34 @@ import JSZip from "jszip";
7
7
  import crypto from "crypto";
8
8
  import { pipeline } from "stream/promises";
9
9
  import { createInflateRaw } from "zlib";
10
- import crypto$1 from "node:crypto";
10
+ //#region src/utils/gradlePhase.ts
11
+ /** `> Task :app:compileReleaseKotlin`, with an outcome such as `UP-TO-DATE`. */
12
+ const TASK = /^> Task (:\S+)/;
13
+ const CONFIGURE = /^> Configure project (:\S*)/;
14
+ /**
15
+ * Reads the phase of a Gradle build from its plain console output: the
16
+ * task it runs, after how many it has run, such as
17
+ * `312 tasks · :app:mergeReleaseResources`.
18
+ */
19
+ const createGradlePhaseReader = () => {
20
+ let tasks = 0;
21
+ return (line) => {
22
+ const task = TASK.exec(line)?.[1];
23
+ if (task) {
24
+ tasks += 1;
25
+ return `${tasks} task${tasks === 1 ? "" : "s"} · ${task}`;
26
+ }
27
+ const project = CONFIGURE.exec(line)?.[1];
28
+ if (project) return `Configuring ${project}`;
29
+ if (line.startsWith("Starting a Gradle Daemon")) return "Starting the Gradle daemon";
30
+ if (line.startsWith("Downloading https://services.gradle.org/")) return "Downloading Gradle";
31
+ };
32
+ };
33
+ //#endregion
11
34
  //#region src/utils/createGradleLogger.ts
12
35
  const createGradleLogger = ({ logPrefix }) => new BuildLogger({
13
36
  logPrefix,
14
- importantLogPatterns: [
15
- "error:",
16
- "Error:",
17
- "ERROR:",
18
- "> Task :",
19
- "BUILD FAILED",
20
- "BUILD SUCCESSFUL",
21
- "FAILURE: Build failed with an exception.",
22
- "* What went wrong:",
23
- "Execution failed for task",
24
- "Could not resolve",
25
- "Failed to",
26
- "Unable to load script",
27
- "Metro encountered an error",
28
- "React Native CLI",
29
- "Android resource compilation failed",
30
- "Duplicate class",
31
- "Program type already present",
32
- "actionable tasks:",
33
- "Deprecated Gradle features were used",
34
- ":app:configureCMake",
35
- ":app:buildCMake",
36
- ":app:compileDebugKotlin",
37
- ":app:compileReleaseKotlin",
38
- ":app:compileDebugJavaWithJavac",
39
- ":app:compileReleaseJavaWithJavac",
40
- ":app:mergeDebugResources",
41
- ":app:mergeReleaseResources",
42
- ":app:processDebugResources",
43
- ":app:processReleaseResources",
44
- ":app:packageDebug",
45
- ":app:packageRelease",
46
- ":app:assembleDebug",
47
- ":app:assembleRelease"
48
- ],
49
- progressStages: [
50
- [
51
- "buildKotlinToolingMetadata",
52
- "generateAutolinkingNewArchitectureFiles",
53
- "generateAutolinkingPackageList",
54
- "generateCodegenSchemaFromJavaScript",
55
- "generateCodegenArtifactsFromSchema",
56
- "generateReactNativeEntryPoint"
57
- ],
58
- [
59
- "createBundleReleaseJsAndAssets",
60
- "bundleReleaseJsAndAssets",
61
- "bundleDebugJsAndAssets"
62
- ],
63
- [
64
- "generateReleaseResValues",
65
- "generateDebugResValues",
66
- "generateReleaseResources",
67
- "generateDebugResources",
68
- "mergeReleaseResources",
69
- "mergeDebugResources"
70
- ],
71
- [
72
- "processReleaseResources",
73
- "processDebugResources",
74
- "processReleaseManifest",
75
- "processDebugManifest",
76
- "processDebugMainManifest",
77
- "parseReleaseLocalResources",
78
- "parseDebugLocalResources"
79
- ],
80
- ["compileReleaseKotlin", "compileDebugKotlin"],
81
- ["compileReleaseJavaWithJavac", "compileDebugJavaWithJavac"],
82
- [
83
- /configureCMake\w+\[/,
84
- /buildCMake\w+\[/,
85
- "mergeReleaseJniLibFolders",
86
- "mergeDebugJniLibFolders",
87
- "mergeReleaseNativeLibs",
88
- "mergeDebugNativeLibs"
89
- ],
90
- [
91
- "desugarReleaseFileDependencies",
92
- "desugarDebugFileDependencies",
93
- "dexBuilderRelease",
94
- "dexBuilderDebug",
95
- "mergeDexRelease",
96
- "mergeExtDexRelease",
97
- "mergeExtDexDebug",
98
- "mergeProjectDexRelease",
99
- "mergeProjectDexDebug",
100
- "mergeLibDexRelease",
101
- "mergeLibDexDebug",
102
- "mergeDebugGlobalSynthetics",
103
- "mergeReleaseGlobalSynthetics",
104
- "transformClassesWithDex"
105
- ],
106
- [
107
- "compressReleaseAssets",
108
- "compressDebugAssets",
109
- "optimizeReleaseResources",
110
- "mergeReleaseAssets",
111
- "mergeDebugAssets"
112
- ],
113
- [
114
- "packageRelease",
115
- "packageDebug",
116
- "assembleRelease",
117
- "assembleDebug"
118
- ],
119
- ["bundleRelease", "bundleDebug"],
120
- ["BUILD SUCCESSFUL", /\d+ actionable tasks:/]
121
- ],
37
+ createPhaseReader: createGradlePhaseReader,
122
38
  failureSummary: {
123
39
  lines: [
124
40
  /^e: /,
@@ -175,21 +91,24 @@ const getGradleWrapper = () => process.platform.startsWith("win") ? "gradlew.bat
175
91
  /**
176
92
  * Run gradle build with specified tasks and arguments
177
93
  */
178
- async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appModuleName }) {
94
+ async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appModuleName, logHeader = [] }) {
179
95
  const gradleArgs = getTaskNames(appModuleName, tasks);
180
96
  gradleArgs.push("-x", "lint");
181
97
  if (args.extraParams) gradleArgs.push(...args.extraParams);
182
- p.log.info([
183
- "Gradle build",
184
- `Project ${path.relative(getCwd(), androidProjectPath) || "."}`,
185
- `App module ${appModuleName}`,
186
- `Tasks ${tasks.join(", ")}`,
187
- "Command",
188
- formatCommand([path.basename(getGradleWrapper()), ...gradleArgs])
189
- ].join("\n"));
190
98
  if ("port" in args && args.port != null) gradleArgs.push(`-PreactNativeDevServerPort=${args.port}`);
191
99
  const logger = createGradleLogger({ logPrefix });
192
- await logger.start();
100
+ await logger.start({
101
+ title: `Building ${tasks.join(", ")}`,
102
+ header: [
103
+ ...logHeader,
104
+ `Project ${path.relative(getCwd(), androidProjectPath) || "."}`,
105
+ `App module ${appModuleName}`,
106
+ `Tasks ${tasks.join(", ")}`,
107
+ `$ ${formatCommand([path.basename(getGradleWrapper()), ...gradleArgs])}`,
108
+ ""
109
+ ]
110
+ });
111
+ let logFilePath = null;
193
112
  try {
194
113
  const gradle = execa(getGradleWrapper(), gradleArgs, {
195
114
  cwd: androidProjectPath,
@@ -201,11 +120,13 @@ async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appM
201
120
  const [gradleResult, loggingResult] = await Promise.allSettled([gradle, outputStream ? logger.processStream(outputStream) : void 0]);
202
121
  if (gradleResult.status === "rejected") throw gradleResult.reason;
203
122
  if (loggingResult.status === "rejected") throw loggingResult.reason;
204
- logger.stop();
123
+ logger.stop(`Built ${tasks.join(", ")}`);
124
+ logFilePath = logger.logFilePath;
205
125
  } catch (e) {
206
126
  logger.writeError(e);
207
- logger.stop("Build failed", false);
208
- throw new BuildFailedError(`Build failed: ${describeFailure(e)}`, {
127
+ const reason = describeFailure(e);
128
+ logger.stop(`Build failed · ${reason.replace(/\.$/, "")}`, false);
129
+ throw new BuildFailedError(`Build failed: ${reason}`, {
209
130
  logPath: logger.logPath,
210
131
  summary: logger.failureSummary,
211
132
  cause: e
@@ -213,11 +134,14 @@ async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appM
213
134
  } finally {
214
135
  await logger.close();
215
136
  }
216
- return findBuildDirectory({
217
- androidProjectPath,
218
- moduleName: appModuleName,
219
- tasks
220
- });
137
+ return {
138
+ ...await findBuildDirectory({
139
+ androidProjectPath,
140
+ moduleName: appModuleName,
141
+ tasks
142
+ }),
143
+ logFilePath
144
+ };
221
145
  }
222
146
  /**
223
147
  * Find the build output directory and artifact path
@@ -511,7 +435,7 @@ const readBuiltVersionCode = async ({ artifactPath, androidProjectPath }) => {
511
435
  };
512
436
  //#endregion
513
437
  //#region src/buildAndroid.ts
514
- const buildAndroid = async ({ schemeConfig, minBundleId = createMinBundleId(), credentials }) => {
438
+ const buildAndroid = async ({ schemeConfig, minBundleId = createMinBundleId(), credentials, logHeader }) => {
515
439
  const androidProjectPath = path.join(getCwd(), "android");
516
440
  const result = await runGradle({
517
441
  args: { extraParams: [`-PMIN_BUNDLE_ID=${minBundleId}`] },
@@ -522,7 +446,8 @@ const buildAndroid = async ({ schemeConfig, minBundleId = createMinBundleId(), c
522
446
  appModuleName: schemeConfig.appModuleName,
523
447
  tasks: schemeConfig.aab ? [`bundle${schemeConfig.variant}`] : [`assemble${schemeConfig.variant}`],
524
448
  logPrefix: `android-${schemeConfig.hotUpdaterSchemeName}-build`,
525
- androidProjectPath
449
+ androidProjectPath,
450
+ logHeader
526
451
  });
527
452
  return {
528
453
  ...result,
@@ -645,8 +570,8 @@ const explain = (error, packageName) => {
645
570
  /**
646
571
  * Requests to the Google Play Developer API for `packageName`, as the
647
572
  * service account: each carries an access token, exchanged for a signed
648
- * assertion and replaced well before it expires. The key issuer checks a
649
- * new key's access with it too.
573
+ * assertion and replaced well before it expires. `checkPlayAccess`
574
+ * checks a key's access with it too.
650
575
  */
651
576
  const createPlayClient = ({ json, packageName, fetch }) => {
652
577
  const account = readServiceAccount(json);
@@ -695,6 +620,66 @@ const createPlayClient = ({ json, packageName, fetch }) => {
695
620
  };
696
621
  };
697
622
  /**
623
+ * Play Console's page that invites `email` as a user. `_` stands for the
624
+ * developer account the browser is signed in to, so no ID is needed.
625
+ */
626
+ const playConsoleInviteUrl = (email) => `https://play.google.com/console/u/0/developers/_/users-and-permissions?invite=${encodeURIComponent(email)}`;
627
+ const readClientEmail = (json) => {
628
+ try {
629
+ const { client_email } = JSON.parse(json);
630
+ return typeof client_email === "string" ? client_email : "";
631
+ } catch {
632
+ return "";
633
+ }
634
+ };
635
+ /**
636
+ * Checks once whether the service account's key reaches `packageName`: it
637
+ * opens an edit of the app and deletes it again, which changes nothing.
638
+ * Throws only for what says nothing about access, such as a network error.
639
+ */
640
+ const checkPlayAccess = async ({ serviceAccount: { json }, packageName, fetch = globalThis.fetch }) => {
641
+ const email = readClientEmail(json);
642
+ const refused = (error) => ({
643
+ status: "refused",
644
+ email,
645
+ reason: error instanceof Error ? error.message : String(error)
646
+ });
647
+ let play;
648
+ try {
649
+ play = createPlayClient({
650
+ json,
651
+ packageName,
652
+ fetch
653
+ });
654
+ } catch (error) {
655
+ return refused(error);
656
+ }
657
+ try {
658
+ await play.authorization();
659
+ } catch (error) {
660
+ if (error instanceof TypeError) throw error;
661
+ return refused(error);
662
+ }
663
+ const edits = `${API_URL}${play.app}/edits`;
664
+ try {
665
+ const edit = await play.request(`Opening an edit of ${packageName}`, "POST", edits, {});
666
+ if (edit?.id) await play.request("Deleting the edit", "DELETE", `${edits}/${encodeURIComponent(edit.id)}`).catch(() => {});
667
+ return { status: "ready" };
668
+ } catch (error) {
669
+ if (error instanceof GooglePlayError) {
670
+ if (error.status === 401 || error.status === 403) return {
671
+ status: "denied",
672
+ email
673
+ };
674
+ if (error.status === 404) return {
675
+ status: "not-found",
676
+ email
677
+ };
678
+ }
679
+ throw error;
680
+ }
681
+ };
682
+ /**
698
683
  * Whether Google Play's internal testing track already releases the
699
684
  * `.aab`'s version code, as when a reused native build was uploaded
700
685
  * before. It reads the track in an edit it then deletes, changing nothing.
@@ -722,26 +707,6 @@ const findPlayInternalRelease = async ({ versionCode, packageName, serviceAccoun
722
707
  }
723
708
  };
724
709
  /**
725
- * Whether Google Play releases any version of `packageName` on any track,
726
- * which means Play has the app's upload key. It reads the tracks in an edit
727
- * it then deletes, changing nothing.
728
- */
729
- const hasPlayReleases = async ({ packageName, serviceAccount: { json }, fetch = globalThis.fetch }) => {
730
- const { app, request } = createPlayClient({
731
- json,
732
- packageName,
733
- fetch
734
- });
735
- const { id: editId } = await request("Opening a Google Play edit", "POST", `${API_URL}${app}/edits`, {});
736
- const edit = `${API_URL}${app}/edits/${encodeURIComponent(editId)}`;
737
- try {
738
- const { tracks = [] } = await request("Reading the tracks", "GET", `${edit}/tracks`);
739
- return tracks.some(({ releases = [] }) => releases.some(({ versionCodes = [] }) => versionCodes.length > 0));
740
- } finally {
741
- await request("Deleting the Google Play edit", "DELETE", edit).catch(() => void 0);
742
- }
743
- };
744
- /**
745
710
  * The common name of every Android debug certificate: Android Studio's and
746
711
  * the Android Gradle plugin's (`CN=Android Debug, O=Android, C=US`), and React
747
712
  * Native's template `debug.keystore` (`CN=Android Debug, OU=Android,
@@ -1084,542 +1049,4 @@ const extractBuiltInBundle = async ({ artifactPath, outputPath }) => {
1084
1049
  await (entry.method === 8 ? pipeline(source, createInflateRaw(), fs.createWriteStream(outputPath)) : pipeline(source, fs.createWriteStream(outputPath)));
1085
1050
  };
1086
1051
  //#endregion
1087
- //#region src/google/googleApi.ts
1088
- /** A Google Cloud or Google Play API request that failed, with Google's error. */
1089
- var GoogleApiError = class extends Error {
1090
- status;
1091
- /** Google's canonical status, such as `PERMISSION_DENIED`. */
1092
- apiStatus;
1093
- apiMessage;
1094
- /** The `ErrorInfo` reasons, such as `SERVICE_DISABLED`. */
1095
- reasons;
1096
- /** The `PreconditionFailure` violation types, such as an org policy constraint. */
1097
- violations;
1098
- /** The `ErrorInfo` consumer of a disabled service, such as `projects/123`. */
1099
- consumer;
1100
- constructor(action, status, body = {}) {
1101
- const apiMessage = body.apiMessage ?? "";
1102
- super(`${action} failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : "."}`);
1103
- this.name = "GoogleApiError";
1104
- this.status = status;
1105
- this.apiStatus = body.apiStatus;
1106
- this.apiMessage = apiMessage;
1107
- this.reasons = body.reasons ?? [];
1108
- this.violations = body.violations ?? [];
1109
- this.consumer = body.consumer;
1110
- }
1111
- };
1112
- /** Google's error body, `{ error: { code, status, message, details } }`. */
1113
- const readError = async (action, response) => {
1114
- let error;
1115
- try {
1116
- error = (await response.json()).error;
1117
- } catch {}
1118
- const details = Array.isArray(error?.details) ? error.details : [];
1119
- const info = details.filter((detail) => typeof detail.reason === "string");
1120
- return new GoogleApiError(action, response.status, {
1121
- ...typeof error?.status === "string" ? { apiStatus: error.status } : {},
1122
- ...typeof error?.message === "string" ? { apiMessage: error.message } : {},
1123
- reasons: info.map((detail) => detail.reason),
1124
- violations: details.flatMap((detail) => (detail.violations ?? []).flatMap((violation) => typeof violation.type === "string" ? [violation.type] : [])),
1125
- ...info.find((detail) => detail.metadata?.["consumer"]) ? { consumer: info.find((detail) => detail.metadata?.["consumer"]).metadata["consumer"] } : {}
1126
- });
1127
- };
1128
- /** Whether Google answered that a service is disabled in a project. */
1129
- const isServiceDisabled = (error) => error instanceof GoogleApiError && (error.reasons.includes("SERVICE_DISABLED") || /has not been used in project .* or it is disabled/i.test(error.apiMessage));
1130
- /** Requests with the signed-in user's access token, throwing `GoogleApiError`. */
1131
- const createGoogleRequest = ({ accessToken, fetch }) => async (action, method, url, body) => {
1132
- const response = await fetch(url, {
1133
- method,
1134
- headers: {
1135
- authorization: `Bearer ${accessToken}`,
1136
- ...body === void 0 ? {} : { "content-type": "application/json" }
1137
- },
1138
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
1139
- });
1140
- if (!response.ok) throw await readError(action, response);
1141
- const text = await response.text();
1142
- return text ? JSON.parse(text) : {};
1143
- };
1144
- /**
1145
- * Polls `operation` at `origin` until it is done, returning its response or
1146
- * throwing its error as a `GoogleApiError` with the operation's code.
1147
- */
1148
- const waitForOperation = async ({ request, origin, operation, action, sleep, intervalMs = 2e3, attempts = 60 }) => {
1149
- let current = operation;
1150
- for (let attempt = 0; !current.done; attempt++) {
1151
- if (attempt >= attempts || !current.name) throw new Error(`${action} did not finish in time.`);
1152
- await sleep(intervalMs);
1153
- current = await request(action, "GET", `${origin}/${current.name}`);
1154
- }
1155
- if (current.error) throw new GoogleApiError(action, 400, {
1156
- apiMessage: current.error.message ?? "",
1157
- ...current.error.code === 7 ? { apiStatus: "PERMISSION_DENIED" } : {},
1158
- ...current.error.code === 8 ? { apiStatus: "RESOURCE_EXHAUSTED" } : {}
1159
- });
1160
- return current.response;
1161
- };
1162
- //#endregion
1163
- //#region src/google/types.ts
1164
- /** A named failure of `issuePlayServiceAccount`, with what to do in its message. */
1165
- var PlayServiceAccountError = class extends Error {
1166
- code;
1167
- constructor(code, message, options) {
1168
- super(message, options);
1169
- this.name = "PlayServiceAccountError";
1170
- this.code = code;
1171
- }
1172
- };
1173
- /** The service account Hot Updater creates, `hot-updater-play@<project>.iam.gserviceaccount.com`. */
1174
- const PLAY_SERVICE_ACCOUNT_ID = "hot-updater-play";
1175
- //#endregion
1176
- //#region src/google/googleKeyIssuer.ts
1177
- const PUBLISHER_API = "https://androidpublisher.googleapis.com/androidpublisher/v3";
1178
- const RESOURCE_MANAGER_API = "https://cloudresourcemanager.googleapis.com";
1179
- const SERVICE_USAGE_API = "https://serviceusage.googleapis.com";
1180
- const IAM_API = "https://iam.googleapis.com/v1";
1181
- const GCLOUD_INSTALL_URL = "https://cloud.google.com/sdk/docs/install";
1182
- /** The Google Play Android Developer API, which the service account calls. */
1183
- const PLAY_API = "androidpublisher.googleapis.com";
1184
- /** IAM creates the service account; the Play Developer API is what it calls. */
1185
- const SERVICES = ["iam.googleapis.com", PLAY_API];
1186
- const KEY_CREATION_CONSTRAINT = "constraints/iam.disableServiceAccountKeyCreation";
1187
- /** Retries while Google enables an API or creates a service account. */
1188
- const PROPAGATION_ATTEMPTS = 12;
1189
- const PROPAGATION_DELAY_MS = 1e4;
1190
- /** Checks of the key's access to the app, about ten minutes of them. */
1191
- const ACCESS_ATTEMPTS = 120;
1192
- const ACCESS_DELAY_MS = 5e3;
1193
- const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1194
- const runWithExeca = async (file, args, options) => (await execa(file, args, options?.interactive ? { stdio: "inherit" } : { stdin: "ignore" })).stdout ?? "";
1195
- /**
1196
- * Play Console's page that invites `email` as a user. `_` stands for the
1197
- * developer account the browser is signed in to, so no ID is needed.
1198
- */
1199
- const playConsoleInviteUrl = (email) => `https://play.google.com/console/u/0/developers/_/users-and-permissions?invite=${encodeURIComponent(email)}`;
1200
- /** A project ID nobody has: Google Cloud's are global. */
1201
- const newProjectId = () => `${PLAY_SERVICE_ACCOUNT_ID}-${crypto$1.randomBytes(3).toString("hex")}`;
1202
- const isNotFound = (error) => error instanceof GoogleApiError && error.status === 404;
1203
- const isConflict = (error) => error instanceof GoogleApiError && error.status === 409;
1204
- /** The API is disabled in another project than the one set up: waiting does not help. */
1205
- const consumerApiDisabled = (error) => new PlayServiceAccountError("api-not-enabled", `${error.apiMessage}\nEnable it in ${error.consumer ?? "that project"}, then run this again.`, { cause: error });
1206
- const isDenied = (error) => error instanceof GoogleApiError && (error.status === 403 || error.apiStatus === "PERMISSION_DENIED");
1207
- const lines = (output) => output.split("\n").map((line) => line.trim()).filter(Boolean);
1208
- /** The Google Accounts gcloud holds, the active one, if any, first. */
1209
- const credentialedAccounts = async (run) => {
1210
- const accounts = lines(await run("gcloud", [
1211
- "auth",
1212
- "list",
1213
- "--format=value(account)"
1214
- ]));
1215
- const [active] = lines(await run("gcloud", [
1216
- "auth",
1217
- "list",
1218
- "--filter=status:ACTIVE",
1219
- "--format=value(account)"
1220
- ]));
1221
- return active !== void 0 && accounts.includes(active) ? [active, ...accounts.filter((account) => account !== active)].map((email) => ({
1222
- email,
1223
- active: email === active
1224
- })) : accounts.map((email) => ({
1225
- email,
1226
- active: false
1227
- }));
1228
- };
1229
- /** Stops before anything else, a prompt included, when gcloud is not installed. */
1230
- const requireGcloud = async (run) => {
1231
- try {
1232
- await run("gcloud", ["--version"]);
1233
- } catch (error) {
1234
- throw new PlayServiceAccountError("gcloud-not-installed", [
1235
- "gcloud CLI is not installed.",
1236
- "Install it, then run this again:",
1237
- GCLOUD_INSTALL_URL
1238
- ].join("\n"), { cause: error });
1239
- }
1240
- };
1241
- /**
1242
- * The access token of the Google Account the developer picks among those
1243
- * gcloud holds, or of the one `gcloud auth login` adds, when they pick
1244
- * another or gcloud holds none. gcloud's active account stays as it was.
1245
- */
1246
- const signInWithGcloud = async ({ run, request, onStep }) => {
1247
- const before = await credentialedAccounts(run);
1248
- let account = before.length > 0 ? await request.chooseGoogleAccount(before) ?? void 0 : void 0;
1249
- if (account === void 0) {
1250
- const signedIn = await run("gcloud", ["auth", "login"], { interactive: true }).then(() => true, () => false);
1251
- const after = await credentialedAccounts(run);
1252
- account = after.find(({ email }) => !before.some((known) => known.email === email))?.email ?? (signedIn ? after.find(({ active }) => active)?.email : void 0);
1253
- if (account === void 0) throw new PlayServiceAccountError("gcloud-not-signed-in", before.length === 0 ? "gcloud has no active Google Account.\nRun gcloud auth login, then run this again." : "gcloud auth login didn't finish.\nRun this again to choose a Google Account.");
1254
- }
1255
- onStep(`Signed in to gcloud as ${account}`);
1256
- const accessToken = (await run("gcloud", [
1257
- "auth",
1258
- "print-access-token",
1259
- account
1260
- ])).trim();
1261
- registerSecretValue(accessToken);
1262
- return accessToken;
1263
- };
1264
- /**
1265
- * `IssuePlayServiceAccount` with the gcloud CLI's sign-in and Google's own
1266
- * APIs:
1267
- *
1268
- * 1. once gcloud is found, `askPackageName` names the app, and the Google
1269
- * Account `chooseGoogleAccount` picks among gcloud's, or the one
1270
- * `gcloud auth login` adds, the access token the Google Cloud steps use;
1271
- * 2. the account's only project with the Play Developer API, else the one
1272
- * `chooseProject` picks, else a new one, gets the IAM and Play Developer
1273
- * APIs enabled, and the service account `hot-updater-play` its JSON key;
1274
- * 3. the developer invites the service account in Play Console, which
1275
- * `onInvite` opens: gcloud's token may not manage Google Play users;
1276
- * 4. the key itself opens an edit of the app until Google Play lets it,
1277
- * and is deleted again when it never does.
1278
- *
1279
- * Each step reuses what an earlier run created: the project and the
1280
- * service account.
1281
- */
1282
- const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor, run = runWithExeca } = {}) => async (request) => {
1283
- const { onStep = () => {} } = request;
1284
- await requireGcloud(run);
1285
- const packageName = await request.askPackageName();
1286
- const accessToken = await signInWithGcloud({
1287
- run,
1288
- request,
1289
- onStep
1290
- });
1291
- const google = createGoogleRequest({
1292
- accessToken,
1293
- fetch
1294
- });
1295
- const project = await chooseProject({
1296
- google,
1297
- request,
1298
- sleep,
1299
- onStep
1300
- });
1301
- const { projectId } = project;
1302
- onStep(`Enabling the APIs in ${projectId}`);
1303
- await enableServices({
1304
- google,
1305
- projectId,
1306
- sleep
1307
- });
1308
- onStep(`Creating the service account in ${projectId}`);
1309
- const email = await ensureServiceAccount({
1310
- google,
1311
- project,
1312
- sleep
1313
- });
1314
- onStep("Creating the service account's JSON key");
1315
- const key = await createKey({
1316
- google,
1317
- project,
1318
- email,
1319
- sleep
1320
- });
1321
- const revoke = async () => {
1322
- try {
1323
- await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`);
1324
- return true;
1325
- } catch (error) {
1326
- return isNotFound(error);
1327
- }
1328
- };
1329
- const inviteUrl = playConsoleInviteUrl(email);
1330
- try {
1331
- await request.onInvite(inviteUrl, email);
1332
- onStep("Waiting for the Play Console invite");
1333
- await waitForPlayAccess({
1334
- json: key.json,
1335
- packageName,
1336
- fetch,
1337
- sleep
1338
- });
1339
- } catch (error) {
1340
- const deleted = await revoke();
1341
- throw new PlayServiceAccountError("play-access-pending", [
1342
- `The key can't release ${packageName} yet.`,
1343
- "Invite the service account in Play Console:",
1344
- email,
1345
- "App permission: Release apps to testing tracks",
1346
- inviteUrl,
1347
- "A new app needs its first .aab uploaded in Play Console.",
1348
- ...deleted ? [] : [`Delete its unused key ${key.keyId} in Google Cloud:`, `https://console.cloud.google.com/iam-admin/serviceaccounts?project=${encodeURIComponent(projectId)}`],
1349
- "Then run: npx hot-updater google-play login"
1350
- ].join("\n"), { cause: error });
1351
- }
1352
- return {
1353
- json: key.json,
1354
- keyId: key.keyId,
1355
- email,
1356
- projectId,
1357
- revoke,
1358
- dispose: async () => {}
1359
- };
1360
- };
1361
- /**
1362
- * Waits until the key opens an edit of `packageName`, which Google Play
1363
- * allows once the service account is a Play Console user with access to
1364
- * the app, then deletes the edit, which changed nothing. Google may not
1365
- * take a key just created for a while either.
1366
- */
1367
- const waitForPlayAccess = async ({ json, packageName, fetch, sleep }) => {
1368
- const play = createPlayClient({
1369
- json,
1370
- packageName,
1371
- fetch
1372
- });
1373
- const edits = `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits`;
1374
- for (let attempt = 1;; attempt++) {
1375
- if (attempt > 1) await sleep(ACCESS_DELAY_MS);
1376
- let ready = true;
1377
- try {
1378
- await play.authorization();
1379
- } catch {
1380
- ready = false;
1381
- }
1382
- if (ready) try {
1383
- const edit = await play.request(`Opening an edit of ${packageName}`, "POST", edits);
1384
- if (edit?.id) await play.request("Deleting the edit", "DELETE", `${edits}/${edit.id}`).catch(() => {});
1385
- return;
1386
- } catch (error) {
1387
- if (!(error instanceof GooglePlayError) || ![
1388
- 401,
1389
- 403,
1390
- 404
1391
- ].includes(error.status)) throw error;
1392
- }
1393
- if (attempt >= ACCESS_ATTEMPTS) throw new Error("Google Play did not let the key reach the app in time.");
1394
- }
1395
- };
1396
- /** The account's active projects, every page of them. */
1397
- const listProjects = async (google) => {
1398
- const projects = [];
1399
- let pageToken;
1400
- do {
1401
- const page = await google("Listing the Google Cloud projects", "GET", `${RESOURCE_MANAGER_API}/v3/projects:search?${new URLSearchParams({
1402
- query: "state:ACTIVE",
1403
- ...pageToken ? { pageToken } : {}
1404
- })}`);
1405
- for (const project of page.projects ?? []) if (project.projectId) projects.push({
1406
- projectId: project.projectId,
1407
- ...project.displayName ? { displayName: project.displayName } : {},
1408
- ...projectNumberOf(project.name)
1409
- });
1410
- pageToken = page.nextPageToken || void 0;
1411
- } while (pageToken);
1412
- return projects;
1413
- };
1414
- /** Service Usage reads of the projects' Play Developer API, at most this many at once. */
1415
- const API_CHECK_CONCURRENCY = 8;
1416
- /**
1417
- * Whether each project has the Google Play Developer API enabled, by one
1418
- * Service Usage read each; a project this account may not read counts as
1419
- * without it.
1420
- */
1421
- const withPlayApiState = async (google, projects) => {
1422
- const states = [];
1423
- for (let start = 0; start < projects.length; start += API_CHECK_CONCURRENCY) states.push(...await Promise.all(projects.slice(start, start + API_CHECK_CONCURRENCY).map(({ projectId }) => google("Reading the project's APIs", "GET", `${SERVICE_USAGE_API}/v1/projects/${encodeURIComponent(projectId)}/services/${PLAY_API}`).then(({ state }) => state === "ENABLED", () => false))));
1424
- return projects.map((project, index) => ({
1425
- ...project,
1426
- playApiEnabled: states[index]
1427
- }));
1428
- };
1429
- /** The display name of the project Hot Updater creates. */
1430
- const PROJECT_DISPLAY_NAME = "Hot Updater Play";
1431
- /**
1432
- * The project the service account goes in: a new one for an account
1433
- * without projects; the only project with the Play Developer API, without
1434
- * asking; else the one the developer picks, or a new one.
1435
- */
1436
- const chooseProject = async ({ google, request, sleep, onStep }) => {
1437
- const listed = await listProjects(google);
1438
- if (listed.length === 0) return createProject({
1439
- google,
1440
- sleep,
1441
- onStep
1442
- });
1443
- const projects = await withPlayApiState(google, listed);
1444
- const enabled = projects.filter(({ playApiEnabled }) => playApiEnabled);
1445
- if (enabled.length === 1) {
1446
- const [only] = enabled;
1447
- onStep(`Using ${only.projectId}, the project with the Play Developer API`);
1448
- return only;
1449
- }
1450
- const chosen = await request.chooseProject(projects.map(({ projectId, displayName, playApiEnabled }) => ({
1451
- projectId,
1452
- ...displayName ? { displayName } : {},
1453
- playApiEnabled
1454
- })));
1455
- if (chosen === null) return createProject({
1456
- google,
1457
- sleep,
1458
- onStep
1459
- });
1460
- return projects.find((project) => project.projectId === chosen) ?? { projectId: chosen };
1461
- };
1462
- /** A new project, `hot-updater-play-<random>`, once Google has created it. */
1463
- const createProject = async ({ google, sleep, onStep }) => {
1464
- const projectId = newProjectId();
1465
- onStep(`Creating the Google Cloud project ${projectId}`);
1466
- try {
1467
- const operation = await google("Creating the Google Cloud project", "POST", `${RESOURCE_MANAGER_API}/v3/projects`, {
1468
- projectId,
1469
- displayName: PROJECT_DISPLAY_NAME
1470
- });
1471
- const created = await waitForOperation({
1472
- request: google,
1473
- origin: `${RESOURCE_MANAGER_API}/v3`,
1474
- operation,
1475
- action: "Creating the Google Cloud project",
1476
- sleep
1477
- });
1478
- return {
1479
- projectId,
1480
- ...projectNumberOf(created?.name)
1481
- };
1482
- } catch (error) {
1483
- if (!(error instanceof GoogleApiError)) throw error;
1484
- if (error.status === 429 || error.apiStatus === "RESOURCE_EXHAUSTED" || /quota/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-quota", [
1485
- "Google Cloud has no project quota left for this account.",
1486
- error.apiMessage || "Quota exceeded.",
1487
- "Delete a project you no longer use, then run this again:",
1488
- "https://console.cloud.google.com/cloud-resource-manager"
1489
- ].join("\n"), { cause: error });
1490
- if (/terms of service/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-create-denied", "This Google Account hasn't accepted the Google Cloud Terms of Service.\nAccept them at https://console.cloud.google.com, then run this again.", { cause: error });
1491
- if (isDenied(error) || error.apiStatus === "FAILED_PRECONDITION") throw new PlayServiceAccountError("project-create-denied", [
1492
- "This Google Account may not create Google Cloud projects.",
1493
- error.apiMessage || "Permission denied.",
1494
- "Ask an admin for the Project Creator role."
1495
- ].join("\n"), { cause: error });
1496
- throw error;
1497
- }
1498
- };
1499
- /** `projectNumber` from a resource name `projects/<number>`. */
1500
- const projectNumberOf = (name) => {
1501
- const number = name?.match(/^projects\/(\d+)$/u)?.[1];
1502
- return number === void 0 ? {} : { projectNumber: number };
1503
- };
1504
- /**
1505
- * Runs `run` again while Google is still enabling an API in `project`,
1506
- * or, with `notFound`, still creating what it reads.
1507
- */
1508
- const whilePropagating = async ({ run, project, sleep, notFound = false }) => {
1509
- for (let attempt = 1;; attempt++) try {
1510
- return await run();
1511
- } catch (error) {
1512
- const disabled = isServiceDisabled(error);
1513
- if (disabled && !consumerIs(error, project)) throw consumerApiDisabled(error);
1514
- if (!disabled && !(notFound && isNotFound(error)) || attempt >= PROPAGATION_ATTEMPTS) {
1515
- if (disabled) throw new PlayServiceAccountError("api-not-enabled", `Google is still enabling the APIs in ${project.projectId}.\n${error.apiMessage}\nWait a few minutes, then run this again.`, { cause: error });
1516
- throw error;
1517
- }
1518
- await sleep(PROPAGATION_DELAY_MS);
1519
- }
1520
- };
1521
- /**
1522
- * Whether a disabled-service error is about `project` itself, which waiting
1523
- * fixes: Google names the consumer by number, and an error naming none
1524
- * counts as the project's own.
1525
- */
1526
- const consumerIs = (error, project) => error.consumer === void 0 || error.consumer === `projects/${project.projectId}` || (project.projectNumber === void 0 ? true : error.consumer === `projects/${project.projectNumber}`);
1527
- const enableServices = async ({ google, projectId, sleep }) => {
1528
- try {
1529
- const operation = await google("Enabling the APIs", "POST", `${SERVICE_USAGE_API}/v1/projects/${projectId}/services:batchEnable`, { serviceIds: SERVICES });
1530
- await waitForOperation({
1531
- request: google,
1532
- origin: `${SERVICE_USAGE_API}/v1`,
1533
- operation,
1534
- action: "Enabling the APIs",
1535
- sleep
1536
- });
1537
- } catch (error) {
1538
- if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", [
1539
- `This Google Account may not enable APIs in ${projectId}.`,
1540
- error.apiMessage || "Permission denied.",
1541
- "Pick a project you own, or ask its owner for the Editor role."
1542
- ].join("\n"), { cause: error });
1543
- throw error;
1544
- }
1545
- };
1546
- /** The `hot-updater-play` service account's email, created unless it exists. */
1547
- const ensureServiceAccount = async ({ google, project, sleep }) => {
1548
- const { projectId } = project;
1549
- const email = `${PLAY_SERVICE_ACCOUNT_ID}@${projectId}.iam.gserviceaccount.com`;
1550
- try {
1551
- return (await whilePropagating({
1552
- project,
1553
- sleep,
1554
- run: () => google("Creating the service account", "POST", `${IAM_API}/projects/${projectId}/serviceAccounts`, {
1555
- accountId: "hot-updater-play",
1556
- serviceAccount: {
1557
- displayName: "Hot Updater Play uploads",
1558
- description: "Uploads Android App Bundles to Google Play for hot-updater native-build android --submit play-internal."
1559
- }
1560
- })
1561
- })).email ?? email;
1562
- } catch (error) {
1563
- if (isConflict(error)) return email;
1564
- if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", [
1565
- `This Google Account may not create service accounts in ${projectId}.`,
1566
- error.apiMessage || "Permission denied.",
1567
- "Pick a project you own, or ask for the Service Account Admin role."
1568
- ].join("\n"), { cause: error });
1569
- throw error;
1570
- }
1571
- };
1572
- /** A new JSON key of the service account, checked and masked in the CLI's output. */
1573
- const createKey = async ({ google, project, email, sleep }) => {
1574
- const { projectId } = project;
1575
- let key;
1576
- try {
1577
- key = await whilePropagating({
1578
- project,
1579
- sleep,
1580
- notFound: true,
1581
- run: () => google("Creating the key", "POST", `${IAM_API}/projects/${projectId}/serviceAccounts/${email}/keys`, {
1582
- privateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE",
1583
- keyAlgorithm: "KEY_ALG_RSA_2048"
1584
- })
1585
- });
1586
- } catch (error) {
1587
- if (error instanceof GoogleApiError && (error.violations.includes(KEY_CREATION_CONSTRAINT) || /disableServiceAccountKeyCreation/u.test(error.apiMessage))) throw new PlayServiceAccountError("key-creation-disabled", [
1588
- `iam.disableServiceAccountKeyCreation forbids keys in ${projectId}.`,
1589
- "Ask an organization policy admin to allow keys for this project,",
1590
- "or run this again with a project outside the organization."
1591
- ].join("\n"), { cause: error });
1592
- throw error;
1593
- }
1594
- if (!key.name || !key.privateKeyData) throw new Error("Google Cloud created the key without its JSON file.");
1595
- registerSecretValue(key.privateKeyData);
1596
- const json = Buffer.from(key.privateKeyData, "base64").toString("utf-8");
1597
- registerSecretValue(json);
1598
- let parsed;
1599
- try {
1600
- parsed = JSON.parse(json);
1601
- } catch {
1602
- parsed = {};
1603
- }
1604
- if (typeof parsed.private_key !== "string") {
1605
- await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`).catch(() => {});
1606
- throw new Error("Google Cloud created a key that is no JSON key file.");
1607
- }
1608
- registerSecretValue(parsed.private_key);
1609
- return {
1610
- json,
1611
- name: key.name,
1612
- keyId: key.name.split("/").pop()
1613
- };
1614
- };
1615
- //#endregion
1616
- //#region src/playServiceAccount.ts
1617
- /**
1618
- * Hot Updater's issuer, the one place that picks how it is done: gcloud's
1619
- * sign-in, then the Cloud Resource Manager, Service Usage, IAM, and Play
1620
- * Developer APIs. Another implementation of `IssuePlayServiceAccount`
1621
- * replaces it here without touching its callers.
1622
- */
1623
- const issuePlayServiceAccount = (request) => createGoogleKeyIssuer()(request);
1624
- //#endregion
1625
- export { PLAY_SERVICE_ACCOUNT_ID, PlayServiceAccountError, PlayVersionCodeUsedError, assertPlaySigned, buildAndroid, enrichNativeBuildAndroidScheme, extractBuiltInBundle, findPlayInternalRelease, hasPlayReleases, issuePlayServiceAccount, readBuiltApp, readBuiltPackageName, readBuiltVersionCode, resolvePlayUploadServiceAccount, uploadToPlay };
1052
+ export { PlayVersionCodeUsedError, assertPlaySigned, buildAndroid, checkPlayAccess, enrichNativeBuildAndroidScheme, extractBuiltInBundle, findPlayInternalRelease, playConsoleInviteUrl, readBuiltApp, readBuiltPackageName, readBuiltVersionCode, resolvePlayUploadServiceAccount, uploadToPlay };