@hot-updater/android-helper 1.2.0-rc.1 → 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.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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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(`
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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
|
|
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(
|
|
646
|
-
|
|
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})
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(`
|
|
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(`
|
|
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.
|
|
1087
|
+
exports.playConsoleInviteUrl = playConsoleInviteUrl;
|
|
1663
1088
|
exports.readBuiltApp = readBuiltApp;
|
|
1664
1089
|
exports.readBuiltPackageName = readBuiltPackageName;
|
|
1665
1090
|
exports.readBuiltVersionCode = readBuiltVersionCode;
|