@hot-updater/android-helper 1.2.0-rc.5 → 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.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;
@@ -1238,11 +1240,8 @@ const activeAccount = async (run) => (await run("gcloud", [
1238
1240
  "--filter=status:ACTIVE",
1239
1241
  "--format=value(account)"
1240
1242
  ])).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 }) => {
1243
+ /** Stops before anything else, a prompt included, when gcloud is not installed. */
1244
+ const requireGcloud = async (run) => {
1246
1245
  try {
1247
1246
  await run("gcloud", ["--version"]);
1248
1247
  } catch (error) {
@@ -1252,6 +1251,9 @@ const signInWithGcloud = async ({ run, onStep }) => {
1252
1251
  GCLOUD_INSTALL_URL
1253
1252
  ].join("\n"), { cause: error });
1254
1253
  }
1254
+ };
1255
+ /** gcloud's access token, after `gcloud auth login` when no account is active. */
1256
+ const signInWithGcloud = async ({ run, onStep }) => {
1255
1257
  let account = await activeAccount(run);
1256
1258
  if (account === void 0) {
1257
1259
  await run("gcloud", ["auth", "login"], { interactive: true }).catch(() => {});
@@ -1267,11 +1269,12 @@ const signInWithGcloud = async ({ run, onStep }) => {
1267
1269
  * `IssuePlayServiceAccount` with the gcloud CLI's sign-in and Google's own
1268
1270
  * APIs:
1269
1271
  *
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;
1272
+ * 1. once gcloud is found, `askPackageName` names the app, and gcloud's
1273
+ * Google Account, signed in with `gcloud auth login` when none is
1274
+ * active, the access token the Google Cloud steps use;
1275
+ * 2. the account's only project with the Play Developer API, else the one
1276
+ * `chooseProject` picks, else a new one, gets the IAM and Play Developer
1277
+ * APIs enabled, and the service account `hot-updater-play` its JSON key;
1275
1278
  * 3. the developer invites the service account in Play Console, which
1276
1279
  * `onInvite` opens: gcloud's token may not manage Google Play users;
1277
1280
  * 4. the key itself opens an edit of the app until Google Play lets it,
@@ -1281,7 +1284,9 @@ const signInWithGcloud = async ({ run, onStep }) => {
1281
1284
  * service account.
1282
1285
  */
1283
1286
  const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor, run = runWithExeca } = {}) => async (request) => {
1284
- const { packageName, onStep = () => {} } = request;
1287
+ const { onStep = () => {} } = request;
1288
+ await requireGcloud(run);
1289
+ const packageName = await request.askPackageName();
1285
1290
  const accessToken = await signInWithGcloud({
1286
1291
  run,
1287
1292
  onStep
@@ -1297,7 +1302,7 @@ const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor, run
1297
1302
  onStep
1298
1303
  });
1299
1304
  const { projectId } = project;
1300
- onStep(`Enabling the Google Play Developer API in ${projectId}`);
1305
+ onStep(`Enabling the APIs in ${projectId}`);
1301
1306
  await enableServices({
1302
1307
  google,
1303
1308
  projectId,
@@ -1408,32 +1413,53 @@ const listProjects = async (google) => {
1408
1413
  } while (pageToken);
1409
1414
  return projects;
1410
1415
  };
1411
- /** The display name of the project Hot Updater creates, which later runs reuse. */
1416
+ /** Service Usage reads of the projects' Play Developer API, at most this many at once. */
1417
+ const API_CHECK_CONCURRENCY = 8;
1418
+ /**
1419
+ * Whether each project has the Google Play Developer API enabled, by one
1420
+ * Service Usage read each; a project this account may not read counts as
1421
+ * without it.
1422
+ */
1423
+ const withPlayApiState = async (google, projects) => {
1424
+ const states = [];
1425
+ 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))));
1426
+ return projects.map((project, index) => ({
1427
+ ...project,
1428
+ playApiEnabled: states[index]
1429
+ }));
1430
+ };
1431
+ /** The display name of the project Hot Updater creates. */
1412
1432
  const PROJECT_DISPLAY_NAME = "Hot Updater Play";
1413
1433
  /**
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.
1434
+ * The project the service account goes in: a new one for an account
1435
+ * without projects; the only project with the Play Developer API, without
1436
+ * asking; else the one the developer picks, or a new one.
1417
1437
  */
1418
1438
  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 };
1439
+ const listed = await listProjects(google);
1440
+ if (listed.length === 0) return createProject({
1441
+ google,
1442
+ sleep,
1443
+ onStep
1444
+ });
1445
+ const projects = await withPlayApiState(google, listed);
1446
+ const enabled = projects.filter(({ playApiEnabled }) => playApiEnabled);
1447
+ if (enabled.length === 1) {
1448
+ const [only] = enabled;
1449
+ onStep(`Using ${only.projectId}, the project with the Play Developer API`);
1450
+ return only;
1436
1451
  }
1452
+ const chosen = await request.chooseProject(projects.map(({ projectId, displayName, playApiEnabled }) => ({
1453
+ projectId,
1454
+ ...displayName ? { displayName } : {},
1455
+ playApiEnabled
1456
+ })));
1457
+ if (chosen === null) return createProject({
1458
+ google,
1459
+ sleep,
1460
+ onStep
1461
+ });
1462
+ return projects.find((project) => project.projectId === chosen) ?? { projectId: chosen };
1437
1463
  };
1438
1464
  /** A new project, `hot-updater-play-<random>`, once Google has created it. */
1439
1465
  const createProject = async ({ google, sleep, onStep }) => {
package/dist/index.d.cts CHANGED
@@ -187,21 +187,28 @@ 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;
190
192
  }
191
193
  interface IssuePlayServiceAccountRequest {
192
- /** The app's package name in Google Play, which the service account may release. */
193
- readonly packageName: string;
194
+ /**
195
+ * Asks for the app's package name in Google Play, which the service
196
+ * account may release: after the sign-in's own tool is found, before
197
+ * anything else.
198
+ */
199
+ readonly askPackageName: () => Promise<string>;
194
200
  /**
195
201
  * Sees Play Console's page that invites the service account `email`, to
196
202
  * print it and open it in a browser; the issuer then waits for the invite.
197
203
  */
198
204
  readonly onInvite: (url: string, email: string) => Promise<void> | void;
199
205
  /**
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.
206
+ * Sees the account's projects when no one of them is the obvious home of
207
+ * the service account, the only one with the Google Play Developer API,
208
+ * and returns the project ID to create it in, or null for a new project.
209
+ * Never called for an account without projects: a new one is created.
203
210
  */
204
- readonly chooseProject: (projects: readonly GoogleCloudProject[]) => Promise<string | undefined>;
211
+ readonly chooseProject: (projects: readonly GoogleCloudProject[]) => Promise<string | null>;
205
212
  /** Reports each step, such as "Creating the service account". */
206
213
  readonly onStep?: (message: string) => void;
207
214
  }
package/dist/index.d.mts CHANGED
@@ -188,21 +188,28 @@ 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;
191
193
  }
192
194
  interface IssuePlayServiceAccountRequest {
193
- /** The app's package name in Google Play, which the service account may release. */
194
- readonly packageName: string;
195
+ /**
196
+ * Asks for the app's package name in Google Play, which the service
197
+ * account may release: after the sign-in's own tool is found, before
198
+ * anything else.
199
+ */
200
+ readonly askPackageName: () => Promise<string>;
195
201
  /**
196
202
  * Sees Play Console's page that invites the service account `email`, to
197
203
  * print it and open it in a browser; the issuer then waits for the invite.
198
204
  */
199
205
  readonly onInvite: (url: string, email: string) => Promise<void> | void;
200
206
  /**
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.
207
+ * Sees the account's projects when no one of them is the obvious home of
208
+ * the service account, the only one with the Google Play Developer API,
209
+ * and returns the project ID to create it in, or null for a new project.
210
+ * Never called for an account without projects: a new one is created.
204
211
  */
205
- readonly chooseProject: (projects: readonly GoogleCloudProject[]) => Promise<string | undefined>;
212
+ readonly chooseProject: (projects: readonly GoogleCloudProject[]) => Promise<string | null>;
206
213
  /** Reports each step, such as "Creating the service account". */
207
214
  readonly onStep?: (message: string) => void;
208
215
  }
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;
@@ -1209,11 +1211,8 @@ const activeAccount = async (run) => (await run("gcloud", [
1209
1211
  "--filter=status:ACTIVE",
1210
1212
  "--format=value(account)"
1211
1213
  ])).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 }) => {
1214
+ /** Stops before anything else, a prompt included, when gcloud is not installed. */
1215
+ const requireGcloud = async (run) => {
1217
1216
  try {
1218
1217
  await run("gcloud", ["--version"]);
1219
1218
  } catch (error) {
@@ -1223,6 +1222,9 @@ const signInWithGcloud = async ({ run, onStep }) => {
1223
1222
  GCLOUD_INSTALL_URL
1224
1223
  ].join("\n"), { cause: error });
1225
1224
  }
1225
+ };
1226
+ /** gcloud's access token, after `gcloud auth login` when no account is active. */
1227
+ const signInWithGcloud = async ({ run, onStep }) => {
1226
1228
  let account = await activeAccount(run);
1227
1229
  if (account === void 0) {
1228
1230
  await run("gcloud", ["auth", "login"], { interactive: true }).catch(() => {});
@@ -1238,11 +1240,12 @@ const signInWithGcloud = async ({ run, onStep }) => {
1238
1240
  * `IssuePlayServiceAccount` with the gcloud CLI's sign-in and Google's own
1239
1241
  * APIs:
1240
1242
  *
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;
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;
1246
1249
  * 3. the developer invites the service account in Play Console, which
1247
1250
  * `onInvite` opens: gcloud's token may not manage Google Play users;
1248
1251
  * 4. the key itself opens an edit of the app until Google Play lets it,
@@ -1252,7 +1255,9 @@ const signInWithGcloud = async ({ run, onStep }) => {
1252
1255
  * service account.
1253
1256
  */
1254
1257
  const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor, run = runWithExeca } = {}) => async (request) => {
1255
- const { packageName, onStep = () => {} } = request;
1258
+ const { onStep = () => {} } = request;
1259
+ await requireGcloud(run);
1260
+ const packageName = await request.askPackageName();
1256
1261
  const accessToken = await signInWithGcloud({
1257
1262
  run,
1258
1263
  onStep
@@ -1268,7 +1273,7 @@ const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor, run
1268
1273
  onStep
1269
1274
  });
1270
1275
  const { projectId } = project;
1271
- onStep(`Enabling the Google Play Developer API in ${projectId}`);
1276
+ onStep(`Enabling the APIs in ${projectId}`);
1272
1277
  await enableServices({
1273
1278
  google,
1274
1279
  projectId,
@@ -1379,32 +1384,53 @@ const listProjects = async (google) => {
1379
1384
  } while (pageToken);
1380
1385
  return projects;
1381
1386
  };
1382
- /** The display name of the project Hot Updater creates, which later runs reuse. */
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. */
1383
1403
  const PROJECT_DISPLAY_NAME = "Hot Updater Play";
1384
1404
  /**
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.
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.
1388
1408
  */
1389
1409
  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 };
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;
1407
1422
  }
1423
+ const chosen = await request.chooseProject(projects.map(({ projectId, displayName, playApiEnabled }) => ({
1424
+ projectId,
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 };
1408
1434
  };
1409
1435
  /** A new project, `hot-updater-play-<random>`, once Google has created it. */
1410
1436
  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.6",
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.6",
33
+ "@hot-updater/plugin-core": "1.2.0-rc.6",
34
+ "@hot-updater/protocol": "1.2.0-rc.6"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^20"