@hot-updater/android-helper 1.2.0-rc.4 → 1.2.0-rc.6

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
@@ -8,7 +8,6 @@ import crypto from "crypto";
8
8
  import { pipeline } from "stream/promises";
9
9
  import { createInflateRaw } from "zlib";
10
10
  import crypto$1 from "node:crypto";
11
- import http from "node:http";
12
11
  //#region src/utils/createGradleLogger.ts
13
12
  const createGradleLogger = ({ logPrefix }) => new BuildLogger({
14
13
  logPrefix,
@@ -646,7 +645,8 @@ const explain = (error, packageName) => {
646
645
  /**
647
646
  * Requests to the Google Play Developer API for `packageName`, as the
648
647
  * service account: each carries an access token, exchanged for a signed
649
- * assertion and replaced well before it expires.
648
+ * assertion and replaced well before it expires. The key issuer checks a
649
+ * new key's access with it too.
650
650
  */
651
651
  const createPlayClient = ({ json, packageName, fetch }) => {
652
652
  const account = readServiceAccount(json);
@@ -722,6 +722,26 @@ const findPlayInternalRelease = async ({ versionCode, packageName, serviceAccoun
722
722
  }
723
723
  };
724
724
  /**
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
+ /**
725
745
  * The common name of every Android debug certificate: Android Studio's and
726
746
  * the Android Gradle plugin's (`CN=Android Debug, O=Android, C=US`), and React
727
747
  * Native's template `debug.keystore` (`CN=Android Debug, OU=Android,
@@ -1150,281 +1170,202 @@ var PlayServiceAccountError = class extends Error {
1150
1170
  this.code = code;
1151
1171
  }
1152
1172
  };
1153
- /** The environment variables that replace Hot Updater's Google OAuth client. */
1154
- const GOOGLE_OAUTH_CLIENT_ENV_NAMES = {
1155
- clientId: "HOT_UPDATER_GOOGLE_OAUTH_CLIENT_ID",
1156
- clientSecret: "HOT_UPDATER_GOOGLE_OAUTH_CLIENT_SECRET"
1157
- };
1158
1173
  /** The service account Hot Updater creates, `hot-updater-play@<project>.iam.gserviceaccount.com`. */
1159
1174
  const PLAY_SERVICE_ACCOUNT_ID = "hot-updater-play";
1160
- /** Where a Play Console admin finds the developer account ID. */
1161
- const PLAY_CONSOLE_URL = "https://play.google.com/console";
1162
- //#endregion
1163
- //#region src/google/googleSignIn.ts
1164
- const AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
1165
- const TOKEN_URL = "https://oauth2.googleapis.com/token";
1166
- const REVOKE_URL = "https://oauth2.googleapis.com/revoke";
1167
- /** How long the browser has to come back with the sign-in. */
1168
- const SIGN_IN_TIMEOUT_MS = 3e5;
1169
- const base64url = (bytes) => bytes.toString("base64url");
1170
- const page = (message) => `<!doctype html><meta charset="utf-8"><title>Hot Updater</title><body style="font-family:system-ui;margin:3rem"><p>${message}</p></body>`;
1171
- /**
1172
- * Signs in with Google as an installed app does (RFC 8252): the browser
1173
- * opens Google's own consent screen, so the password and the second factor
1174
- * stay with Google, and the authorization code comes back to a loopback
1175
- * server on 127.0.0.1, bound to a random port for this sign-in only. The
1176
- * code is exchanged with PKCE (S256), and `state` ties the answer to this
1177
- * request.
1178
- *
1179
- * @param onUrl Sees the sign-in URL, to print and open in a browser.
1180
- */
1181
- const signInWithGoogle = async ({ client, scopes, onUrl, fetch = globalThis.fetch, timeoutMs = SIGN_IN_TIMEOUT_MS }) => {
1182
- const verifier = base64url(crypto$1.randomBytes(32));
1183
- const challenge = base64url(crypto$1.createHash("sha256").update(verifier).digest());
1184
- const state = base64url(crypto$1.randomBytes(16));
1185
- const server = http.createServer();
1186
- await new Promise((resolve, reject) => {
1187
- server.once("error", reject);
1188
- server.listen(0, "127.0.0.1", () => resolve());
1189
- });
1190
- try {
1191
- const { port } = server.address();
1192
- const redirectUri = `http://127.0.0.1:${port}`;
1193
- const code = new Promise((resolve, reject) => {
1194
- const timer = setTimeout(() => reject(new PlayServiceAccountError("sign-in-cancelled", "The Google sign-in did not finish in time.")), timeoutMs);
1195
- timer.unref();
1196
- server.on("request", (req, res) => {
1197
- const query = new URL(req.url ?? "/", redirectUri).searchParams;
1198
- if (query.get("state") !== state) {
1199
- res.writeHead(404).end();
1200
- return;
1201
- }
1202
- const error = query.get("error");
1203
- const answer = query.get("code");
1204
- res.writeHead(error || !answer ? 400 : 200, { "content-type": "text/html; charset=utf-8" }).end(page(error || !answer ? "The sign-in did not finish. Return to the terminal." : "Signed in. Close this tab and return to the terminal."));
1205
- clearTimeout(timer);
1206
- if (error || !answer) reject(new PlayServiceAccountError("sign-in-cancelled", error === "access_denied" ? "The Google sign-in was cancelled." : `The Google sign-in failed: ${error ?? "no authorization code"}.`));
1207
- else resolve(answer);
1208
- });
1209
- });
1210
- code.catch(() => {});
1211
- const url = new URL(AUTHORIZATION_URL);
1212
- url.search = new URLSearchParams({
1213
- client_id: client.clientId,
1214
- redirect_uri: redirectUri,
1215
- response_type: "code",
1216
- scope: scopes.join(" "),
1217
- code_challenge: challenge,
1218
- code_challenge_method: "S256",
1219
- state,
1220
- prompt: "select_account"
1221
- }).toString();
1222
- await onUrl(url.toString());
1223
- const authorizationCode = await code;
1224
- registerSecretValue(authorizationCode);
1225
- const response = await fetch(TOKEN_URL, {
1226
- method: "POST",
1227
- headers: { "content-type": "application/x-www-form-urlencoded" },
1228
- body: new URLSearchParams({
1229
- code: authorizationCode,
1230
- client_id: client.clientId,
1231
- ...client.clientSecret ? { client_secret: client.clientSecret } : {},
1232
- redirect_uri: redirectUri,
1233
- grant_type: "authorization_code",
1234
- code_verifier: verifier
1235
- }).toString()
1236
- });
1237
- const token = await response.json().catch(() => ({}));
1238
- if (!response.ok || typeof token.access_token !== "string") throw new PlayServiceAccountError(token.error === "invalid_client" ? "sign-in-not-configured" : "sign-in-cancelled", `Google refused the sign-in (HTTP ${response.status}).${typeof token.error_description === "string" ? `\n${token.error_description}` : typeof token.error === "string" ? `\n${token.error}` : ""}`);
1239
- registerSecretValue(token.access_token);
1240
- return {
1241
- accessToken: token.access_token,
1242
- scopes: typeof token.scope === "string" ? token.scope.split(" ") : []
1243
- };
1244
- } finally {
1245
- server.closeAllConnections();
1246
- await new Promise((resolve) => server.close(() => resolve()));
1247
- }
1248
- };
1249
- /** Ends a sign-in: Google revokes its access token. Whether Google did. */
1250
- const revokeGoogleToken = async (accessToken, fetch = globalThis.fetch) => {
1251
- try {
1252
- return (await fetch(REVOKE_URL, {
1253
- method: "POST",
1254
- headers: { "content-type": "application/x-www-form-urlencoded" },
1255
- body: new URLSearchParams({ token: accessToken }).toString()
1256
- })).ok;
1257
- } catch {
1258
- return false;
1259
- }
1260
- };
1261
1175
  //#endregion
1262
1176
  //#region src/google/googleKeyIssuer.ts
1263
- const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
1264
- const ANDROID_PUBLISHER_SCOPE = "https://www.googleapis.com/auth/androidpublisher";
1265
- const SCOPES = [CLOUD_PLATFORM_SCOPE, ANDROID_PUBLISHER_SCOPE];
1266
1177
  const PUBLISHER_API = "https://androidpublisher.googleapis.com/androidpublisher/v3";
1267
1178
  const RESOURCE_MANAGER_API = "https://cloudresourcemanager.googleapis.com";
1268
1179
  const SERVICE_USAGE_API = "https://serviceusage.googleapis.com";
1269
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";
1270
1184
  /** IAM creates the service account; the Play Developer API is what it calls. */
1271
- const SERVICES = ["iam.googleapis.com", "androidpublisher.googleapis.com"];
1272
- /**
1273
- * What the service account may do in Play Console, for the app only: read
1274
- * it, and release to testing tracks, which `--submit play-internal` does.
1275
- */
1276
- const APP_PERMISSIONS = ["CAN_VIEW_NON_FINANCIAL_DATA", "CAN_MANAGE_TRACK_APKS"];
1185
+ const SERVICES = ["iam.googleapis.com", PLAY_API];
1277
1186
  const KEY_CREATION_CONSTRAINT = "constraints/iam.disableServiceAccountKeyCreation";
1278
1187
  /** Retries while Google enables an API or creates a service account. */
1279
1188
  const PROPAGATION_ATTEMPTS = 12;
1280
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;
1281
1193
  const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1282
- /** The developer account ID in `input`, a number or a Play Console URL. */
1283
- const parseDeveloperId = (input) => {
1284
- const trimmed = input.trim();
1285
- if (/^\d+$/u.test(trimmed)) return trimmed;
1286
- return trimmed.match(/\/developers\/(\d+)(?:[/?#]|$)/u)?.[1];
1287
- };
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)}`;
1288
1200
  /** A project ID nobody has: Google Cloud's are global. */
1289
1201
  const newProjectId = () => `${PLAY_SERVICE_ACCOUNT_ID}-${crypto$1.randomBytes(3).toString("hex")}`;
1290
1202
  const isNotFound = (error) => error instanceof GoogleApiError && error.status === 404;
1291
1203
  const isConflict = (error) => error instanceof GoogleApiError && error.status === 409;
1292
- /** The API is disabled in the OAuth client's own project: waiting does not help. */
1293
- const clientApiDisabled = (error) => new PlayServiceAccountError("api-not-enabled", `${error.apiMessage}\nEnable it in the OAuth client's project (${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientId}).`, { cause: error });
1294
- /** Google's own words, after a message that names the likely cause. */
1295
- const googleSays = (error) => error instanceof GoogleApiError && error.apiMessage ? `\nGoogle: ${error.apiMessage}` : "";
1296
- /** A Play Console refusal, as `not-play-admin`. */
1297
- const notPlayAdmin = (developerId, error) => new PlayServiceAccountError("not-play-admin", `This Google Account isn't an admin of Play Console developer ${developerId}.${googleSays(error)}\nSign in with an account that has the Admin permission there.`, { cause: error });
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 });
1298
1206
  const isDenied = (error) => error instanceof GoogleApiError && (error.status === 403 || error.apiStatus === "PERMISSION_DENIED");
1207
+ /** The Google Account gcloud uses, if one is active. */
1208
+ const activeAccount = async (run) => (await run("gcloud", [
1209
+ "auth",
1210
+ "list",
1211
+ "--filter=status:ACTIVE",
1212
+ "--format=value(account)"
1213
+ ])).trim().split("\n")[0]?.trim() || void 0;
1214
+ /** Stops before anything else, a prompt included, when gcloud is not installed. */
1215
+ const requireGcloud = async (run) => {
1216
+ try {
1217
+ await run("gcloud", ["--version"]);
1218
+ } catch (error) {
1219
+ throw new PlayServiceAccountError("gcloud-not-installed", [
1220
+ "gcloud CLI is not installed.",
1221
+ "Install it, then run this again:",
1222
+ GCLOUD_INSTALL_URL
1223
+ ].join("\n"), { cause: error });
1224
+ }
1225
+ };
1226
+ /** gcloud's access token, after `gcloud auth login` when no account is active. */
1227
+ const signInWithGcloud = async ({ run, onStep }) => {
1228
+ let account = await activeAccount(run);
1229
+ if (account === void 0) {
1230
+ await run("gcloud", ["auth", "login"], { interactive: true }).catch(() => {});
1231
+ account = await activeAccount(run);
1232
+ if (account === void 0) throw new PlayServiceAccountError("gcloud-not-signed-in", "gcloud has no active Google Account.\nRun gcloud auth login, then run this again.");
1233
+ }
1234
+ onStep(`Signed in to gcloud as ${account}`);
1235
+ const accessToken = (await run("gcloud", ["auth", "print-access-token"])).trim();
1236
+ registerSecretValue(accessToken);
1237
+ return accessToken;
1238
+ };
1299
1239
  /**
1300
- * `IssuePlayServiceAccount` with Google's own APIs, after a sign-in on
1301
- * Google's consent screen (OAuth for installed apps, with PKCE and a
1302
- * loopback redirect):
1240
+ * `IssuePlayServiceAccount` with the gcloud CLI's sign-in and Google's own
1241
+ * APIs:
1303
1242
  *
1304
- * 1. the Play Console side is checked first, with no change anywhere: the
1305
- * developer account answers to the account as its admin, and has the app;
1306
- * 2. the chosen Google Cloud project, or a new one, gets the IAM and Play
1307
- * Developer APIs enabled, and the service account `hot-updater-play`;
1308
- * 3. the service account becomes a Play Console user that may release the
1309
- * app to testing tracks;
1310
- * 4. only then is its JSON key created, the last step, so a failure before
1311
- * it leaves no key behind.
1243
+ * 1. once gcloud is found, `askPackageName` names the app, and gcloud's
1244
+ * Google Account, signed in with `gcloud auth login` when none is
1245
+ * active, the access token the Google Cloud steps use;
1246
+ * 2. the account's only project with the Play Developer API, else the one
1247
+ * `chooseProject` picks, else a new one, gets the IAM and Play Developer
1248
+ * APIs enabled, and the service account `hot-updater-play` its JSON key;
1249
+ * 3. the developer invites the service account in Play Console, which
1250
+ * `onInvite` opens: gcloud's token may not manage Google Play users;
1251
+ * 4. the key itself opens an edit of the app until Google Play lets it,
1252
+ * and is deleted again when it never does.
1312
1253
  *
1313
- * Each step reuses what an earlier run created: the project, the service
1314
- * account, and its Play Console access.
1254
+ * Each step reuses what an earlier run created: the project and the
1255
+ * service account.
1315
1256
  */
1316
- const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor } = {}) => async (request) => {
1317
- const { packageName, oauthClient, onStep = () => {} } = request;
1318
- if (!oauthClient?.clientId) throw new PlayServiceAccountError("sign-in-not-configured", [
1319
- "This build of Hot Updater has no Google OAuth client to sign in with.",
1320
- "Set these to a \"Desktop app\" OAuth client:",
1321
- `${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientId}, ${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientSecret}`
1322
- ].join("\n"));
1323
- const signIn = await signInWithGoogle({
1324
- client: oauthClient,
1325
- scopes: SCOPES,
1326
- onUrl: request.onSignInUrl,
1257
+ const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor, run = runWithExeca } = {}) => async (request) => {
1258
+ const { onStep = () => {} } = request;
1259
+ await requireGcloud(run);
1260
+ const packageName = await request.askPackageName();
1261
+ const accessToken = await signInWithGcloud({
1262
+ run,
1263
+ onStep
1264
+ });
1265
+ const google = createGoogleRequest({
1266
+ accessToken,
1327
1267
  fetch
1328
1268
  });
1329
- const dispose = async () => {
1330
- await revokeGoogleToken(signIn.accessToken, fetch);
1269
+ const project = await chooseProject({
1270
+ google,
1271
+ request,
1272
+ sleep,
1273
+ onStep
1274
+ });
1275
+ const { projectId } = project;
1276
+ onStep(`Enabling the APIs in ${projectId}`);
1277
+ await enableServices({
1278
+ google,
1279
+ projectId,
1280
+ sleep
1281
+ });
1282
+ onStep(`Creating the service account in ${projectId}`);
1283
+ const email = await ensureServiceAccount({
1284
+ google,
1285
+ project,
1286
+ sleep
1287
+ });
1288
+ onStep("Creating the service account's JSON key");
1289
+ const key = await createKey({
1290
+ google,
1291
+ project,
1292
+ email,
1293
+ sleep
1294
+ });
1295
+ const revoke = async () => {
1296
+ try {
1297
+ await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`);
1298
+ return true;
1299
+ } catch (error) {
1300
+ return isNotFound(error);
1301
+ }
1331
1302
  };
1303
+ const inviteUrl = playConsoleInviteUrl(email);
1332
1304
  try {
1333
- const missing = SCOPES.filter((scope) => !signIn.scopes.includes(scope));
1334
- if (missing.length > 0) throw new PlayServiceAccountError("scope-not-granted", `The sign-in didn't allow ${missing.map((scope) => scope === ANDROID_PUBLISHER_SCOPE ? "managing Google Play" : "managing Google Cloud").join(" or ")}.\nRun this again and keep every box on Google's consent screen ticked.`);
1335
- const google = createGoogleRequest({
1336
- accessToken: signIn.accessToken,
1337
- fetch
1338
- });
1339
- const developerInput = await request.askDeveloperId();
1340
- const developerId = parseDeveloperId(developerInput);
1341
- if (developerId === void 0) throw new PlayServiceAccountError("developer-not-found", `"${developerInput.trim()}" is not a developer account ID.\nIt is the number after /developers/ in Play Console's address.`);
1342
- onStep("Checking the Play Console developer account");
1343
- await checkPlayConsole({
1344
- google,
1345
- developerId,
1346
- packageName
1347
- });
1348
- const project = await chooseProject({
1349
- google,
1350
- request,
1351
- sleep,
1352
- onStep
1353
- });
1354
- const { projectId } = project;
1355
- onStep(`Enabling the Google Play Developer API in ${projectId}`);
1356
- await enableServices({
1357
- google,
1358
- projectId,
1359
- sleep
1360
- });
1361
- onStep(`Creating the service account in ${projectId}`);
1362
- const email = await ensureServiceAccount({
1363
- google,
1364
- project,
1365
- sleep
1366
- });
1367
- onStep(`Giving ${email} release access to ${packageName}`);
1368
- await grantPlayAccess({
1369
- google,
1370
- developerId,
1371
- email,
1372
- packageName
1373
- });
1374
- onStep("Creating the service account's JSON key");
1375
- const key = await createKey({
1376
- google,
1377
- project,
1378
- email,
1305
+ await request.onInvite(inviteUrl, email);
1306
+ onStep("Waiting for the Play Console invite");
1307
+ await waitForPlayAccess({
1308
+ json: key.json,
1309
+ packageName,
1310
+ fetch,
1379
1311
  sleep
1380
1312
  });
1381
- return {
1382
- json: key.json,
1383
- keyId: key.keyId,
1384
- email,
1385
- projectId,
1386
- developerId,
1387
- revoke: async () => {
1388
- try {
1389
- await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`);
1390
- return true;
1391
- } catch (error) {
1392
- return isNotFound(error);
1393
- }
1394
- },
1395
- dispose
1396
- };
1397
1313
  } catch (error) {
1398
- await dispose();
1399
- throw error;
1314
+ const deleted = await revoke();
1315
+ throw new PlayServiceAccountError("play-access-pending", [
1316
+ `The key can't release ${packageName} yet.`,
1317
+ "Invite the service account in Play Console with release access:",
1318
+ email,
1319
+ inviteUrl,
1320
+ "A new app needs its first .aab uploaded in Play Console.",
1321
+ ...deleted ? [] : [`Delete its unused key ${key.keyId} in Google Cloud:`, `https://console.cloud.google.com/iam-admin/serviceaccounts?project=${encodeURIComponent(projectId)}`],
1322
+ "Then run: npx hot-updater google-play login"
1323
+ ].join("\n"), { cause: error });
1400
1324
  }
1325
+ return {
1326
+ json: key.json,
1327
+ keyId: key.keyId,
1328
+ email,
1329
+ projectId,
1330
+ revoke,
1331
+ dispose: async () => {}
1332
+ };
1401
1333
  };
1402
1334
  /**
1403
- * Whether the signed-in account administers `developerId`, and Google Play
1404
- * has `packageName` there, asked without changing anything: listing the
1405
- * account's users takes the admin permission, and an edit opened on the app
1406
- * is deleted again.
1335
+ * Waits until the key opens an edit of `packageName`, which Google Play
1336
+ * allows once the service account is a Play Console user with access to
1337
+ * the app, then deletes the edit, which changed nothing. Google may not
1338
+ * take a key just created for a while either.
1407
1339
  */
1408
- const checkPlayConsole = async ({ google, developerId, packageName }) => {
1409
- try {
1410
- await google("Listing the Play Console users", "GET", `${PUBLISHER_API}/developers/${developerId}/users?pageSize=1`);
1411
- } catch (error) {
1412
- if (isServiceDisabled(error)) throw clientApiDisabled(error);
1413
- if (isNotFound(error)) throw new PlayServiceAccountError("developer-not-found", `Play Console has no developer account ${developerId}.${googleSays(error)}\nIt is the number after /developers/ in Play Console's address.`, { cause: error });
1414
- if (isDenied(error)) throw notPlayAdmin(developerId, error);
1415
- throw error;
1416
- }
1417
- let edit;
1418
- try {
1419
- edit = await google(`Opening an edit of ${packageName}`, "POST", `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits`);
1420
- } catch (error) {
1421
- if (isServiceDisabled(error)) throw clientApiDisabled(error);
1422
- if (isNotFound(error) || isDenied(error)) throw appNotFound(packageName, developerId, error);
1423
- throw error;
1340
+ const waitForPlayAccess = async ({ json, packageName, fetch, sleep }) => {
1341
+ const play = createPlayClient({
1342
+ json,
1343
+ packageName,
1344
+ fetch
1345
+ });
1346
+ const edits = `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits`;
1347
+ for (let attempt = 1;; attempt++) {
1348
+ if (attempt > 1) await sleep(ACCESS_DELAY_MS);
1349
+ let ready = true;
1350
+ try {
1351
+ await play.authorization();
1352
+ } catch {
1353
+ ready = false;
1354
+ }
1355
+ if (ready) try {
1356
+ const edit = await play.request(`Opening an edit of ${packageName}`, "POST", edits);
1357
+ if (edit?.id) await play.request("Deleting the edit", "DELETE", `${edits}/${edit.id}`).catch(() => {});
1358
+ return;
1359
+ } catch (error) {
1360
+ if (!(error instanceof GooglePlayError) || ![
1361
+ 401,
1362
+ 403,
1363
+ 404
1364
+ ].includes(error.status)) throw error;
1365
+ }
1366
+ if (attempt >= ACCESS_ATTEMPTS) throw new Error("Google Play did not let the key reach the app in time.");
1424
1367
  }
1425
- if (edit.id) await google("Deleting the edit", "DELETE", `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits/${edit.id}`).catch(() => {});
1426
1368
  };
1427
- const appNotFound = (packageName, developerId, cause) => new PlayServiceAccountError("app-not-found", `Google Play has no app ${packageName} in the developer account ${developerId}. Google takes the first .aab of a new app only in Play Console: create the app there, upload one .aab by hand (for example to internal testing), then run this again${googleSays(cause)}.`, { cause });
1428
1369
  /** The account's active projects, every page of them. */
1429
1370
  const listProjects = async (google) => {
1430
1371
  const projects = [];
@@ -1443,20 +1384,62 @@ const listProjects = async (google) => {
1443
1384
  } while (pageToken);
1444
1385
  return projects;
1445
1386
  };
1446
- /** The project the developer picks, or a new one when there is none or they ask. */
1387
+ /** Service Usage reads of the projects' Play Developer API, at most this many at once. */
1388
+ const API_CHECK_CONCURRENCY = 8;
1389
+ /**
1390
+ * Whether each project has the Google Play Developer API enabled, by one
1391
+ * Service Usage read each; a project this account may not read counts as
1392
+ * without it.
1393
+ */
1394
+ const withPlayApiState = async (google, projects) => {
1395
+ const states = [];
1396
+ 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))));
1397
+ return projects.map((project, index) => ({
1398
+ ...project,
1399
+ playApiEnabled: states[index]
1400
+ }));
1401
+ };
1402
+ /** The display name of the project Hot Updater creates. */
1403
+ const PROJECT_DISPLAY_NAME = "Hot Updater Play";
1404
+ /**
1405
+ * The project the service account goes in: a new one for an account
1406
+ * without projects; the only project with the Play Developer API, without
1407
+ * asking; else the one the developer picks, or a new one.
1408
+ */
1447
1409
  const chooseProject = async ({ google, request, sleep, onStep }) => {
1448
- const projects = await listProjects(google);
1449
- const chosen = projects.length > 0 ? await request.chooseProject(projects.map(({ projectId, displayName }) => ({
1410
+ const listed = await listProjects(google);
1411
+ if (listed.length === 0) return createProject({
1412
+ google,
1413
+ sleep,
1414
+ onStep
1415
+ });
1416
+ const projects = await withPlayApiState(google, listed);
1417
+ const enabled = projects.filter(({ playApiEnabled }) => playApiEnabled);
1418
+ if (enabled.length === 1) {
1419
+ const [only] = enabled;
1420
+ onStep(`Using ${only.projectId}, the project with the Play Developer API`);
1421
+ return only;
1422
+ }
1423
+ const chosen = await request.chooseProject(projects.map(({ projectId, displayName, playApiEnabled }) => ({
1450
1424
  projectId,
1451
- ...displayName ? { displayName } : {}
1452
- }))) : void 0;
1453
- if (chosen !== void 0) return projects.find((project) => project.projectId === chosen) ?? { projectId: chosen };
1425
+ ...displayName ? { displayName } : {},
1426
+ playApiEnabled
1427
+ })));
1428
+ if (chosen === null) return createProject({
1429
+ google,
1430
+ sleep,
1431
+ onStep
1432
+ });
1433
+ return projects.find((project) => project.projectId === chosen) ?? { projectId: chosen };
1434
+ };
1435
+ /** A new project, `hot-updater-play-<random>`, once Google has created it. */
1436
+ const createProject = async ({ google, sleep, onStep }) => {
1454
1437
  const projectId = newProjectId();
1455
1438
  onStep(`Creating the Google Cloud project ${projectId}`);
1456
1439
  try {
1457
1440
  const operation = await google("Creating the Google Cloud project", "POST", `${RESOURCE_MANAGER_API}/v3/projects`, {
1458
1441
  projectId,
1459
- displayName: "Hot Updater Play"
1442
+ displayName: PROJECT_DISPLAY_NAME
1460
1443
  });
1461
1444
  const created = await waitForOperation({
1462
1445
  request: google,
@@ -1474,14 +1457,14 @@ const chooseProject = async ({ google, request, sleep, onStep }) => {
1474
1457
  if (error.status === 429 || error.apiStatus === "RESOURCE_EXHAUSTED" || /quota/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-quota", [
1475
1458
  "Google Cloud has no project quota left for this account.",
1476
1459
  error.apiMessage || "Quota exceeded.",
1477
- "Run this again and pick an existing project, or delete one at:",
1460
+ "Delete a project you no longer use, then run this again:",
1478
1461
  "https://console.cloud.google.com/cloud-resource-manager"
1479
1462
  ].join("\n"), { cause: error });
1480
1463
  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 });
1481
1464
  if (isDenied(error) || error.apiStatus === "FAILED_PRECONDITION") throw new PlayServiceAccountError("project-create-denied", [
1482
1465
  "This Google Account may not create Google Cloud projects.",
1483
1466
  error.apiMessage || "Permission denied.",
1484
- "Pick an existing project, or ask an admin for the Project Creator role."
1467
+ "Ask an admin for the Project Creator role."
1485
1468
  ].join("\n"), { cause: error });
1486
1469
  throw error;
1487
1470
  }
@@ -1500,7 +1483,7 @@ const whilePropagating = async ({ run, project, sleep, notFound = false }) => {
1500
1483
  return await run();
1501
1484
  } catch (error) {
1502
1485
  const disabled = isServiceDisabled(error);
1503
- if (disabled && !consumerIs(error, project)) throw clientApiDisabled(error);
1486
+ if (disabled && !consumerIs(error, project)) throw consumerApiDisabled(error);
1504
1487
  if (!disabled && !(notFound && isNotFound(error)) || attempt >= PROPAGATION_ATTEMPTS) {
1505
1488
  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 });
1506
1489
  throw error;
@@ -1559,59 +1542,6 @@ const ensureServiceAccount = async ({ google, project, sleep }) => {
1559
1542
  throw error;
1560
1543
  }
1561
1544
  };
1562
- /**
1563
- * Makes `email` a Play Console user that may release `packageName`, keeping
1564
- * any permission it has: the user and the app's grant are created, or the
1565
- * grant gains the release permissions.
1566
- */
1567
- const grantPlayAccess = async ({ google, developerId, email, packageName }) => {
1568
- const developer = `${PUBLISHER_API}/developers/${developerId}`;
1569
- const user = `${developer}/users/${encodeURIComponent(email)}`;
1570
- try {
1571
- await google("Adding the Play Console user", "POST", `${developer}/users`, {
1572
- name: `developers/${developerId}/users/${email}`,
1573
- email
1574
- });
1575
- } catch (error) {
1576
- if (isDenied(error)) throw notPlayAdmin(developerId, error);
1577
- if (!isConflict(error)) throw error;
1578
- }
1579
- try {
1580
- await google("Granting access to the app", "POST", `${user}/grants`, {
1581
- name: `developers/${developerId}/users/${email}/grants/${packageName}`,
1582
- packageName,
1583
- appLevelPermissions: APP_PERMISSIONS
1584
- });
1585
- return;
1586
- } catch (error) {
1587
- if (isNotFound(error)) throw appNotFound(packageName, developerId, error);
1588
- if (isDenied(error)) throw notPlayAdmin(developerId, error);
1589
- if (!isConflict(error)) throw error;
1590
- }
1591
- const granted = await findGrant({
1592
- google,
1593
- developer,
1594
- email,
1595
- packageName
1596
- });
1597
- const missing = APP_PERMISSIONS.filter((permission) => !granted.includes(permission));
1598
- if (missing.length === 0) return;
1599
- await google("Granting access to the app", "PATCH", `${user}/grants/${encodeURIComponent(packageName)}?updateMask=appLevelPermissions`, { appLevelPermissions: [...granted, ...missing] });
1600
- };
1601
- /** The app permissions `email` holds on `packageName`, from the user list. */
1602
- const findGrant = async ({ google, developer, email, packageName }) => {
1603
- let pageToken;
1604
- do {
1605
- const page = await google("Listing the Play Console users", "GET", `${developer}/users?${new URLSearchParams({
1606
- pageSize: "100",
1607
- ...pageToken ? { pageToken } : {}
1608
- })}`);
1609
- const found = page.users?.find((candidate) => candidate.email === email);
1610
- if (found) return found.grants?.find((grant) => grant.packageName === packageName)?.appLevelPermissions ?? [];
1611
- pageToken = page.nextPageToken || void 0;
1612
- } while (pageToken);
1613
- return [];
1614
- };
1615
1545
  /** A new JSON key of the service account, checked and masked in the CLI's output. */
1616
1546
  const createKey = async ({ google, project, email, sleep }) => {
1617
1547
  const { projectId } = project;
@@ -1658,23 +1588,11 @@ const createKey = async ({ google, project, email, sleep }) => {
1658
1588
  //#endregion
1659
1589
  //#region src/playServiceAccount.ts
1660
1590
  /**
1661
- * Hot Updater's Google OAuth client, a "Desktop app" client the sign-in
1662
- * opens Google's consent screen for. Empty in a build that has none, where
1663
- * `HOT_UPDATER_GOOGLE_OAUTH_CLIENT_ID` and `_SECRET` name one.
1591
+ * Hot Updater's issuer, the one place that picks how it is done: gcloud's
1592
+ * sign-in, then the Cloud Resource Manager, Service Usage, IAM, and Play
1593
+ * Developer APIs. Another implementation of `IssuePlayServiceAccount`
1594
+ * replaces it here without touching its callers.
1664
1595
  */
1665
- const HOT_UPDATER_GOOGLE_OAUTH_CLIENT = {
1666
- clientId: "",
1667
- clientSecret: ""
1668
- };
1669
- /**
1670
- * Hot Updater's issuer, the one place that picks how it is done: Google's
1671
- * OAuth for installed apps, then the Cloud Resource Manager, Service Usage,
1672
- * IAM, and Play Developer APIs. Another implementation of
1673
- * `IssuePlayServiceAccount` replaces it here without touching its callers.
1674
- */
1675
- const issuePlayServiceAccount = (request) => createGoogleKeyIssuer()({
1676
- ...request,
1677
- oauthClient: request.oauthClient ?? HOT_UPDATER_GOOGLE_OAUTH_CLIENT
1678
- });
1596
+ const issuePlayServiceAccount = (request) => createGoogleKeyIssuer()(request);
1679
1597
  //#endregion
1680
- export { GOOGLE_OAUTH_CLIENT_ENV_NAMES, HOT_UPDATER_GOOGLE_OAUTH_CLIENT, PLAY_CONSOLE_URL, PLAY_SERVICE_ACCOUNT_ID, PlayServiceAccountError, PlayVersionCodeUsedError, assertPlaySigned, buildAndroid, enrichNativeBuildAndroidScheme, extractBuiltInBundle, findPlayInternalRelease, issuePlayServiceAccount, readBuiltApp, readBuiltPackageName, readBuiltVersionCode, resolvePlayUploadServiceAccount, uploadToPlay };
1598
+ export { PLAY_SERVICE_ACCOUNT_ID, PlayServiceAccountError, PlayVersionCodeUsedError, assertPlaySigned, buildAndroid, enrichNativeBuildAndroidScheme, extractBuiltInBundle, findPlayInternalRelease, hasPlayReleases, issuePlayServiceAccount, readBuiltApp, readBuiltPackageName, readBuiltVersionCode, resolvePlayUploadServiceAccount, uploadToPlay };