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