@hot-updater/android-helper 1.2.0-rc.1 → 1.2.0-rc.11

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
@@ -35,121 +35,34 @@ let crypto = require("crypto");
35
35
  crypto = __toESM(crypto, 1);
36
36
  let stream_promises = require("stream/promises");
37
37
  let zlib = require("zlib");
38
- let node_crypto = require("node:crypto");
39
- node_crypto = __toESM(node_crypto, 1);
40
- let node_http = require("node:http");
41
- node_http = __toESM(node_http, 1);
38
+ //#region src/utils/gradlePhase.ts
39
+ /** `> Task :app:compileReleaseKotlin`, with an outcome such as `UP-TO-DATE`. */
40
+ const TASK = /^> Task (:\S+)/;
41
+ const CONFIGURE = /^> Configure project (:\S*)/;
42
+ /**
43
+ * Reads the phase of a Gradle build from its plain console output: the
44
+ * task it runs, after how many it has run, such as
45
+ * `312 tasks · :app:mergeReleaseResources`.
46
+ */
47
+ const createGradlePhaseReader = () => {
48
+ let tasks = 0;
49
+ return (line) => {
50
+ const task = TASK.exec(line)?.[1];
51
+ if (task) {
52
+ tasks += 1;
53
+ return `${tasks} task${tasks === 1 ? "" : "s"} · ${task}`;
54
+ }
55
+ const project = CONFIGURE.exec(line)?.[1];
56
+ if (project) return `Configuring ${project}`;
57
+ if (line.startsWith("Starting a Gradle Daemon")) return "Starting the Gradle daemon";
58
+ if (line.startsWith("Downloading https://services.gradle.org/")) return "Downloading Gradle";
59
+ };
60
+ };
61
+ //#endregion
42
62
  //#region src/utils/createGradleLogger.ts
43
63
  const createGradleLogger = ({ logPrefix }) => new _hot_updater_cli_tools.BuildLogger({
44
64
  logPrefix,
45
- importantLogPatterns: [
46
- "error:",
47
- "Error:",
48
- "ERROR:",
49
- "> Task :",
50
- "BUILD FAILED",
51
- "BUILD SUCCESSFUL",
52
- "FAILURE: Build failed with an exception.",
53
- "* What went wrong:",
54
- "Execution failed for task",
55
- "Could not resolve",
56
- "Failed to",
57
- "Unable to load script",
58
- "Metro encountered an error",
59
- "React Native CLI",
60
- "Android resource compilation failed",
61
- "Duplicate class",
62
- "Program type already present",
63
- "actionable tasks:",
64
- "Deprecated Gradle features were used",
65
- ":app:configureCMake",
66
- ":app:buildCMake",
67
- ":app:compileDebugKotlin",
68
- ":app:compileReleaseKotlin",
69
- ":app:compileDebugJavaWithJavac",
70
- ":app:compileReleaseJavaWithJavac",
71
- ":app:mergeDebugResources",
72
- ":app:mergeReleaseResources",
73
- ":app:processDebugResources",
74
- ":app:processReleaseResources",
75
- ":app:packageDebug",
76
- ":app:packageRelease",
77
- ":app:assembleDebug",
78
- ":app:assembleRelease"
79
- ],
80
- progressStages: [
81
- [
82
- "buildKotlinToolingMetadata",
83
- "generateAutolinkingNewArchitectureFiles",
84
- "generateAutolinkingPackageList",
85
- "generateCodegenSchemaFromJavaScript",
86
- "generateCodegenArtifactsFromSchema",
87
- "generateReactNativeEntryPoint"
88
- ],
89
- [
90
- "createBundleReleaseJsAndAssets",
91
- "bundleReleaseJsAndAssets",
92
- "bundleDebugJsAndAssets"
93
- ],
94
- [
95
- "generateReleaseResValues",
96
- "generateDebugResValues",
97
- "generateReleaseResources",
98
- "generateDebugResources",
99
- "mergeReleaseResources",
100
- "mergeDebugResources"
101
- ],
102
- [
103
- "processReleaseResources",
104
- "processDebugResources",
105
- "processReleaseManifest",
106
- "processDebugManifest",
107
- "processDebugMainManifest",
108
- "parseReleaseLocalResources",
109
- "parseDebugLocalResources"
110
- ],
111
- ["compileReleaseKotlin", "compileDebugKotlin"],
112
- ["compileReleaseJavaWithJavac", "compileDebugJavaWithJavac"],
113
- [
114
- /configureCMake\w+\[/,
115
- /buildCMake\w+\[/,
116
- "mergeReleaseJniLibFolders",
117
- "mergeDebugJniLibFolders",
118
- "mergeReleaseNativeLibs",
119
- "mergeDebugNativeLibs"
120
- ],
121
- [
122
- "desugarReleaseFileDependencies",
123
- "desugarDebugFileDependencies",
124
- "dexBuilderRelease",
125
- "dexBuilderDebug",
126
- "mergeDexRelease",
127
- "mergeExtDexRelease",
128
- "mergeExtDexDebug",
129
- "mergeProjectDexRelease",
130
- "mergeProjectDexDebug",
131
- "mergeLibDexRelease",
132
- "mergeLibDexDebug",
133
- "mergeDebugGlobalSynthetics",
134
- "mergeReleaseGlobalSynthetics",
135
- "transformClassesWithDex"
136
- ],
137
- [
138
- "compressReleaseAssets",
139
- "compressDebugAssets",
140
- "optimizeReleaseResources",
141
- "mergeReleaseAssets",
142
- "mergeDebugAssets"
143
- ],
144
- [
145
- "packageRelease",
146
- "packageDebug",
147
- "assembleRelease",
148
- "assembleDebug"
149
- ],
150
- ["bundleRelease", "bundleDebug"],
151
- ["BUILD SUCCESSFUL", /\d+ actionable tasks:/]
152
- ],
65
+ createPhaseReader: createGradlePhaseReader,
153
66
  failureSummary: {
154
67
  lines: [
155
68
  /^e: /,
@@ -164,6 +77,26 @@ const createGradleLogger = ({ logPrefix }) => new _hot_updater_cli_tools.BuildLo
164
77
  }
165
78
  });
166
79
  //#endregion
80
+ //#region src/utils/formatCommand.ts
81
+ /**
82
+ * A command to print, broken at spaces with ` \` once a line would pass
83
+ * `width`, each continuation indented two spaces, so a terminal never wraps
84
+ * it mid-word and it still pastes as one command.
85
+ */
86
+ const formatCommand = (words, width = 74) => {
87
+ const lines = [];
88
+ let line = "";
89
+ for (const word of words) {
90
+ const next = line ? `${line} ${word}` : word;
91
+ if (line && next.length > width - 2) {
92
+ lines.push(`${line} \\`);
93
+ line = ` ${word}`;
94
+ } else line = next;
95
+ }
96
+ lines.push(line);
97
+ return lines.join("\n");
98
+ };
99
+ //#endregion
167
100
  //#region src/builder/runGradle.ts
168
101
  /** Why Gradle failed, without its command line. */
169
102
  const describeFailure = (error) => {
@@ -186,19 +119,24 @@ const getGradleWrapper = () => process.platform.startsWith("win") ? "gradlew.bat
186
119
  /**
187
120
  * Run gradle build with specified tasks and arguments
188
121
  */
189
- async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appModuleName }) {
122
+ async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appModuleName, logHeader = [] }) {
190
123
  const gradleArgs = getTaskNames(appModuleName, tasks);
191
124
  gradleArgs.push("-x", "lint");
192
125
  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
- `);
199
126
  if ("port" in args && args.port != null) gradleArgs.push(`-PreactNativeDevServerPort=${args.port}`);
200
127
  const logger = createGradleLogger({ logPrefix });
201
- await logger.start();
128
+ await logger.start({
129
+ title: `Building ${tasks.join(", ")}`,
130
+ header: [
131
+ ...logHeader,
132
+ `Project ${path.default.relative((0, _hot_updater_cli_tools.getCwd)(), androidProjectPath) || "."}`,
133
+ `App module ${appModuleName}`,
134
+ `Tasks ${tasks.join(", ")}`,
135
+ `$ ${formatCommand([path.default.basename(getGradleWrapper()), ...gradleArgs])}`,
136
+ ""
137
+ ]
138
+ });
139
+ let logFilePath = null;
202
140
  try {
203
141
  const gradle = (0, execa.execa)(getGradleWrapper(), gradleArgs, {
204
142
  cwd: androidProjectPath,
@@ -210,11 +148,13 @@ Args ${gradleArgs.join(" ")}
210
148
  const [gradleResult, loggingResult] = await Promise.allSettled([gradle, outputStream ? logger.processStream(outputStream) : void 0]);
211
149
  if (gradleResult.status === "rejected") throw gradleResult.reason;
212
150
  if (loggingResult.status === "rejected") throw loggingResult.reason;
213
- logger.stop();
151
+ logger.stop(`Built ${tasks.join(", ")}`);
152
+ logFilePath = logger.logFilePath;
214
153
  } catch (e) {
215
154
  logger.writeError(e);
216
- logger.stop("Build failed", false);
217
- throw new _hot_updater_cli_tools.BuildFailedError(`Build failed: ${describeFailure(e)}`, {
155
+ const reason = describeFailure(e);
156
+ logger.stop(`Build failed · ${reason.replace(/\.$/, "")}`, false);
157
+ throw new _hot_updater_cli_tools.BuildFailedError(`Build failed: ${reason}`, {
218
158
  logPath: logger.logPath,
219
159
  summary: logger.failureSummary,
220
160
  cause: e
@@ -222,11 +162,14 @@ Args ${gradleArgs.join(" ")}
222
162
  } finally {
223
163
  await logger.close();
224
164
  }
225
- return findBuildDirectory({
226
- androidProjectPath,
227
- moduleName: appModuleName,
228
- tasks
229
- });
165
+ return {
166
+ ...await findBuildDirectory({
167
+ androidProjectPath,
168
+ moduleName: appModuleName,
169
+ tasks
170
+ }),
171
+ logFilePath
172
+ };
230
173
  }
231
174
  /**
232
175
  * Find the build output directory and artifact path
@@ -257,7 +200,7 @@ async function getBuildOutputFilePath({ aab, appModuleName, buildDirectory, vari
257
200
  const outputFile = `${appModuleName}-${variant}.${aab ? "aab" : "apk"}`;
258
201
  const outputFilePath = path.default.join(buildDirectory, outputFile);
259
202
  if (fs.default.existsSync(outputFilePath)) return outputFilePath;
260
- _hot_updater_cli_tools.p.log.error(`Failed to find the output file for ${outputFilePath}`);
203
+ _hot_updater_cli_tools.p.log.error(`Gradle wrote no ${outputFilePath}.`);
261
204
  process.exit(1);
262
205
  }
263
206
  //#endregion
@@ -277,7 +220,7 @@ const resolveGradleSigningEnv = async ({ schemeConfig, credentials }) => {
277
220
  const reference = schemeConfig.signing?.keystore;
278
221
  if (!reference) return;
279
222
  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.`);
223
+ _hot_updater_cli_tools.p.log.info(`${schemeConfig.variant} signs with the debug keystore; signing.keystore is unused.`);
281
224
  return;
282
225
  }
283
226
  const keystore = await (credentials ?? (0, _hot_updater_cli_tools.createCredentialResolver)()).resolve({
@@ -430,7 +373,7 @@ const writeBaseModuleApk = async (aabPath, directory) => {
430
373
  const readBuiltApp = async ({ artifactPath, androidProjectPath }) => {
431
374
  const aapt2 = findAapt2(androidProjectPath);
432
375
  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.");
376
+ _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
377
  return;
435
378
  }
436
379
  const directory = await fs.default.promises.mkdtemp(path.default.join(os.default.tmpdir(), "android-helper-"));
@@ -455,7 +398,7 @@ const readBuiltApp = async ({ artifactPath, androidProjectPath }) => {
455
398
  };
456
399
  const appVersion = await resolve(parseManifestVersionName(xmltree));
457
400
  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.");
401
+ 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
402
  const versionCode = parseManifestVersionCode(xmltree);
460
403
  return {
461
404
  appVersion: appVersion ?? null,
@@ -520,7 +463,7 @@ const readBuiltVersionCode = async ({ artifactPath, androidProjectPath }) => {
520
463
  };
521
464
  //#endregion
522
465
  //#region src/buildAndroid.ts
523
- const buildAndroid = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials }) => {
466
+ const buildAndroid = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials, logHeader }) => {
524
467
  const androidProjectPath = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "android");
525
468
  const result = await runGradle({
526
469
  args: { extraParams: [`-PMIN_BUNDLE_ID=${minBundleId}`] },
@@ -531,7 +474,8 @@ const buildAndroid = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_t
531
474
  appModuleName: schemeConfig.appModuleName,
532
475
  tasks: schemeConfig.aab ? [`bundle${schemeConfig.variant}`] : [`assemble${schemeConfig.variant}`],
533
476
  logPrefix: `android-${schemeConfig.hotUpdaterSchemeName}-build`,
534
- androidProjectPath
477
+ androidProjectPath,
478
+ logHeader
535
479
  });
536
480
  return {
537
481
  ...result,
@@ -586,7 +530,7 @@ const readServiceAccount = (json) => {
586
530
  throw new Error("The Play service account is not a JSON key file.");
587
531
  }
588
532
  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.");
533
+ 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
534
  (0, _hot_updater_cli_tools.registerSecretValue)(private_key);
591
535
  let privateKey;
592
536
  try {
@@ -632,7 +576,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
632
576
  var PlayVersionCodeUsedError = class extends Error {
633
577
  name = "PlayVersionCodeUsedError";
634
578
  constructor(options) {
635
- super("Google Play already has this .aab's version code. Raise versionCode for every build you upload.", options);
579
+ super("Google Play already has this .aab's version code.\nRaise versionCode for every build you upload.", options);
636
580
  }
637
581
  };
638
582
  /**
@@ -642,15 +586,20 @@ var PlayVersionCodeUsedError = class extends Error {
642
586
  const explain = (error, packageName) => {
643
587
  if (!(error instanceof GooglePlayError)) return error;
644
588
  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 });
589
+ if (status === 401 || /does not have permission/i.test(apiMessage)) return new Error([
590
+ `The Play service account can't release ${packageName}.`,
591
+ "Invite its email in Play Console (Users and permissions) to release to",
592
+ "testing tracks, and enable the Google Play Android Developer API."
593
+ ].join("\n"), { cause: error });
594
+ 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
595
  if (/version code .*(already been used|already used)/i.test(apiMessage)) return new PlayVersionCodeUsedError({ cause: error });
648
596
  return error;
649
597
  };
650
598
  /**
651
599
  * Requests to the Google Play Developer API for `packageName`, as the
652
600
  * service account: each carries an access token, exchanged for a signed
653
- * assertion and replaced well before it expires.
601
+ * assertion and replaced well before it expires. `checkPlayAccess`
602
+ * checks a key's access with it too.
654
603
  */
655
604
  const createPlayClient = ({ json, packageName, fetch }) => {
656
605
  const account = readServiceAccount(json);
@@ -668,7 +617,7 @@ const createPlayClient = ({ json, packageName, fetch }) => {
668
617
  assertion
669
618
  }).toString()
670
619
  });
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."}`);
620
+ 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
621
  const { access_token } = await response.json();
673
622
  if (typeof access_token !== "string" || !access_token) throw new Error("Google answered the Play service account with no access token.");
674
623
  (0, _hot_updater_cli_tools.registerSecretValue)(access_token);
@@ -699,6 +648,66 @@ const createPlayClient = ({ json, packageName, fetch }) => {
699
648
  };
700
649
  };
701
650
  /**
651
+ * Play Console's page that invites `email` as a user. `_` stands for the
652
+ * developer account the browser is signed in to, so no ID is needed.
653
+ */
654
+ const playConsoleInviteUrl = (email) => `https://play.google.com/console/u/0/developers/_/users-and-permissions?invite=${encodeURIComponent(email)}`;
655
+ const readClientEmail = (json) => {
656
+ try {
657
+ const { client_email } = JSON.parse(json);
658
+ return typeof client_email === "string" ? client_email : "";
659
+ } catch {
660
+ return "";
661
+ }
662
+ };
663
+ /**
664
+ * Checks once whether the service account's key reaches `packageName`: it
665
+ * opens an edit of the app and deletes it again, which changes nothing.
666
+ * Throws only for what says nothing about access, such as a network error.
667
+ */
668
+ const checkPlayAccess = async ({ serviceAccount: { json }, packageName, fetch = globalThis.fetch }) => {
669
+ const email = readClientEmail(json);
670
+ const refused = (error) => ({
671
+ status: "refused",
672
+ email,
673
+ reason: error instanceof Error ? error.message : String(error)
674
+ });
675
+ let play;
676
+ try {
677
+ play = createPlayClient({
678
+ json,
679
+ packageName,
680
+ fetch
681
+ });
682
+ } catch (error) {
683
+ return refused(error);
684
+ }
685
+ try {
686
+ await play.authorization();
687
+ } catch (error) {
688
+ if (error instanceof TypeError) throw error;
689
+ return refused(error);
690
+ }
691
+ const edits = `${API_URL}${play.app}/edits`;
692
+ try {
693
+ const edit = await play.request(`Opening an edit of ${packageName}`, "POST", edits, {});
694
+ if (edit?.id) await play.request("Deleting the edit", "DELETE", `${edits}/${encodeURIComponent(edit.id)}`).catch(() => {});
695
+ return { status: "ready" };
696
+ } catch (error) {
697
+ if (error instanceof GooglePlayError) {
698
+ if (error.status === 401 || error.status === 403) return {
699
+ status: "denied",
700
+ email
701
+ };
702
+ if (error.status === 404) return {
703
+ status: "not-found",
704
+ email
705
+ };
706
+ }
707
+ throw error;
708
+ }
709
+ };
710
+ /**
702
711
  * Whether Google Play's internal testing track already releases the
703
712
  * `.aab`'s version code, as when a reused native build was uploaded
704
713
  * before. It reads the track in an edit it then deletes, changing nothing.
@@ -746,7 +755,11 @@ const assertPlaySigned = async (aabPath) => {
746
755
  "-jarfile",
747
756
  aabPath
748
757
  ], { 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.`);
758
+ if (exitCode === 0 && ANDROID_DEBUG_SIGNER.test(String(stdout))) throw new Error([
759
+ "Google Play refuses an .aab signed with the Android debug keystore.",
760
+ "Set the scheme's signing.keystore, or a release signingConfig in",
761
+ "android/app/build.gradle, then build again."
762
+ ].join("\n"));
750
763
  };
751
764
  /**
752
765
  * Uploads an `.aab` to Google Play's internal testing track with the Google
@@ -797,7 +810,7 @@ const uploadToPlay = async ({ aabPath, packageName, serviceAccount: { json }, fe
797
810
  };
798
811
  } catch (error) {
799
812
  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.`);
813
+ _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
814
  await release("draft");
802
815
  return {
803
816
  versionCode,
@@ -819,10 +832,14 @@ const commit = async ({ request, edit, retryDelayMs }) => {
819
832
  let changesNotSentForReview = false;
820
833
  for (let attempt = 1;; attempt++) try {
821
834
  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.");
835
+ 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
836
  return;
824
837
  } 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 });
838
+ if (!(error instanceof GooglePlayError) || error.status >= 500 || error.status === 429) throw new Error([
839
+ "Google Play didn't confirm the release.",
840
+ error instanceof Error ? error.message : String(error),
841
+ "Check internal testing in Play Console before you upload it again."
842
+ ].join("\n"), { cause: error });
826
843
  if (error.status !== 400) throw error;
827
844
  if (!changesNotSentForReview && error.apiMessage.includes(NEEDS_CHANGES_NOT_SENT_FOR_REVIEW)) {
828
845
  changesNotSentForReview = true;
@@ -925,9 +942,9 @@ const confirmedOffset = (response) => {
925
942
  * from new apps, or a debug variant, which Play refuses.
926
943
  */
927
944
  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).`);
945
+ if (androidScheme.aab === false) throw new Error(`The scheme ${scheme} builds an .apk (aab: false); Google Play takes an .aab.`);
929
946
  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.`);
947
+ if (/debug$/i.test(variant)) throw new Error(`The scheme ${scheme} builds ${variant}; Google Play takes a release build.`);
931
948
  return {
932
949
  kind: "play-service-account",
933
950
  name: "play-service-account"
@@ -1060,606 +1077,14 @@ const extractBuiltInBundle = async ({ artifactPath, outputPath }) => {
1060
1077
  await (entry.method === 8 ? (0, stream_promises.pipeline)(source, (0, zlib.createInflateRaw)(), fs.default.createWriteStream(outputPath)) : (0, stream_promises.pipeline)(source, fs.default.createWriteStream(outputPath)));
1061
1078
  };
1062
1079
  //#endregion
1063
- //#region src/google/googleApi.ts
1064
- /** A Google Cloud or Google Play API request that failed, with Google's error. */
1065
- var GoogleApiError = class extends Error {
1066
- status;
1067
- /** Google's canonical status, such as `PERMISSION_DENIED`. */
1068
- apiStatus;
1069
- apiMessage;
1070
- /** The `ErrorInfo` reasons, such as `SERVICE_DISABLED`. */
1071
- reasons;
1072
- /** The `PreconditionFailure` violation types, such as an org policy constraint. */
1073
- violations;
1074
- /** The `ErrorInfo` consumer of a disabled service, such as `projects/123`. */
1075
- consumer;
1076
- constructor(action, status, body = {}) {
1077
- const apiMessage = body.apiMessage ?? "";
1078
- super(`${action} failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : "."}`);
1079
- this.name = "GoogleApiError";
1080
- this.status = status;
1081
- this.apiStatus = body.apiStatus;
1082
- this.apiMessage = apiMessage;
1083
- this.reasons = body.reasons ?? [];
1084
- this.violations = body.violations ?? [];
1085
- this.consumer = body.consumer;
1086
- }
1087
- };
1088
- /** Google's error body, `{ error: { code, status, message, details } }`. */
1089
- const readError = async (action, response) => {
1090
- let error;
1091
- try {
1092
- error = (await response.json()).error;
1093
- } catch {}
1094
- const details = Array.isArray(error?.details) ? error.details : [];
1095
- const info = details.filter((detail) => typeof detail.reason === "string");
1096
- return new GoogleApiError(action, response.status, {
1097
- ...typeof error?.status === "string" ? { apiStatus: error.status } : {},
1098
- ...typeof error?.message === "string" ? { apiMessage: error.message } : {},
1099
- reasons: info.map((detail) => detail.reason),
1100
- violations: details.flatMap((detail) => (detail.violations ?? []).flatMap((violation) => typeof violation.type === "string" ? [violation.type] : [])),
1101
- ...info.find((detail) => detail.metadata?.["consumer"]) ? { consumer: info.find((detail) => detail.metadata?.["consumer"]).metadata["consumer"] } : {}
1102
- });
1103
- };
1104
- /** Whether Google answered that a service is disabled in a project. */
1105
- 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));
1106
- /** Requests with the signed-in user's access token, throwing `GoogleApiError`. */
1107
- const createGoogleRequest = ({ accessToken, fetch }) => async (action, method, url, body) => {
1108
- const response = await fetch(url, {
1109
- method,
1110
- headers: {
1111
- authorization: `Bearer ${accessToken}`,
1112
- ...body === void 0 ? {} : { "content-type": "application/json" }
1113
- },
1114
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
1115
- });
1116
- if (!response.ok) throw await readError(action, response);
1117
- const text = await response.text();
1118
- return text ? JSON.parse(text) : {};
1119
- };
1120
- /**
1121
- * Polls `operation` at `origin` until it is done, returning its response or
1122
- * throwing its error as a `GoogleApiError` with the operation's code.
1123
- */
1124
- const waitForOperation = async ({ request, origin, operation, action, sleep, intervalMs = 2e3, attempts = 60 }) => {
1125
- let current = operation;
1126
- for (let attempt = 0; !current.done; attempt++) {
1127
- if (attempt >= attempts || !current.name) throw new Error(`${action} did not finish in time.`);
1128
- await sleep(intervalMs);
1129
- current = await request(action, "GET", `${origin}/${current.name}`);
1130
- }
1131
- if (current.error) throw new GoogleApiError(action, 400, {
1132
- apiMessage: current.error.message ?? "",
1133
- ...current.error.code === 7 ? { apiStatus: "PERMISSION_DENIED" } : {},
1134
- ...current.error.code === 8 ? { apiStatus: "RESOURCE_EXHAUSTED" } : {}
1135
- });
1136
- return current.response;
1137
- };
1138
- //#endregion
1139
- //#region src/google/types.ts
1140
- /** A named failure of `issuePlayServiceAccount`, with what to do in its message. */
1141
- var PlayServiceAccountError = class extends Error {
1142
- code;
1143
- constructor(code, message, options) {
1144
- super(message, options);
1145
- this.name = "PlayServiceAccountError";
1146
- this.code = code;
1147
- }
1148
- };
1149
- /** The environment variables that replace Hot Updater's Google OAuth client. */
1150
- const GOOGLE_OAUTH_CLIENT_ENV_NAMES = {
1151
- clientId: "HOT_UPDATER_GOOGLE_OAUTH_CLIENT_ID",
1152
- clientSecret: "HOT_UPDATER_GOOGLE_OAUTH_CLIENT_SECRET"
1153
- };
1154
- /** The service account Hot Updater creates, `hot-updater-play@<project>.iam.gserviceaccount.com`. */
1155
- const PLAY_SERVICE_ACCOUNT_ID = "hot-updater-play";
1156
- /** Where a Play Console admin finds the developer account ID. */
1157
- const PLAY_CONSOLE_URL = "https://play.google.com/console";
1158
- //#endregion
1159
- //#region src/google/googleSignIn.ts
1160
- const AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
1161
- const TOKEN_URL = "https://oauth2.googleapis.com/token";
1162
- const REVOKE_URL = "https://oauth2.googleapis.com/revoke";
1163
- /** How long the browser has to come back with the sign-in. */
1164
- const SIGN_IN_TIMEOUT_MS = 3e5;
1165
- const base64url = (bytes) => bytes.toString("base64url");
1166
- 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>`;
1167
- /**
1168
- * Signs in with Google as an installed app does (RFC 8252): the browser
1169
- * opens Google's own consent screen, so the password and the second factor
1170
- * stay with Google, and the authorization code comes back to a loopback
1171
- * server on 127.0.0.1, bound to a random port for this sign-in only. The
1172
- * code is exchanged with PKCE (S256), and `state` ties the answer to this
1173
- * request.
1174
- *
1175
- * @param onUrl Sees the sign-in URL, to print and open in a browser.
1176
- */
1177
- const signInWithGoogle = async ({ client, scopes, onUrl, fetch = globalThis.fetch, timeoutMs = SIGN_IN_TIMEOUT_MS }) => {
1178
- const verifier = base64url(node_crypto.default.randomBytes(32));
1179
- const challenge = base64url(node_crypto.default.createHash("sha256").update(verifier).digest());
1180
- const state = base64url(node_crypto.default.randomBytes(16));
1181
- const server = node_http.default.createServer();
1182
- await new Promise((resolve, reject) => {
1183
- server.once("error", reject);
1184
- server.listen(0, "127.0.0.1", () => resolve());
1185
- });
1186
- try {
1187
- const { port } = server.address();
1188
- const redirectUri = `http://127.0.0.1:${port}`;
1189
- const code = new Promise((resolve, reject) => {
1190
- const timer = setTimeout(() => reject(new PlayServiceAccountError("sign-in-cancelled", "The Google sign-in did not finish in time.")), timeoutMs);
1191
- timer.unref();
1192
- server.on("request", (req, res) => {
1193
- const query = new URL(req.url ?? "/", redirectUri).searchParams;
1194
- if (query.get("state") !== state) {
1195
- res.writeHead(404).end();
1196
- return;
1197
- }
1198
- const error = query.get("error");
1199
- const answer = query.get("code");
1200
- 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."));
1201
- clearTimeout(timer);
1202
- 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"}.`));
1203
- else resolve(answer);
1204
- });
1205
- });
1206
- code.catch(() => {});
1207
- const url = new URL(AUTHORIZATION_URL);
1208
- url.search = new URLSearchParams({
1209
- client_id: client.clientId,
1210
- redirect_uri: redirectUri,
1211
- response_type: "code",
1212
- scope: scopes.join(" "),
1213
- code_challenge: challenge,
1214
- code_challenge_method: "S256",
1215
- state,
1216
- prompt: "select_account"
1217
- }).toString();
1218
- await onUrl(url.toString());
1219
- const authorizationCode = await code;
1220
- (0, _hot_updater_cli_tools.registerSecretValue)(authorizationCode);
1221
- const response = await fetch(TOKEN_URL, {
1222
- method: "POST",
1223
- headers: { "content-type": "application/x-www-form-urlencoded" },
1224
- body: new URLSearchParams({
1225
- code: authorizationCode,
1226
- client_id: client.clientId,
1227
- ...client.clientSecret ? { client_secret: client.clientSecret } : {},
1228
- redirect_uri: redirectUri,
1229
- grant_type: "authorization_code",
1230
- code_verifier: verifier
1231
- }).toString()
1232
- });
1233
- 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}` : "."}`);
1235
- (0, _hot_updater_cli_tools.registerSecretValue)(token.access_token);
1236
- return {
1237
- accessToken: token.access_token,
1238
- scopes: typeof token.scope === "string" ? token.scope.split(" ") : []
1239
- };
1240
- } finally {
1241
- server.closeAllConnections();
1242
- await new Promise((resolve) => server.close(() => resolve()));
1243
- }
1244
- };
1245
- /** Ends a sign-in: Google revokes its access token. Whether Google did. */
1246
- const revokeGoogleToken = async (accessToken, fetch = globalThis.fetch) => {
1247
- try {
1248
- return (await fetch(REVOKE_URL, {
1249
- method: "POST",
1250
- headers: { "content-type": "application/x-www-form-urlencoded" },
1251
- body: new URLSearchParams({ token: accessToken }).toString()
1252
- })).ok;
1253
- } catch {
1254
- return false;
1255
- }
1256
- };
1257
- //#endregion
1258
- //#region src/google/googleKeyIssuer.ts
1259
- const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
1260
- const ANDROID_PUBLISHER_SCOPE = "https://www.googleapis.com/auth/androidpublisher";
1261
- const SCOPES = [CLOUD_PLATFORM_SCOPE, ANDROID_PUBLISHER_SCOPE];
1262
- const PUBLISHER_API = "https://androidpublisher.googleapis.com/androidpublisher/v3";
1263
- const RESOURCE_MANAGER_API = "https://cloudresourcemanager.googleapis.com";
1264
- const SERVICE_USAGE_API = "https://serviceusage.googleapis.com";
1265
- const IAM_API = "https://iam.googleapis.com/v1";
1266
- /** IAM creates the service account; the Play Developer API is what it calls. */
1267
- const SERVICES = ["iam.googleapis.com", "androidpublisher.googleapis.com"];
1268
- /**
1269
- * What the service account may do in Play Console, for the app only: read
1270
- * it, and release to testing tracks, which `--submit play-internal` does.
1271
- */
1272
- const APP_PERMISSIONS = ["CAN_VIEW_NON_FINANCIAL_DATA", "CAN_MANAGE_TRACK_APKS"];
1273
- const KEY_CREATION_CONSTRAINT = "constraints/iam.disableServiceAccountKeyCreation";
1274
- /** Retries while Google enables an API or creates a service account. */
1275
- const PROPAGATION_ATTEMPTS = 12;
1276
- const PROPAGATION_DELAY_MS = 1e4;
1277
- const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1278
- /** The developer account ID in `input`, a number or a Play Console URL. */
1279
- const parseDeveloperId = (input) => {
1280
- const trimmed = input.trim();
1281
- if (/^\d+$/u.test(trimmed)) return trimmed;
1282
- return trimmed.match(/\/developers\/(\d+)(?:[/?#]|$)/u)?.[1];
1283
- };
1284
- /** A project ID nobody has: Google Cloud's are global. */
1285
- const newProjectId = () => `${PLAY_SERVICE_ACCOUNT_ID}-${node_crypto.default.randomBytes(3).toString("hex")}`;
1286
- const isNotFound = (error) => error instanceof GoogleApiError && error.status === 404;
1287
- const isConflict = (error) => error instanceof GoogleApiError && error.status === 409;
1288
- /** 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 });
1290
- /** Google's own words, after a message that names the likely cause. */
1291
- const googleSays = (error) => error instanceof GoogleApiError && error.apiMessage ? ` (Google: ${error.apiMessage})` : "";
1292
- /** 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 });
1294
- const isDenied = (error) => error instanceof GoogleApiError && (error.status === 403 || error.apiStatus === "PERMISSION_DENIED");
1295
- /**
1296
- * `IssuePlayServiceAccount` with Google's own APIs, after a sign-in on
1297
- * Google's consent screen (OAuth for installed apps, with PKCE and a
1298
- * loopback redirect):
1299
- *
1300
- * 1. the Play Console side is checked first, with no change anywhere: the
1301
- * developer account answers to the account as its admin, and has the app;
1302
- * 2. the chosen Google Cloud project, or a new one, gets the IAM and Play
1303
- * Developer APIs enabled, and the service account `hot-updater-play`;
1304
- * 3. the service account becomes a Play Console user that may release the
1305
- * app to testing tracks;
1306
- * 4. only then is its JSON key created, the last step, so a failure before
1307
- * it leaves no key behind.
1308
- *
1309
- * Each step reuses what an earlier run created: the project, the service
1310
- * account, and its Play Console access.
1311
- */
1312
- const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor } = {}) => async (request) => {
1313
- 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.`);
1315
- const signIn = await signInWithGoogle({
1316
- client: oauthClient,
1317
- scopes: SCOPES,
1318
- onUrl: request.onSignInUrl,
1319
- fetch
1320
- });
1321
- const dispose = async () => {
1322
- await revokeGoogleToken(signIn.accessToken, fetch);
1323
- };
1324
- try {
1325
- 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.`);
1327
- const google = createGoogleRequest({
1328
- accessToken: signIn.accessToken,
1329
- fetch
1330
- });
1331
- const developerInput = await request.askDeveloperId();
1332
- 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.`);
1334
- onStep("Checking the Play Console developer account");
1335
- await checkPlayConsole({
1336
- google,
1337
- developerId,
1338
- packageName
1339
- });
1340
- const project = await chooseProject({
1341
- google,
1342
- request,
1343
- sleep,
1344
- onStep
1345
- });
1346
- const { projectId } = project;
1347
- onStep(`Enabling the Google Play Developer API in ${projectId}`);
1348
- await enableServices({
1349
- google,
1350
- projectId,
1351
- sleep
1352
- });
1353
- onStep(`Creating the service account in ${projectId}`);
1354
- const email = await ensureServiceAccount({
1355
- google,
1356
- project,
1357
- sleep
1358
- });
1359
- onStep(`Giving ${email} release access to ${packageName}`);
1360
- await grantPlayAccess({
1361
- google,
1362
- developerId,
1363
- email,
1364
- packageName
1365
- });
1366
- onStep("Creating the service account's JSON key");
1367
- const key = await createKey({
1368
- google,
1369
- project,
1370
- email,
1371
- sleep
1372
- });
1373
- return {
1374
- json: key.json,
1375
- keyId: key.keyId,
1376
- email,
1377
- projectId,
1378
- developerId,
1379
- revoke: async () => {
1380
- try {
1381
- await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`);
1382
- return true;
1383
- } catch (error) {
1384
- return isNotFound(error);
1385
- }
1386
- },
1387
- dispose
1388
- };
1389
- } catch (error) {
1390
- await dispose();
1391
- throw error;
1392
- }
1393
- };
1394
- /**
1395
- * Whether the signed-in account administers `developerId`, and Google Play
1396
- * has `packageName` there, asked without changing anything: listing the
1397
- * account's users takes the admin permission, and an edit opened on the app
1398
- * is deleted again.
1399
- */
1400
- const checkPlayConsole = async ({ google, developerId, packageName }) => {
1401
- try {
1402
- await google("Listing the Play Console users", "GET", `${PUBLISHER_API}/developers/${developerId}/users?pageSize=1`);
1403
- } catch (error) {
1404
- 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 });
1406
- if (isDenied(error)) throw notPlayAdmin(developerId, error);
1407
- throw error;
1408
- }
1409
- let edit;
1410
- try {
1411
- edit = await google(`Opening an edit of ${packageName}`, "POST", `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits`);
1412
- } catch (error) {
1413
- if (isServiceDisabled(error)) throw clientApiDisabled(error);
1414
- if (isNotFound(error) || isDenied(error)) throw appNotFound(packageName, developerId, error);
1415
- throw error;
1416
- }
1417
- if (edit.id) await google("Deleting the edit", "DELETE", `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits/${edit.id}`).catch(() => {});
1418
- };
1419
- 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 });
1420
- /** The account's active projects, every page of them. */
1421
- const listProjects = async (google) => {
1422
- const projects = [];
1423
- let pageToken;
1424
- do {
1425
- const page = await google("Listing the Google Cloud projects", "GET", `${RESOURCE_MANAGER_API}/v3/projects:search?${new URLSearchParams({
1426
- query: "state:ACTIVE",
1427
- ...pageToken ? { pageToken } : {}
1428
- })}`);
1429
- for (const project of page.projects ?? []) if (project.projectId) projects.push({
1430
- projectId: project.projectId,
1431
- ...project.displayName ? { displayName: project.displayName } : {},
1432
- ...projectNumberOf(project.name)
1433
- });
1434
- pageToken = page.nextPageToken || void 0;
1435
- } while (pageToken);
1436
- return projects;
1437
- };
1438
- /** The project the developer picks, or a new one when there is none or they ask. */
1439
- const chooseProject = async ({ google, request, sleep, onStep }) => {
1440
- const projects = await listProjects(google);
1441
- const chosen = projects.length > 0 ? await request.chooseProject(projects.map(({ projectId, displayName }) => ({
1442
- projectId,
1443
- ...displayName ? { displayName } : {}
1444
- }))) : void 0;
1445
- if (chosen !== void 0) return projects.find((project) => project.projectId === chosen) ?? { projectId: chosen };
1446
- const projectId = newProjectId();
1447
- onStep(`Creating the Google Cloud project ${projectId}`);
1448
- try {
1449
- const operation = await google("Creating the Google Cloud project", "POST", `${RESOURCE_MANAGER_API}/v3/projects`, {
1450
- projectId,
1451
- displayName: "Hot Updater Play"
1452
- });
1453
- const created = await waitForOperation({
1454
- request: google,
1455
- origin: `${RESOURCE_MANAGER_API}/v3`,
1456
- operation,
1457
- action: "Creating the Google Cloud project",
1458
- sleep
1459
- });
1460
- return {
1461
- projectId,
1462
- ...projectNumberOf(created?.name)
1463
- };
1464
- } catch (error) {
1465
- 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 });
1469
- throw error;
1470
- }
1471
- };
1472
- /** `projectNumber` from a resource name `projects/<number>`. */
1473
- const projectNumberOf = (name) => {
1474
- const number = name?.match(/^projects\/(\d+)$/u)?.[1];
1475
- return number === void 0 ? {} : { projectNumber: number };
1476
- };
1477
- /**
1478
- * Runs `run` again while Google is still enabling an API in `project`,
1479
- * or, with `notFound`, still creating what it reads.
1480
- */
1481
- const whilePropagating = async ({ run, project, sleep, notFound = false }) => {
1482
- for (let attempt = 1;; attempt++) try {
1483
- return await run();
1484
- } catch (error) {
1485
- const disabled = isServiceDisabled(error);
1486
- if (disabled && !consumerIs(error, project)) throw clientApiDisabled(error);
1487
- 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 });
1489
- throw error;
1490
- }
1491
- await sleep(PROPAGATION_DELAY_MS);
1492
- }
1493
- };
1494
- /**
1495
- * Whether a disabled-service error is about `project` itself, which waiting
1496
- * fixes: Google names the consumer by number, and an error naming none
1497
- * counts as the project's own.
1498
- */
1499
- const consumerIs = (error, project) => error.consumer === void 0 || error.consumer === `projects/${project.projectId}` || (project.projectNumber === void 0 ? true : error.consumer === `projects/${project.projectNumber}`);
1500
- const enableServices = async ({ google, projectId, sleep }) => {
1501
- try {
1502
- const operation = await google("Enabling the APIs", "POST", `${SERVICE_USAGE_API}/v1/projects/${projectId}/services:batchEnable`, { serviceIds: SERVICES });
1503
- await waitForOperation({
1504
- request: google,
1505
- origin: `${SERVICE_USAGE_API}/v1`,
1506
- operation,
1507
- action: "Enabling the APIs",
1508
- sleep
1509
- });
1510
- } 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 });
1512
- throw error;
1513
- }
1514
- };
1515
- /** The `hot-updater-play` service account's email, created unless it exists. */
1516
- const ensureServiceAccount = async ({ google, project, sleep }) => {
1517
- const { projectId } = project;
1518
- const email = `${PLAY_SERVICE_ACCOUNT_ID}@${projectId}.iam.gserviceaccount.com`;
1519
- try {
1520
- return (await whilePropagating({
1521
- project,
1522
- sleep,
1523
- run: () => google("Creating the service account", "POST", `${IAM_API}/projects/${projectId}/serviceAccounts`, {
1524
- accountId: "hot-updater-play",
1525
- serviceAccount: {
1526
- displayName: "Hot Updater Play uploads",
1527
- description: "Uploads Android App Bundles to Google Play for hot-updater native-build android --submit play-internal."
1528
- }
1529
- })
1530
- })).email ?? email;
1531
- } catch (error) {
1532
- 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 });
1534
- throw error;
1535
- }
1536
- };
1537
- /**
1538
- * Makes `email` a Play Console user that may release `packageName`, keeping
1539
- * any permission it has: the user and the app's grant are created, or the
1540
- * grant gains the release permissions.
1541
- */
1542
- const grantPlayAccess = async ({ google, developerId, email, packageName }) => {
1543
- const developer = `${PUBLISHER_API}/developers/${developerId}`;
1544
- const user = `${developer}/users/${encodeURIComponent(email)}`;
1545
- try {
1546
- await google("Adding the Play Console user", "POST", `${developer}/users`, {
1547
- name: `developers/${developerId}/users/${email}`,
1548
- email
1549
- });
1550
- } catch (error) {
1551
- if (isDenied(error)) throw notPlayAdmin(developerId, error);
1552
- if (!isConflict(error)) throw error;
1553
- }
1554
- try {
1555
- await google("Granting access to the app", "POST", `${user}/grants`, {
1556
- name: `developers/${developerId}/users/${email}/grants/${packageName}`,
1557
- packageName,
1558
- appLevelPermissions: APP_PERMISSIONS
1559
- });
1560
- return;
1561
- } catch (error) {
1562
- if (isNotFound(error)) throw appNotFound(packageName, developerId, error);
1563
- if (isDenied(error)) throw notPlayAdmin(developerId, error);
1564
- if (!isConflict(error)) throw error;
1565
- }
1566
- const granted = await findGrant({
1567
- google,
1568
- developer,
1569
- email,
1570
- packageName
1571
- });
1572
- const missing = APP_PERMISSIONS.filter((permission) => !granted.includes(permission));
1573
- if (missing.length === 0) return;
1574
- await google("Granting access to the app", "PATCH", `${user}/grants/${encodeURIComponent(packageName)}?updateMask=appLevelPermissions`, { appLevelPermissions: [...granted, ...missing] });
1575
- };
1576
- /** The app permissions `email` holds on `packageName`, from the user list. */
1577
- const findGrant = async ({ google, developer, email, packageName }) => {
1578
- let pageToken;
1579
- do {
1580
- const page = await google("Listing the Play Console users", "GET", `${developer}/users?${new URLSearchParams({
1581
- pageSize: "100",
1582
- ...pageToken ? { pageToken } : {}
1583
- })}`);
1584
- const found = page.users?.find((candidate) => candidate.email === email);
1585
- if (found) return found.grants?.find((grant) => grant.packageName === packageName)?.appLevelPermissions ?? [];
1586
- pageToken = page.nextPageToken || void 0;
1587
- } while (pageToken);
1588
- return [];
1589
- };
1590
- /** A new JSON key of the service account, checked and masked in the CLI's output. */
1591
- const createKey = async ({ google, project, email, sleep }) => {
1592
- const { projectId } = project;
1593
- let key;
1594
- try {
1595
- key = await whilePropagating({
1596
- project,
1597
- sleep,
1598
- notFound: true,
1599
- run: () => google("Creating the key", "POST", `${IAM_API}/projects/${projectId}/serviceAccounts/${email}/keys`, {
1600
- privateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE",
1601
- keyAlgorithm: "KEY_ALG_RSA_2048"
1602
- })
1603
- });
1604
- } 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 });
1606
- throw error;
1607
- }
1608
- if (!key.name || !key.privateKeyData) throw new Error("Google Cloud created the key without its JSON file.");
1609
- (0, _hot_updater_cli_tools.registerSecretValue)(key.privateKeyData);
1610
- const json = Buffer.from(key.privateKeyData, "base64").toString("utf-8");
1611
- (0, _hot_updater_cli_tools.registerSecretValue)(json);
1612
- let parsed;
1613
- try {
1614
- parsed = JSON.parse(json);
1615
- } catch {
1616
- parsed = {};
1617
- }
1618
- if (typeof parsed.private_key !== "string") {
1619
- await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`).catch(() => {});
1620
- throw new Error("Google Cloud created a key that is no JSON key file.");
1621
- }
1622
- (0, _hot_updater_cli_tools.registerSecretValue)(parsed.private_key);
1623
- return {
1624
- json,
1625
- name: key.name,
1626
- keyId: key.name.split("/").pop()
1627
- };
1628
- };
1629
- //#endregion
1630
- //#region src/playServiceAccount.ts
1631
- /**
1632
- * Hot Updater's Google OAuth client, a "Desktop app" client the sign-in
1633
- * opens Google's consent screen for. Empty in a build that has none, where
1634
- * `HOT_UPDATER_GOOGLE_OAUTH_CLIENT_ID` and `_SECRET` name one.
1635
- */
1636
- const HOT_UPDATER_GOOGLE_OAUTH_CLIENT = {
1637
- clientId: "",
1638
- clientSecret: ""
1639
- };
1640
- /**
1641
- * Hot Updater's issuer, the one place that picks how it is done: Google's
1642
- * OAuth for installed apps, then the Cloud Resource Manager, Service Usage,
1643
- * IAM, and Play Developer APIs. Another implementation of
1644
- * `IssuePlayServiceAccount` replaces it here without touching its callers.
1645
- */
1646
- const issuePlayServiceAccount = (request) => createGoogleKeyIssuer()({
1647
- ...request,
1648
- oauthClient: request.oauthClient ?? HOT_UPDATER_GOOGLE_OAUTH_CLIENT
1649
- });
1650
- //#endregion
1651
- exports.GOOGLE_OAUTH_CLIENT_ENV_NAMES = GOOGLE_OAUTH_CLIENT_ENV_NAMES;
1652
- exports.HOT_UPDATER_GOOGLE_OAUTH_CLIENT = HOT_UPDATER_GOOGLE_OAUTH_CLIENT;
1653
- exports.PLAY_CONSOLE_URL = PLAY_CONSOLE_URL;
1654
- exports.PLAY_SERVICE_ACCOUNT_ID = PLAY_SERVICE_ACCOUNT_ID;
1655
- exports.PlayServiceAccountError = PlayServiceAccountError;
1656
1080
  exports.PlayVersionCodeUsedError = PlayVersionCodeUsedError;
1657
1081
  exports.assertPlaySigned = assertPlaySigned;
1658
1082
  exports.buildAndroid = buildAndroid;
1083
+ exports.checkPlayAccess = checkPlayAccess;
1659
1084
  exports.enrichNativeBuildAndroidScheme = enrichNativeBuildAndroidScheme;
1660
1085
  exports.extractBuiltInBundle = extractBuiltInBundle;
1661
1086
  exports.findPlayInternalRelease = findPlayInternalRelease;
1662
- exports.issuePlayServiceAccount = issuePlayServiceAccount;
1087
+ exports.playConsoleInviteUrl = playConsoleInviteUrl;
1663
1088
  exports.readBuiltApp = readBuiltApp;
1664
1089
  exports.readBuiltPackageName = readBuiltPackageName;
1665
1090
  exports.readBuiltVersionCode = readBuiltVersionCode;