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

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
@@ -1208,8 +1208,10 @@ const RESOURCE_MANAGER_API = "https://cloudresourcemanager.googleapis.com";
1208
1208
  const SERVICE_USAGE_API = "https://serviceusage.googleapis.com";
1209
1209
  const IAM_API = "https://iam.googleapis.com/v1";
1210
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";
1211
1213
  /** IAM creates the service account; the Play Developer API is what it calls. */
1212
- const SERVICES = ["iam.googleapis.com", "androidpublisher.googleapis.com"];
1214
+ const SERVICES = ["iam.googleapis.com", PLAY_API];
1213
1215
  const KEY_CREATION_CONSTRAINT = "constraints/iam.disableServiceAccountKeyCreation";
1214
1216
  /** Retries while Google enables an API or creates a service account. */
1215
1217
  const PROPAGATION_ATTEMPTS = 12;
@@ -1231,18 +1233,30 @@ const isConflict = (error) => error instanceof GoogleApiError && error.status ==
1231
1233
  /** The API is disabled in another project than the one set up: waiting does not help. */
1232
1234
  const consumerApiDisabled = (error) => new PlayServiceAccountError("api-not-enabled", `${error.apiMessage}\nEnable it in ${error.consumer ?? "that project"}, then run this again.`, { cause: error });
1233
1235
  const isDenied = (error) => error instanceof GoogleApiError && (error.status === 403 || error.apiStatus === "PERMISSION_DENIED");
1234
- /** The Google Account gcloud uses, if one is active. */
1235
- const activeAccount = async (run) => (await run("gcloud", [
1236
- "auth",
1237
- "list",
1238
- "--filter=status:ACTIVE",
1239
- "--format=value(account)"
1240
- ])).trim().split("\n")[0]?.trim() || void 0;
1241
- /**
1242
- * gcloud's access token, after `gcloud auth login` when no account is
1243
- * active. Stops before any change when gcloud is not installed.
1244
- */
1245
- const signInWithGcloud = async ({ run, onStep }) => {
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) => {
1246
1260
  try {
1247
1261
  await run("gcloud", ["--version"]);
1248
1262
  } catch (error) {
@@ -1252,14 +1266,27 @@ const signInWithGcloud = async ({ run, onStep }) => {
1252
1266
  GCLOUD_INSTALL_URL
1253
1267
  ].join("\n"), { cause: error });
1254
1268
  }
1255
- let account = await activeAccount(run);
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;
1256
1278
  if (account === void 0) {
1257
- await run("gcloud", ["auth", "login"], { interactive: true }).catch(() => {});
1258
- account = await activeAccount(run);
1259
- 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.");
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.");
1260
1283
  }
1261
1284
  onStep(`Signed in to gcloud as ${account}`);
1262
- const accessToken = (await run("gcloud", ["auth", "print-access-token"])).trim();
1285
+ const accessToken = (await run("gcloud", [
1286
+ "auth",
1287
+ "print-access-token",
1288
+ account
1289
+ ])).trim();
1263
1290
  (0, _hot_updater_cli_tools.registerSecretValue)(accessToken);
1264
1291
  return accessToken;
1265
1292
  };
@@ -1267,11 +1294,12 @@ const signInWithGcloud = async ({ run, onStep }) => {
1267
1294
  * `IssuePlayServiceAccount` with the gcloud CLI's sign-in and Google's own
1268
1295
  * APIs:
1269
1296
  *
1270
- * 1. gcloud's Google Account, signed in with `gcloud auth login` when none
1271
- * is active, names the access token the Google Cloud steps use;
1272
- * 2. the Google Cloud project "Hot Updater Play" an earlier run created, or
1273
- * a new one, gets the IAM and Play Developer APIs enabled, and the
1274
- * service account `hot-updater-play` its JSON key;
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;
1275
1303
  * 3. the developer invites the service account in Play Console, which
1276
1304
  * `onInvite` opens: gcloud's token may not manage Google Play users;
1277
1305
  * 4. the key itself opens an edit of the app until Google Play lets it,
@@ -1281,9 +1309,12 @@ const signInWithGcloud = async ({ run, onStep }) => {
1281
1309
  * service account.
1282
1310
  */
1283
1311
  const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor, run = runWithExeca } = {}) => async (request) => {
1284
- const { packageName, onStep = () => {} } = request;
1312
+ const { onStep = () => {} } = request;
1313
+ await requireGcloud(run);
1314
+ const packageName = await request.askPackageName();
1285
1315
  const accessToken = await signInWithGcloud({
1286
1316
  run,
1317
+ request,
1287
1318
  onStep
1288
1319
  });
1289
1320
  const google = createGoogleRequest({
@@ -1297,7 +1328,7 @@ const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor, run
1297
1328
  onStep
1298
1329
  });
1299
1330
  const { projectId } = project;
1300
- onStep(`Enabling the Google Play Developer API in ${projectId}`);
1331
+ onStep(`Enabling the APIs in ${projectId}`);
1301
1332
  await enableServices({
1302
1333
  google,
1303
1334
  projectId,
@@ -1338,8 +1369,9 @@ const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor, run
1338
1369
  const deleted = await revoke();
1339
1370
  throw new PlayServiceAccountError("play-access-pending", [
1340
1371
  `The key can't release ${packageName} yet.`,
1341
- "Invite the service account in Play Console with release access:",
1372
+ "Invite the service account in Play Console:",
1342
1373
  email,
1374
+ "App permission: Release apps to testing tracks",
1343
1375
  inviteUrl,
1344
1376
  "A new app needs its first .aab uploaded in Play Console.",
1345
1377
  ...deleted ? [] : [`Delete its unused key ${key.keyId} in Google Cloud:`, `https://console.cloud.google.com/iam-admin/serviceaccounts?project=${encodeURIComponent(projectId)}`],
@@ -1408,32 +1440,53 @@ const listProjects = async (google) => {
1408
1440
  } while (pageToken);
1409
1441
  return projects;
1410
1442
  };
1411
- /** The display name of the project Hot Updater creates, which later runs reuse. */
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. */
1412
1459
  const PROJECT_DISPLAY_NAME = "Hot Updater Play";
1413
1460
  /**
1414
- * The project the service account goes in, without asking: the one an
1415
- * earlier run created, else a new one. Only when Google refuses to create
1416
- * one does the developer pick among the account's projects.
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.
1417
1464
  */
1418
1465
  const chooseProject = async ({ google, request, sleep, onStep }) => {
1419
- const projects = await listProjects(google);
1420
- const reused = projects.find((project) => project.displayName === PROJECT_DISPLAY_NAME);
1421
- if (reused) return reused;
1422
- try {
1423
- return await createProject({
1424
- google,
1425
- sleep,
1426
- onStep
1427
- });
1428
- } catch (error) {
1429
- if (!(error instanceof PlayServiceAccountError) || error.code !== "project-quota" && error.code !== "project-create-denied" || projects.length === 0) throw error;
1430
- const chosen = await request.chooseProject(projects.map(({ projectId, displayName }) => ({
1431
- projectId,
1432
- ...displayName ? { displayName } : {}
1433
- })));
1434
- if (chosen === void 0) throw error;
1435
- return projects.find((project) => project.projectId === chosen) ?? { projectId: chosen };
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;
1436
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 };
1437
1490
  };
1438
1491
  /** A new project, `hot-updater-play-<random>`, once Google has created it. */
1439
1492
  const createProject = async ({ google, sleep, onStep }) => {
package/dist/index.d.cts CHANGED
@@ -158,7 +158,7 @@ declare const readBuiltVersionCode: ({ artifactPath, androidProjectPath }: {
158
158
  type PlayServiceAccountErrorCode =
159
159
  /** The gcloud CLI, which signs in to Google, is not installed. */
160
160
  "gcloud-not-installed" |
161
- /** `gcloud auth login` ended with no active Google Account. */
161
+ /** gcloud ended with no Google Account to use: none signed in, or the sign-in was cancelled. */
162
162
  "gcloud-not-signed-in" |
163
163
  /** The Google Account may not create a Google Cloud project. */
164
164
  "project-create-denied" |
@@ -187,21 +187,40 @@ declare const PLAY_SERVICE_ACCOUNT_ID = "hot-updater-play";
187
187
  interface GoogleCloudProject {
188
188
  readonly projectId: string;
189
189
  readonly displayName?: string;
190
+ /** Whether the Google Play Android Developer API is enabled in it. */
191
+ readonly playApiEnabled: boolean;
192
+ }
193
+ /** A Google Account the gcloud CLI holds credentials for. */
194
+ interface GoogleAccount {
195
+ readonly email: string;
196
+ /** Whether it is gcloud's active account. */
197
+ readonly active: boolean;
190
198
  }
191
199
  interface IssuePlayServiceAccountRequest {
192
- /** The app's package name in Google Play, which the service account may release. */
193
- readonly packageName: string;
200
+ /**
201
+ * Asks for the app's package name in Google Play, which the service
202
+ * account may release: after the sign-in's own tool is found, before
203
+ * anything else.
204
+ */
205
+ readonly askPackageName: () => Promise<string>;
206
+ /**
207
+ * Sees the Google Accounts gcloud already holds, the active one first,
208
+ * after the package name, and returns the one to use, or null to sign in
209
+ * with another. Never called when gcloud holds none: it signs in.
210
+ */
211
+ readonly chooseGoogleAccount: (accounts: readonly GoogleAccount[]) => Promise<string | null>;
194
212
  /**
195
213
  * Sees Play Console's page that invites the service account `email`, to
196
214
  * print it and open it in a browser; the issuer then waits for the invite.
197
215
  */
198
216
  readonly onInvite: (url: string, email: string) => Promise<void> | void;
199
217
  /**
200
- * Sees the account's projects when Google refuses to create one for it,
201
- * and returns the one to create the service account in, or undefined to
202
- * stop with Google's refusal.
218
+ * Sees the account's projects when no one of them is the obvious home of
219
+ * the service account, the only one with the Google Play Developer API,
220
+ * and returns the project ID to create it in, or null for a new project.
221
+ * Never called for an account without projects: a new one is created.
203
222
  */
204
- readonly chooseProject: (projects: readonly GoogleCloudProject[]) => Promise<string | undefined>;
223
+ readonly chooseProject: (projects: readonly GoogleCloudProject[]) => Promise<string | null>;
205
224
  /** Reports each step, such as "Creating the service account". */
206
225
  readonly onStep?: (message: string) => void;
207
226
  }
@@ -236,4 +255,4 @@ type IssuePlayServiceAccount = (request: IssuePlayServiceAccountRequest) => Prom
236
255
  */
237
256
  declare const issuePlayServiceAccount: IssuePlayServiceAccount;
238
257
  //#endregion
239
- export { type EnrichedNativeBuildAndroidScheme, type GoogleCloudProject, type IssuePlayServiceAccount, type IssuePlayServiceAccountRequest, type IssuedPlayServiceAccount, PLAY_SERVICE_ACCOUNT_ID, PlayServiceAccountError, type PlayServiceAccountErrorCode, PlayVersionCodeUsedError, type UploadToPlayResult, assertPlaySigned, buildAndroid, enrichNativeBuildAndroidScheme, extractBuiltInBundle, findPlayInternalRelease, hasPlayReleases, issuePlayServiceAccount, readBuiltApp, readBuiltPackageName, readBuiltVersionCode, resolvePlayUploadServiceAccount, uploadToPlay };
258
+ export { type EnrichedNativeBuildAndroidScheme, type GoogleAccount, type GoogleCloudProject, type IssuePlayServiceAccount, type IssuePlayServiceAccountRequest, type IssuedPlayServiceAccount, PLAY_SERVICE_ACCOUNT_ID, PlayServiceAccountError, type PlayServiceAccountErrorCode, PlayVersionCodeUsedError, type UploadToPlayResult, assertPlaySigned, buildAndroid, enrichNativeBuildAndroidScheme, extractBuiltInBundle, findPlayInternalRelease, hasPlayReleases, issuePlayServiceAccount, readBuiltApp, readBuiltPackageName, readBuiltVersionCode, resolvePlayUploadServiceAccount, uploadToPlay };
package/dist/index.d.mts CHANGED
@@ -159,7 +159,7 @@ declare const readBuiltVersionCode: ({ artifactPath, androidProjectPath }: {
159
159
  type PlayServiceAccountErrorCode =
160
160
  /** The gcloud CLI, which signs in to Google, is not installed. */
161
161
  "gcloud-not-installed" |
162
- /** `gcloud auth login` ended with no active Google Account. */
162
+ /** gcloud ended with no Google Account to use: none signed in, or the sign-in was cancelled. */
163
163
  "gcloud-not-signed-in" |
164
164
  /** The Google Account may not create a Google Cloud project. */
165
165
  "project-create-denied" |
@@ -188,21 +188,40 @@ declare const PLAY_SERVICE_ACCOUNT_ID = "hot-updater-play";
188
188
  interface GoogleCloudProject {
189
189
  readonly projectId: string;
190
190
  readonly displayName?: string;
191
+ /** Whether the Google Play Android Developer API is enabled in it. */
192
+ readonly playApiEnabled: boolean;
193
+ }
194
+ /** A Google Account the gcloud CLI holds credentials for. */
195
+ interface GoogleAccount {
196
+ readonly email: string;
197
+ /** Whether it is gcloud's active account. */
198
+ readonly active: boolean;
191
199
  }
192
200
  interface IssuePlayServiceAccountRequest {
193
- /** The app's package name in Google Play, which the service account may release. */
194
- readonly packageName: string;
201
+ /**
202
+ * Asks for the app's package name in Google Play, which the service
203
+ * account may release: after the sign-in's own tool is found, before
204
+ * anything else.
205
+ */
206
+ readonly askPackageName: () => Promise<string>;
207
+ /**
208
+ * Sees the Google Accounts gcloud already holds, the active one first,
209
+ * after the package name, and returns the one to use, or null to sign in
210
+ * with another. Never called when gcloud holds none: it signs in.
211
+ */
212
+ readonly chooseGoogleAccount: (accounts: readonly GoogleAccount[]) => Promise<string | null>;
195
213
  /**
196
214
  * Sees Play Console's page that invites the service account `email`, to
197
215
  * print it and open it in a browser; the issuer then waits for the invite.
198
216
  */
199
217
  readonly onInvite: (url: string, email: string) => Promise<void> | void;
200
218
  /**
201
- * Sees the account's projects when Google refuses to create one for it,
202
- * and returns the one to create the service account in, or undefined to
203
- * stop with Google's refusal.
219
+ * Sees the account's projects when no one of them is the obvious home of
220
+ * the service account, the only one with the Google Play Developer API,
221
+ * and returns the project ID to create it in, or null for a new project.
222
+ * Never called for an account without projects: a new one is created.
204
223
  */
205
- readonly chooseProject: (projects: readonly GoogleCloudProject[]) => Promise<string | undefined>;
224
+ readonly chooseProject: (projects: readonly GoogleCloudProject[]) => Promise<string | null>;
206
225
  /** Reports each step, such as "Creating the service account". */
207
226
  readonly onStep?: (message: string) => void;
208
227
  }
@@ -237,4 +256,4 @@ type IssuePlayServiceAccount = (request: IssuePlayServiceAccountRequest) => Prom
237
256
  */
238
257
  declare const issuePlayServiceAccount: IssuePlayServiceAccount;
239
258
  //#endregion
240
- export { type EnrichedNativeBuildAndroidScheme, type GoogleCloudProject, type IssuePlayServiceAccount, type IssuePlayServiceAccountRequest, type IssuedPlayServiceAccount, PLAY_SERVICE_ACCOUNT_ID, PlayServiceAccountError, type PlayServiceAccountErrorCode, PlayVersionCodeUsedError, type UploadToPlayResult, assertPlaySigned, buildAndroid, enrichNativeBuildAndroidScheme, extractBuiltInBundle, findPlayInternalRelease, hasPlayReleases, issuePlayServiceAccount, readBuiltApp, readBuiltPackageName, readBuiltVersionCode, resolvePlayUploadServiceAccount, uploadToPlay };
259
+ export { type EnrichedNativeBuildAndroidScheme, type GoogleAccount, type GoogleCloudProject, type IssuePlayServiceAccount, type IssuePlayServiceAccountRequest, type IssuedPlayServiceAccount, PLAY_SERVICE_ACCOUNT_ID, PlayServiceAccountError, type PlayServiceAccountErrorCode, PlayVersionCodeUsedError, type UploadToPlayResult, assertPlaySigned, buildAndroid, enrichNativeBuildAndroidScheme, extractBuiltInBundle, findPlayInternalRelease, hasPlayReleases, issuePlayServiceAccount, readBuiltApp, readBuiltPackageName, readBuiltVersionCode, resolvePlayUploadServiceAccount, uploadToPlay };
package/dist/index.mjs CHANGED
@@ -1179,8 +1179,10 @@ const RESOURCE_MANAGER_API = "https://cloudresourcemanager.googleapis.com";
1179
1179
  const SERVICE_USAGE_API = "https://serviceusage.googleapis.com";
1180
1180
  const IAM_API = "https://iam.googleapis.com/v1";
1181
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";
1182
1184
  /** IAM creates the service account; the Play Developer API is what it calls. */
1183
- const SERVICES = ["iam.googleapis.com", "androidpublisher.googleapis.com"];
1185
+ const SERVICES = ["iam.googleapis.com", PLAY_API];
1184
1186
  const KEY_CREATION_CONSTRAINT = "constraints/iam.disableServiceAccountKeyCreation";
1185
1187
  /** Retries while Google enables an API or creates a service account. */
1186
1188
  const PROPAGATION_ATTEMPTS = 12;
@@ -1202,18 +1204,30 @@ const isConflict = (error) => error instanceof GoogleApiError && error.status ==
1202
1204
  /** The API is disabled in another project than the one set up: waiting does not help. */
1203
1205
  const consumerApiDisabled = (error) => new PlayServiceAccountError("api-not-enabled", `${error.apiMessage}\nEnable it in ${error.consumer ?? "that project"}, then run this again.`, { cause: error });
1204
1206
  const isDenied = (error) => error instanceof GoogleApiError && (error.status === 403 || error.apiStatus === "PERMISSION_DENIED");
1205
- /** The Google Account gcloud uses, if one is active. */
1206
- const activeAccount = async (run) => (await run("gcloud", [
1207
- "auth",
1208
- "list",
1209
- "--filter=status:ACTIVE",
1210
- "--format=value(account)"
1211
- ])).trim().split("\n")[0]?.trim() || void 0;
1212
- /**
1213
- * gcloud's access token, after `gcloud auth login` when no account is
1214
- * active. Stops before any change when gcloud is not installed.
1215
- */
1216
- const signInWithGcloud = async ({ run, onStep }) => {
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) => {
1217
1231
  try {
1218
1232
  await run("gcloud", ["--version"]);
1219
1233
  } catch (error) {
@@ -1223,14 +1237,27 @@ const signInWithGcloud = async ({ run, onStep }) => {
1223
1237
  GCLOUD_INSTALL_URL
1224
1238
  ].join("\n"), { cause: error });
1225
1239
  }
1226
- let account = await activeAccount(run);
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;
1227
1249
  if (account === void 0) {
1228
- await run("gcloud", ["auth", "login"], { interactive: true }).catch(() => {});
1229
- account = await activeAccount(run);
1230
- 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.");
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.");
1231
1254
  }
1232
1255
  onStep(`Signed in to gcloud as ${account}`);
1233
- const accessToken = (await run("gcloud", ["auth", "print-access-token"])).trim();
1256
+ const accessToken = (await run("gcloud", [
1257
+ "auth",
1258
+ "print-access-token",
1259
+ account
1260
+ ])).trim();
1234
1261
  registerSecretValue(accessToken);
1235
1262
  return accessToken;
1236
1263
  };
@@ -1238,11 +1265,12 @@ const signInWithGcloud = async ({ run, onStep }) => {
1238
1265
  * `IssuePlayServiceAccount` with the gcloud CLI's sign-in and Google's own
1239
1266
  * APIs:
1240
1267
  *
1241
- * 1. gcloud's Google Account, signed in with `gcloud auth login` when none
1242
- * is active, names the access token the Google Cloud steps use;
1243
- * 2. the Google Cloud project "Hot Updater Play" an earlier run created, or
1244
- * a new one, gets the IAM and Play Developer APIs enabled, and the
1245
- * service account `hot-updater-play` its JSON key;
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;
1246
1274
  * 3. the developer invites the service account in Play Console, which
1247
1275
  * `onInvite` opens: gcloud's token may not manage Google Play users;
1248
1276
  * 4. the key itself opens an edit of the app until Google Play lets it,
@@ -1252,9 +1280,12 @@ const signInWithGcloud = async ({ run, onStep }) => {
1252
1280
  * service account.
1253
1281
  */
1254
1282
  const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor, run = runWithExeca } = {}) => async (request) => {
1255
- const { packageName, onStep = () => {} } = request;
1283
+ const { onStep = () => {} } = request;
1284
+ await requireGcloud(run);
1285
+ const packageName = await request.askPackageName();
1256
1286
  const accessToken = await signInWithGcloud({
1257
1287
  run,
1288
+ request,
1258
1289
  onStep
1259
1290
  });
1260
1291
  const google = createGoogleRequest({
@@ -1268,7 +1299,7 @@ const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor, run
1268
1299
  onStep
1269
1300
  });
1270
1301
  const { projectId } = project;
1271
- onStep(`Enabling the Google Play Developer API in ${projectId}`);
1302
+ onStep(`Enabling the APIs in ${projectId}`);
1272
1303
  await enableServices({
1273
1304
  google,
1274
1305
  projectId,
@@ -1309,8 +1340,9 @@ const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor, run
1309
1340
  const deleted = await revoke();
1310
1341
  throw new PlayServiceAccountError("play-access-pending", [
1311
1342
  `The key can't release ${packageName} yet.`,
1312
- "Invite the service account in Play Console with release access:",
1343
+ "Invite the service account in Play Console:",
1313
1344
  email,
1345
+ "App permission: Release apps to testing tracks",
1314
1346
  inviteUrl,
1315
1347
  "A new app needs its first .aab uploaded in Play Console.",
1316
1348
  ...deleted ? [] : [`Delete its unused key ${key.keyId} in Google Cloud:`, `https://console.cloud.google.com/iam-admin/serviceaccounts?project=${encodeURIComponent(projectId)}`],
@@ -1379,32 +1411,53 @@ const listProjects = async (google) => {
1379
1411
  } while (pageToken);
1380
1412
  return projects;
1381
1413
  };
1382
- /** The display name of the project Hot Updater creates, which later runs reuse. */
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. */
1383
1430
  const PROJECT_DISPLAY_NAME = "Hot Updater Play";
1384
1431
  /**
1385
- * The project the service account goes in, without asking: the one an
1386
- * earlier run created, else a new one. Only when Google refuses to create
1387
- * one does the developer pick among the account's projects.
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.
1388
1435
  */
1389
1436
  const chooseProject = async ({ google, request, sleep, onStep }) => {
1390
- const projects = await listProjects(google);
1391
- const reused = projects.find((project) => project.displayName === PROJECT_DISPLAY_NAME);
1392
- if (reused) return reused;
1393
- try {
1394
- return await createProject({
1395
- google,
1396
- sleep,
1397
- onStep
1398
- });
1399
- } catch (error) {
1400
- if (!(error instanceof PlayServiceAccountError) || error.code !== "project-quota" && error.code !== "project-create-denied" || projects.length === 0) throw error;
1401
- const chosen = await request.chooseProject(projects.map(({ projectId, displayName }) => ({
1402
- projectId,
1403
- ...displayName ? { displayName } : {}
1404
- })));
1405
- if (chosen === void 0) throw error;
1406
- return projects.find((project) => project.projectId === chosen) ?? { projectId: chosen };
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;
1407
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 };
1408
1461
  };
1409
1462
  /** A new project, `hot-updater-play-<random>`, once Google has created it. */
1410
1463
  const createProject = async ({ google, sleep, onStep }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/android-helper",
3
- "version": "1.2.0-rc.5",
3
+ "version": "1.2.0-rc.7",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.mjs",
@@ -29,9 +29,9 @@
29
29
  "dependencies": {
30
30
  "execa": "9.5.2",
31
31
  "jszip": "^3.10.1",
32
- "@hot-updater/cli-tools": "1.2.0-rc.5",
33
- "@hot-updater/plugin-core": "1.2.0-rc.5",
34
- "@hot-updater/protocol": "1.2.0-rc.5"
32
+ "@hot-updater/cli-tools": "1.2.0-rc.7",
33
+ "@hot-updater/protocol": "1.2.0-rc.7",
34
+ "@hot-updater/plugin-core": "1.2.0-rc.7"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^20"