@hot-updater/android-helper 1.2.0-rc.7 → 1.2.0-rc.8
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 +118 -694
- package/dist/index.d.cts +44 -114
- package/dist/index.d.mts +44 -114
- package/dist/index.mjs +117 -690
- package/package.json +4 -4
package/dist/index.mjs
CHANGED
|
@@ -7,118 +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
|
-
|
|
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
|
|
11
34
|
//#region src/utils/createGradleLogger.ts
|
|
12
35
|
const createGradleLogger = ({ logPrefix }) => new BuildLogger({
|
|
13
36
|
logPrefix,
|
|
14
|
-
|
|
15
|
-
"error:",
|
|
16
|
-
"Error:",
|
|
17
|
-
"ERROR:",
|
|
18
|
-
"> Task :",
|
|
19
|
-
"BUILD FAILED",
|
|
20
|
-
"BUILD SUCCESSFUL",
|
|
21
|
-
"FAILURE: Build failed with an exception.",
|
|
22
|
-
"* What went wrong:",
|
|
23
|
-
"Execution failed for task",
|
|
24
|
-
"Could not resolve",
|
|
25
|
-
"Failed to",
|
|
26
|
-
"Unable to load script",
|
|
27
|
-
"Metro encountered an error",
|
|
28
|
-
"React Native CLI",
|
|
29
|
-
"Android resource compilation failed",
|
|
30
|
-
"Duplicate class",
|
|
31
|
-
"Program type already present",
|
|
32
|
-
"actionable tasks:",
|
|
33
|
-
"Deprecated Gradle features were used",
|
|
34
|
-
":app:configureCMake",
|
|
35
|
-
":app:buildCMake",
|
|
36
|
-
":app:compileDebugKotlin",
|
|
37
|
-
":app:compileReleaseKotlin",
|
|
38
|
-
":app:compileDebugJavaWithJavac",
|
|
39
|
-
":app:compileReleaseJavaWithJavac",
|
|
40
|
-
":app:mergeDebugResources",
|
|
41
|
-
":app:mergeReleaseResources",
|
|
42
|
-
":app:processDebugResources",
|
|
43
|
-
":app:processReleaseResources",
|
|
44
|
-
":app:packageDebug",
|
|
45
|
-
":app:packageRelease",
|
|
46
|
-
":app:assembleDebug",
|
|
47
|
-
":app:assembleRelease"
|
|
48
|
-
],
|
|
49
|
-
progressStages: [
|
|
50
|
-
[
|
|
51
|
-
"buildKotlinToolingMetadata",
|
|
52
|
-
"generateAutolinkingNewArchitectureFiles",
|
|
53
|
-
"generateAutolinkingPackageList",
|
|
54
|
-
"generateCodegenSchemaFromJavaScript",
|
|
55
|
-
"generateCodegenArtifactsFromSchema",
|
|
56
|
-
"generateReactNativeEntryPoint"
|
|
57
|
-
],
|
|
58
|
-
[
|
|
59
|
-
"createBundleReleaseJsAndAssets",
|
|
60
|
-
"bundleReleaseJsAndAssets",
|
|
61
|
-
"bundleDebugJsAndAssets"
|
|
62
|
-
],
|
|
63
|
-
[
|
|
64
|
-
"generateReleaseResValues",
|
|
65
|
-
"generateDebugResValues",
|
|
66
|
-
"generateReleaseResources",
|
|
67
|
-
"generateDebugResources",
|
|
68
|
-
"mergeReleaseResources",
|
|
69
|
-
"mergeDebugResources"
|
|
70
|
-
],
|
|
71
|
-
[
|
|
72
|
-
"processReleaseResources",
|
|
73
|
-
"processDebugResources",
|
|
74
|
-
"processReleaseManifest",
|
|
75
|
-
"processDebugManifest",
|
|
76
|
-
"processDebugMainManifest",
|
|
77
|
-
"parseReleaseLocalResources",
|
|
78
|
-
"parseDebugLocalResources"
|
|
79
|
-
],
|
|
80
|
-
["compileReleaseKotlin", "compileDebugKotlin"],
|
|
81
|
-
["compileReleaseJavaWithJavac", "compileDebugJavaWithJavac"],
|
|
82
|
-
[
|
|
83
|
-
/configureCMake\w+\[/,
|
|
84
|
-
/buildCMake\w+\[/,
|
|
85
|
-
"mergeReleaseJniLibFolders",
|
|
86
|
-
"mergeDebugJniLibFolders",
|
|
87
|
-
"mergeReleaseNativeLibs",
|
|
88
|
-
"mergeDebugNativeLibs"
|
|
89
|
-
],
|
|
90
|
-
[
|
|
91
|
-
"desugarReleaseFileDependencies",
|
|
92
|
-
"desugarDebugFileDependencies",
|
|
93
|
-
"dexBuilderRelease",
|
|
94
|
-
"dexBuilderDebug",
|
|
95
|
-
"mergeDexRelease",
|
|
96
|
-
"mergeExtDexRelease",
|
|
97
|
-
"mergeExtDexDebug",
|
|
98
|
-
"mergeProjectDexRelease",
|
|
99
|
-
"mergeProjectDexDebug",
|
|
100
|
-
"mergeLibDexRelease",
|
|
101
|
-
"mergeLibDexDebug",
|
|
102
|
-
"mergeDebugGlobalSynthetics",
|
|
103
|
-
"mergeReleaseGlobalSynthetics",
|
|
104
|
-
"transformClassesWithDex"
|
|
105
|
-
],
|
|
106
|
-
[
|
|
107
|
-
"compressReleaseAssets",
|
|
108
|
-
"compressDebugAssets",
|
|
109
|
-
"optimizeReleaseResources",
|
|
110
|
-
"mergeReleaseAssets",
|
|
111
|
-
"mergeDebugAssets"
|
|
112
|
-
],
|
|
113
|
-
[
|
|
114
|
-
"packageRelease",
|
|
115
|
-
"packageDebug",
|
|
116
|
-
"assembleRelease",
|
|
117
|
-
"assembleDebug"
|
|
118
|
-
],
|
|
119
|
-
["bundleRelease", "bundleDebug"],
|
|
120
|
-
["BUILD SUCCESSFUL", /\d+ actionable tasks:/]
|
|
121
|
-
],
|
|
37
|
+
createPhaseReader: createGradlePhaseReader,
|
|
122
38
|
failureSummary: {
|
|
123
39
|
lines: [
|
|
124
40
|
/^e: /,
|
|
@@ -175,21 +91,24 @@ const getGradleWrapper = () => process.platform.startsWith("win") ? "gradlew.bat
|
|
|
175
91
|
/**
|
|
176
92
|
* Run gradle build with specified tasks and arguments
|
|
177
93
|
*/
|
|
178
|
-
async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appModuleName }) {
|
|
94
|
+
async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appModuleName, logHeader = [] }) {
|
|
179
95
|
const gradleArgs = getTaskNames(appModuleName, tasks);
|
|
180
96
|
gradleArgs.push("-x", "lint");
|
|
181
97
|
if (args.extraParams) gradleArgs.push(...args.extraParams);
|
|
182
|
-
p.log.info([
|
|
183
|
-
"Gradle build",
|
|
184
|
-
`Project ${path.relative(getCwd(), androidProjectPath) || "."}`,
|
|
185
|
-
`App module ${appModuleName}`,
|
|
186
|
-
`Tasks ${tasks.join(", ")}`,
|
|
187
|
-
"Command",
|
|
188
|
-
formatCommand([path.basename(getGradleWrapper()), ...gradleArgs])
|
|
189
|
-
].join("\n"));
|
|
190
98
|
if ("port" in args && args.port != null) gradleArgs.push(`-PreactNativeDevServerPort=${args.port}`);
|
|
191
99
|
const logger = createGradleLogger({ logPrefix });
|
|
192
|
-
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;
|
|
193
112
|
try {
|
|
194
113
|
const gradle = execa(getGradleWrapper(), gradleArgs, {
|
|
195
114
|
cwd: androidProjectPath,
|
|
@@ -201,11 +120,13 @@ async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appM
|
|
|
201
120
|
const [gradleResult, loggingResult] = await Promise.allSettled([gradle, outputStream ? logger.processStream(outputStream) : void 0]);
|
|
202
121
|
if (gradleResult.status === "rejected") throw gradleResult.reason;
|
|
203
122
|
if (loggingResult.status === "rejected") throw loggingResult.reason;
|
|
204
|
-
logger.stop();
|
|
123
|
+
logger.stop(`Built ${tasks.join(", ")}`);
|
|
124
|
+
logFilePath = logger.logFilePath;
|
|
205
125
|
} catch (e) {
|
|
206
126
|
logger.writeError(e);
|
|
207
|
-
|
|
208
|
-
|
|
127
|
+
const reason = describeFailure(e);
|
|
128
|
+
logger.stop(`Build failed · ${reason.replace(/\.$/, "")}`, false);
|
|
129
|
+
throw new BuildFailedError(`Build failed: ${reason}`, {
|
|
209
130
|
logPath: logger.logPath,
|
|
210
131
|
summary: logger.failureSummary,
|
|
211
132
|
cause: e
|
|
@@ -213,11 +134,14 @@ async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appM
|
|
|
213
134
|
} finally {
|
|
214
135
|
await logger.close();
|
|
215
136
|
}
|
|
216
|
-
return
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
137
|
+
return {
|
|
138
|
+
...await findBuildDirectory({
|
|
139
|
+
androidProjectPath,
|
|
140
|
+
moduleName: appModuleName,
|
|
141
|
+
tasks
|
|
142
|
+
}),
|
|
143
|
+
logFilePath
|
|
144
|
+
};
|
|
221
145
|
}
|
|
222
146
|
/**
|
|
223
147
|
* Find the build output directory and artifact path
|
|
@@ -511,7 +435,7 @@ const readBuiltVersionCode = async ({ artifactPath, androidProjectPath }) => {
|
|
|
511
435
|
};
|
|
512
436
|
//#endregion
|
|
513
437
|
//#region src/buildAndroid.ts
|
|
514
|
-
const buildAndroid = async ({ schemeConfig, minBundleId = createMinBundleId(), credentials }) => {
|
|
438
|
+
const buildAndroid = async ({ schemeConfig, minBundleId = createMinBundleId(), credentials, logHeader }) => {
|
|
515
439
|
const androidProjectPath = path.join(getCwd(), "android");
|
|
516
440
|
const result = await runGradle({
|
|
517
441
|
args: { extraParams: [`-PMIN_BUNDLE_ID=${minBundleId}`] },
|
|
@@ -522,7 +446,8 @@ const buildAndroid = async ({ schemeConfig, minBundleId = createMinBundleId(), c
|
|
|
522
446
|
appModuleName: schemeConfig.appModuleName,
|
|
523
447
|
tasks: schemeConfig.aab ? [`bundle${schemeConfig.variant}`] : [`assemble${schemeConfig.variant}`],
|
|
524
448
|
logPrefix: `android-${schemeConfig.hotUpdaterSchemeName}-build`,
|
|
525
|
-
androidProjectPath
|
|
449
|
+
androidProjectPath,
|
|
450
|
+
logHeader
|
|
526
451
|
});
|
|
527
452
|
return {
|
|
528
453
|
...result,
|
|
@@ -645,8 +570,8 @@ const explain = (error, packageName) => {
|
|
|
645
570
|
/**
|
|
646
571
|
* Requests to the Google Play Developer API for `packageName`, as the
|
|
647
572
|
* service account: each carries an access token, exchanged for a signed
|
|
648
|
-
* assertion and replaced well before it expires.
|
|
649
|
-
*
|
|
573
|
+
* assertion and replaced well before it expires. `checkPlayAccess`
|
|
574
|
+
* checks a key's access with it too.
|
|
650
575
|
*/
|
|
651
576
|
const createPlayClient = ({ json, packageName, fetch }) => {
|
|
652
577
|
const account = readServiceAccount(json);
|
|
@@ -695,6 +620,66 @@ const createPlayClient = ({ json, packageName, fetch }) => {
|
|
|
695
620
|
};
|
|
696
621
|
};
|
|
697
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
|
+
/**
|
|
698
683
|
* Whether Google Play's internal testing track already releases the
|
|
699
684
|
* `.aab`'s version code, as when a reused native build was uploaded
|
|
700
685
|
* before. It reads the track in an edit it then deletes, changing nothing.
|
|
@@ -722,26 +707,6 @@ const findPlayInternalRelease = async ({ versionCode, packageName, serviceAccoun
|
|
|
722
707
|
}
|
|
723
708
|
};
|
|
724
709
|
/**
|
|
725
|
-
* Whether Google Play releases any version of `packageName` on any track,
|
|
726
|
-
* which means Play has the app's upload key. It reads the tracks in an edit
|
|
727
|
-
* it then deletes, changing nothing.
|
|
728
|
-
*/
|
|
729
|
-
const hasPlayReleases = async ({ packageName, serviceAccount: { json }, fetch = globalThis.fetch }) => {
|
|
730
|
-
const { app, request } = createPlayClient({
|
|
731
|
-
json,
|
|
732
|
-
packageName,
|
|
733
|
-
fetch
|
|
734
|
-
});
|
|
735
|
-
const { id: editId } = await request("Opening a Google Play edit", "POST", `${API_URL}${app}/edits`, {});
|
|
736
|
-
const edit = `${API_URL}${app}/edits/${encodeURIComponent(editId)}`;
|
|
737
|
-
try {
|
|
738
|
-
const { tracks = [] } = await request("Reading the tracks", "GET", `${edit}/tracks`);
|
|
739
|
-
return tracks.some(({ releases = [] }) => releases.some(({ versionCodes = [] }) => versionCodes.length > 0));
|
|
740
|
-
} finally {
|
|
741
|
-
await request("Deleting the Google Play edit", "DELETE", edit).catch(() => void 0);
|
|
742
|
-
}
|
|
743
|
-
};
|
|
744
|
-
/**
|
|
745
710
|
* The common name of every Android debug certificate: Android Studio's and
|
|
746
711
|
* the Android Gradle plugin's (`CN=Android Debug, O=Android, C=US`), and React
|
|
747
712
|
* Native's template `debug.keystore` (`CN=Android Debug, OU=Android,
|
|
@@ -1084,542 +1049,4 @@ const extractBuiltInBundle = async ({ artifactPath, outputPath }) => {
|
|
|
1084
1049
|
await (entry.method === 8 ? pipeline(source, createInflateRaw(), fs.createWriteStream(outputPath)) : pipeline(source, fs.createWriteStream(outputPath)));
|
|
1085
1050
|
};
|
|
1086
1051
|
//#endregion
|
|
1087
|
-
|
|
1088
|
-
/** A Google Cloud or Google Play API request that failed, with Google's error. */
|
|
1089
|
-
var GoogleApiError = class extends Error {
|
|
1090
|
-
status;
|
|
1091
|
-
/** Google's canonical status, such as `PERMISSION_DENIED`. */
|
|
1092
|
-
apiStatus;
|
|
1093
|
-
apiMessage;
|
|
1094
|
-
/** The `ErrorInfo` reasons, such as `SERVICE_DISABLED`. */
|
|
1095
|
-
reasons;
|
|
1096
|
-
/** The `PreconditionFailure` violation types, such as an org policy constraint. */
|
|
1097
|
-
violations;
|
|
1098
|
-
/** The `ErrorInfo` consumer of a disabled service, such as `projects/123`. */
|
|
1099
|
-
consumer;
|
|
1100
|
-
constructor(action, status, body = {}) {
|
|
1101
|
-
const apiMessage = body.apiMessage ?? "";
|
|
1102
|
-
super(`${action} failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : "."}`);
|
|
1103
|
-
this.name = "GoogleApiError";
|
|
1104
|
-
this.status = status;
|
|
1105
|
-
this.apiStatus = body.apiStatus;
|
|
1106
|
-
this.apiMessage = apiMessage;
|
|
1107
|
-
this.reasons = body.reasons ?? [];
|
|
1108
|
-
this.violations = body.violations ?? [];
|
|
1109
|
-
this.consumer = body.consumer;
|
|
1110
|
-
}
|
|
1111
|
-
};
|
|
1112
|
-
/** Google's error body, `{ error: { code, status, message, details } }`. */
|
|
1113
|
-
const readError = async (action, response) => {
|
|
1114
|
-
let error;
|
|
1115
|
-
try {
|
|
1116
|
-
error = (await response.json()).error;
|
|
1117
|
-
} catch {}
|
|
1118
|
-
const details = Array.isArray(error?.details) ? error.details : [];
|
|
1119
|
-
const info = details.filter((detail) => typeof detail.reason === "string");
|
|
1120
|
-
return new GoogleApiError(action, response.status, {
|
|
1121
|
-
...typeof error?.status === "string" ? { apiStatus: error.status } : {},
|
|
1122
|
-
...typeof error?.message === "string" ? { apiMessage: error.message } : {},
|
|
1123
|
-
reasons: info.map((detail) => detail.reason),
|
|
1124
|
-
violations: details.flatMap((detail) => (detail.violations ?? []).flatMap((violation) => typeof violation.type === "string" ? [violation.type] : [])),
|
|
1125
|
-
...info.find((detail) => detail.metadata?.["consumer"]) ? { consumer: info.find((detail) => detail.metadata?.["consumer"]).metadata["consumer"] } : {}
|
|
1126
|
-
});
|
|
1127
|
-
};
|
|
1128
|
-
/** Whether Google answered that a service is disabled in a project. */
|
|
1129
|
-
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));
|
|
1130
|
-
/** Requests with the signed-in user's access token, throwing `GoogleApiError`. */
|
|
1131
|
-
const createGoogleRequest = ({ accessToken, fetch }) => async (action, method, url, body) => {
|
|
1132
|
-
const response = await fetch(url, {
|
|
1133
|
-
method,
|
|
1134
|
-
headers: {
|
|
1135
|
-
authorization: `Bearer ${accessToken}`,
|
|
1136
|
-
...body === void 0 ? {} : { "content-type": "application/json" }
|
|
1137
|
-
},
|
|
1138
|
-
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
1139
|
-
});
|
|
1140
|
-
if (!response.ok) throw await readError(action, response);
|
|
1141
|
-
const text = await response.text();
|
|
1142
|
-
return text ? JSON.parse(text) : {};
|
|
1143
|
-
};
|
|
1144
|
-
/**
|
|
1145
|
-
* Polls `operation` at `origin` until it is done, returning its response or
|
|
1146
|
-
* throwing its error as a `GoogleApiError` with the operation's code.
|
|
1147
|
-
*/
|
|
1148
|
-
const waitForOperation = async ({ request, origin, operation, action, sleep, intervalMs = 2e3, attempts = 60 }) => {
|
|
1149
|
-
let current = operation;
|
|
1150
|
-
for (let attempt = 0; !current.done; attempt++) {
|
|
1151
|
-
if (attempt >= attempts || !current.name) throw new Error(`${action} did not finish in time.`);
|
|
1152
|
-
await sleep(intervalMs);
|
|
1153
|
-
current = await request(action, "GET", `${origin}/${current.name}`);
|
|
1154
|
-
}
|
|
1155
|
-
if (current.error) throw new GoogleApiError(action, 400, {
|
|
1156
|
-
apiMessage: current.error.message ?? "",
|
|
1157
|
-
...current.error.code === 7 ? { apiStatus: "PERMISSION_DENIED" } : {},
|
|
1158
|
-
...current.error.code === 8 ? { apiStatus: "RESOURCE_EXHAUSTED" } : {}
|
|
1159
|
-
});
|
|
1160
|
-
return current.response;
|
|
1161
|
-
};
|
|
1162
|
-
//#endregion
|
|
1163
|
-
//#region src/google/types.ts
|
|
1164
|
-
/** A named failure of `issuePlayServiceAccount`, with what to do in its message. */
|
|
1165
|
-
var PlayServiceAccountError = class extends Error {
|
|
1166
|
-
code;
|
|
1167
|
-
constructor(code, message, options) {
|
|
1168
|
-
super(message, options);
|
|
1169
|
-
this.name = "PlayServiceAccountError";
|
|
1170
|
-
this.code = code;
|
|
1171
|
-
}
|
|
1172
|
-
};
|
|
1173
|
-
/** The service account Hot Updater creates, `hot-updater-play@<project>.iam.gserviceaccount.com`. */
|
|
1174
|
-
const PLAY_SERVICE_ACCOUNT_ID = "hot-updater-play";
|
|
1175
|
-
//#endregion
|
|
1176
|
-
//#region src/google/googleKeyIssuer.ts
|
|
1177
|
-
const PUBLISHER_API = "https://androidpublisher.googleapis.com/androidpublisher/v3";
|
|
1178
|
-
const RESOURCE_MANAGER_API = "https://cloudresourcemanager.googleapis.com";
|
|
1179
|
-
const SERVICE_USAGE_API = "https://serviceusage.googleapis.com";
|
|
1180
|
-
const IAM_API = "https://iam.googleapis.com/v1";
|
|
1181
|
-
const GCLOUD_INSTALL_URL = "https://cloud.google.com/sdk/docs/install";
|
|
1182
|
-
/** The Google Play Android Developer API, which the service account calls. */
|
|
1183
|
-
const PLAY_API = "androidpublisher.googleapis.com";
|
|
1184
|
-
/** IAM creates the service account; the Play Developer API is what it calls. */
|
|
1185
|
-
const SERVICES = ["iam.googleapis.com", PLAY_API];
|
|
1186
|
-
const KEY_CREATION_CONSTRAINT = "constraints/iam.disableServiceAccountKeyCreation";
|
|
1187
|
-
/** Retries while Google enables an API or creates a service account. */
|
|
1188
|
-
const PROPAGATION_ATTEMPTS = 12;
|
|
1189
|
-
const PROPAGATION_DELAY_MS = 1e4;
|
|
1190
|
-
/** Checks of the key's access to the app, about ten minutes of them. */
|
|
1191
|
-
const ACCESS_ATTEMPTS = 120;
|
|
1192
|
-
const ACCESS_DELAY_MS = 5e3;
|
|
1193
|
-
const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1194
|
-
const runWithExeca = async (file, args, options) => (await execa(file, args, options?.interactive ? { stdio: "inherit" } : { stdin: "ignore" })).stdout ?? "";
|
|
1195
|
-
/**
|
|
1196
|
-
* Play Console's page that invites `email` as a user. `_` stands for the
|
|
1197
|
-
* developer account the browser is signed in to, so no ID is needed.
|
|
1198
|
-
*/
|
|
1199
|
-
const playConsoleInviteUrl = (email) => `https://play.google.com/console/u/0/developers/_/users-and-permissions?invite=${encodeURIComponent(email)}`;
|
|
1200
|
-
/** A project ID nobody has: Google Cloud's are global. */
|
|
1201
|
-
const newProjectId = () => `${PLAY_SERVICE_ACCOUNT_ID}-${crypto$1.randomBytes(3).toString("hex")}`;
|
|
1202
|
-
const isNotFound = (error) => error instanceof GoogleApiError && error.status === 404;
|
|
1203
|
-
const isConflict = (error) => error instanceof GoogleApiError && error.status === 409;
|
|
1204
|
-
/** The API is disabled in another project than the one set up: waiting does not help. */
|
|
1205
|
-
const consumerApiDisabled = (error) => new PlayServiceAccountError("api-not-enabled", `${error.apiMessage}\nEnable it in ${error.consumer ?? "that project"}, then run this again.`, { cause: error });
|
|
1206
|
-
const isDenied = (error) => error instanceof GoogleApiError && (error.status === 403 || error.apiStatus === "PERMISSION_DENIED");
|
|
1207
|
-
const lines = (output) => output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
1208
|
-
/** The Google Accounts gcloud holds, the active one, if any, first. */
|
|
1209
|
-
const credentialedAccounts = async (run) => {
|
|
1210
|
-
const accounts = lines(await run("gcloud", [
|
|
1211
|
-
"auth",
|
|
1212
|
-
"list",
|
|
1213
|
-
"--format=value(account)"
|
|
1214
|
-
]));
|
|
1215
|
-
const [active] = lines(await run("gcloud", [
|
|
1216
|
-
"auth",
|
|
1217
|
-
"list",
|
|
1218
|
-
"--filter=status:ACTIVE",
|
|
1219
|
-
"--format=value(account)"
|
|
1220
|
-
]));
|
|
1221
|
-
return active !== void 0 && accounts.includes(active) ? [active, ...accounts.filter((account) => account !== active)].map((email) => ({
|
|
1222
|
-
email,
|
|
1223
|
-
active: email === active
|
|
1224
|
-
})) : accounts.map((email) => ({
|
|
1225
|
-
email,
|
|
1226
|
-
active: false
|
|
1227
|
-
}));
|
|
1228
|
-
};
|
|
1229
|
-
/** Stops before anything else, a prompt included, when gcloud is not installed. */
|
|
1230
|
-
const requireGcloud = async (run) => {
|
|
1231
|
-
try {
|
|
1232
|
-
await run("gcloud", ["--version"]);
|
|
1233
|
-
} catch (error) {
|
|
1234
|
-
throw new PlayServiceAccountError("gcloud-not-installed", [
|
|
1235
|
-
"gcloud CLI is not installed.",
|
|
1236
|
-
"Install it, then run this again:",
|
|
1237
|
-
GCLOUD_INSTALL_URL
|
|
1238
|
-
].join("\n"), { cause: error });
|
|
1239
|
-
}
|
|
1240
|
-
};
|
|
1241
|
-
/**
|
|
1242
|
-
* The access token of the Google Account the developer picks among those
|
|
1243
|
-
* gcloud holds, or of the one `gcloud auth login` adds, when they pick
|
|
1244
|
-
* another or gcloud holds none. gcloud's active account stays as it was.
|
|
1245
|
-
*/
|
|
1246
|
-
const signInWithGcloud = async ({ run, request, onStep }) => {
|
|
1247
|
-
const before = await credentialedAccounts(run);
|
|
1248
|
-
let account = before.length > 0 ? await request.chooseGoogleAccount(before) ?? void 0 : void 0;
|
|
1249
|
-
if (account === void 0) {
|
|
1250
|
-
const signedIn = await run("gcloud", ["auth", "login"], { interactive: true }).then(() => true, () => false);
|
|
1251
|
-
const after = await credentialedAccounts(run);
|
|
1252
|
-
account = after.find(({ email }) => !before.some((known) => known.email === email))?.email ?? (signedIn ? after.find(({ active }) => active)?.email : void 0);
|
|
1253
|
-
if (account === void 0) throw new PlayServiceAccountError("gcloud-not-signed-in", before.length === 0 ? "gcloud has no active Google Account.\nRun gcloud auth login, then run this again." : "gcloud auth login didn't finish.\nRun this again to choose a Google Account.");
|
|
1254
|
-
}
|
|
1255
|
-
onStep(`Signed in to gcloud as ${account}`);
|
|
1256
|
-
const accessToken = (await run("gcloud", [
|
|
1257
|
-
"auth",
|
|
1258
|
-
"print-access-token",
|
|
1259
|
-
account
|
|
1260
|
-
])).trim();
|
|
1261
|
-
registerSecretValue(accessToken);
|
|
1262
|
-
return accessToken;
|
|
1263
|
-
};
|
|
1264
|
-
/**
|
|
1265
|
-
* `IssuePlayServiceAccount` with the gcloud CLI's sign-in and Google's own
|
|
1266
|
-
* APIs:
|
|
1267
|
-
*
|
|
1268
|
-
* 1. once gcloud is found, `askPackageName` names the app, and the Google
|
|
1269
|
-
* Account `chooseGoogleAccount` picks among gcloud's, or the one
|
|
1270
|
-
* `gcloud auth login` adds, the access token the Google Cloud steps use;
|
|
1271
|
-
* 2. the account's only project with the Play Developer API, else the one
|
|
1272
|
-
* `chooseProject` picks, else a new one, gets the IAM and Play Developer
|
|
1273
|
-
* APIs enabled, and the service account `hot-updater-play` its JSON key;
|
|
1274
|
-
* 3. the developer invites the service account in Play Console, which
|
|
1275
|
-
* `onInvite` opens: gcloud's token may not manage Google Play users;
|
|
1276
|
-
* 4. the key itself opens an edit of the app until Google Play lets it,
|
|
1277
|
-
* and is deleted again when it never does.
|
|
1278
|
-
*
|
|
1279
|
-
* Each step reuses what an earlier run created: the project and the
|
|
1280
|
-
* service account.
|
|
1281
|
-
*/
|
|
1282
|
-
const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor, run = runWithExeca } = {}) => async (request) => {
|
|
1283
|
-
const { onStep = () => {} } = request;
|
|
1284
|
-
await requireGcloud(run);
|
|
1285
|
-
const packageName = await request.askPackageName();
|
|
1286
|
-
const accessToken = await signInWithGcloud({
|
|
1287
|
-
run,
|
|
1288
|
-
request,
|
|
1289
|
-
onStep
|
|
1290
|
-
});
|
|
1291
|
-
const google = createGoogleRequest({
|
|
1292
|
-
accessToken,
|
|
1293
|
-
fetch
|
|
1294
|
-
});
|
|
1295
|
-
const project = await chooseProject({
|
|
1296
|
-
google,
|
|
1297
|
-
request,
|
|
1298
|
-
sleep,
|
|
1299
|
-
onStep
|
|
1300
|
-
});
|
|
1301
|
-
const { projectId } = project;
|
|
1302
|
-
onStep(`Enabling the APIs in ${projectId}`);
|
|
1303
|
-
await enableServices({
|
|
1304
|
-
google,
|
|
1305
|
-
projectId,
|
|
1306
|
-
sleep
|
|
1307
|
-
});
|
|
1308
|
-
onStep(`Creating the service account in ${projectId}`);
|
|
1309
|
-
const email = await ensureServiceAccount({
|
|
1310
|
-
google,
|
|
1311
|
-
project,
|
|
1312
|
-
sleep
|
|
1313
|
-
});
|
|
1314
|
-
onStep("Creating the service account's JSON key");
|
|
1315
|
-
const key = await createKey({
|
|
1316
|
-
google,
|
|
1317
|
-
project,
|
|
1318
|
-
email,
|
|
1319
|
-
sleep
|
|
1320
|
-
});
|
|
1321
|
-
const revoke = async () => {
|
|
1322
|
-
try {
|
|
1323
|
-
await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`);
|
|
1324
|
-
return true;
|
|
1325
|
-
} catch (error) {
|
|
1326
|
-
return isNotFound(error);
|
|
1327
|
-
}
|
|
1328
|
-
};
|
|
1329
|
-
const inviteUrl = playConsoleInviteUrl(email);
|
|
1330
|
-
try {
|
|
1331
|
-
await request.onInvite(inviteUrl, email);
|
|
1332
|
-
onStep("Waiting for the Play Console invite");
|
|
1333
|
-
await waitForPlayAccess({
|
|
1334
|
-
json: key.json,
|
|
1335
|
-
packageName,
|
|
1336
|
-
fetch,
|
|
1337
|
-
sleep
|
|
1338
|
-
});
|
|
1339
|
-
} catch (error) {
|
|
1340
|
-
const deleted = await revoke();
|
|
1341
|
-
throw new PlayServiceAccountError("play-access-pending", [
|
|
1342
|
-
`The key can't release ${packageName} yet.`,
|
|
1343
|
-
"Invite the service account in Play Console:",
|
|
1344
|
-
email,
|
|
1345
|
-
"App permission: Release apps to testing tracks",
|
|
1346
|
-
inviteUrl,
|
|
1347
|
-
"A new app needs its first .aab uploaded in Play Console.",
|
|
1348
|
-
...deleted ? [] : [`Delete its unused key ${key.keyId} in Google Cloud:`, `https://console.cloud.google.com/iam-admin/serviceaccounts?project=${encodeURIComponent(projectId)}`],
|
|
1349
|
-
"Then run: npx hot-updater google-play login"
|
|
1350
|
-
].join("\n"), { cause: error });
|
|
1351
|
-
}
|
|
1352
|
-
return {
|
|
1353
|
-
json: key.json,
|
|
1354
|
-
keyId: key.keyId,
|
|
1355
|
-
email,
|
|
1356
|
-
projectId,
|
|
1357
|
-
revoke,
|
|
1358
|
-
dispose: async () => {}
|
|
1359
|
-
};
|
|
1360
|
-
};
|
|
1361
|
-
/**
|
|
1362
|
-
* Waits until the key opens an edit of `packageName`, which Google Play
|
|
1363
|
-
* allows once the service account is a Play Console user with access to
|
|
1364
|
-
* the app, then deletes the edit, which changed nothing. Google may not
|
|
1365
|
-
* take a key just created for a while either.
|
|
1366
|
-
*/
|
|
1367
|
-
const waitForPlayAccess = async ({ json, packageName, fetch, sleep }) => {
|
|
1368
|
-
const play = createPlayClient({
|
|
1369
|
-
json,
|
|
1370
|
-
packageName,
|
|
1371
|
-
fetch
|
|
1372
|
-
});
|
|
1373
|
-
const edits = `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits`;
|
|
1374
|
-
for (let attempt = 1;; attempt++) {
|
|
1375
|
-
if (attempt > 1) await sleep(ACCESS_DELAY_MS);
|
|
1376
|
-
let ready = true;
|
|
1377
|
-
try {
|
|
1378
|
-
await play.authorization();
|
|
1379
|
-
} catch {
|
|
1380
|
-
ready = false;
|
|
1381
|
-
}
|
|
1382
|
-
if (ready) try {
|
|
1383
|
-
const edit = await play.request(`Opening an edit of ${packageName}`, "POST", edits);
|
|
1384
|
-
if (edit?.id) await play.request("Deleting the edit", "DELETE", `${edits}/${edit.id}`).catch(() => {});
|
|
1385
|
-
return;
|
|
1386
|
-
} catch (error) {
|
|
1387
|
-
if (!(error instanceof GooglePlayError) || ![
|
|
1388
|
-
401,
|
|
1389
|
-
403,
|
|
1390
|
-
404
|
|
1391
|
-
].includes(error.status)) throw error;
|
|
1392
|
-
}
|
|
1393
|
-
if (attempt >= ACCESS_ATTEMPTS) throw new Error("Google Play did not let the key reach the app in time.");
|
|
1394
|
-
}
|
|
1395
|
-
};
|
|
1396
|
-
/** The account's active projects, every page of them. */
|
|
1397
|
-
const listProjects = async (google) => {
|
|
1398
|
-
const projects = [];
|
|
1399
|
-
let pageToken;
|
|
1400
|
-
do {
|
|
1401
|
-
const page = await google("Listing the Google Cloud projects", "GET", `${RESOURCE_MANAGER_API}/v3/projects:search?${new URLSearchParams({
|
|
1402
|
-
query: "state:ACTIVE",
|
|
1403
|
-
...pageToken ? { pageToken } : {}
|
|
1404
|
-
})}`);
|
|
1405
|
-
for (const project of page.projects ?? []) if (project.projectId) projects.push({
|
|
1406
|
-
projectId: project.projectId,
|
|
1407
|
-
...project.displayName ? { displayName: project.displayName } : {},
|
|
1408
|
-
...projectNumberOf(project.name)
|
|
1409
|
-
});
|
|
1410
|
-
pageToken = page.nextPageToken || void 0;
|
|
1411
|
-
} while (pageToken);
|
|
1412
|
-
return projects;
|
|
1413
|
-
};
|
|
1414
|
-
/** Service Usage reads of the projects' Play Developer API, at most this many at once. */
|
|
1415
|
-
const API_CHECK_CONCURRENCY = 8;
|
|
1416
|
-
/**
|
|
1417
|
-
* Whether each project has the Google Play Developer API enabled, by one
|
|
1418
|
-
* Service Usage read each; a project this account may not read counts as
|
|
1419
|
-
* without it.
|
|
1420
|
-
*/
|
|
1421
|
-
const withPlayApiState = async (google, projects) => {
|
|
1422
|
-
const states = [];
|
|
1423
|
-
for (let start = 0; start < projects.length; start += API_CHECK_CONCURRENCY) states.push(...await Promise.all(projects.slice(start, start + API_CHECK_CONCURRENCY).map(({ projectId }) => google("Reading the project's APIs", "GET", `${SERVICE_USAGE_API}/v1/projects/${encodeURIComponent(projectId)}/services/${PLAY_API}`).then(({ state }) => state === "ENABLED", () => false))));
|
|
1424
|
-
return projects.map((project, index) => ({
|
|
1425
|
-
...project,
|
|
1426
|
-
playApiEnabled: states[index]
|
|
1427
|
-
}));
|
|
1428
|
-
};
|
|
1429
|
-
/** The display name of the project Hot Updater creates. */
|
|
1430
|
-
const PROJECT_DISPLAY_NAME = "Hot Updater Play";
|
|
1431
|
-
/**
|
|
1432
|
-
* The project the service account goes in: a new one for an account
|
|
1433
|
-
* without projects; the only project with the Play Developer API, without
|
|
1434
|
-
* asking; else the one the developer picks, or a new one.
|
|
1435
|
-
*/
|
|
1436
|
-
const chooseProject = async ({ google, request, sleep, onStep }) => {
|
|
1437
|
-
const listed = await listProjects(google);
|
|
1438
|
-
if (listed.length === 0) return createProject({
|
|
1439
|
-
google,
|
|
1440
|
-
sleep,
|
|
1441
|
-
onStep
|
|
1442
|
-
});
|
|
1443
|
-
const projects = await withPlayApiState(google, listed);
|
|
1444
|
-
const enabled = projects.filter(({ playApiEnabled }) => playApiEnabled);
|
|
1445
|
-
if (enabled.length === 1) {
|
|
1446
|
-
const [only] = enabled;
|
|
1447
|
-
onStep(`Using ${only.projectId}, the project with the Play Developer API`);
|
|
1448
|
-
return only;
|
|
1449
|
-
}
|
|
1450
|
-
const chosen = await request.chooseProject(projects.map(({ projectId, displayName, playApiEnabled }) => ({
|
|
1451
|
-
projectId,
|
|
1452
|
-
...displayName ? { displayName } : {},
|
|
1453
|
-
playApiEnabled
|
|
1454
|
-
})));
|
|
1455
|
-
if (chosen === null) return createProject({
|
|
1456
|
-
google,
|
|
1457
|
-
sleep,
|
|
1458
|
-
onStep
|
|
1459
|
-
});
|
|
1460
|
-
return projects.find((project) => project.projectId === chosen) ?? { projectId: chosen };
|
|
1461
|
-
};
|
|
1462
|
-
/** A new project, `hot-updater-play-<random>`, once Google has created it. */
|
|
1463
|
-
const createProject = async ({ google, sleep, onStep }) => {
|
|
1464
|
-
const projectId = newProjectId();
|
|
1465
|
-
onStep(`Creating the Google Cloud project ${projectId}`);
|
|
1466
|
-
try {
|
|
1467
|
-
const operation = await google("Creating the Google Cloud project", "POST", `${RESOURCE_MANAGER_API}/v3/projects`, {
|
|
1468
|
-
projectId,
|
|
1469
|
-
displayName: PROJECT_DISPLAY_NAME
|
|
1470
|
-
});
|
|
1471
|
-
const created = await waitForOperation({
|
|
1472
|
-
request: google,
|
|
1473
|
-
origin: `${RESOURCE_MANAGER_API}/v3`,
|
|
1474
|
-
operation,
|
|
1475
|
-
action: "Creating the Google Cloud project",
|
|
1476
|
-
sleep
|
|
1477
|
-
});
|
|
1478
|
-
return {
|
|
1479
|
-
projectId,
|
|
1480
|
-
...projectNumberOf(created?.name)
|
|
1481
|
-
};
|
|
1482
|
-
} catch (error) {
|
|
1483
|
-
if (!(error instanceof GoogleApiError)) throw error;
|
|
1484
|
-
if (error.status === 429 || error.apiStatus === "RESOURCE_EXHAUSTED" || /quota/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-quota", [
|
|
1485
|
-
"Google Cloud has no project quota left for this account.",
|
|
1486
|
-
error.apiMessage || "Quota exceeded.",
|
|
1487
|
-
"Delete a project you no longer use, then run this again:",
|
|
1488
|
-
"https://console.cloud.google.com/cloud-resource-manager"
|
|
1489
|
-
].join("\n"), { cause: error });
|
|
1490
|
-
if (/terms of service/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-create-denied", "This Google Account hasn't accepted the Google Cloud Terms of Service.\nAccept them at https://console.cloud.google.com, then run this again.", { cause: error });
|
|
1491
|
-
if (isDenied(error) || error.apiStatus === "FAILED_PRECONDITION") throw new PlayServiceAccountError("project-create-denied", [
|
|
1492
|
-
"This Google Account may not create Google Cloud projects.",
|
|
1493
|
-
error.apiMessage || "Permission denied.",
|
|
1494
|
-
"Ask an admin for the Project Creator role."
|
|
1495
|
-
].join("\n"), { cause: error });
|
|
1496
|
-
throw error;
|
|
1497
|
-
}
|
|
1498
|
-
};
|
|
1499
|
-
/** `projectNumber` from a resource name `projects/<number>`. */
|
|
1500
|
-
const projectNumberOf = (name) => {
|
|
1501
|
-
const number = name?.match(/^projects\/(\d+)$/u)?.[1];
|
|
1502
|
-
return number === void 0 ? {} : { projectNumber: number };
|
|
1503
|
-
};
|
|
1504
|
-
/**
|
|
1505
|
-
* Runs `run` again while Google is still enabling an API in `project`,
|
|
1506
|
-
* or, with `notFound`, still creating what it reads.
|
|
1507
|
-
*/
|
|
1508
|
-
const whilePropagating = async ({ run, project, sleep, notFound = false }) => {
|
|
1509
|
-
for (let attempt = 1;; attempt++) try {
|
|
1510
|
-
return await run();
|
|
1511
|
-
} catch (error) {
|
|
1512
|
-
const disabled = isServiceDisabled(error);
|
|
1513
|
-
if (disabled && !consumerIs(error, project)) throw consumerApiDisabled(error);
|
|
1514
|
-
if (!disabled && !(notFound && isNotFound(error)) || attempt >= PROPAGATION_ATTEMPTS) {
|
|
1515
|
-
if (disabled) throw new PlayServiceAccountError("api-not-enabled", `Google is still enabling the APIs in ${project.projectId}.\n${error.apiMessage}\nWait a few minutes, then run this again.`, { cause: error });
|
|
1516
|
-
throw error;
|
|
1517
|
-
}
|
|
1518
|
-
await sleep(PROPAGATION_DELAY_MS);
|
|
1519
|
-
}
|
|
1520
|
-
};
|
|
1521
|
-
/**
|
|
1522
|
-
* Whether a disabled-service error is about `project` itself, which waiting
|
|
1523
|
-
* fixes: Google names the consumer by number, and an error naming none
|
|
1524
|
-
* counts as the project's own.
|
|
1525
|
-
*/
|
|
1526
|
-
const consumerIs = (error, project) => error.consumer === void 0 || error.consumer === `projects/${project.projectId}` || (project.projectNumber === void 0 ? true : error.consumer === `projects/${project.projectNumber}`);
|
|
1527
|
-
const enableServices = async ({ google, projectId, sleep }) => {
|
|
1528
|
-
try {
|
|
1529
|
-
const operation = await google("Enabling the APIs", "POST", `${SERVICE_USAGE_API}/v1/projects/${projectId}/services:batchEnable`, { serviceIds: SERVICES });
|
|
1530
|
-
await waitForOperation({
|
|
1531
|
-
request: google,
|
|
1532
|
-
origin: `${SERVICE_USAGE_API}/v1`,
|
|
1533
|
-
operation,
|
|
1534
|
-
action: "Enabling the APIs",
|
|
1535
|
-
sleep
|
|
1536
|
-
});
|
|
1537
|
-
} catch (error) {
|
|
1538
|
-
if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", [
|
|
1539
|
-
`This Google Account may not enable APIs in ${projectId}.`,
|
|
1540
|
-
error.apiMessage || "Permission denied.",
|
|
1541
|
-
"Pick a project you own, or ask its owner for the Editor role."
|
|
1542
|
-
].join("\n"), { cause: error });
|
|
1543
|
-
throw error;
|
|
1544
|
-
}
|
|
1545
|
-
};
|
|
1546
|
-
/** The `hot-updater-play` service account's email, created unless it exists. */
|
|
1547
|
-
const ensureServiceAccount = async ({ google, project, sleep }) => {
|
|
1548
|
-
const { projectId } = project;
|
|
1549
|
-
const email = `${PLAY_SERVICE_ACCOUNT_ID}@${projectId}.iam.gserviceaccount.com`;
|
|
1550
|
-
try {
|
|
1551
|
-
return (await whilePropagating({
|
|
1552
|
-
project,
|
|
1553
|
-
sleep,
|
|
1554
|
-
run: () => google("Creating the service account", "POST", `${IAM_API}/projects/${projectId}/serviceAccounts`, {
|
|
1555
|
-
accountId: "hot-updater-play",
|
|
1556
|
-
serviceAccount: {
|
|
1557
|
-
displayName: "Hot Updater Play uploads",
|
|
1558
|
-
description: "Uploads Android App Bundles to Google Play for hot-updater native-build android --submit play-internal."
|
|
1559
|
-
}
|
|
1560
|
-
})
|
|
1561
|
-
})).email ?? email;
|
|
1562
|
-
} catch (error) {
|
|
1563
|
-
if (isConflict(error)) return email;
|
|
1564
|
-
if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", [
|
|
1565
|
-
`This Google Account may not create service accounts in ${projectId}.`,
|
|
1566
|
-
error.apiMessage || "Permission denied.",
|
|
1567
|
-
"Pick a project you own, or ask for the Service Account Admin role."
|
|
1568
|
-
].join("\n"), { cause: error });
|
|
1569
|
-
throw error;
|
|
1570
|
-
}
|
|
1571
|
-
};
|
|
1572
|
-
/** A new JSON key of the service account, checked and masked in the CLI's output. */
|
|
1573
|
-
const createKey = async ({ google, project, email, sleep }) => {
|
|
1574
|
-
const { projectId } = project;
|
|
1575
|
-
let key;
|
|
1576
|
-
try {
|
|
1577
|
-
key = await whilePropagating({
|
|
1578
|
-
project,
|
|
1579
|
-
sleep,
|
|
1580
|
-
notFound: true,
|
|
1581
|
-
run: () => google("Creating the key", "POST", `${IAM_API}/projects/${projectId}/serviceAccounts/${email}/keys`, {
|
|
1582
|
-
privateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE",
|
|
1583
|
-
keyAlgorithm: "KEY_ALG_RSA_2048"
|
|
1584
|
-
})
|
|
1585
|
-
});
|
|
1586
|
-
} catch (error) {
|
|
1587
|
-
if (error instanceof GoogleApiError && (error.violations.includes(KEY_CREATION_CONSTRAINT) || /disableServiceAccountKeyCreation/u.test(error.apiMessage))) throw new PlayServiceAccountError("key-creation-disabled", [
|
|
1588
|
-
`iam.disableServiceAccountKeyCreation forbids keys in ${projectId}.`,
|
|
1589
|
-
"Ask an organization policy admin to allow keys for this project,",
|
|
1590
|
-
"or run this again with a project outside the organization."
|
|
1591
|
-
].join("\n"), { cause: error });
|
|
1592
|
-
throw error;
|
|
1593
|
-
}
|
|
1594
|
-
if (!key.name || !key.privateKeyData) throw new Error("Google Cloud created the key without its JSON file.");
|
|
1595
|
-
registerSecretValue(key.privateKeyData);
|
|
1596
|
-
const json = Buffer.from(key.privateKeyData, "base64").toString("utf-8");
|
|
1597
|
-
registerSecretValue(json);
|
|
1598
|
-
let parsed;
|
|
1599
|
-
try {
|
|
1600
|
-
parsed = JSON.parse(json);
|
|
1601
|
-
} catch {
|
|
1602
|
-
parsed = {};
|
|
1603
|
-
}
|
|
1604
|
-
if (typeof parsed.private_key !== "string") {
|
|
1605
|
-
await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`).catch(() => {});
|
|
1606
|
-
throw new Error("Google Cloud created a key that is no JSON key file.");
|
|
1607
|
-
}
|
|
1608
|
-
registerSecretValue(parsed.private_key);
|
|
1609
|
-
return {
|
|
1610
|
-
json,
|
|
1611
|
-
name: key.name,
|
|
1612
|
-
keyId: key.name.split("/").pop()
|
|
1613
|
-
};
|
|
1614
|
-
};
|
|
1615
|
-
//#endregion
|
|
1616
|
-
//#region src/playServiceAccount.ts
|
|
1617
|
-
/**
|
|
1618
|
-
* Hot Updater's issuer, the one place that picks how it is done: gcloud's
|
|
1619
|
-
* sign-in, then the Cloud Resource Manager, Service Usage, IAM, and Play
|
|
1620
|
-
* Developer APIs. Another implementation of `IssuePlayServiceAccount`
|
|
1621
|
-
* replaces it here without touching its callers.
|
|
1622
|
-
*/
|
|
1623
|
-
const issuePlayServiceAccount = (request) => createGoogleKeyIssuer()(request);
|
|
1624
|
-
//#endregion
|
|
1625
|
-
export { PLAY_SERVICE_ACCOUNT_ID, PlayServiceAccountError, PlayVersionCodeUsedError, assertPlaySigned, buildAndroid, enrichNativeBuildAndroidScheme, extractBuiltInBundle, findPlayInternalRelease, hasPlayReleases, issuePlayServiceAccount, readBuiltApp, readBuiltPackageName, readBuiltVersionCode, resolvePlayUploadServiceAccount, uploadToPlay };
|
|
1052
|
+
export { PlayVersionCodeUsedError, assertPlaySigned, buildAndroid, checkPlayAccess, enrichNativeBuildAndroidScheme, extractBuiltInBundle, findPlayInternalRelease, playConsoleInviteUrl, readBuiltApp, readBuiltPackageName, readBuiltVersionCode, resolvePlayUploadServiceAccount, uploadToPlay };
|