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