@hot-updater/android-helper 1.2.0-rc.0 → 1.2.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -7,119 +7,34 @@ import JSZip from "jszip";
7
7
  import crypto from "crypto";
8
8
  import { pipeline } from "stream/promises";
9
9
  import { createInflateRaw } from "zlib";
10
- import crypto$1 from "node:crypto";
11
- import http from "node:http";
10
+ //#region src/utils/gradlePhase.ts
11
+ /** `> Task :app:compileReleaseKotlin`, with an outcome such as `UP-TO-DATE`. */
12
+ const TASK = /^> Task (:\S+)/;
13
+ const CONFIGURE = /^> Configure project (:\S*)/;
14
+ /**
15
+ * Reads the phase of a Gradle build from its plain console output: the
16
+ * task it runs, after how many it has run, such as
17
+ * `312 tasks · :app:mergeReleaseResources`.
18
+ */
19
+ const createGradlePhaseReader = () => {
20
+ let tasks = 0;
21
+ return (line) => {
22
+ const task = TASK.exec(line)?.[1];
23
+ if (task) {
24
+ tasks += 1;
25
+ return `${tasks} task${tasks === 1 ? "" : "s"} · ${task}`;
26
+ }
27
+ const project = CONFIGURE.exec(line)?.[1];
28
+ if (project) return `Configuring ${project}`;
29
+ if (line.startsWith("Starting a Gradle Daemon")) return "Starting the Gradle daemon";
30
+ if (line.startsWith("Downloading https://services.gradle.org/")) return "Downloading Gradle";
31
+ };
32
+ };
33
+ //#endregion
12
34
  //#region src/utils/createGradleLogger.ts
13
35
  const createGradleLogger = ({ logPrefix }) => new BuildLogger({
14
36
  logPrefix,
15
- importantLogPatterns: [
16
- "error:",
17
- "Error:",
18
- "ERROR:",
19
- "> Task :",
20
- "BUILD FAILED",
21
- "BUILD SUCCESSFUL",
22
- "FAILURE: Build failed with an exception.",
23
- "* What went wrong:",
24
- "Execution failed for task",
25
- "Could not resolve",
26
- "Failed to",
27
- "Unable to load script",
28
- "Metro encountered an error",
29
- "React Native CLI",
30
- "Android resource compilation failed",
31
- "Duplicate class",
32
- "Program type already present",
33
- "actionable tasks:",
34
- "Deprecated Gradle features were used",
35
- ":app:configureCMake",
36
- ":app:buildCMake",
37
- ":app:compileDebugKotlin",
38
- ":app:compileReleaseKotlin",
39
- ":app:compileDebugJavaWithJavac",
40
- ":app:compileReleaseJavaWithJavac",
41
- ":app:mergeDebugResources",
42
- ":app:mergeReleaseResources",
43
- ":app:processDebugResources",
44
- ":app:processReleaseResources",
45
- ":app:packageDebug",
46
- ":app:packageRelease",
47
- ":app:assembleDebug",
48
- ":app:assembleRelease"
49
- ],
50
- progressStages: [
51
- [
52
- "buildKotlinToolingMetadata",
53
- "generateAutolinkingNewArchitectureFiles",
54
- "generateAutolinkingPackageList",
55
- "generateCodegenSchemaFromJavaScript",
56
- "generateCodegenArtifactsFromSchema",
57
- "generateReactNativeEntryPoint"
58
- ],
59
- [
60
- "createBundleReleaseJsAndAssets",
61
- "bundleReleaseJsAndAssets",
62
- "bundleDebugJsAndAssets"
63
- ],
64
- [
65
- "generateReleaseResValues",
66
- "generateDebugResValues",
67
- "generateReleaseResources",
68
- "generateDebugResources",
69
- "mergeReleaseResources",
70
- "mergeDebugResources"
71
- ],
72
- [
73
- "processReleaseResources",
74
- "processDebugResources",
75
- "processReleaseManifest",
76
- "processDebugManifest",
77
- "processDebugMainManifest",
78
- "parseReleaseLocalResources",
79
- "parseDebugLocalResources"
80
- ],
81
- ["compileReleaseKotlin", "compileDebugKotlin"],
82
- ["compileReleaseJavaWithJavac", "compileDebugJavaWithJavac"],
83
- [
84
- /configureCMake\w+\[/,
85
- /buildCMake\w+\[/,
86
- "mergeReleaseJniLibFolders",
87
- "mergeDebugJniLibFolders",
88
- "mergeReleaseNativeLibs",
89
- "mergeDebugNativeLibs"
90
- ],
91
- [
92
- "desugarReleaseFileDependencies",
93
- "desugarDebugFileDependencies",
94
- "dexBuilderRelease",
95
- "dexBuilderDebug",
96
- "mergeDexRelease",
97
- "mergeExtDexRelease",
98
- "mergeExtDexDebug",
99
- "mergeProjectDexRelease",
100
- "mergeProjectDexDebug",
101
- "mergeLibDexRelease",
102
- "mergeLibDexDebug",
103
- "mergeDebugGlobalSynthetics",
104
- "mergeReleaseGlobalSynthetics",
105
- "transformClassesWithDex"
106
- ],
107
- [
108
- "compressReleaseAssets",
109
- "compressDebugAssets",
110
- "optimizeReleaseResources",
111
- "mergeReleaseAssets",
112
- "mergeDebugAssets"
113
- ],
114
- [
115
- "packageRelease",
116
- "packageDebug",
117
- "assembleRelease",
118
- "assembleDebug"
119
- ],
120
- ["bundleRelease", "bundleDebug"],
121
- ["BUILD SUCCESSFUL", /\d+ actionable tasks:/]
122
- ],
37
+ createPhaseReader: createGradlePhaseReader,
123
38
  failureSummary: {
124
39
  lines: [
125
40
  /^e: /,
@@ -134,6 +49,26 @@ const createGradleLogger = ({ logPrefix }) => new BuildLogger({
134
49
  }
135
50
  });
136
51
  //#endregion
52
+ //#region src/utils/formatCommand.ts
53
+ /**
54
+ * A command to print, broken at spaces with ` \` once a line would pass
55
+ * `width`, each continuation indented two spaces, so a terminal never wraps
56
+ * it mid-word and it still pastes as one command.
57
+ */
58
+ const formatCommand = (words, width = 74) => {
59
+ const lines = [];
60
+ let line = "";
61
+ for (const word of words) {
62
+ const next = line ? `${line} ${word}` : word;
63
+ if (line && next.length > width - 2) {
64
+ lines.push(`${line} \\`);
65
+ line = ` ${word}`;
66
+ } else line = next;
67
+ }
68
+ lines.push(line);
69
+ return lines.join("\n");
70
+ };
71
+ //#endregion
137
72
  //#region src/builder/runGradle.ts
138
73
  /** Why Gradle failed, without its command line. */
139
74
  const describeFailure = (error) => {
@@ -156,19 +91,24 @@ const getGradleWrapper = () => process.platform.startsWith("win") ? "gradlew.bat
156
91
  /**
157
92
  * Run gradle build with specified tasks and arguments
158
93
  */
159
- async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appModuleName }) {
94
+ async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appModuleName, logHeader = [] }) {
160
95
  const gradleArgs = getTaskNames(appModuleName, tasks);
161
96
  gradleArgs.push("-x", "lint");
162
97
  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
- `);
169
98
  if ("port" in args && args.port != null) gradleArgs.push(`-PreactNativeDevServerPort=${args.port}`);
170
99
  const logger = createGradleLogger({ logPrefix });
171
- await logger.start();
100
+ await logger.start({
101
+ title: `Building ${tasks.join(", ")}`,
102
+ header: [
103
+ ...logHeader,
104
+ `Project ${path.relative(getCwd(), androidProjectPath) || "."}`,
105
+ `App module ${appModuleName}`,
106
+ `Tasks ${tasks.join(", ")}`,
107
+ `$ ${formatCommand([path.basename(getGradleWrapper()), ...gradleArgs])}`,
108
+ ""
109
+ ]
110
+ });
111
+ let logFilePath = null;
172
112
  try {
173
113
  const gradle = execa(getGradleWrapper(), gradleArgs, {
174
114
  cwd: androidProjectPath,
@@ -180,11 +120,13 @@ Args ${gradleArgs.join(" ")}
180
120
  const [gradleResult, loggingResult] = await Promise.allSettled([gradle, outputStream ? logger.processStream(outputStream) : void 0]);
181
121
  if (gradleResult.status === "rejected") throw gradleResult.reason;
182
122
  if (loggingResult.status === "rejected") throw loggingResult.reason;
183
- logger.stop();
123
+ logger.stop(`Built ${tasks.join(", ")}`);
124
+ logFilePath = logger.logFilePath;
184
125
  } catch (e) {
185
126
  logger.writeError(e);
186
- logger.stop("Build failed", false);
187
- throw new BuildFailedError(`Build failed: ${describeFailure(e)}`, {
127
+ const reason = describeFailure(e);
128
+ logger.stop(`Build failed · ${reason.replace(/\.$/, "")}`, false);
129
+ throw new BuildFailedError(`Build failed: ${reason}`, {
188
130
  logPath: logger.logPath,
189
131
  summary: logger.failureSummary,
190
132
  cause: e
@@ -192,11 +134,14 @@ Args ${gradleArgs.join(" ")}
192
134
  } finally {
193
135
  await logger.close();
194
136
  }
195
- return findBuildDirectory({
196
- androidProjectPath,
197
- moduleName: appModuleName,
198
- tasks
199
- });
137
+ return {
138
+ ...await findBuildDirectory({
139
+ androidProjectPath,
140
+ moduleName: appModuleName,
141
+ tasks
142
+ }),
143
+ logFilePath
144
+ };
200
145
  }
201
146
  /**
202
147
  * Find the build output directory and artifact path
@@ -227,7 +172,7 @@ async function getBuildOutputFilePath({ aab, appModuleName, buildDirectory, vari
227
172
  const outputFile = `${appModuleName}-${variant}.${aab ? "aab" : "apk"}`;
228
173
  const outputFilePath = path.join(buildDirectory, outputFile);
229
174
  if (fs.existsSync(outputFilePath)) return outputFilePath;
230
- p.log.error(`Failed to find the output file for ${outputFilePath}`);
175
+ p.log.error(`Gradle wrote no ${outputFilePath}.`);
231
176
  process.exit(1);
232
177
  }
233
178
  //#endregion
@@ -247,7 +192,7 @@ const resolveGradleSigningEnv = async ({ schemeConfig, credentials }) => {
247
192
  const reference = schemeConfig.signing?.keystore;
248
193
  if (!reference) return;
249
194
  if (isDebugVariant(schemeConfig.variant)) {
250
- p.log.info(`${schemeConfig.variant} signs with the debug keystore, so signing.keystore is not used.`);
195
+ p.log.info(`${schemeConfig.variant} signs with the debug keystore; signing.keystore is unused.`);
251
196
  return;
252
197
  }
253
198
  const keystore = await (credentials ?? createCredentialResolver()).resolve({
@@ -400,7 +345,7 @@ const writeBaseModuleApk = async (aabPath, directory) => {
400
345
  const readBuiltApp = async ({ artifactPath, androidProjectPath }) => {
401
346
  const aapt2 = findAapt2(androidProjectPath);
402
347
  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.");
348
+ p.log.warn("No aapt2 in the Android SDK build-tools.\nThe build reports the project files' app version and channel.");
404
349
  return;
405
350
  }
406
351
  const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "android-helper-"));
@@ -425,7 +370,7 @@ const readBuiltApp = async ({ artifactPath, androidProjectPath }) => {
425
370
  };
426
371
  const appVersion = await resolve(parseManifestVersionName(xmltree));
427
372
  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.");
373
+ 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
374
  const versionCode = parseManifestVersionCode(xmltree);
430
375
  return {
431
376
  appVersion: appVersion ?? null,
@@ -490,7 +435,7 @@ const readBuiltVersionCode = async ({ artifactPath, androidProjectPath }) => {
490
435
  };
491
436
  //#endregion
492
437
  //#region src/buildAndroid.ts
493
- const buildAndroid = async ({ schemeConfig, minBundleId = createMinBundleId(), credentials }) => {
438
+ const buildAndroid = async ({ schemeConfig, minBundleId = createMinBundleId(), credentials, logHeader }) => {
494
439
  const androidProjectPath = path.join(getCwd(), "android");
495
440
  const result = await runGradle({
496
441
  args: { extraParams: [`-PMIN_BUNDLE_ID=${minBundleId}`] },
@@ -501,7 +446,8 @@ const buildAndroid = async ({ schemeConfig, minBundleId = createMinBundleId(), c
501
446
  appModuleName: schemeConfig.appModuleName,
502
447
  tasks: schemeConfig.aab ? [`bundle${schemeConfig.variant}`] : [`assemble${schemeConfig.variant}`],
503
448
  logPrefix: `android-${schemeConfig.hotUpdaterSchemeName}-build`,
504
- androidProjectPath
449
+ androidProjectPath,
450
+ logHeader
505
451
  });
506
452
  return {
507
453
  ...result,
@@ -556,7 +502,7 @@ const readServiceAccount = (json) => {
556
502
  throw new Error("The Play service account is not a JSON key file.");
557
503
  }
558
504
  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.");
505
+ 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
506
  registerSecretValue(private_key);
561
507
  let privateKey;
562
508
  try {
@@ -602,7 +548,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
602
548
  var PlayVersionCodeUsedError = class extends Error {
603
549
  name = "PlayVersionCodeUsedError";
604
550
  constructor(options) {
605
- super("Google Play already has this .aab's version code. Raise versionCode for every build you upload.", options);
551
+ super("Google Play already has this .aab's version code.\nRaise versionCode for every build you upload.", options);
606
552
  }
607
553
  };
608
554
  /**
@@ -612,15 +558,20 @@ var PlayVersionCodeUsedError = class extends Error {
612
558
  const explain = (error, packageName) => {
613
559
  if (!(error instanceof GooglePlayError)) return error;
614
560
  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 });
561
+ if (status === 401 || /does not have permission/i.test(apiMessage)) return new Error([
562
+ `The Play service account can't release ${packageName}.`,
563
+ "Invite its email in Play Console (Users and permissions) to release to",
564
+ "testing tracks, and enable the Google Play Android Developer API."
565
+ ].join("\n"), { cause: error });
566
+ 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
567
  if (/version code .*(already been used|already used)/i.test(apiMessage)) return new PlayVersionCodeUsedError({ cause: error });
618
568
  return error;
619
569
  };
620
570
  /**
621
571
  * Requests to the Google Play Developer API for `packageName`, as the
622
572
  * service account: each carries an access token, exchanged for a signed
623
- * assertion and replaced well before it expires.
573
+ * assertion and replaced well before it expires. `checkPlayAccess`
574
+ * checks a key's access with it too.
624
575
  */
625
576
  const createPlayClient = ({ json, packageName, fetch }) => {
626
577
  const account = readServiceAccount(json);
@@ -638,7 +589,7 @@ const createPlayClient = ({ json, packageName, fetch }) => {
638
589
  assertion
639
590
  }).toString()
640
591
  });
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."}`);
592
+ 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
593
  const { access_token } = await response.json();
643
594
  if (typeof access_token !== "string" || !access_token) throw new Error("Google answered the Play service account with no access token.");
644
595
  registerSecretValue(access_token);
@@ -669,6 +620,66 @@ const createPlayClient = ({ json, packageName, fetch }) => {
669
620
  };
670
621
  };
671
622
  /**
623
+ * Play Console's page that invites `email` as a user. `_` stands for the
624
+ * developer account the browser is signed in to, so no ID is needed.
625
+ */
626
+ const playConsoleInviteUrl = (email) => `https://play.google.com/console/u/0/developers/_/users-and-permissions?invite=${encodeURIComponent(email)}`;
627
+ const readClientEmail = (json) => {
628
+ try {
629
+ const { client_email } = JSON.parse(json);
630
+ return typeof client_email === "string" ? client_email : "";
631
+ } catch {
632
+ return "";
633
+ }
634
+ };
635
+ /**
636
+ * Checks once whether the service account's key reaches `packageName`: it
637
+ * opens an edit of the app and deletes it again, which changes nothing.
638
+ * Throws only for what says nothing about access, such as a network error.
639
+ */
640
+ const checkPlayAccess = async ({ serviceAccount: { json }, packageName, fetch = globalThis.fetch }) => {
641
+ const email = readClientEmail(json);
642
+ const refused = (error) => ({
643
+ status: "refused",
644
+ email,
645
+ reason: error instanceof Error ? error.message : String(error)
646
+ });
647
+ let play;
648
+ try {
649
+ play = createPlayClient({
650
+ json,
651
+ packageName,
652
+ fetch
653
+ });
654
+ } catch (error) {
655
+ return refused(error);
656
+ }
657
+ try {
658
+ await play.authorization();
659
+ } catch (error) {
660
+ if (error instanceof TypeError) throw error;
661
+ return refused(error);
662
+ }
663
+ const edits = `${API_URL}${play.app}/edits`;
664
+ try {
665
+ const edit = await play.request(`Opening an edit of ${packageName}`, "POST", edits, {});
666
+ if (edit?.id) await play.request("Deleting the edit", "DELETE", `${edits}/${encodeURIComponent(edit.id)}`).catch(() => {});
667
+ return { status: "ready" };
668
+ } catch (error) {
669
+ if (error instanceof GooglePlayError) {
670
+ if (error.status === 401 || error.status === 403) return {
671
+ status: "denied",
672
+ email
673
+ };
674
+ if (error.status === 404) return {
675
+ status: "not-found",
676
+ email
677
+ };
678
+ }
679
+ throw error;
680
+ }
681
+ };
682
+ /**
672
683
  * Whether Google Play's internal testing track already releases the
673
684
  * `.aab`'s version code, as when a reused native build was uploaded
674
685
  * before. It reads the track in an edit it then deletes, changing nothing.
@@ -716,7 +727,11 @@ const assertPlaySigned = async (aabPath) => {
716
727
  "-jarfile",
717
728
  aabPath
718
729
  ], { 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.`);
730
+ if (exitCode === 0 && ANDROID_DEBUG_SIGNER.test(String(stdout))) throw new Error([
731
+ "Google Play refuses an .aab signed with the Android debug keystore.",
732
+ "Set the scheme's signing.keystore, or a release signingConfig in",
733
+ "android/app/build.gradle, then build again."
734
+ ].join("\n"));
720
735
  };
721
736
  /**
722
737
  * Uploads an `.aab` to Google Play's internal testing track with the Google
@@ -767,7 +782,7 @@ const uploadToPlay = async ({ aabPath, packageName, serviceAccount: { json }, fe
767
782
  };
768
783
  } catch (error) {
769
784
  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.`);
785
+ p.log.warn(`${packageName} isn't published yet, so version code ${versionCode} is a draft.\nRoll it out in Play Console.`);
771
786
  await release("draft");
772
787
  return {
773
788
  versionCode,
@@ -789,10 +804,14 @@ const commit = async ({ request, edit, retryDelayMs }) => {
789
804
  let changesNotSentForReview = false;
790
805
  for (let attempt = 1;; attempt++) try {
791
806
  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.");
807
+ if (changesNotSentForReview) p.log.warn("Google Play saved the release without sending it for review.\nSend the changes for review in Play Console.");
793
808
  return;
794
809
  } 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 });
810
+ if (!(error instanceof GooglePlayError) || error.status >= 500 || error.status === 429) throw new Error([
811
+ "Google Play didn't confirm the release.",
812
+ error instanceof Error ? error.message : String(error),
813
+ "Check internal testing in Play Console before you upload it again."
814
+ ].join("\n"), { cause: error });
796
815
  if (error.status !== 400) throw error;
797
816
  if (!changesNotSentForReview && error.apiMessage.includes(NEEDS_CHANGES_NOT_SENT_FOR_REVIEW)) {
798
817
  changesNotSentForReview = true;
@@ -895,9 +914,9 @@ const confirmedOffset = (response) => {
895
914
  * from new apps, or a debug variant, which Play refuses.
896
915
  */
897
916
  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).`);
917
+ if (androidScheme.aab === false) throw new Error(`The scheme ${scheme} builds an .apk (aab: false); Google Play takes an .aab.`);
899
918
  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.`);
919
+ if (/debug$/i.test(variant)) throw new Error(`The scheme ${scheme} builds ${variant}; Google Play takes a release build.`);
901
920
  return {
902
921
  kind: "play-service-account",
903
922
  name: "play-service-account"
@@ -1030,592 +1049,4 @@ const extractBuiltInBundle = async ({ artifactPath, outputPath }) => {
1030
1049
  await (entry.method === 8 ? pipeline(source, createInflateRaw(), fs.createWriteStream(outputPath)) : pipeline(source, fs.createWriteStream(outputPath)));
1031
1050
  };
1032
1051
  //#endregion
1033
- //#region src/google/googleApi.ts
1034
- /** A Google Cloud or Google Play API request that failed, with Google's error. */
1035
- var GoogleApiError = class extends Error {
1036
- status;
1037
- /** Google's canonical status, such as `PERMISSION_DENIED`. */
1038
- apiStatus;
1039
- apiMessage;
1040
- /** The `ErrorInfo` reasons, such as `SERVICE_DISABLED`. */
1041
- reasons;
1042
- /** The `PreconditionFailure` violation types, such as an org policy constraint. */
1043
- violations;
1044
- /** The `ErrorInfo` consumer of a disabled service, such as `projects/123`. */
1045
- consumer;
1046
- constructor(action, status, body = {}) {
1047
- const apiMessage = body.apiMessage ?? "";
1048
- super(`${action} failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : "."}`);
1049
- this.name = "GoogleApiError";
1050
- this.status = status;
1051
- this.apiStatus = body.apiStatus;
1052
- this.apiMessage = apiMessage;
1053
- this.reasons = body.reasons ?? [];
1054
- this.violations = body.violations ?? [];
1055
- this.consumer = body.consumer;
1056
- }
1057
- };
1058
- /** Google's error body, `{ error: { code, status, message, details } }`. */
1059
- const readError = async (action, response) => {
1060
- let error;
1061
- try {
1062
- error = (await response.json()).error;
1063
- } catch {}
1064
- const details = Array.isArray(error?.details) ? error.details : [];
1065
- const info = details.filter((detail) => typeof detail.reason === "string");
1066
- return new GoogleApiError(action, response.status, {
1067
- ...typeof error?.status === "string" ? { apiStatus: error.status } : {},
1068
- ...typeof error?.message === "string" ? { apiMessage: error.message } : {},
1069
- reasons: info.map((detail) => detail.reason),
1070
- violations: details.flatMap((detail) => (detail.violations ?? []).flatMap((violation) => typeof violation.type === "string" ? [violation.type] : [])),
1071
- ...info.find((detail) => detail.metadata?.["consumer"]) ? { consumer: info.find((detail) => detail.metadata?.["consumer"]).metadata["consumer"] } : {}
1072
- });
1073
- };
1074
- /** Whether Google answered that a service is disabled in a project. */
1075
- const isServiceDisabled = (error) => error instanceof GoogleApiError && (error.reasons.includes("SERVICE_DISABLED") || /has not been used in project .* or it is disabled/i.test(error.apiMessage));
1076
- /** Requests with the signed-in user's access token, throwing `GoogleApiError`. */
1077
- const createGoogleRequest = ({ accessToken, fetch }) => async (action, method, url, body) => {
1078
- const response = await fetch(url, {
1079
- method,
1080
- headers: {
1081
- authorization: `Bearer ${accessToken}`,
1082
- ...body === void 0 ? {} : { "content-type": "application/json" }
1083
- },
1084
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
1085
- });
1086
- if (!response.ok) throw await readError(action, response);
1087
- const text = await response.text();
1088
- return text ? JSON.parse(text) : {};
1089
- };
1090
- /**
1091
- * Polls `operation` at `origin` until it is done, returning its response or
1092
- * throwing its error as a `GoogleApiError` with the operation's code.
1093
- */
1094
- const waitForOperation = async ({ request, origin, operation, action, sleep, intervalMs = 2e3, attempts = 60 }) => {
1095
- let current = operation;
1096
- for (let attempt = 0; !current.done; attempt++) {
1097
- if (attempt >= attempts || !current.name) throw new Error(`${action} did not finish in time.`);
1098
- await sleep(intervalMs);
1099
- current = await request(action, "GET", `${origin}/${current.name}`);
1100
- }
1101
- if (current.error) throw new GoogleApiError(action, 400, {
1102
- apiMessage: current.error.message ?? "",
1103
- ...current.error.code === 7 ? { apiStatus: "PERMISSION_DENIED" } : {},
1104
- ...current.error.code === 8 ? { apiStatus: "RESOURCE_EXHAUSTED" } : {}
1105
- });
1106
- return current.response;
1107
- };
1108
- //#endregion
1109
- //#region src/google/types.ts
1110
- /** A named failure of `issuePlayServiceAccount`, with what to do in its message. */
1111
- var PlayServiceAccountError = class extends Error {
1112
- code;
1113
- constructor(code, message, options) {
1114
- super(message, options);
1115
- this.name = "PlayServiceAccountError";
1116
- this.code = code;
1117
- }
1118
- };
1119
- /** The environment variables that replace Hot Updater's Google OAuth client. */
1120
- const GOOGLE_OAUTH_CLIENT_ENV_NAMES = {
1121
- clientId: "HOT_UPDATER_GOOGLE_OAUTH_CLIENT_ID",
1122
- clientSecret: "HOT_UPDATER_GOOGLE_OAUTH_CLIENT_SECRET"
1123
- };
1124
- /** The service account Hot Updater creates, `hot-updater-play@<project>.iam.gserviceaccount.com`. */
1125
- const PLAY_SERVICE_ACCOUNT_ID = "hot-updater-play";
1126
- /** Where a Play Console admin finds the developer account ID. */
1127
- const PLAY_CONSOLE_URL = "https://play.google.com/console";
1128
- //#endregion
1129
- //#region src/google/googleSignIn.ts
1130
- const AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
1131
- const TOKEN_URL = "https://oauth2.googleapis.com/token";
1132
- const REVOKE_URL = "https://oauth2.googleapis.com/revoke";
1133
- /** How long the browser has to come back with the sign-in. */
1134
- const SIGN_IN_TIMEOUT_MS = 3e5;
1135
- const base64url = (bytes) => bytes.toString("base64url");
1136
- const page = (message) => `<!doctype html><meta charset="utf-8"><title>Hot Updater</title><body style="font-family:system-ui;margin:3rem"><p>${message}</p></body>`;
1137
- /**
1138
- * Signs in with Google as an installed app does (RFC 8252): the browser
1139
- * opens Google's own consent screen, so the password and the second factor
1140
- * stay with Google, and the authorization code comes back to a loopback
1141
- * server on 127.0.0.1, bound to a random port for this sign-in only. The
1142
- * code is exchanged with PKCE (S256), and `state` ties the answer to this
1143
- * request.
1144
- *
1145
- * @param onUrl Sees the sign-in URL, to print and open in a browser.
1146
- */
1147
- const signInWithGoogle = async ({ client, scopes, onUrl, fetch = globalThis.fetch, timeoutMs = SIGN_IN_TIMEOUT_MS }) => {
1148
- const verifier = base64url(crypto$1.randomBytes(32));
1149
- const challenge = base64url(crypto$1.createHash("sha256").update(verifier).digest());
1150
- const state = base64url(crypto$1.randomBytes(16));
1151
- const server = http.createServer();
1152
- await new Promise((resolve, reject) => {
1153
- server.once("error", reject);
1154
- server.listen(0, "127.0.0.1", () => resolve());
1155
- });
1156
- try {
1157
- const { port } = server.address();
1158
- const redirectUri = `http://127.0.0.1:${port}`;
1159
- const code = new Promise((resolve, reject) => {
1160
- const timer = setTimeout(() => reject(new PlayServiceAccountError("sign-in-cancelled", "The Google sign-in did not finish in time.")), timeoutMs);
1161
- timer.unref();
1162
- server.on("request", (req, res) => {
1163
- const query = new URL(req.url ?? "/", redirectUri).searchParams;
1164
- if (query.get("state") !== state) {
1165
- res.writeHead(404).end();
1166
- return;
1167
- }
1168
- const error = query.get("error");
1169
- const answer = query.get("code");
1170
- res.writeHead(error || !answer ? 400 : 200, { "content-type": "text/html; charset=utf-8" }).end(page(error || !answer ? "The sign-in did not finish. Return to the terminal." : "Signed in. Close this tab and return to the terminal."));
1171
- clearTimeout(timer);
1172
- if (error || !answer) reject(new PlayServiceAccountError("sign-in-cancelled", error === "access_denied" ? "The Google sign-in was cancelled." : `The Google sign-in failed: ${error ?? "no authorization code"}.`));
1173
- else resolve(answer);
1174
- });
1175
- });
1176
- code.catch(() => {});
1177
- const url = new URL(AUTHORIZATION_URL);
1178
- url.search = new URLSearchParams({
1179
- client_id: client.clientId,
1180
- redirect_uri: redirectUri,
1181
- response_type: "code",
1182
- scope: scopes.join(" "),
1183
- code_challenge: challenge,
1184
- code_challenge_method: "S256",
1185
- state,
1186
- prompt: "select_account"
1187
- }).toString();
1188
- await onUrl(url.toString());
1189
- const authorizationCode = await code;
1190
- registerSecretValue(authorizationCode);
1191
- const response = await fetch(TOKEN_URL, {
1192
- method: "POST",
1193
- headers: { "content-type": "application/x-www-form-urlencoded" },
1194
- body: new URLSearchParams({
1195
- code: authorizationCode,
1196
- client_id: client.clientId,
1197
- ...client.clientSecret ? { client_secret: client.clientSecret } : {},
1198
- redirect_uri: redirectUri,
1199
- grant_type: "authorization_code",
1200
- code_verifier: verifier
1201
- }).toString()
1202
- });
1203
- 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}` : "."}`);
1205
- registerSecretValue(token.access_token);
1206
- return {
1207
- accessToken: token.access_token,
1208
- scopes: typeof token.scope === "string" ? token.scope.split(" ") : []
1209
- };
1210
- } finally {
1211
- server.closeAllConnections();
1212
- await new Promise((resolve) => server.close(() => resolve()));
1213
- }
1214
- };
1215
- /** Ends a sign-in: Google revokes its access token. Whether Google did. */
1216
- const revokeGoogleToken = async (accessToken, fetch = globalThis.fetch) => {
1217
- try {
1218
- return (await fetch(REVOKE_URL, {
1219
- method: "POST",
1220
- headers: { "content-type": "application/x-www-form-urlencoded" },
1221
- body: new URLSearchParams({ token: accessToken }).toString()
1222
- })).ok;
1223
- } catch {
1224
- return false;
1225
- }
1226
- };
1227
- //#endregion
1228
- //#region src/google/googleKeyIssuer.ts
1229
- const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
1230
- const ANDROID_PUBLISHER_SCOPE = "https://www.googleapis.com/auth/androidpublisher";
1231
- const SCOPES = [CLOUD_PLATFORM_SCOPE, ANDROID_PUBLISHER_SCOPE];
1232
- const PUBLISHER_API = "https://androidpublisher.googleapis.com/androidpublisher/v3";
1233
- const RESOURCE_MANAGER_API = "https://cloudresourcemanager.googleapis.com";
1234
- const SERVICE_USAGE_API = "https://serviceusage.googleapis.com";
1235
- const IAM_API = "https://iam.googleapis.com/v1";
1236
- /** IAM creates the service account; the Play Developer API is what it calls. */
1237
- const SERVICES = ["iam.googleapis.com", "androidpublisher.googleapis.com"];
1238
- /**
1239
- * What the service account may do in Play Console, for the app only: read
1240
- * it, and release to testing tracks, which `--submit play-internal` does.
1241
- */
1242
- const APP_PERMISSIONS = ["CAN_VIEW_NON_FINANCIAL_DATA", "CAN_MANAGE_TRACK_APKS"];
1243
- const KEY_CREATION_CONSTRAINT = "constraints/iam.disableServiceAccountKeyCreation";
1244
- /** Retries while Google enables an API or creates a service account. */
1245
- const PROPAGATION_ATTEMPTS = 12;
1246
- const PROPAGATION_DELAY_MS = 1e4;
1247
- const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1248
- /** The developer account ID in `input`, a number or a Play Console URL. */
1249
- const parseDeveloperId = (input) => {
1250
- const trimmed = input.trim();
1251
- if (/^\d+$/u.test(trimmed)) return trimmed;
1252
- return trimmed.match(/\/developers\/(\d+)(?:[/?#]|$)/u)?.[1];
1253
- };
1254
- /** A project ID nobody has: Google Cloud's are global. */
1255
- const newProjectId = () => `${PLAY_SERVICE_ACCOUNT_ID}-${crypto$1.randomBytes(3).toString("hex")}`;
1256
- const isNotFound = (error) => error instanceof GoogleApiError && error.status === 404;
1257
- const isConflict = (error) => error instanceof GoogleApiError && error.status === 409;
1258
- /** 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 });
1260
- /** Google's own words, after a message that names the likely cause. */
1261
- const googleSays = (error) => error instanceof GoogleApiError && error.apiMessage ? ` (Google: ${error.apiMessage})` : "";
1262
- /** 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 });
1264
- const isDenied = (error) => error instanceof GoogleApiError && (error.status === 403 || error.apiStatus === "PERMISSION_DENIED");
1265
- /**
1266
- * `IssuePlayServiceAccount` with Google's own APIs, after a sign-in on
1267
- * Google's consent screen (OAuth for installed apps, with PKCE and a
1268
- * loopback redirect):
1269
- *
1270
- * 1. the Play Console side is checked first, with no change anywhere: the
1271
- * developer account answers to the account as its admin, and has the app;
1272
- * 2. the chosen Google Cloud project, or a new one, gets the IAM and Play
1273
- * Developer APIs enabled, and the service account `hot-updater-play`;
1274
- * 3. the service account becomes a Play Console user that may release the
1275
- * app to testing tracks;
1276
- * 4. only then is its JSON key created, the last step, so a failure before
1277
- * it leaves no key behind.
1278
- *
1279
- * Each step reuses what an earlier run created: the project, the service
1280
- * account, and its Play Console access.
1281
- */
1282
- const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor } = {}) => async (request) => {
1283
- 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.`);
1285
- const signIn = await signInWithGoogle({
1286
- client: oauthClient,
1287
- scopes: SCOPES,
1288
- onUrl: request.onSignInUrl,
1289
- fetch
1290
- });
1291
- const dispose = async () => {
1292
- await revokeGoogleToken(signIn.accessToken, fetch);
1293
- };
1294
- try {
1295
- 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.`);
1297
- const google = createGoogleRequest({
1298
- accessToken: signIn.accessToken,
1299
- fetch
1300
- });
1301
- const developerInput = await request.askDeveloperId();
1302
- 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.`);
1304
- onStep("Checking the Play Console developer account");
1305
- await checkPlayConsole({
1306
- google,
1307
- developerId,
1308
- packageName
1309
- });
1310
- const project = await chooseProject({
1311
- google,
1312
- request,
1313
- sleep,
1314
- onStep
1315
- });
1316
- const { projectId } = project;
1317
- onStep(`Enabling the Google Play Developer API in ${projectId}`);
1318
- await enableServices({
1319
- google,
1320
- projectId,
1321
- sleep
1322
- });
1323
- onStep(`Creating the service account in ${projectId}`);
1324
- const email = await ensureServiceAccount({
1325
- google,
1326
- project,
1327
- sleep
1328
- });
1329
- onStep(`Giving ${email} release access to ${packageName}`);
1330
- await grantPlayAccess({
1331
- google,
1332
- developerId,
1333
- email,
1334
- packageName
1335
- });
1336
- onStep("Creating the service account's JSON key");
1337
- const key = await createKey({
1338
- google,
1339
- project,
1340
- email,
1341
- sleep
1342
- });
1343
- return {
1344
- json: key.json,
1345
- keyId: key.keyId,
1346
- email,
1347
- projectId,
1348
- developerId,
1349
- revoke: async () => {
1350
- try {
1351
- await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`);
1352
- return true;
1353
- } catch (error) {
1354
- return isNotFound(error);
1355
- }
1356
- },
1357
- dispose
1358
- };
1359
- } catch (error) {
1360
- await dispose();
1361
- throw error;
1362
- }
1363
- };
1364
- /**
1365
- * Whether the signed-in account administers `developerId`, and Google Play
1366
- * has `packageName` there, asked without changing anything: listing the
1367
- * account's users takes the admin permission, and an edit opened on the app
1368
- * is deleted again.
1369
- */
1370
- const checkPlayConsole = async ({ google, developerId, packageName }) => {
1371
- try {
1372
- await google("Listing the Play Console users", "GET", `${PUBLISHER_API}/developers/${developerId}/users?pageSize=1`);
1373
- } catch (error) {
1374
- 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 });
1376
- if (isDenied(error)) throw notPlayAdmin(developerId, error);
1377
- throw error;
1378
- }
1379
- let edit;
1380
- try {
1381
- edit = await google(`Opening an edit of ${packageName}`, "POST", `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits`);
1382
- } catch (error) {
1383
- if (isServiceDisabled(error)) throw clientApiDisabled(error);
1384
- if (isNotFound(error) || isDenied(error)) throw appNotFound(packageName, developerId, error);
1385
- throw error;
1386
- }
1387
- if (edit.id) await google("Deleting the edit", "DELETE", `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits/${edit.id}`).catch(() => {});
1388
- };
1389
- const appNotFound = (packageName, developerId, cause) => new PlayServiceAccountError("app-not-found", `Google Play has no app ${packageName} in the developer account ${developerId}. Google takes the first .aab of a new app only in Play Console: create the app there, upload one .aab by hand (for example to internal testing), then run this again${googleSays(cause)}.`, { cause });
1390
- /** The account's active projects, every page of them. */
1391
- const listProjects = async (google) => {
1392
- const projects = [];
1393
- let pageToken;
1394
- do {
1395
- const page = await google("Listing the Google Cloud projects", "GET", `${RESOURCE_MANAGER_API}/v3/projects:search?${new URLSearchParams({
1396
- query: "state:ACTIVE",
1397
- ...pageToken ? { pageToken } : {}
1398
- })}`);
1399
- for (const project of page.projects ?? []) if (project.projectId) projects.push({
1400
- projectId: project.projectId,
1401
- ...project.displayName ? { displayName: project.displayName } : {},
1402
- ...projectNumberOf(project.name)
1403
- });
1404
- pageToken = page.nextPageToken || void 0;
1405
- } while (pageToken);
1406
- return projects;
1407
- };
1408
- /** The project the developer picks, or a new one when there is none or they ask. */
1409
- const chooseProject = async ({ google, request, sleep, onStep }) => {
1410
- const projects = await listProjects(google);
1411
- const chosen = projects.length > 0 ? await request.chooseProject(projects.map(({ projectId, displayName }) => ({
1412
- projectId,
1413
- ...displayName ? { displayName } : {}
1414
- }))) : void 0;
1415
- if (chosen !== void 0) return projects.find((project) => project.projectId === chosen) ?? { projectId: chosen };
1416
- const projectId = newProjectId();
1417
- onStep(`Creating the Google Cloud project ${projectId}`);
1418
- try {
1419
- const operation = await google("Creating the Google Cloud project", "POST", `${RESOURCE_MANAGER_API}/v3/projects`, {
1420
- projectId,
1421
- displayName: "Hot Updater Play"
1422
- });
1423
- const created = await waitForOperation({
1424
- request: google,
1425
- origin: `${RESOURCE_MANAGER_API}/v3`,
1426
- operation,
1427
- action: "Creating the Google Cloud project",
1428
- sleep
1429
- });
1430
- return {
1431
- projectId,
1432
- ...projectNumberOf(created?.name)
1433
- };
1434
- } catch (error) {
1435
- 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 });
1439
- throw error;
1440
- }
1441
- };
1442
- /** `projectNumber` from a resource name `projects/<number>`. */
1443
- const projectNumberOf = (name) => {
1444
- const number = name?.match(/^projects\/(\d+)$/u)?.[1];
1445
- return number === void 0 ? {} : { projectNumber: number };
1446
- };
1447
- /**
1448
- * Runs `run` again while Google is still enabling an API in `project`,
1449
- * or, with `notFound`, still creating what it reads.
1450
- */
1451
- const whilePropagating = async ({ run, project, sleep, notFound = false }) => {
1452
- for (let attempt = 1;; attempt++) try {
1453
- return await run();
1454
- } catch (error) {
1455
- const disabled = isServiceDisabled(error);
1456
- if (disabled && !consumerIs(error, project)) throw clientApiDisabled(error);
1457
- 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 });
1459
- throw error;
1460
- }
1461
- await sleep(PROPAGATION_DELAY_MS);
1462
- }
1463
- };
1464
- /**
1465
- * Whether a disabled-service error is about `project` itself, which waiting
1466
- * fixes: Google names the consumer by number, and an error naming none
1467
- * counts as the project's own.
1468
- */
1469
- const consumerIs = (error, project) => error.consumer === void 0 || error.consumer === `projects/${project.projectId}` || (project.projectNumber === void 0 ? true : error.consumer === `projects/${project.projectNumber}`);
1470
- const enableServices = async ({ google, projectId, sleep }) => {
1471
- try {
1472
- const operation = await google("Enabling the APIs", "POST", `${SERVICE_USAGE_API}/v1/projects/${projectId}/services:batchEnable`, { serviceIds: SERVICES });
1473
- await waitForOperation({
1474
- request: google,
1475
- origin: `${SERVICE_USAGE_API}/v1`,
1476
- operation,
1477
- action: "Enabling the APIs",
1478
- sleep
1479
- });
1480
- } 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 });
1482
- throw error;
1483
- }
1484
- };
1485
- /** The `hot-updater-play` service account's email, created unless it exists. */
1486
- const ensureServiceAccount = async ({ google, project, sleep }) => {
1487
- const { projectId } = project;
1488
- const email = `${PLAY_SERVICE_ACCOUNT_ID}@${projectId}.iam.gserviceaccount.com`;
1489
- try {
1490
- return (await whilePropagating({
1491
- project,
1492
- sleep,
1493
- run: () => google("Creating the service account", "POST", `${IAM_API}/projects/${projectId}/serviceAccounts`, {
1494
- accountId: "hot-updater-play",
1495
- serviceAccount: {
1496
- displayName: "Hot Updater Play uploads",
1497
- description: "Uploads Android App Bundles to Google Play for hot-updater native-build android --submit play-internal."
1498
- }
1499
- })
1500
- })).email ?? email;
1501
- } catch (error) {
1502
- 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 });
1504
- throw error;
1505
- }
1506
- };
1507
- /**
1508
- * Makes `email` a Play Console user that may release `packageName`, keeping
1509
- * any permission it has: the user and the app's grant are created, or the
1510
- * grant gains the release permissions.
1511
- */
1512
- const grantPlayAccess = async ({ google, developerId, email, packageName }) => {
1513
- const developer = `${PUBLISHER_API}/developers/${developerId}`;
1514
- const user = `${developer}/users/${encodeURIComponent(email)}`;
1515
- try {
1516
- await google("Adding the Play Console user", "POST", `${developer}/users`, {
1517
- name: `developers/${developerId}/users/${email}`,
1518
- email
1519
- });
1520
- } catch (error) {
1521
- if (isDenied(error)) throw notPlayAdmin(developerId, error);
1522
- if (!isConflict(error)) throw error;
1523
- }
1524
- try {
1525
- await google("Granting access to the app", "POST", `${user}/grants`, {
1526
- name: `developers/${developerId}/users/${email}/grants/${packageName}`,
1527
- packageName,
1528
- appLevelPermissions: APP_PERMISSIONS
1529
- });
1530
- return;
1531
- } catch (error) {
1532
- if (isNotFound(error)) throw appNotFound(packageName, developerId, error);
1533
- if (isDenied(error)) throw notPlayAdmin(developerId, error);
1534
- if (!isConflict(error)) throw error;
1535
- }
1536
- const granted = await findGrant({
1537
- google,
1538
- developer,
1539
- email,
1540
- packageName
1541
- });
1542
- const missing = APP_PERMISSIONS.filter((permission) => !granted.includes(permission));
1543
- if (missing.length === 0) return;
1544
- await google("Granting access to the app", "PATCH", `${user}/grants/${encodeURIComponent(packageName)}?updateMask=appLevelPermissions`, { appLevelPermissions: [...granted, ...missing] });
1545
- };
1546
- /** The app permissions `email` holds on `packageName`, from the user list. */
1547
- const findGrant = async ({ google, developer, email, packageName }) => {
1548
- let pageToken;
1549
- do {
1550
- const page = await google("Listing the Play Console users", "GET", `${developer}/users?${new URLSearchParams({
1551
- pageSize: "100",
1552
- ...pageToken ? { pageToken } : {}
1553
- })}`);
1554
- const found = page.users?.find((candidate) => candidate.email === email);
1555
- if (found) return found.grants?.find((grant) => grant.packageName === packageName)?.appLevelPermissions ?? [];
1556
- pageToken = page.nextPageToken || void 0;
1557
- } while (pageToken);
1558
- return [];
1559
- };
1560
- /** A new JSON key of the service account, checked and masked in the CLI's output. */
1561
- const createKey = async ({ google, project, email, sleep }) => {
1562
- const { projectId } = project;
1563
- let key;
1564
- try {
1565
- key = await whilePropagating({
1566
- project,
1567
- sleep,
1568
- notFound: true,
1569
- run: () => google("Creating the key", "POST", `${IAM_API}/projects/${projectId}/serviceAccounts/${email}/keys`, {
1570
- privateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE",
1571
- keyAlgorithm: "KEY_ALG_RSA_2048"
1572
- })
1573
- });
1574
- } 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 });
1576
- throw error;
1577
- }
1578
- if (!key.name || !key.privateKeyData) throw new Error("Google Cloud created the key without its JSON file.");
1579
- registerSecretValue(key.privateKeyData);
1580
- const json = Buffer.from(key.privateKeyData, "base64").toString("utf-8");
1581
- registerSecretValue(json);
1582
- let parsed;
1583
- try {
1584
- parsed = JSON.parse(json);
1585
- } catch {
1586
- parsed = {};
1587
- }
1588
- if (typeof parsed.private_key !== "string") {
1589
- await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`).catch(() => {});
1590
- throw new Error("Google Cloud created a key that is no JSON key file.");
1591
- }
1592
- registerSecretValue(parsed.private_key);
1593
- return {
1594
- json,
1595
- name: key.name,
1596
- keyId: key.name.split("/").pop()
1597
- };
1598
- };
1599
- //#endregion
1600
- //#region src/playServiceAccount.ts
1601
- /**
1602
- * Hot Updater's Google OAuth client, a "Desktop app" client the sign-in
1603
- * opens Google's consent screen for. Empty in a build that has none, where
1604
- * `HOT_UPDATER_GOOGLE_OAUTH_CLIENT_ID` and `_SECRET` name one.
1605
- */
1606
- const HOT_UPDATER_GOOGLE_OAUTH_CLIENT = {
1607
- clientId: "",
1608
- clientSecret: ""
1609
- };
1610
- /**
1611
- * Hot Updater's issuer, the one place that picks how it is done: Google's
1612
- * OAuth for installed apps, then the Cloud Resource Manager, Service Usage,
1613
- * IAM, and Play Developer APIs. Another implementation of
1614
- * `IssuePlayServiceAccount` replaces it here without touching its callers.
1615
- */
1616
- const issuePlayServiceAccount = (request) => createGoogleKeyIssuer()({
1617
- ...request,
1618
- oauthClient: request.oauthClient ?? HOT_UPDATER_GOOGLE_OAUTH_CLIENT
1619
- });
1620
- //#endregion
1621
- export { GOOGLE_OAUTH_CLIENT_ENV_NAMES, HOT_UPDATER_GOOGLE_OAUTH_CLIENT, PLAY_CONSOLE_URL, PLAY_SERVICE_ACCOUNT_ID, PlayServiceAccountError, PlayVersionCodeUsedError, assertPlaySigned, buildAndroid, enrichNativeBuildAndroidScheme, extractBuiltInBundle, findPlayInternalRelease, issuePlayServiceAccount, readBuiltApp, readBuiltPackageName, readBuiltVersionCode, resolvePlayUploadServiceAccount, uploadToPlay };
1052
+ export { PlayVersionCodeUsedError, assertPlaySigned, buildAndroid, checkPlayAccess, enrichNativeBuildAndroidScheme, extractBuiltInBundle, findPlayInternalRelease, playConsoleInviteUrl, readBuiltApp, readBuiltPackageName, readBuiltVersionCode, resolvePlayUploadServiceAccount, uploadToPlay };