@hot-updater/android-helper 1.2.0-rc.2 → 1.2.0-rc.3

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
@@ -164,6 +164,26 @@ const createGradleLogger = ({ logPrefix }) => new _hot_updater_cli_tools.BuildLo
164
164
  }
165
165
  });
166
166
  //#endregion
167
+ //#region src/utils/formatCommand.ts
168
+ /**
169
+ * A command to print, broken at spaces with ` \` once a line would pass
170
+ * `width`, each continuation indented two spaces, so a terminal never wraps
171
+ * it mid-word and it still pastes as one command.
172
+ */
173
+ const formatCommand = (words, width = 74) => {
174
+ const lines = [];
175
+ let line = "";
176
+ for (const word of words) {
177
+ const next = line ? `${line} ${word}` : word;
178
+ if (line && next.length > width - 2) {
179
+ lines.push(`${line} \\`);
180
+ line = ` ${word}`;
181
+ } else line = next;
182
+ }
183
+ lines.push(line);
184
+ return lines.join("\n");
185
+ };
186
+ //#endregion
167
187
  //#region src/builder/runGradle.ts
168
188
  /** Why Gradle failed, without its command line. */
169
189
  const describeFailure = (error) => {
@@ -190,12 +210,14 @@ async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appM
190
210
  const gradleArgs = getTaskNames(appModuleName, tasks);
191
211
  gradleArgs.push("-x", "lint");
192
212
  if (args.extraParams) gradleArgs.push(...args.extraParams);
193
- _hot_updater_cli_tools.p.log.info(`Run Gradle Settings:
194
- Project ${androidProjectPath}
195
- App Module ${appModuleName}
196
- Tasks ${tasks.join(", ")}
197
- Args ${gradleArgs.join(" ")}
198
- `);
213
+ _hot_updater_cli_tools.p.log.info([
214
+ "Gradle build",
215
+ `Project ${path.default.relative((0, _hot_updater_cli_tools.getCwd)(), androidProjectPath) || "."}`,
216
+ `App module ${appModuleName}`,
217
+ `Tasks ${tasks.join(", ")}`,
218
+ "Command",
219
+ formatCommand([path.default.basename(getGradleWrapper()), ...gradleArgs])
220
+ ].join("\n"));
199
221
  if ("port" in args && args.port != null) gradleArgs.push(`-PreactNativeDevServerPort=${args.port}`);
200
222
  const logger = createGradleLogger({ logPrefix });
201
223
  await logger.start();
@@ -257,7 +279,7 @@ async function getBuildOutputFilePath({ aab, appModuleName, buildDirectory, vari
257
279
  const outputFile = `${appModuleName}-${variant}.${aab ? "aab" : "apk"}`;
258
280
  const outputFilePath = path.default.join(buildDirectory, outputFile);
259
281
  if (fs.default.existsSync(outputFilePath)) return outputFilePath;
260
- _hot_updater_cli_tools.p.log.error(`Failed to find the output file for ${outputFilePath}`);
282
+ _hot_updater_cli_tools.p.log.error(`Gradle wrote no ${outputFilePath}.`);
261
283
  process.exit(1);
262
284
  }
263
285
  //#endregion
@@ -277,7 +299,7 @@ const resolveGradleSigningEnv = async ({ schemeConfig, credentials }) => {
277
299
  const reference = schemeConfig.signing?.keystore;
278
300
  if (!reference) return;
279
301
  if (isDebugVariant(schemeConfig.variant)) {
280
- _hot_updater_cli_tools.p.log.info(`${schemeConfig.variant} signs with the debug keystore, so signing.keystore is not used.`);
302
+ _hot_updater_cli_tools.p.log.info(`${schemeConfig.variant} signs with the debug keystore; signing.keystore is unused.`);
281
303
  return;
282
304
  }
283
305
  const keystore = await (credentials ?? (0, _hot_updater_cli_tools.createCredentialResolver)()).resolve({
@@ -430,7 +452,7 @@ const writeBaseModuleApk = async (aabPath, directory) => {
430
452
  const readBuiltApp = async ({ artifactPath, androidProjectPath }) => {
431
453
  const aapt2 = findAapt2(androidProjectPath);
432
454
  if (!aapt2) {
433
- _hot_updater_cli_tools.p.log.warn("aapt2 was not found in the Android SDK's build-tools, so the build reports the app version and channel of the project files.");
455
+ _hot_updater_cli_tools.p.log.warn("No aapt2 in the Android SDK build-tools.\nThe build reports the project files' app version and channel.");
434
456
  return;
435
457
  }
436
458
  const directory = await fs.default.promises.mkdtemp(path.default.join(os.default.tmpdir(), "android-helper-"));
@@ -455,7 +477,7 @@ const readBuiltApp = async ({ artifactPath, androidProjectPath }) => {
455
477
  };
456
478
  const appVersion = await resolve(parseManifestVersionName(xmltree));
457
479
  const channel = await resolve(parseManifestMetaData(xmltree, CHANNEL_META_DATA_KEY));
458
- if (appVersion === void 0 || channel === void 0) _hot_updater_cli_tools.p.log.warn("aapt2 printed the artifact's versionName or com.hotupdater.CHANNEL in a form that cannot be read exactly, so the build reports the project files' value for it.");
480
+ if (appVersion === void 0 || channel === void 0) _hot_updater_cli_tools.p.log.warn("Couldn't read the artifact's versionName or com.hotupdater.CHANNEL exactly.\nThe build reports the project files' value instead.");
459
481
  const versionCode = parseManifestVersionCode(xmltree);
460
482
  return {
461
483
  appVersion: appVersion ?? null,
@@ -586,7 +608,7 @@ const readServiceAccount = (json) => {
586
608
  throw new Error("The Play service account is not a JSON key file.");
587
609
  }
588
610
  const { client_email, private_key, token_uri } = parsed;
589
- if (typeof client_email !== "string" || typeof private_key !== "string") throw new Error("The Play service account has no client_email or private_key: use the JSON key file Google Cloud creates for the service account.");
611
+ if (typeof client_email !== "string" || typeof private_key !== "string") throw new Error("The Play service account has no client_email or private_key.\nUse the JSON key file Google Cloud creates for it.");
590
612
  (0, _hot_updater_cli_tools.registerSecretValue)(private_key);
591
613
  let privateKey;
592
614
  try {
@@ -632,7 +654,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
632
654
  var PlayVersionCodeUsedError = class extends Error {
633
655
  name = "PlayVersionCodeUsedError";
634
656
  constructor(options) {
635
- super("Google Play already has this .aab's version code. Raise versionCode for every build you upload.", options);
657
+ super("Google Play already has this .aab's version code.\nRaise versionCode for every build you upload.", options);
636
658
  }
637
659
  };
638
660
  /**
@@ -642,8 +664,12 @@ var PlayVersionCodeUsedError = class extends Error {
642
664
  const explain = (error, packageName) => {
643
665
  if (!(error instanceof GooglePlayError)) return error;
644
666
  const { status, apiMessage } = error;
645
- if (status === 401 || /does not have permission/i.test(apiMessage)) return new Error(`The Play service account cannot release ${packageName}. Invite its email in Play Console under Users and permissions, with permission to release to testing tracks, and enable the Google Play Android Developer API in its Google Cloud project.`, { cause: error });
646
- if (/package not found/i.test(apiMessage)) return new Error(`Google Play has no app ${packageName}. Create the app in Play Console and upload its first .aab there; the API releases the builds after it.`, { cause: error });
667
+ if (status === 401 || /does not have permission/i.test(apiMessage)) return new Error([
668
+ `The Play service account can't release ${packageName}.`,
669
+ "Invite its email in Play Console (Users and permissions) to release to",
670
+ "testing tracks, and enable the Google Play Android Developer API."
671
+ ].join("\n"), { cause: error });
672
+ if (/package not found/i.test(apiMessage)) return new Error(`Google Play has no app ${packageName}.\nCreate it in Play Console and upload its first .aab there.`, { cause: error });
647
673
  if (/version code .*(already been used|already used)/i.test(apiMessage)) return new PlayVersionCodeUsedError({ cause: error });
648
674
  return error;
649
675
  };
@@ -668,7 +694,7 @@ const createPlayClient = ({ json, packageName, fetch }) => {
668
694
  assertion
669
695
  }).toString()
670
696
  });
671
- if (!response.ok) throw new Error(`Google refused the Play service account (HTTP ${response.status}): ${await readApiMessage(response) || "check that its key is current and the service account is enabled."}`);
697
+ if (!response.ok) throw new Error(`Google refused the Play service account (HTTP ${response.status}).\n${await readApiMessage(response) || "Check that its key is current and the service account is enabled."}`);
672
698
  const { access_token } = await response.json();
673
699
  if (typeof access_token !== "string" || !access_token) throw new Error("Google answered the Play service account with no access token.");
674
700
  (0, _hot_updater_cli_tools.registerSecretValue)(access_token);
@@ -746,7 +772,11 @@ const assertPlaySigned = async (aabPath) => {
746
772
  "-jarfile",
747
773
  aabPath
748
774
  ], { reject: false });
749
- if (exitCode === 0 && ANDROID_DEBUG_SIGNER.test(String(stdout))) throw new Error(`Google Play refuses an .aab signed with the Android debug keystore, and ${aabPath} is. Set the scheme's signing.keystore, or a release signingConfig in android/app/build.gradle, then build again.`);
775
+ if (exitCode === 0 && ANDROID_DEBUG_SIGNER.test(String(stdout))) throw new Error([
776
+ "Google Play refuses an .aab signed with the Android debug keystore.",
777
+ "Set the scheme's signing.keystore, or a release signingConfig in",
778
+ "android/app/build.gradle, then build again."
779
+ ].join("\n"));
750
780
  };
751
781
  /**
752
782
  * Uploads an `.aab` to Google Play's internal testing track with the Google
@@ -797,7 +827,7 @@ const uploadToPlay = async ({ aabPath, packageName, serviceAccount: { json }, fe
797
827
  };
798
828
  } catch (error) {
799
829
  if (!(error instanceof GooglePlayError) || !DRAFT_APP_ERRORS.some((message) => error.apiMessage.includes(message))) throw error;
800
- _hot_updater_cli_tools.p.log.warn(`Play Console has not published ${packageName} yet, which takes only draft releases. The internal release of version code ${versionCode} is a draft: roll it out in Play Console.`);
830
+ _hot_updater_cli_tools.p.log.warn(`${packageName} isn't published yet, so version code ${versionCode} is a draft.\nRoll it out in Play Console.`);
801
831
  await release("draft");
802
832
  return {
803
833
  versionCode,
@@ -819,10 +849,14 @@ const commit = async ({ request, edit, retryDelayMs }) => {
819
849
  let changesNotSentForReview = false;
820
850
  for (let attempt = 1;; attempt++) try {
821
851
  await request("Committing the Google Play edit", "POST", `${edit}:commit${changesNotSentForReview ? "?changesNotSentForReview=true" : ""}`);
822
- if (changesNotSentForReview) _hot_updater_cli_tools.p.log.warn("Google Play saved the release without sending it for review, as the app's state requires. Send the changes for review in Play Console.");
852
+ if (changesNotSentForReview) _hot_updater_cli_tools.p.log.warn("Google Play saved the release without sending it for review.\nSend the changes for review in Play Console.");
823
853
  return;
824
854
  } catch (error) {
825
- if (!(error instanceof GooglePlayError) || error.status >= 500 || error.status === 429) throw new Error(`Google Play did not confirm the release: ${error instanceof Error ? error.message : String(error)} Check internal testing in Play Console before you upload this version code again.`, { cause: error });
855
+ if (!(error instanceof GooglePlayError) || error.status >= 500 || error.status === 429) throw new Error([
856
+ "Google Play didn't confirm the release.",
857
+ error instanceof Error ? error.message : String(error),
858
+ "Check internal testing in Play Console before you upload it again."
859
+ ].join("\n"), { cause: error });
826
860
  if (error.status !== 400) throw error;
827
861
  if (!changesNotSentForReview && error.apiMessage.includes(NEEDS_CHANGES_NOT_SENT_FOR_REVIEW)) {
828
862
  changesNotSentForReview = true;
@@ -925,9 +959,9 @@ const confirmedOffset = (response) => {
925
959
  * from new apps, or a debug variant, which Play refuses.
926
960
  */
927
961
  const resolvePlayUploadServiceAccount = ({ scheme, androidScheme }) => {
928
- if (androidScheme.aab === false) throw new Error(`Google Play takes an .aab, and the scheme ${scheme} builds an .apk (aab: false).`);
962
+ if (androidScheme.aab === false) throw new Error(`The scheme ${scheme} builds an .apk (aab: false); Google Play takes an .aab.`);
929
963
  const variant = androidScheme.variant ?? "Release";
930
- if (/debug$/i.test(variant)) throw new Error(`Google Play takes a release build, and the scheme ${scheme} builds the ${variant} variant, which is debuggable and loads its JavaScript from Metro.`);
964
+ if (/debug$/i.test(variant)) throw new Error(`The scheme ${scheme} builds ${variant}; Google Play takes a release build.`);
931
965
  return {
932
966
  kind: "play-service-account",
933
967
  name: "play-service-account"
@@ -1231,7 +1265,7 @@ const signInWithGoogle = async ({ client, scopes, onUrl, fetch = globalThis.fetc
1231
1265
  }).toString()
1232
1266
  });
1233
1267
  const token = await response.json().catch(() => ({}));
1234
- 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" ? `: ${token.error_description}` : typeof token.error === "string" ? `: ${token.error}` : "."}`);
1268
+ 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}` : ""}`);
1235
1269
  (0, _hot_updater_cli_tools.registerSecretValue)(token.access_token);
1236
1270
  return {
1237
1271
  accessToken: token.access_token,
@@ -1286,11 +1320,11 @@ const newProjectId = () => `${PLAY_SERVICE_ACCOUNT_ID}-${node_crypto.default.ran
1286
1320
  const isNotFound = (error) => error instanceof GoogleApiError && error.status === 404;
1287
1321
  const isConflict = (error) => error instanceof GoogleApiError && error.status === 409;
1288
1322
  /** The API is disabled in the OAuth client's own project: waiting does not help. */
1289
- const clientApiDisabled = (error) => new PlayServiceAccountError("api-not-enabled", `${error.apiMessage} The Google OAuth client Hot Updater signs in with belongs to that project, which must enable the API (see ${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientId}).`, { cause: error });
1323
+ 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 });
1290
1324
  /** Google's own words, after a message that names the likely cause. */
1291
- const googleSays = (error) => error instanceof GoogleApiError && error.apiMessage ? ` (Google: ${error.apiMessage})` : "";
1325
+ const googleSays = (error) => error instanceof GoogleApiError && error.apiMessage ? `\nGoogle: ${error.apiMessage}` : "";
1292
1326
  /** A Play Console refusal, as `not-play-admin`. */
1293
- const notPlayAdmin = (developerId, error) => new PlayServiceAccountError("not-play-admin", `The Google Account you signed in with is not an admin of the Play Console developer account ${developerId}. Sign in with an account that has the Admin permission there (Users and permissions in ${PLAY_CONSOLE_URL})${googleSays(error)}.`, { cause: error });
1327
+ 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 });
1294
1328
  const isDenied = (error) => error instanceof GoogleApiError && (error.status === 403 || error.apiStatus === "PERMISSION_DENIED");
1295
1329
  /**
1296
1330
  * `IssuePlayServiceAccount` with Google's own APIs, after a sign-in on
@@ -1311,7 +1345,11 @@ const isDenied = (error) => error instanceof GoogleApiError && (error.status ===
1311
1345
  */
1312
1346
  const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor } = {}) => async (request) => {
1313
1347
  const { packageName, oauthClient, onStep = () => {} } = request;
1314
- if (!oauthClient?.clientId) throw new PlayServiceAccountError("sign-in-not-configured", `This build of Hot Updater has no Google OAuth client to sign in with. Set ${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientId} and ${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientSecret} to a "Desktop app" OAuth client.`);
1348
+ if (!oauthClient?.clientId) throw new PlayServiceAccountError("sign-in-not-configured", [
1349
+ "This build of Hot Updater has no Google OAuth client to sign in with.",
1350
+ "Set these to a \"Desktop app\" OAuth client:",
1351
+ `${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientId}, ${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientSecret}`
1352
+ ].join("\n"));
1315
1353
  const signIn = await signInWithGoogle({
1316
1354
  client: oauthClient,
1317
1355
  scopes: SCOPES,
@@ -1323,14 +1361,14 @@ const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor } =
1323
1361
  };
1324
1362
  try {
1325
1363
  const missing = SCOPES.filter((scope) => !signIn.scopes.includes(scope));
1326
- if (missing.length > 0) throw new PlayServiceAccountError("scope-not-granted", `The sign-in did not allow ${missing.map((scope) => scope === ANDROID_PUBLISHER_SCOPE ? "managing Google Play" : "managing Google Cloud").join(" or ")}. Run this again and keep every box on Google's consent screen ticked.`);
1364
+ 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.`);
1327
1365
  const google = createGoogleRequest({
1328
1366
  accessToken: signIn.accessToken,
1329
1367
  fetch
1330
1368
  });
1331
1369
  const developerInput = await request.askDeveloperId();
1332
1370
  const developerId = parseDeveloperId(developerInput);
1333
- if (developerId === void 0) throw new PlayServiceAccountError("developer-not-found", `"${developerInput.trim()}" is no developer account ID. Open ${PLAY_CONSOLE_URL}: it is the number after /developers/ in the address.`);
1371
+ 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.`);
1334
1372
  onStep("Checking the Play Console developer account");
1335
1373
  await checkPlayConsole({
1336
1374
  google,
@@ -1402,7 +1440,7 @@ const checkPlayConsole = async ({ google, developerId, packageName }) => {
1402
1440
  await google("Listing the Play Console users", "GET", `${PUBLISHER_API}/developers/${developerId}/users?pageSize=1`);
1403
1441
  } catch (error) {
1404
1442
  if (isServiceDisabled(error)) throw clientApiDisabled(error);
1405
- if (isNotFound(error)) throw new PlayServiceAccountError("developer-not-found", `Play Console has no developer account ${developerId}. Open ${PLAY_CONSOLE_URL}: the developer account ID is the number after /developers/ in the address${googleSays(error)}.`, { cause: error });
1443
+ 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 });
1406
1444
  if (isDenied(error)) throw notPlayAdmin(developerId, error);
1407
1445
  throw error;
1408
1446
  }
@@ -1463,9 +1501,18 @@ const chooseProject = async ({ google, request, sleep, onStep }) => {
1463
1501
  };
1464
1502
  } catch (error) {
1465
1503
  if (!(error instanceof GoogleApiError)) throw error;
1466
- if (error.status === 429 || error.apiStatus === "RESOURCE_EXHAUSTED" || /quota/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-quota", `Google Cloud has no project quota left for this account: ${error.apiMessage || "quota exceeded"}. Delete a project you no longer use (https://console.cloud.google.com/cloud-resource-manager), or run this again and pick an existing project.`, { cause: error });
1467
- if (/terms of service/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-create-denied", "This Google Account has not accepted the Google Cloud Terms of Service. Open https://console.cloud.google.com once to accept them, then run this again.", { cause: error });
1468
- if (isDenied(error) || error.apiStatus === "FAILED_PRECONDITION") throw new PlayServiceAccountError("project-create-denied", `This Google Account may not create Google Cloud projects: ${error.apiMessage || "permission denied"}. Ask your organization's admin for the Project Creator role, or run this again and pick an existing project.`, { cause: error });
1504
+ if (error.status === 429 || error.apiStatus === "RESOURCE_EXHAUSTED" || /quota/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-quota", [
1505
+ "Google Cloud has no project quota left for this account.",
1506
+ error.apiMessage || "Quota exceeded.",
1507
+ "Run this again and pick an existing project, or delete one at:",
1508
+ "https://console.cloud.google.com/cloud-resource-manager"
1509
+ ].join("\n"), { cause: error });
1510
+ 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 });
1511
+ if (isDenied(error) || error.apiStatus === "FAILED_PRECONDITION") throw new PlayServiceAccountError("project-create-denied", [
1512
+ "This Google Account may not create Google Cloud projects.",
1513
+ error.apiMessage || "Permission denied.",
1514
+ "Pick an existing project, or ask an admin for the Project Creator role."
1515
+ ].join("\n"), { cause: error });
1469
1516
  throw error;
1470
1517
  }
1471
1518
  };
@@ -1485,7 +1532,7 @@ const whilePropagating = async ({ run, project, sleep, notFound = false }) => {
1485
1532
  const disabled = isServiceDisabled(error);
1486
1533
  if (disabled && !consumerIs(error, project)) throw clientApiDisabled(error);
1487
1534
  if (!disabled && !(notFound && isNotFound(error)) || attempt >= PROPAGATION_ATTEMPTS) {
1488
- if (disabled) throw new PlayServiceAccountError("api-not-enabled", `Google is still enabling the APIs in ${project.projectId}: ${error.apiMessage} Wait a few minutes, then run this again.`, { cause: error });
1535
+ 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 });
1489
1536
  throw error;
1490
1537
  }
1491
1538
  await sleep(PROPAGATION_DELAY_MS);
@@ -1508,7 +1555,11 @@ const enableServices = async ({ google, projectId, sleep }) => {
1508
1555
  sleep
1509
1556
  });
1510
1557
  } catch (error) {
1511
- if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", `This Google Account may not enable APIs in ${projectId}: ${error.apiMessage || "permission denied"}. Pick a project you own, or ask its owner for the Editor role.`, { cause: error });
1558
+ if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", [
1559
+ `This Google Account may not enable APIs in ${projectId}.`,
1560
+ error.apiMessage || "Permission denied.",
1561
+ "Pick a project you own, or ask its owner for the Editor role."
1562
+ ].join("\n"), { cause: error });
1512
1563
  throw error;
1513
1564
  }
1514
1565
  };
@@ -1530,7 +1581,11 @@ const ensureServiceAccount = async ({ google, project, sleep }) => {
1530
1581
  })).email ?? email;
1531
1582
  } catch (error) {
1532
1583
  if (isConflict(error)) return email;
1533
- if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", `This Google Account may not create service accounts in ${projectId}: ${error.apiMessage || "permission denied"}. Pick a project you own, or ask its owner for the Service Account Admin role.`, { cause: error });
1584
+ if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", [
1585
+ `This Google Account may not create service accounts in ${projectId}.`,
1586
+ error.apiMessage || "Permission denied.",
1587
+ "Pick a project you own, or ask for the Service Account Admin role."
1588
+ ].join("\n"), { cause: error });
1534
1589
  throw error;
1535
1590
  }
1536
1591
  };
@@ -1602,7 +1657,11 @@ const createKey = async ({ google, project, email, sleep }) => {
1602
1657
  })
1603
1658
  });
1604
1659
  } catch (error) {
1605
- if (error instanceof GoogleApiError && (error.violations.includes(KEY_CREATION_CONSTRAINT) || /disableServiceAccountKeyCreation/u.test(error.apiMessage))) throw new PlayServiceAccountError("key-creation-disabled", `An organization policy (iam.disableServiceAccountKeyCreation) forbids service account keys in ${projectId}; organizations created since 2024 enforce it by default. Ask an organization policy admin to allow keys for this project, or run this again with a project outside the organization. The service account ${email} and its Play Console access remain for the next run.`, { cause: error });
1660
+ if (error instanceof GoogleApiError && (error.violations.includes(KEY_CREATION_CONSTRAINT) || /disableServiceAccountKeyCreation/u.test(error.apiMessage))) throw new PlayServiceAccountError("key-creation-disabled", [
1661
+ `iam.disableServiceAccountKeyCreation forbids keys in ${projectId}.`,
1662
+ "Ask an organization policy admin to allow keys for this project,",
1663
+ "or run this again with a project outside the organization."
1664
+ ].join("\n"), { cause: error });
1606
1665
  throw error;
1607
1666
  }
1608
1667
  if (!key.name || !key.privateKeyData) throw new Error("Google Cloud created the key without its JSON file.");
package/dist/index.mjs CHANGED
@@ -134,6 +134,26 @@ const createGradleLogger = ({ logPrefix }) => new BuildLogger({
134
134
  }
135
135
  });
136
136
  //#endregion
137
+ //#region src/utils/formatCommand.ts
138
+ /**
139
+ * A command to print, broken at spaces with ` \` once a line would pass
140
+ * `width`, each continuation indented two spaces, so a terminal never wraps
141
+ * it mid-word and it still pastes as one command.
142
+ */
143
+ const formatCommand = (words, width = 74) => {
144
+ const lines = [];
145
+ let line = "";
146
+ for (const word of words) {
147
+ const next = line ? `${line} ${word}` : word;
148
+ if (line && next.length > width - 2) {
149
+ lines.push(`${line} \\`);
150
+ line = ` ${word}`;
151
+ } else line = next;
152
+ }
153
+ lines.push(line);
154
+ return lines.join("\n");
155
+ };
156
+ //#endregion
137
157
  //#region src/builder/runGradle.ts
138
158
  /** Why Gradle failed, without its command line. */
139
159
  const describeFailure = (error) => {
@@ -160,12 +180,14 @@ async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appM
160
180
  const gradleArgs = getTaskNames(appModuleName, tasks);
161
181
  gradleArgs.push("-x", "lint");
162
182
  if (args.extraParams) gradleArgs.push(...args.extraParams);
163
- p.log.info(`Run Gradle Settings:
164
- Project ${androidProjectPath}
165
- App Module ${appModuleName}
166
- Tasks ${tasks.join(", ")}
167
- Args ${gradleArgs.join(" ")}
168
- `);
183
+ p.log.info([
184
+ "Gradle build",
185
+ `Project ${path.relative(getCwd(), androidProjectPath) || "."}`,
186
+ `App module ${appModuleName}`,
187
+ `Tasks ${tasks.join(", ")}`,
188
+ "Command",
189
+ formatCommand([path.basename(getGradleWrapper()), ...gradleArgs])
190
+ ].join("\n"));
169
191
  if ("port" in args && args.port != null) gradleArgs.push(`-PreactNativeDevServerPort=${args.port}`);
170
192
  const logger = createGradleLogger({ logPrefix });
171
193
  await logger.start();
@@ -227,7 +249,7 @@ async function getBuildOutputFilePath({ aab, appModuleName, buildDirectory, vari
227
249
  const outputFile = `${appModuleName}-${variant}.${aab ? "aab" : "apk"}`;
228
250
  const outputFilePath = path.join(buildDirectory, outputFile);
229
251
  if (fs.existsSync(outputFilePath)) return outputFilePath;
230
- p.log.error(`Failed to find the output file for ${outputFilePath}`);
252
+ p.log.error(`Gradle wrote no ${outputFilePath}.`);
231
253
  process.exit(1);
232
254
  }
233
255
  //#endregion
@@ -247,7 +269,7 @@ const resolveGradleSigningEnv = async ({ schemeConfig, credentials }) => {
247
269
  const reference = schemeConfig.signing?.keystore;
248
270
  if (!reference) return;
249
271
  if (isDebugVariant(schemeConfig.variant)) {
250
- p.log.info(`${schemeConfig.variant} signs with the debug keystore, so signing.keystore is not used.`);
272
+ p.log.info(`${schemeConfig.variant} signs with the debug keystore; signing.keystore is unused.`);
251
273
  return;
252
274
  }
253
275
  const keystore = await (credentials ?? createCredentialResolver()).resolve({
@@ -400,7 +422,7 @@ const writeBaseModuleApk = async (aabPath, directory) => {
400
422
  const readBuiltApp = async ({ artifactPath, androidProjectPath }) => {
401
423
  const aapt2 = findAapt2(androidProjectPath);
402
424
  if (!aapt2) {
403
- p.log.warn("aapt2 was not found in the Android SDK's build-tools, so the build reports the app version and channel of the project files.");
425
+ p.log.warn("No aapt2 in the Android SDK build-tools.\nThe build reports the project files' app version and channel.");
404
426
  return;
405
427
  }
406
428
  const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "android-helper-"));
@@ -425,7 +447,7 @@ const readBuiltApp = async ({ artifactPath, androidProjectPath }) => {
425
447
  };
426
448
  const appVersion = await resolve(parseManifestVersionName(xmltree));
427
449
  const channel = await resolve(parseManifestMetaData(xmltree, CHANNEL_META_DATA_KEY));
428
- if (appVersion === void 0 || channel === void 0) p.log.warn("aapt2 printed the artifact's versionName or com.hotupdater.CHANNEL in a form that cannot be read exactly, so the build reports the project files' value for it.");
450
+ if (appVersion === void 0 || channel === void 0) p.log.warn("Couldn't read the artifact's versionName or com.hotupdater.CHANNEL exactly.\nThe build reports the project files' value instead.");
429
451
  const versionCode = parseManifestVersionCode(xmltree);
430
452
  return {
431
453
  appVersion: appVersion ?? null,
@@ -556,7 +578,7 @@ const readServiceAccount = (json) => {
556
578
  throw new Error("The Play service account is not a JSON key file.");
557
579
  }
558
580
  const { client_email, private_key, token_uri } = parsed;
559
- if (typeof client_email !== "string" || typeof private_key !== "string") throw new Error("The Play service account has no client_email or private_key: use the JSON key file Google Cloud creates for the service account.");
581
+ if (typeof client_email !== "string" || typeof private_key !== "string") throw new Error("The Play service account has no client_email or private_key.\nUse the JSON key file Google Cloud creates for it.");
560
582
  registerSecretValue(private_key);
561
583
  let privateKey;
562
584
  try {
@@ -602,7 +624,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
602
624
  var PlayVersionCodeUsedError = class extends Error {
603
625
  name = "PlayVersionCodeUsedError";
604
626
  constructor(options) {
605
- super("Google Play already has this .aab's version code. Raise versionCode for every build you upload.", options);
627
+ super("Google Play already has this .aab's version code.\nRaise versionCode for every build you upload.", options);
606
628
  }
607
629
  };
608
630
  /**
@@ -612,8 +634,12 @@ var PlayVersionCodeUsedError = class extends Error {
612
634
  const explain = (error, packageName) => {
613
635
  if (!(error instanceof GooglePlayError)) return error;
614
636
  const { status, apiMessage } = error;
615
- if (status === 401 || /does not have permission/i.test(apiMessage)) return new Error(`The Play service account cannot release ${packageName}. Invite its email in Play Console under Users and permissions, with permission to release to testing tracks, and enable the Google Play Android Developer API in its Google Cloud project.`, { cause: error });
616
- if (/package not found/i.test(apiMessage)) return new Error(`Google Play has no app ${packageName}. Create the app in Play Console and upload its first .aab there; the API releases the builds after it.`, { cause: error });
637
+ if (status === 401 || /does not have permission/i.test(apiMessage)) return new Error([
638
+ `The Play service account can't release ${packageName}.`,
639
+ "Invite its email in Play Console (Users and permissions) to release to",
640
+ "testing tracks, and enable the Google Play Android Developer API."
641
+ ].join("\n"), { cause: error });
642
+ if (/package not found/i.test(apiMessage)) return new Error(`Google Play has no app ${packageName}.\nCreate it in Play Console and upload its first .aab there.`, { cause: error });
617
643
  if (/version code .*(already been used|already used)/i.test(apiMessage)) return new PlayVersionCodeUsedError({ cause: error });
618
644
  return error;
619
645
  };
@@ -638,7 +664,7 @@ const createPlayClient = ({ json, packageName, fetch }) => {
638
664
  assertion
639
665
  }).toString()
640
666
  });
641
- if (!response.ok) throw new Error(`Google refused the Play service account (HTTP ${response.status}): ${await readApiMessage(response) || "check that its key is current and the service account is enabled."}`);
667
+ if (!response.ok) throw new Error(`Google refused the Play service account (HTTP ${response.status}).\n${await readApiMessage(response) || "Check that its key is current and the service account is enabled."}`);
642
668
  const { access_token } = await response.json();
643
669
  if (typeof access_token !== "string" || !access_token) throw new Error("Google answered the Play service account with no access token.");
644
670
  registerSecretValue(access_token);
@@ -716,7 +742,11 @@ const assertPlaySigned = async (aabPath) => {
716
742
  "-jarfile",
717
743
  aabPath
718
744
  ], { reject: false });
719
- if (exitCode === 0 && ANDROID_DEBUG_SIGNER.test(String(stdout))) throw new Error(`Google Play refuses an .aab signed with the Android debug keystore, and ${aabPath} is. Set the scheme's signing.keystore, or a release signingConfig in android/app/build.gradle, then build again.`);
745
+ if (exitCode === 0 && ANDROID_DEBUG_SIGNER.test(String(stdout))) throw new Error([
746
+ "Google Play refuses an .aab signed with the Android debug keystore.",
747
+ "Set the scheme's signing.keystore, or a release signingConfig in",
748
+ "android/app/build.gradle, then build again."
749
+ ].join("\n"));
720
750
  };
721
751
  /**
722
752
  * Uploads an `.aab` to Google Play's internal testing track with the Google
@@ -767,7 +797,7 @@ const uploadToPlay = async ({ aabPath, packageName, serviceAccount: { json }, fe
767
797
  };
768
798
  } catch (error) {
769
799
  if (!(error instanceof GooglePlayError) || !DRAFT_APP_ERRORS.some((message) => error.apiMessage.includes(message))) throw error;
770
- p.log.warn(`Play Console has not published ${packageName} yet, which takes only draft releases. The internal release of version code ${versionCode} is a draft: roll it out in Play Console.`);
800
+ p.log.warn(`${packageName} isn't published yet, so version code ${versionCode} is a draft.\nRoll it out in Play Console.`);
771
801
  await release("draft");
772
802
  return {
773
803
  versionCode,
@@ -789,10 +819,14 @@ const commit = async ({ request, edit, retryDelayMs }) => {
789
819
  let changesNotSentForReview = false;
790
820
  for (let attempt = 1;; attempt++) try {
791
821
  await request("Committing the Google Play edit", "POST", `${edit}:commit${changesNotSentForReview ? "?changesNotSentForReview=true" : ""}`);
792
- if (changesNotSentForReview) p.log.warn("Google Play saved the release without sending it for review, as the app's state requires. Send the changes for review in Play Console.");
822
+ if (changesNotSentForReview) p.log.warn("Google Play saved the release without sending it for review.\nSend the changes for review in Play Console.");
793
823
  return;
794
824
  } catch (error) {
795
- if (!(error instanceof GooglePlayError) || error.status >= 500 || error.status === 429) throw new Error(`Google Play did not confirm the release: ${error instanceof Error ? error.message : String(error)} Check internal testing in Play Console before you upload this version code again.`, { cause: error });
825
+ if (!(error instanceof GooglePlayError) || error.status >= 500 || error.status === 429) throw new Error([
826
+ "Google Play didn't confirm the release.",
827
+ error instanceof Error ? error.message : String(error),
828
+ "Check internal testing in Play Console before you upload it again."
829
+ ].join("\n"), { cause: error });
796
830
  if (error.status !== 400) throw error;
797
831
  if (!changesNotSentForReview && error.apiMessage.includes(NEEDS_CHANGES_NOT_SENT_FOR_REVIEW)) {
798
832
  changesNotSentForReview = true;
@@ -895,9 +929,9 @@ const confirmedOffset = (response) => {
895
929
  * from new apps, or a debug variant, which Play refuses.
896
930
  */
897
931
  const resolvePlayUploadServiceAccount = ({ scheme, androidScheme }) => {
898
- if (androidScheme.aab === false) throw new Error(`Google Play takes an .aab, and the scheme ${scheme} builds an .apk (aab: false).`);
932
+ if (androidScheme.aab === false) throw new Error(`The scheme ${scheme} builds an .apk (aab: false); Google Play takes an .aab.`);
899
933
  const variant = androidScheme.variant ?? "Release";
900
- if (/debug$/i.test(variant)) throw new Error(`Google Play takes a release build, and the scheme ${scheme} builds the ${variant} variant, which is debuggable and loads its JavaScript from Metro.`);
934
+ if (/debug$/i.test(variant)) throw new Error(`The scheme ${scheme} builds ${variant}; Google Play takes a release build.`);
901
935
  return {
902
936
  kind: "play-service-account",
903
937
  name: "play-service-account"
@@ -1201,7 +1235,7 @@ const signInWithGoogle = async ({ client, scopes, onUrl, fetch = globalThis.fetc
1201
1235
  }).toString()
1202
1236
  });
1203
1237
  const token = await response.json().catch(() => ({}));
1204
- 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" ? `: ${token.error_description}` : typeof token.error === "string" ? `: ${token.error}` : "."}`);
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}` : ""}`);
1205
1239
  registerSecretValue(token.access_token);
1206
1240
  return {
1207
1241
  accessToken: token.access_token,
@@ -1256,11 +1290,11 @@ const newProjectId = () => `${PLAY_SERVICE_ACCOUNT_ID}-${crypto$1.randomBytes(3)
1256
1290
  const isNotFound = (error) => error instanceof GoogleApiError && error.status === 404;
1257
1291
  const isConflict = (error) => error instanceof GoogleApiError && error.status === 409;
1258
1292
  /** The API is disabled in the OAuth client's own project: waiting does not help. */
1259
- const clientApiDisabled = (error) => new PlayServiceAccountError("api-not-enabled", `${error.apiMessage} The Google OAuth client Hot Updater signs in with belongs to that project, which must enable the API (see ${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientId}).`, { cause: error });
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 });
1260
1294
  /** Google's own words, after a message that names the likely cause. */
1261
- const googleSays = (error) => error instanceof GoogleApiError && error.apiMessage ? ` (Google: ${error.apiMessage})` : "";
1295
+ const googleSays = (error) => error instanceof GoogleApiError && error.apiMessage ? `\nGoogle: ${error.apiMessage}` : "";
1262
1296
  /** A Play Console refusal, as `not-play-admin`. */
1263
- const notPlayAdmin = (developerId, error) => new PlayServiceAccountError("not-play-admin", `The Google Account you signed in with is not an admin of the Play Console developer account ${developerId}. Sign in with an account that has the Admin permission there (Users and permissions in ${PLAY_CONSOLE_URL})${googleSays(error)}.`, { cause: error });
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 });
1264
1298
  const isDenied = (error) => error instanceof GoogleApiError && (error.status === 403 || error.apiStatus === "PERMISSION_DENIED");
1265
1299
  /**
1266
1300
  * `IssuePlayServiceAccount` with Google's own APIs, after a sign-in on
@@ -1281,7 +1315,11 @@ const isDenied = (error) => error instanceof GoogleApiError && (error.status ===
1281
1315
  */
1282
1316
  const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor } = {}) => async (request) => {
1283
1317
  const { packageName, oauthClient, onStep = () => {} } = request;
1284
- if (!oauthClient?.clientId) throw new PlayServiceAccountError("sign-in-not-configured", `This build of Hot Updater has no Google OAuth client to sign in with. Set ${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientId} and ${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientSecret} to a "Desktop app" OAuth client.`);
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"));
1285
1323
  const signIn = await signInWithGoogle({
1286
1324
  client: oauthClient,
1287
1325
  scopes: SCOPES,
@@ -1293,14 +1331,14 @@ const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor } =
1293
1331
  };
1294
1332
  try {
1295
1333
  const missing = SCOPES.filter((scope) => !signIn.scopes.includes(scope));
1296
- if (missing.length > 0) throw new PlayServiceAccountError("scope-not-granted", `The sign-in did not allow ${missing.map((scope) => scope === ANDROID_PUBLISHER_SCOPE ? "managing Google Play" : "managing Google Cloud").join(" or ")}. Run this again and keep every box on Google's consent screen ticked.`);
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.`);
1297
1335
  const google = createGoogleRequest({
1298
1336
  accessToken: signIn.accessToken,
1299
1337
  fetch
1300
1338
  });
1301
1339
  const developerInput = await request.askDeveloperId();
1302
1340
  const developerId = parseDeveloperId(developerInput);
1303
- if (developerId === void 0) throw new PlayServiceAccountError("developer-not-found", `"${developerInput.trim()}" is no developer account ID. Open ${PLAY_CONSOLE_URL}: it is the number after /developers/ in the address.`);
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.`);
1304
1342
  onStep("Checking the Play Console developer account");
1305
1343
  await checkPlayConsole({
1306
1344
  google,
@@ -1372,7 +1410,7 @@ const checkPlayConsole = async ({ google, developerId, packageName }) => {
1372
1410
  await google("Listing the Play Console users", "GET", `${PUBLISHER_API}/developers/${developerId}/users?pageSize=1`);
1373
1411
  } catch (error) {
1374
1412
  if (isServiceDisabled(error)) throw clientApiDisabled(error);
1375
- if (isNotFound(error)) throw new PlayServiceAccountError("developer-not-found", `Play Console has no developer account ${developerId}. Open ${PLAY_CONSOLE_URL}: the developer account ID is the number after /developers/ in the address${googleSays(error)}.`, { cause: 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 });
1376
1414
  if (isDenied(error)) throw notPlayAdmin(developerId, error);
1377
1415
  throw error;
1378
1416
  }
@@ -1433,9 +1471,18 @@ const chooseProject = async ({ google, request, sleep, onStep }) => {
1433
1471
  };
1434
1472
  } catch (error) {
1435
1473
  if (!(error instanceof GoogleApiError)) throw error;
1436
- if (error.status === 429 || error.apiStatus === "RESOURCE_EXHAUSTED" || /quota/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-quota", `Google Cloud has no project quota left for this account: ${error.apiMessage || "quota exceeded"}. Delete a project you no longer use (https://console.cloud.google.com/cloud-resource-manager), or run this again and pick an existing project.`, { cause: error });
1437
- if (/terms of service/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-create-denied", "This Google Account has not accepted the Google Cloud Terms of Service. Open https://console.cloud.google.com once to accept them, then run this again.", { cause: error });
1438
- if (isDenied(error) || error.apiStatus === "FAILED_PRECONDITION") throw new PlayServiceAccountError("project-create-denied", `This Google Account may not create Google Cloud projects: ${error.apiMessage || "permission denied"}. Ask your organization's admin for the Project Creator role, or run this again and pick an existing project.`, { cause: error });
1474
+ if (error.status === 429 || error.apiStatus === "RESOURCE_EXHAUSTED" || /quota/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-quota", [
1475
+ "Google Cloud has no project quota left for this account.",
1476
+ error.apiMessage || "Quota exceeded.",
1477
+ "Run this again and pick an existing project, or delete one at:",
1478
+ "https://console.cloud.google.com/cloud-resource-manager"
1479
+ ].join("\n"), { cause: error });
1480
+ 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
+ if (isDenied(error) || error.apiStatus === "FAILED_PRECONDITION") throw new PlayServiceAccountError("project-create-denied", [
1482
+ "This Google Account may not create Google Cloud projects.",
1483
+ error.apiMessage || "Permission denied.",
1484
+ "Pick an existing project, or ask an admin for the Project Creator role."
1485
+ ].join("\n"), { cause: error });
1439
1486
  throw error;
1440
1487
  }
1441
1488
  };
@@ -1455,7 +1502,7 @@ const whilePropagating = async ({ run, project, sleep, notFound = false }) => {
1455
1502
  const disabled = isServiceDisabled(error);
1456
1503
  if (disabled && !consumerIs(error, project)) throw clientApiDisabled(error);
1457
1504
  if (!disabled && !(notFound && isNotFound(error)) || attempt >= PROPAGATION_ATTEMPTS) {
1458
- if (disabled) throw new PlayServiceAccountError("api-not-enabled", `Google is still enabling the APIs in ${project.projectId}: ${error.apiMessage} Wait a few minutes, then run this again.`, { cause: error });
1505
+ 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 });
1459
1506
  throw error;
1460
1507
  }
1461
1508
  await sleep(PROPAGATION_DELAY_MS);
@@ -1478,7 +1525,11 @@ const enableServices = async ({ google, projectId, sleep }) => {
1478
1525
  sleep
1479
1526
  });
1480
1527
  } catch (error) {
1481
- if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", `This Google Account may not enable APIs in ${projectId}: ${error.apiMessage || "permission denied"}. Pick a project you own, or ask its owner for the Editor role.`, { cause: error });
1528
+ if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", [
1529
+ `This Google Account may not enable APIs in ${projectId}.`,
1530
+ error.apiMessage || "Permission denied.",
1531
+ "Pick a project you own, or ask its owner for the Editor role."
1532
+ ].join("\n"), { cause: error });
1482
1533
  throw error;
1483
1534
  }
1484
1535
  };
@@ -1500,7 +1551,11 @@ const ensureServiceAccount = async ({ google, project, sleep }) => {
1500
1551
  })).email ?? email;
1501
1552
  } catch (error) {
1502
1553
  if (isConflict(error)) return email;
1503
- if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", `This Google Account may not create service accounts in ${projectId}: ${error.apiMessage || "permission denied"}. Pick a project you own, or ask its owner for the Service Account Admin role.`, { cause: error });
1554
+ if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", [
1555
+ `This Google Account may not create service accounts in ${projectId}.`,
1556
+ error.apiMessage || "Permission denied.",
1557
+ "Pick a project you own, or ask for the Service Account Admin role."
1558
+ ].join("\n"), { cause: error });
1504
1559
  throw error;
1505
1560
  }
1506
1561
  };
@@ -1572,7 +1627,11 @@ const createKey = async ({ google, project, email, sleep }) => {
1572
1627
  })
1573
1628
  });
1574
1629
  } catch (error) {
1575
- if (error instanceof GoogleApiError && (error.violations.includes(KEY_CREATION_CONSTRAINT) || /disableServiceAccountKeyCreation/u.test(error.apiMessage))) throw new PlayServiceAccountError("key-creation-disabled", `An organization policy (iam.disableServiceAccountKeyCreation) forbids service account keys in ${projectId}; organizations created since 2024 enforce it by default. Ask an organization policy admin to allow keys for this project, or run this again with a project outside the organization. The service account ${email} and its Play Console access remain for the next run.`, { cause: error });
1630
+ if (error instanceof GoogleApiError && (error.violations.includes(KEY_CREATION_CONSTRAINT) || /disableServiceAccountKeyCreation/u.test(error.apiMessage))) throw new PlayServiceAccountError("key-creation-disabled", [
1631
+ `iam.disableServiceAccountKeyCreation forbids keys in ${projectId}.`,
1632
+ "Ask an organization policy admin to allow keys for this project,",
1633
+ "or run this again with a project outside the organization."
1634
+ ].join("\n"), { cause: error });
1576
1635
  throw error;
1577
1636
  }
1578
1637
  if (!key.name || !key.privateKeyData) throw new Error("Google Cloud created the key without its JSON file.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/android-helper",
3
- "version": "1.2.0-rc.2",
3
+ "version": "1.2.0-rc.3",
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.2",
33
- "@hot-updater/plugin-core": "1.2.0-rc.2",
34
- "@hot-updater/protocol": "1.2.0-rc.2"
32
+ "@hot-updater/cli-tools": "1.2.0-rc.3",
33
+ "@hot-updater/plugin-core": "1.2.0-rc.3",
34
+ "@hot-updater/protocol": "1.2.0-rc.3"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^20"