@hot-updater/android-helper 1.2.0-rc.4 → 1.2.0-rc.6
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 +240 -325
- package/dist/index.d.cts +48 -61
- package/dist/index.d.mts +48 -61
- package/dist/index.mjs +240 -322
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -37,8 +37,6 @@ let stream_promises = require("stream/promises");
|
|
|
37
37
|
let zlib = require("zlib");
|
|
38
38
|
let node_crypto = require("node:crypto");
|
|
39
39
|
node_crypto = __toESM(node_crypto, 1);
|
|
40
|
-
let node_http = require("node:http");
|
|
41
|
-
node_http = __toESM(node_http, 1);
|
|
42
40
|
//#region src/utils/createGradleLogger.ts
|
|
43
41
|
const createGradleLogger = ({ logPrefix }) => new _hot_updater_cli_tools.BuildLogger({
|
|
44
42
|
logPrefix,
|
|
@@ -676,7 +674,8 @@ const explain = (error, packageName) => {
|
|
|
676
674
|
/**
|
|
677
675
|
* Requests to the Google Play Developer API for `packageName`, as the
|
|
678
676
|
* service account: each carries an access token, exchanged for a signed
|
|
679
|
-
* assertion and replaced well before it expires.
|
|
677
|
+
* assertion and replaced well before it expires. The key issuer checks a
|
|
678
|
+
* new key's access with it too.
|
|
680
679
|
*/
|
|
681
680
|
const createPlayClient = ({ json, packageName, fetch }) => {
|
|
682
681
|
const account = readServiceAccount(json);
|
|
@@ -752,6 +751,26 @@ const findPlayInternalRelease = async ({ versionCode, packageName, serviceAccoun
|
|
|
752
751
|
}
|
|
753
752
|
};
|
|
754
753
|
/**
|
|
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
|
+
/**
|
|
755
774
|
* The common name of every Android debug certificate: Android Studio's and
|
|
756
775
|
* the Android Gradle plugin's (`CN=Android Debug, O=Android, C=US`), and React
|
|
757
776
|
* Native's template `debug.keystore` (`CN=Android Debug, OU=Android,
|
|
@@ -1180,281 +1199,202 @@ var PlayServiceAccountError = class extends Error {
|
|
|
1180
1199
|
this.code = code;
|
|
1181
1200
|
}
|
|
1182
1201
|
};
|
|
1183
|
-
/** The environment variables that replace Hot Updater's Google OAuth client. */
|
|
1184
|
-
const GOOGLE_OAUTH_CLIENT_ENV_NAMES = {
|
|
1185
|
-
clientId: "HOT_UPDATER_GOOGLE_OAUTH_CLIENT_ID",
|
|
1186
|
-
clientSecret: "HOT_UPDATER_GOOGLE_OAUTH_CLIENT_SECRET"
|
|
1187
|
-
};
|
|
1188
1202
|
/** The service account Hot Updater creates, `hot-updater-play@<project>.iam.gserviceaccount.com`. */
|
|
1189
1203
|
const PLAY_SERVICE_ACCOUNT_ID = "hot-updater-play";
|
|
1190
|
-
/** Where a Play Console admin finds the developer account ID. */
|
|
1191
|
-
const PLAY_CONSOLE_URL = "https://play.google.com/console";
|
|
1192
|
-
//#endregion
|
|
1193
|
-
//#region src/google/googleSignIn.ts
|
|
1194
|
-
const AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
1195
|
-
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
1196
|
-
const REVOKE_URL = "https://oauth2.googleapis.com/revoke";
|
|
1197
|
-
/** How long the browser has to come back with the sign-in. */
|
|
1198
|
-
const SIGN_IN_TIMEOUT_MS = 3e5;
|
|
1199
|
-
const base64url = (bytes) => bytes.toString("base64url");
|
|
1200
|
-
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>`;
|
|
1201
|
-
/**
|
|
1202
|
-
* Signs in with Google as an installed app does (RFC 8252): the browser
|
|
1203
|
-
* opens Google's own consent screen, so the password and the second factor
|
|
1204
|
-
* stay with Google, and the authorization code comes back to a loopback
|
|
1205
|
-
* server on 127.0.0.1, bound to a random port for this sign-in only. The
|
|
1206
|
-
* code is exchanged with PKCE (S256), and `state` ties the answer to this
|
|
1207
|
-
* request.
|
|
1208
|
-
*
|
|
1209
|
-
* @param onUrl Sees the sign-in URL, to print and open in a browser.
|
|
1210
|
-
*/
|
|
1211
|
-
const signInWithGoogle = async ({ client, scopes, onUrl, fetch = globalThis.fetch, timeoutMs = SIGN_IN_TIMEOUT_MS }) => {
|
|
1212
|
-
const verifier = base64url(node_crypto.default.randomBytes(32));
|
|
1213
|
-
const challenge = base64url(node_crypto.default.createHash("sha256").update(verifier).digest());
|
|
1214
|
-
const state = base64url(node_crypto.default.randomBytes(16));
|
|
1215
|
-
const server = node_http.default.createServer();
|
|
1216
|
-
await new Promise((resolve, reject) => {
|
|
1217
|
-
server.once("error", reject);
|
|
1218
|
-
server.listen(0, "127.0.0.1", () => resolve());
|
|
1219
|
-
});
|
|
1220
|
-
try {
|
|
1221
|
-
const { port } = server.address();
|
|
1222
|
-
const redirectUri = `http://127.0.0.1:${port}`;
|
|
1223
|
-
const code = new Promise((resolve, reject) => {
|
|
1224
|
-
const timer = setTimeout(() => reject(new PlayServiceAccountError("sign-in-cancelled", "The Google sign-in did not finish in time.")), timeoutMs);
|
|
1225
|
-
timer.unref();
|
|
1226
|
-
server.on("request", (req, res) => {
|
|
1227
|
-
const query = new URL(req.url ?? "/", redirectUri).searchParams;
|
|
1228
|
-
if (query.get("state") !== state) {
|
|
1229
|
-
res.writeHead(404).end();
|
|
1230
|
-
return;
|
|
1231
|
-
}
|
|
1232
|
-
const error = query.get("error");
|
|
1233
|
-
const answer = query.get("code");
|
|
1234
|
-
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."));
|
|
1235
|
-
clearTimeout(timer);
|
|
1236
|
-
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"}.`));
|
|
1237
|
-
else resolve(answer);
|
|
1238
|
-
});
|
|
1239
|
-
});
|
|
1240
|
-
code.catch(() => {});
|
|
1241
|
-
const url = new URL(AUTHORIZATION_URL);
|
|
1242
|
-
url.search = new URLSearchParams({
|
|
1243
|
-
client_id: client.clientId,
|
|
1244
|
-
redirect_uri: redirectUri,
|
|
1245
|
-
response_type: "code",
|
|
1246
|
-
scope: scopes.join(" "),
|
|
1247
|
-
code_challenge: challenge,
|
|
1248
|
-
code_challenge_method: "S256",
|
|
1249
|
-
state,
|
|
1250
|
-
prompt: "select_account"
|
|
1251
|
-
}).toString();
|
|
1252
|
-
await onUrl(url.toString());
|
|
1253
|
-
const authorizationCode = await code;
|
|
1254
|
-
(0, _hot_updater_cli_tools.registerSecretValue)(authorizationCode);
|
|
1255
|
-
const response = await fetch(TOKEN_URL, {
|
|
1256
|
-
method: "POST",
|
|
1257
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1258
|
-
body: new URLSearchParams({
|
|
1259
|
-
code: authorizationCode,
|
|
1260
|
-
client_id: client.clientId,
|
|
1261
|
-
...client.clientSecret ? { client_secret: client.clientSecret } : {},
|
|
1262
|
-
redirect_uri: redirectUri,
|
|
1263
|
-
grant_type: "authorization_code",
|
|
1264
|
-
code_verifier: verifier
|
|
1265
|
-
}).toString()
|
|
1266
|
-
});
|
|
1267
|
-
const token = await response.json().catch(() => ({}));
|
|
1268
|
-
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" ? `\n${token.error_description}` : typeof token.error === "string" ? `\n${token.error}` : ""}`);
|
|
1269
|
-
(0, _hot_updater_cli_tools.registerSecretValue)(token.access_token);
|
|
1270
|
-
return {
|
|
1271
|
-
accessToken: token.access_token,
|
|
1272
|
-
scopes: typeof token.scope === "string" ? token.scope.split(" ") : []
|
|
1273
|
-
};
|
|
1274
|
-
} finally {
|
|
1275
|
-
server.closeAllConnections();
|
|
1276
|
-
await new Promise((resolve) => server.close(() => resolve()));
|
|
1277
|
-
}
|
|
1278
|
-
};
|
|
1279
|
-
/** Ends a sign-in: Google revokes its access token. Whether Google did. */
|
|
1280
|
-
const revokeGoogleToken = async (accessToken, fetch = globalThis.fetch) => {
|
|
1281
|
-
try {
|
|
1282
|
-
return (await fetch(REVOKE_URL, {
|
|
1283
|
-
method: "POST",
|
|
1284
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1285
|
-
body: new URLSearchParams({ token: accessToken }).toString()
|
|
1286
|
-
})).ok;
|
|
1287
|
-
} catch {
|
|
1288
|
-
return false;
|
|
1289
|
-
}
|
|
1290
|
-
};
|
|
1291
1204
|
//#endregion
|
|
1292
1205
|
//#region src/google/googleKeyIssuer.ts
|
|
1293
|
-
const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
|
|
1294
|
-
const ANDROID_PUBLISHER_SCOPE = "https://www.googleapis.com/auth/androidpublisher";
|
|
1295
|
-
const SCOPES = [CLOUD_PLATFORM_SCOPE, ANDROID_PUBLISHER_SCOPE];
|
|
1296
1206
|
const PUBLISHER_API = "https://androidpublisher.googleapis.com/androidpublisher/v3";
|
|
1297
1207
|
const RESOURCE_MANAGER_API = "https://cloudresourcemanager.googleapis.com";
|
|
1298
1208
|
const SERVICE_USAGE_API = "https://serviceusage.googleapis.com";
|
|
1299
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";
|
|
1300
1213
|
/** IAM creates the service account; the Play Developer API is what it calls. */
|
|
1301
|
-
const SERVICES = ["iam.googleapis.com",
|
|
1302
|
-
/**
|
|
1303
|
-
* What the service account may do in Play Console, for the app only: read
|
|
1304
|
-
* it, and release to testing tracks, which `--submit play-internal` does.
|
|
1305
|
-
*/
|
|
1306
|
-
const APP_PERMISSIONS = ["CAN_VIEW_NON_FINANCIAL_DATA", "CAN_MANAGE_TRACK_APKS"];
|
|
1214
|
+
const SERVICES = ["iam.googleapis.com", PLAY_API];
|
|
1307
1215
|
const KEY_CREATION_CONSTRAINT = "constraints/iam.disableServiceAccountKeyCreation";
|
|
1308
1216
|
/** Retries while Google enables an API or creates a service account. */
|
|
1309
1217
|
const PROPAGATION_ATTEMPTS = 12;
|
|
1310
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;
|
|
1311
1222
|
const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
}
|
|
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)}`;
|
|
1318
1229
|
/** A project ID nobody has: Google Cloud's are global. */
|
|
1319
1230
|
const newProjectId = () => `${PLAY_SERVICE_ACCOUNT_ID}-${node_crypto.default.randomBytes(3).toString("hex")}`;
|
|
1320
1231
|
const isNotFound = (error) => error instanceof GoogleApiError && error.status === 404;
|
|
1321
1232
|
const isConflict = (error) => error instanceof GoogleApiError && error.status === 409;
|
|
1322
|
-
/** The API is disabled in the
|
|
1323
|
-
const
|
|
1324
|
-
/** Google's own words, after a message that names the likely cause. */
|
|
1325
|
-
const googleSays = (error) => error instanceof GoogleApiError && error.apiMessage ? `\nGoogle: ${error.apiMessage}` : "";
|
|
1326
|
-
/** A Play Console refusal, as `not-play-admin`. */
|
|
1327
|
-
const notPlayAdmin = (developerId, error) => new PlayServiceAccountError("not-play-admin", `This Google Account isn't an admin of Play Console developer ${developerId}.${googleSays(error)}\nSign in with an account that has the Admin permission there.`, { cause: error });
|
|
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 });
|
|
1328
1235
|
const isDenied = (error) => error instanceof GoogleApiError && (error.status === 403 || error.apiStatus === "PERMISSION_DENIED");
|
|
1236
|
+
/** The Google Account gcloud uses, if one is active. */
|
|
1237
|
+
const activeAccount = async (run) => (await run("gcloud", [
|
|
1238
|
+
"auth",
|
|
1239
|
+
"list",
|
|
1240
|
+
"--filter=status:ACTIVE",
|
|
1241
|
+
"--format=value(account)"
|
|
1242
|
+
])).trim().split("\n")[0]?.trim() || void 0;
|
|
1243
|
+
/** Stops before anything else, a prompt included, when gcloud is not installed. */
|
|
1244
|
+
const requireGcloud = async (run) => {
|
|
1245
|
+
try {
|
|
1246
|
+
await run("gcloud", ["--version"]);
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
throw new PlayServiceAccountError("gcloud-not-installed", [
|
|
1249
|
+
"gcloud CLI is not installed.",
|
|
1250
|
+
"Install it, then run this again:",
|
|
1251
|
+
GCLOUD_INSTALL_URL
|
|
1252
|
+
].join("\n"), { cause: error });
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
/** gcloud's access token, after `gcloud auth login` when no account is active. */
|
|
1256
|
+
const signInWithGcloud = async ({ run, onStep }) => {
|
|
1257
|
+
let account = await activeAccount(run);
|
|
1258
|
+
if (account === void 0) {
|
|
1259
|
+
await run("gcloud", ["auth", "login"], { interactive: true }).catch(() => {});
|
|
1260
|
+
account = await activeAccount(run);
|
|
1261
|
+
if (account === void 0) throw new PlayServiceAccountError("gcloud-not-signed-in", "gcloud has no active Google Account.\nRun gcloud auth login, then run this again.");
|
|
1262
|
+
}
|
|
1263
|
+
onStep(`Signed in to gcloud as ${account}`);
|
|
1264
|
+
const accessToken = (await run("gcloud", ["auth", "print-access-token"])).trim();
|
|
1265
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(accessToken);
|
|
1266
|
+
return accessToken;
|
|
1267
|
+
};
|
|
1329
1268
|
/**
|
|
1330
|
-
* `IssuePlayServiceAccount` with
|
|
1331
|
-
*
|
|
1332
|
-
* loopback redirect):
|
|
1269
|
+
* `IssuePlayServiceAccount` with the gcloud CLI's sign-in and Google's own
|
|
1270
|
+
* APIs:
|
|
1333
1271
|
*
|
|
1334
|
-
* 1.
|
|
1335
|
-
*
|
|
1336
|
-
*
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
1272
|
+
* 1. once gcloud is found, `askPackageName` names the app, and gcloud's
|
|
1273
|
+
* Google Account, signed in with `gcloud auth login` when none is
|
|
1274
|
+
* active, the access token the Google Cloud steps use;
|
|
1275
|
+
* 2. the account's only project with the Play Developer API, else the one
|
|
1276
|
+
* `chooseProject` picks, else a new one, gets the IAM and Play Developer
|
|
1277
|
+
* APIs enabled, and the service account `hot-updater-play` its JSON key;
|
|
1278
|
+
* 3. the developer invites the service account in Play Console, which
|
|
1279
|
+
* `onInvite` opens: gcloud's token may not manage Google Play users;
|
|
1280
|
+
* 4. the key itself opens an edit of the app until Google Play lets it,
|
|
1281
|
+
* and is deleted again when it never does.
|
|
1342
1282
|
*
|
|
1343
|
-
* Each step reuses what an earlier run created: the project
|
|
1344
|
-
* account
|
|
1283
|
+
* Each step reuses what an earlier run created: the project and the
|
|
1284
|
+
* service account.
|
|
1345
1285
|
*/
|
|
1346
|
-
const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor } = {}) => async (request) => {
|
|
1347
|
-
const {
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
onUrl: request.onSignInUrl,
|
|
1286
|
+
const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor, run = runWithExeca } = {}) => async (request) => {
|
|
1287
|
+
const { onStep = () => {} } = request;
|
|
1288
|
+
await requireGcloud(run);
|
|
1289
|
+
const packageName = await request.askPackageName();
|
|
1290
|
+
const accessToken = await signInWithGcloud({
|
|
1291
|
+
run,
|
|
1292
|
+
onStep
|
|
1293
|
+
});
|
|
1294
|
+
const google = createGoogleRequest({
|
|
1295
|
+
accessToken,
|
|
1357
1296
|
fetch
|
|
1358
1297
|
});
|
|
1359
|
-
const
|
|
1360
|
-
|
|
1298
|
+
const project = await chooseProject({
|
|
1299
|
+
google,
|
|
1300
|
+
request,
|
|
1301
|
+
sleep,
|
|
1302
|
+
onStep
|
|
1303
|
+
});
|
|
1304
|
+
const { projectId } = project;
|
|
1305
|
+
onStep(`Enabling the APIs in ${projectId}`);
|
|
1306
|
+
await enableServices({
|
|
1307
|
+
google,
|
|
1308
|
+
projectId,
|
|
1309
|
+
sleep
|
|
1310
|
+
});
|
|
1311
|
+
onStep(`Creating the service account in ${projectId}`);
|
|
1312
|
+
const email = await ensureServiceAccount({
|
|
1313
|
+
google,
|
|
1314
|
+
project,
|
|
1315
|
+
sleep
|
|
1316
|
+
});
|
|
1317
|
+
onStep("Creating the service account's JSON key");
|
|
1318
|
+
const key = await createKey({
|
|
1319
|
+
google,
|
|
1320
|
+
project,
|
|
1321
|
+
email,
|
|
1322
|
+
sleep
|
|
1323
|
+
});
|
|
1324
|
+
const revoke = async () => {
|
|
1325
|
+
try {
|
|
1326
|
+
await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`);
|
|
1327
|
+
return true;
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
return isNotFound(error);
|
|
1330
|
+
}
|
|
1361
1331
|
};
|
|
1332
|
+
const inviteUrl = playConsoleInviteUrl(email);
|
|
1362
1333
|
try {
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
const developerInput = await request.askDeveloperId();
|
|
1370
|
-
const developerId = parseDeveloperId(developerInput);
|
|
1371
|
-
if (developerId === void 0) throw new PlayServiceAccountError("developer-not-found", `"${developerInput.trim()}" is not a developer account ID.\nIt is the number after /developers/ in Play Console's address.`);
|
|
1372
|
-
onStep("Checking the Play Console developer account");
|
|
1373
|
-
await checkPlayConsole({
|
|
1374
|
-
google,
|
|
1375
|
-
developerId,
|
|
1376
|
-
packageName
|
|
1377
|
-
});
|
|
1378
|
-
const project = await chooseProject({
|
|
1379
|
-
google,
|
|
1380
|
-
request,
|
|
1381
|
-
sleep,
|
|
1382
|
-
onStep
|
|
1383
|
-
});
|
|
1384
|
-
const { projectId } = project;
|
|
1385
|
-
onStep(`Enabling the Google Play Developer API in ${projectId}`);
|
|
1386
|
-
await enableServices({
|
|
1387
|
-
google,
|
|
1388
|
-
projectId,
|
|
1389
|
-
sleep
|
|
1390
|
-
});
|
|
1391
|
-
onStep(`Creating the service account in ${projectId}`);
|
|
1392
|
-
const email = await ensureServiceAccount({
|
|
1393
|
-
google,
|
|
1394
|
-
project,
|
|
1395
|
-
sleep
|
|
1396
|
-
});
|
|
1397
|
-
onStep(`Giving ${email} release access to ${packageName}`);
|
|
1398
|
-
await grantPlayAccess({
|
|
1399
|
-
google,
|
|
1400
|
-
developerId,
|
|
1401
|
-
email,
|
|
1402
|
-
packageName
|
|
1403
|
-
});
|
|
1404
|
-
onStep("Creating the service account's JSON key");
|
|
1405
|
-
const key = await createKey({
|
|
1406
|
-
google,
|
|
1407
|
-
project,
|
|
1408
|
-
email,
|
|
1334
|
+
await request.onInvite(inviteUrl, email);
|
|
1335
|
+
onStep("Waiting for the Play Console invite");
|
|
1336
|
+
await waitForPlayAccess({
|
|
1337
|
+
json: key.json,
|
|
1338
|
+
packageName,
|
|
1339
|
+
fetch,
|
|
1409
1340
|
sleep
|
|
1410
1341
|
});
|
|
1411
|
-
return {
|
|
1412
|
-
json: key.json,
|
|
1413
|
-
keyId: key.keyId,
|
|
1414
|
-
email,
|
|
1415
|
-
projectId,
|
|
1416
|
-
developerId,
|
|
1417
|
-
revoke: async () => {
|
|
1418
|
-
try {
|
|
1419
|
-
await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`);
|
|
1420
|
-
return true;
|
|
1421
|
-
} catch (error) {
|
|
1422
|
-
return isNotFound(error);
|
|
1423
|
-
}
|
|
1424
|
-
},
|
|
1425
|
-
dispose
|
|
1426
|
-
};
|
|
1427
1342
|
} catch (error) {
|
|
1428
|
-
await
|
|
1429
|
-
throw
|
|
1343
|
+
const deleted = await revoke();
|
|
1344
|
+
throw new PlayServiceAccountError("play-access-pending", [
|
|
1345
|
+
`The key can't release ${packageName} yet.`,
|
|
1346
|
+
"Invite the service account in Play Console with release access:",
|
|
1347
|
+
email,
|
|
1348
|
+
inviteUrl,
|
|
1349
|
+
"A new app needs its first .aab uploaded in Play Console.",
|
|
1350
|
+
...deleted ? [] : [`Delete its unused key ${key.keyId} in Google Cloud:`, `https://console.cloud.google.com/iam-admin/serviceaccounts?project=${encodeURIComponent(projectId)}`],
|
|
1351
|
+
"Then run: npx hot-updater google-play login"
|
|
1352
|
+
].join("\n"), { cause: error });
|
|
1430
1353
|
}
|
|
1354
|
+
return {
|
|
1355
|
+
json: key.json,
|
|
1356
|
+
keyId: key.keyId,
|
|
1357
|
+
email,
|
|
1358
|
+
projectId,
|
|
1359
|
+
revoke,
|
|
1360
|
+
dispose: async () => {}
|
|
1361
|
+
};
|
|
1431
1362
|
};
|
|
1432
1363
|
/**
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
1364
|
+
* Waits until the key opens an edit of `packageName`, which Google Play
|
|
1365
|
+
* allows once the service account is a Play Console user with access to
|
|
1366
|
+
* the app, then deletes the edit, which changed nothing. Google may not
|
|
1367
|
+
* take a key just created for a while either.
|
|
1437
1368
|
*/
|
|
1438
|
-
const
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1369
|
+
const waitForPlayAccess = async ({ json, packageName, fetch, sleep }) => {
|
|
1370
|
+
const play = createPlayClient({
|
|
1371
|
+
json,
|
|
1372
|
+
packageName,
|
|
1373
|
+
fetch
|
|
1374
|
+
});
|
|
1375
|
+
const edits = `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits`;
|
|
1376
|
+
for (let attempt = 1;; attempt++) {
|
|
1377
|
+
if (attempt > 1) await sleep(ACCESS_DELAY_MS);
|
|
1378
|
+
let ready = true;
|
|
1379
|
+
try {
|
|
1380
|
+
await play.authorization();
|
|
1381
|
+
} catch {
|
|
1382
|
+
ready = false;
|
|
1383
|
+
}
|
|
1384
|
+
if (ready) try {
|
|
1385
|
+
const edit = await play.request(`Opening an edit of ${packageName}`, "POST", edits);
|
|
1386
|
+
if (edit?.id) await play.request("Deleting the edit", "DELETE", `${edits}/${edit.id}`).catch(() => {});
|
|
1387
|
+
return;
|
|
1388
|
+
} catch (error) {
|
|
1389
|
+
if (!(error instanceof GooglePlayError) || ![
|
|
1390
|
+
401,
|
|
1391
|
+
403,
|
|
1392
|
+
404
|
|
1393
|
+
].includes(error.status)) throw error;
|
|
1394
|
+
}
|
|
1395
|
+
if (attempt >= ACCESS_ATTEMPTS) throw new Error("Google Play did not let the key reach the app in time.");
|
|
1454
1396
|
}
|
|
1455
|
-
if (edit.id) await google("Deleting the edit", "DELETE", `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits/${edit.id}`).catch(() => {});
|
|
1456
1397
|
};
|
|
1457
|
-
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 });
|
|
1458
1398
|
/** The account's active projects, every page of them. */
|
|
1459
1399
|
const listProjects = async (google) => {
|
|
1460
1400
|
const projects = [];
|
|
@@ -1473,20 +1413,62 @@ const listProjects = async (google) => {
|
|
|
1473
1413
|
} while (pageToken);
|
|
1474
1414
|
return projects;
|
|
1475
1415
|
};
|
|
1476
|
-
/**
|
|
1416
|
+
/** Service Usage reads of the projects' Play Developer API, at most this many at once. */
|
|
1417
|
+
const API_CHECK_CONCURRENCY = 8;
|
|
1418
|
+
/**
|
|
1419
|
+
* Whether each project has the Google Play Developer API enabled, by one
|
|
1420
|
+
* Service Usage read each; a project this account may not read counts as
|
|
1421
|
+
* without it.
|
|
1422
|
+
*/
|
|
1423
|
+
const withPlayApiState = async (google, projects) => {
|
|
1424
|
+
const states = [];
|
|
1425
|
+
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))));
|
|
1426
|
+
return projects.map((project, index) => ({
|
|
1427
|
+
...project,
|
|
1428
|
+
playApiEnabled: states[index]
|
|
1429
|
+
}));
|
|
1430
|
+
};
|
|
1431
|
+
/** The display name of the project Hot Updater creates. */
|
|
1432
|
+
const PROJECT_DISPLAY_NAME = "Hot Updater Play";
|
|
1433
|
+
/**
|
|
1434
|
+
* The project the service account goes in: a new one for an account
|
|
1435
|
+
* without projects; the only project with the Play Developer API, without
|
|
1436
|
+
* asking; else the one the developer picks, or a new one.
|
|
1437
|
+
*/
|
|
1477
1438
|
const chooseProject = async ({ google, request, sleep, onStep }) => {
|
|
1478
|
-
const
|
|
1479
|
-
|
|
1439
|
+
const listed = await listProjects(google);
|
|
1440
|
+
if (listed.length === 0) return createProject({
|
|
1441
|
+
google,
|
|
1442
|
+
sleep,
|
|
1443
|
+
onStep
|
|
1444
|
+
});
|
|
1445
|
+
const projects = await withPlayApiState(google, listed);
|
|
1446
|
+
const enabled = projects.filter(({ playApiEnabled }) => playApiEnabled);
|
|
1447
|
+
if (enabled.length === 1) {
|
|
1448
|
+
const [only] = enabled;
|
|
1449
|
+
onStep(`Using ${only.projectId}, the project with the Play Developer API`);
|
|
1450
|
+
return only;
|
|
1451
|
+
}
|
|
1452
|
+
const chosen = await request.chooseProject(projects.map(({ projectId, displayName, playApiEnabled }) => ({
|
|
1480
1453
|
projectId,
|
|
1481
|
-
...displayName ? { displayName } : {}
|
|
1482
|
-
|
|
1483
|
-
|
|
1454
|
+
...displayName ? { displayName } : {},
|
|
1455
|
+
playApiEnabled
|
|
1456
|
+
})));
|
|
1457
|
+
if (chosen === null) return createProject({
|
|
1458
|
+
google,
|
|
1459
|
+
sleep,
|
|
1460
|
+
onStep
|
|
1461
|
+
});
|
|
1462
|
+
return projects.find((project) => project.projectId === chosen) ?? { projectId: chosen };
|
|
1463
|
+
};
|
|
1464
|
+
/** A new project, `hot-updater-play-<random>`, once Google has created it. */
|
|
1465
|
+
const createProject = async ({ google, sleep, onStep }) => {
|
|
1484
1466
|
const projectId = newProjectId();
|
|
1485
1467
|
onStep(`Creating the Google Cloud project ${projectId}`);
|
|
1486
1468
|
try {
|
|
1487
1469
|
const operation = await google("Creating the Google Cloud project", "POST", `${RESOURCE_MANAGER_API}/v3/projects`, {
|
|
1488
1470
|
projectId,
|
|
1489
|
-
displayName:
|
|
1471
|
+
displayName: PROJECT_DISPLAY_NAME
|
|
1490
1472
|
});
|
|
1491
1473
|
const created = await waitForOperation({
|
|
1492
1474
|
request: google,
|
|
@@ -1504,14 +1486,14 @@ const chooseProject = async ({ google, request, sleep, onStep }) => {
|
|
|
1504
1486
|
if (error.status === 429 || error.apiStatus === "RESOURCE_EXHAUSTED" || /quota/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-quota", [
|
|
1505
1487
|
"Google Cloud has no project quota left for this account.",
|
|
1506
1488
|
error.apiMessage || "Quota exceeded.",
|
|
1507
|
-
"
|
|
1489
|
+
"Delete a project you no longer use, then run this again:",
|
|
1508
1490
|
"https://console.cloud.google.com/cloud-resource-manager"
|
|
1509
1491
|
].join("\n"), { cause: error });
|
|
1510
1492
|
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 });
|
|
1511
1493
|
if (isDenied(error) || error.apiStatus === "FAILED_PRECONDITION") throw new PlayServiceAccountError("project-create-denied", [
|
|
1512
1494
|
"This Google Account may not create Google Cloud projects.",
|
|
1513
1495
|
error.apiMessage || "Permission denied.",
|
|
1514
|
-
"
|
|
1496
|
+
"Ask an admin for the Project Creator role."
|
|
1515
1497
|
].join("\n"), { cause: error });
|
|
1516
1498
|
throw error;
|
|
1517
1499
|
}
|
|
@@ -1530,7 +1512,7 @@ const whilePropagating = async ({ run, project, sleep, notFound = false }) => {
|
|
|
1530
1512
|
return await run();
|
|
1531
1513
|
} catch (error) {
|
|
1532
1514
|
const disabled = isServiceDisabled(error);
|
|
1533
|
-
if (disabled && !consumerIs(error, project)) throw
|
|
1515
|
+
if (disabled && !consumerIs(error, project)) throw consumerApiDisabled(error);
|
|
1534
1516
|
if (!disabled && !(notFound && isNotFound(error)) || attempt >= PROPAGATION_ATTEMPTS) {
|
|
1535
1517
|
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 });
|
|
1536
1518
|
throw error;
|
|
@@ -1589,59 +1571,6 @@ const ensureServiceAccount = async ({ google, project, sleep }) => {
|
|
|
1589
1571
|
throw error;
|
|
1590
1572
|
}
|
|
1591
1573
|
};
|
|
1592
|
-
/**
|
|
1593
|
-
* Makes `email` a Play Console user that may release `packageName`, keeping
|
|
1594
|
-
* any permission it has: the user and the app's grant are created, or the
|
|
1595
|
-
* grant gains the release permissions.
|
|
1596
|
-
*/
|
|
1597
|
-
const grantPlayAccess = async ({ google, developerId, email, packageName }) => {
|
|
1598
|
-
const developer = `${PUBLISHER_API}/developers/${developerId}`;
|
|
1599
|
-
const user = `${developer}/users/${encodeURIComponent(email)}`;
|
|
1600
|
-
try {
|
|
1601
|
-
await google("Adding the Play Console user", "POST", `${developer}/users`, {
|
|
1602
|
-
name: `developers/${developerId}/users/${email}`,
|
|
1603
|
-
email
|
|
1604
|
-
});
|
|
1605
|
-
} catch (error) {
|
|
1606
|
-
if (isDenied(error)) throw notPlayAdmin(developerId, error);
|
|
1607
|
-
if (!isConflict(error)) throw error;
|
|
1608
|
-
}
|
|
1609
|
-
try {
|
|
1610
|
-
await google("Granting access to the app", "POST", `${user}/grants`, {
|
|
1611
|
-
name: `developers/${developerId}/users/${email}/grants/${packageName}`,
|
|
1612
|
-
packageName,
|
|
1613
|
-
appLevelPermissions: APP_PERMISSIONS
|
|
1614
|
-
});
|
|
1615
|
-
return;
|
|
1616
|
-
} catch (error) {
|
|
1617
|
-
if (isNotFound(error)) throw appNotFound(packageName, developerId, error);
|
|
1618
|
-
if (isDenied(error)) throw notPlayAdmin(developerId, error);
|
|
1619
|
-
if (!isConflict(error)) throw error;
|
|
1620
|
-
}
|
|
1621
|
-
const granted = await findGrant({
|
|
1622
|
-
google,
|
|
1623
|
-
developer,
|
|
1624
|
-
email,
|
|
1625
|
-
packageName
|
|
1626
|
-
});
|
|
1627
|
-
const missing = APP_PERMISSIONS.filter((permission) => !granted.includes(permission));
|
|
1628
|
-
if (missing.length === 0) return;
|
|
1629
|
-
await google("Granting access to the app", "PATCH", `${user}/grants/${encodeURIComponent(packageName)}?updateMask=appLevelPermissions`, { appLevelPermissions: [...granted, ...missing] });
|
|
1630
|
-
};
|
|
1631
|
-
/** The app permissions `email` holds on `packageName`, from the user list. */
|
|
1632
|
-
const findGrant = async ({ google, developer, email, packageName }) => {
|
|
1633
|
-
let pageToken;
|
|
1634
|
-
do {
|
|
1635
|
-
const page = await google("Listing the Play Console users", "GET", `${developer}/users?${new URLSearchParams({
|
|
1636
|
-
pageSize: "100",
|
|
1637
|
-
...pageToken ? { pageToken } : {}
|
|
1638
|
-
})}`);
|
|
1639
|
-
const found = page.users?.find((candidate) => candidate.email === email);
|
|
1640
|
-
if (found) return found.grants?.find((grant) => grant.packageName === packageName)?.appLevelPermissions ?? [];
|
|
1641
|
-
pageToken = page.nextPageToken || void 0;
|
|
1642
|
-
} while (pageToken);
|
|
1643
|
-
return [];
|
|
1644
|
-
};
|
|
1645
1574
|
/** A new JSON key of the service account, checked and masked in the CLI's output. */
|
|
1646
1575
|
const createKey = async ({ google, project, email, sleep }) => {
|
|
1647
1576
|
const { projectId } = project;
|
|
@@ -1688,28 +1617,13 @@ const createKey = async ({ google, project, email, sleep }) => {
|
|
|
1688
1617
|
//#endregion
|
|
1689
1618
|
//#region src/playServiceAccount.ts
|
|
1690
1619
|
/**
|
|
1691
|
-
* Hot Updater's
|
|
1692
|
-
*
|
|
1693
|
-
*
|
|
1620
|
+
* Hot Updater's issuer, the one place that picks how it is done: gcloud's
|
|
1621
|
+
* sign-in, then the Cloud Resource Manager, Service Usage, IAM, and Play
|
|
1622
|
+
* Developer APIs. Another implementation of `IssuePlayServiceAccount`
|
|
1623
|
+
* replaces it here without touching its callers.
|
|
1694
1624
|
*/
|
|
1695
|
-
const
|
|
1696
|
-
clientId: "",
|
|
1697
|
-
clientSecret: ""
|
|
1698
|
-
};
|
|
1699
|
-
/**
|
|
1700
|
-
* Hot Updater's issuer, the one place that picks how it is done: Google's
|
|
1701
|
-
* OAuth for installed apps, then the Cloud Resource Manager, Service Usage,
|
|
1702
|
-
* IAM, and Play Developer APIs. Another implementation of
|
|
1703
|
-
* `IssuePlayServiceAccount` replaces it here without touching its callers.
|
|
1704
|
-
*/
|
|
1705
|
-
const issuePlayServiceAccount = (request) => createGoogleKeyIssuer()({
|
|
1706
|
-
...request,
|
|
1707
|
-
oauthClient: request.oauthClient ?? HOT_UPDATER_GOOGLE_OAUTH_CLIENT
|
|
1708
|
-
});
|
|
1625
|
+
const issuePlayServiceAccount = (request) => createGoogleKeyIssuer()(request);
|
|
1709
1626
|
//#endregion
|
|
1710
|
-
exports.GOOGLE_OAUTH_CLIENT_ENV_NAMES = GOOGLE_OAUTH_CLIENT_ENV_NAMES;
|
|
1711
|
-
exports.HOT_UPDATER_GOOGLE_OAUTH_CLIENT = HOT_UPDATER_GOOGLE_OAUTH_CLIENT;
|
|
1712
|
-
exports.PLAY_CONSOLE_URL = PLAY_CONSOLE_URL;
|
|
1713
1627
|
exports.PLAY_SERVICE_ACCOUNT_ID = PLAY_SERVICE_ACCOUNT_ID;
|
|
1714
1628
|
exports.PlayServiceAccountError = PlayServiceAccountError;
|
|
1715
1629
|
exports.PlayVersionCodeUsedError = PlayVersionCodeUsedError;
|
|
@@ -1718,6 +1632,7 @@ exports.buildAndroid = buildAndroid;
|
|
|
1718
1632
|
exports.enrichNativeBuildAndroidScheme = enrichNativeBuildAndroidScheme;
|
|
1719
1633
|
exports.extractBuiltInBundle = extractBuiltInBundle;
|
|
1720
1634
|
exports.findPlayInternalRelease = findPlayInternalRelease;
|
|
1635
|
+
exports.hasPlayReleases = hasPlayReleases;
|
|
1721
1636
|
exports.issuePlayServiceAccount = issuePlayServiceAccount;
|
|
1722
1637
|
exports.readBuiltApp = readBuiltApp;
|
|
1723
1638
|
exports.readBuiltPackageName = readBuiltPackageName;
|