@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.cjs +165 -740
- package/dist/index.d.cts +44 -115
- package/dist/index.d.mts +44 -115
- package/dist/index.mjs +164 -733
- package/package.json +4 -4
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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(`
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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
|
|
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(
|
|
616
|
-
|
|
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})
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(`
|
|
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(`
|
|
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
|
-
|
|
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 };
|