firebase-tools 13.35.0 → 14.0.0
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/lib/appdistribution/client.js +4 -2
- package/lib/apphosting/backend.js +65 -11
- package/lib/apphosting/config.js +130 -101
- package/lib/apphosting/rollout.js +3 -9
- package/lib/apphosting/secrets/dialogs.js +5 -2
- package/lib/apphosting/secrets/index.js +45 -3
- package/lib/apphosting/yaml.js +19 -8
- package/lib/commands/appdistribution-groups-create.js +1 -1
- package/lib/commands/appdistribution-groups-delete.js +1 -1
- package/lib/commands/appdistribution-groups-list.js +1 -1
- package/lib/commands/appdistribution-testers-add.js +1 -1
- package/lib/commands/appdistribution-testers-remove.js +1 -1
- package/lib/commands/apphosting-backends-create.js +1 -8
- package/lib/commands/apphosting-backends-delete.js +16 -26
- package/lib/commands/apphosting-backends-get.js +10 -16
- package/lib/commands/apphosting-backends-list.js +4 -10
- package/lib/commands/apphosting-rollouts-create.js +1 -8
- package/lib/commands/apphosting-secrets-access.js +1 -1
- package/lib/commands/apphosting-secrets-describe.js +1 -1
- package/lib/commands/apphosting-secrets-grantaccess.js +19 -9
- package/lib/commands/apphosting-secrets-set.js +31 -1
- package/lib/commands/apps-android-sha-create.js +1 -1
- package/lib/commands/apps-android-sha-delete.js +1 -1
- package/lib/commands/apps-android-sha-list.js +1 -1
- package/lib/commands/apps-create.js +1 -1
- package/lib/commands/apps-init.js +1 -1
- package/lib/commands/auth-export.js +1 -1
- package/lib/commands/auth-import.js +1 -1
- package/lib/commands/database-instances-create.js +1 -1
- package/lib/commands/database-profile.js +1 -2
- package/lib/commands/database-settings-set.js +1 -1
- package/lib/commands/database-update.js +1 -1
- package/lib/commands/dataconnect-sdk-generate.js +1 -1
- package/lib/commands/dataconnect-services-list.js +1 -1
- package/lib/commands/dataconnect-sql-diff.js +1 -1
- package/lib/commands/dataconnect-sql-grant.js +1 -1
- package/lib/commands/dataconnect-sql-migrate.js +2 -2
- package/lib/commands/dataconnect-sql-setup.js +1 -1
- package/lib/commands/dataconnect-sql-shell.js +1 -1
- package/lib/commands/deploy.js +3 -3
- package/lib/commands/emulators-exec.js +1 -1
- package/lib/commands/ext-dev-register.js +1 -1
- package/lib/commands/ext-dev-usage.js +2 -2
- package/lib/commands/ext-install.js +2 -3
- package/lib/commands/firestore-backups-delete.js +2 -2
- package/lib/commands/firestore-backups-get.js +1 -1
- package/lib/commands/firestore-backups-list.js +2 -2
- package/lib/commands/firestore-backups-schedules-create.js +4 -4
- package/lib/commands/firestore-backups-schedules-delete.js +2 -2
- package/lib/commands/firestore-backups-schedules-list.js +2 -2
- package/lib/commands/firestore-backups-schedules-update.js +1 -1
- package/lib/commands/firestore-databases-create.js +6 -6
- package/lib/commands/firestore-databases-delete.js +2 -2
- package/lib/commands/firestore-databases-get.js +1 -1
- package/lib/commands/firestore-databases-list.js +1 -1
- package/lib/commands/firestore-databases-restore.js +5 -5
- package/lib/commands/firestore-databases-update.js +4 -4
- package/lib/commands/firestore-delete.js +7 -8
- package/lib/commands/firestore-indexes-list.js +4 -4
- package/lib/commands/firestore-locations.js +1 -1
- package/lib/commands/functions-artifacts-setpolicy.js +11 -13
- package/lib/commands/functions-config-export.js +1 -1
- package/lib/commands/functions-deletegcfartifacts.js +1 -1
- package/lib/commands/functions-secrets-access.js +1 -1
- package/lib/commands/functions-secrets-describe.js +1 -1
- package/lib/commands/functions-secrets-destroy.js +2 -2
- package/lib/commands/functions-secrets-get.js +1 -1
- package/lib/commands/functions-secrets-prune.js +2 -2
- package/lib/commands/functions-secrets-set.js +3 -3
- package/lib/commands/index.js +0 -4
- package/lib/commands/init.js +3 -2
- package/lib/commands/internaltesting-frameworks-compose.js +1 -1
- package/lib/commands/remoteconfig-versions-list.js +1 -1
- package/lib/commands/setup-emulators-database.js +1 -1
- package/lib/commands/setup-emulators-dataconnect.js +1 -1
- package/lib/commands/setup-emulators-firestore.js +1 -1
- package/lib/commands/setup-emulators-pubsub.js +1 -1
- package/lib/commands/setup-emulators-storage.js +1 -1
- package/lib/commands/setup-emulators-ui.js +1 -1
- package/lib/config.js +16 -18
- package/lib/dataconnect/dataplaneClient.js +3 -1
- package/lib/deploy/functions/build.js +1 -0
- package/lib/deploy/functions/prompts.js +30 -1
- package/lib/deploy/functions/release/index.js +27 -9
- package/lib/deploy/functions/runtimes/discovery/v1alpha1.js +4 -4
- package/lib/emulator/apphosting/config.js +15 -14
- package/lib/emulator/auth/operations.js +2 -1
- package/lib/emulator/constants.js +1 -1
- package/lib/emulator/dataconnect/pgliteServer.js +2 -1
- package/lib/emulator/dataconnectEmulator.js +2 -0
- package/lib/emulator/downloadableEmulators.js +9 -9
- package/lib/emulator/env.js +2 -1
- package/lib/emulator/initEmulators.js +29 -4
- package/lib/experiments.js +1 -13
- package/lib/functions/artifacts.js +89 -1
- package/lib/gcp/cloudfunctions.js +1 -1
- package/lib/gcp/cloudfunctionsv2.js +3 -3
- package/lib/gcp/cloudsql/permissions.js +2 -1
- package/lib/gcp/cloudsql/permissions_setup.js +8 -5
- package/lib/gcp/proto.js +4 -3
- package/lib/init/features/dataconnect/sdk.js +4 -5
- package/package.json +2 -2
- package/standalone/package.json +1 -1
- package/templates/init/dataconnect/dataconnect.yaml +1 -1
- package/lib/commands/apphosting-config-export.js +0 -29
- package/lib/commands/experimental-functions-shell.js +0 -13
|
@@ -99,6 +99,7 @@ class AppDistributionClient {
|
|
|
99
99
|
utils.logSuccess("distributed to testers/groups successfully");
|
|
100
100
|
}
|
|
101
101
|
async listTesters(projectName, groupName) {
|
|
102
|
+
var _a;
|
|
102
103
|
const listTestersResponse = {
|
|
103
104
|
testers: [],
|
|
104
105
|
};
|
|
@@ -119,7 +120,7 @@ class AppDistributionClient {
|
|
|
119
120
|
catch (err) {
|
|
120
121
|
throw new error_1.FirebaseError(`Client request failed to list testers ${err}`);
|
|
121
122
|
}
|
|
122
|
-
for (const t of apiResponse.body.testers) {
|
|
123
|
+
for (const t of (_a = apiResponse.body.testers) !== null && _a !== void 0 ? _a : []) {
|
|
123
124
|
listTestersResponse.testers.push({
|
|
124
125
|
name: t.name,
|
|
125
126
|
displayName: t.displayName,
|
|
@@ -159,6 +160,7 @@ class AppDistributionClient {
|
|
|
159
160
|
return apiResponse.body;
|
|
160
161
|
}
|
|
161
162
|
async listGroups(projectName) {
|
|
163
|
+
var _a;
|
|
162
164
|
const listGroupsResponse = {
|
|
163
165
|
groups: [],
|
|
164
166
|
};
|
|
@@ -170,7 +172,7 @@ class AppDistributionClient {
|
|
|
170
172
|
const apiResponse = await client.get(`${projectName}/groups`, {
|
|
171
173
|
queryParams,
|
|
172
174
|
});
|
|
173
|
-
listGroupsResponse.groups.push(...(apiResponse.body.groups
|
|
175
|
+
listGroupsResponse.groups.push(...((_a = apiResponse.body.groups) !== null && _a !== void 0 ? _a : []));
|
|
174
176
|
pageToken = apiResponse.body.nextPageToken;
|
|
175
177
|
}
|
|
176
178
|
catch (err) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getBackendForAmbiguousLocation = exports.getBackendForLocation = exports.promptLocation = exports.deleteBackendAndPoll = exports.setDefaultTrafficPolicy = exports.createBackend = exports.ensureAppHostingComputeServiceAccount = exports.createGitRepoLink = exports.doSetup = void 0;
|
|
3
|
+
exports.getBackend = exports.getBackendForAmbiguousLocation = exports.chooseBackends = exports.getBackendForLocation = exports.promptLocation = exports.deleteBackendAndPoll = exports.setDefaultTrafficPolicy = exports.createBackend = exports.ensureAppHostingComputeServiceAccount = exports.createGitRepoLink = exports.doSetup = void 0;
|
|
4
4
|
const clc = require("colorette");
|
|
5
5
|
const poller = require("../operation-poller");
|
|
6
6
|
const apphosting = require("../gcp/apphosting");
|
|
@@ -50,7 +50,7 @@ async function awaitTlsReady(url) {
|
|
|
50
50
|
}
|
|
51
51
|
} while (!ready);
|
|
52
52
|
}
|
|
53
|
-
async function doSetup(projectId, webAppName,
|
|
53
|
+
async function doSetup(projectId, webAppName, serviceAccount) {
|
|
54
54
|
await Promise.all([
|
|
55
55
|
(0, ensureApiEnabled_1.ensure)(projectId, (0, api_1.developerConnectOrigin)(), "apphosting", true),
|
|
56
56
|
(0, ensureApiEnabled_1.ensure)(projectId, (0, api_1.cloudbuildOrigin)(), "apphosting", true),
|
|
@@ -60,14 +60,7 @@ async function doSetup(projectId, webAppName, location, serviceAccount) {
|
|
|
60
60
|
(0, ensureApiEnabled_1.ensure)(projectId, (0, api_1.iamOrigin)(), "apphosting", true),
|
|
61
61
|
]);
|
|
62
62
|
await ensureAppHostingComputeServiceAccount(projectId, serviceAccount);
|
|
63
|
-
const
|
|
64
|
-
if (location) {
|
|
65
|
-
if (!allowedLocations.includes(location)) {
|
|
66
|
-
throw new error_1.FirebaseError(`Invalid location ${location}. Valid choices are ${allowedLocations.join(", ")}`);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
location =
|
|
70
|
-
location || (await promptLocation(projectId, "Select a location to host your backend:\n"));
|
|
63
|
+
const location = await promptLocation(projectId, "Select a primary region to host your backend:\n");
|
|
71
64
|
const gitRepositoryLink = await githubConnections.linkGitHubRepository(projectId, location);
|
|
72
65
|
const rootDir = await (0, prompt_1.promptOnce)({
|
|
73
66
|
name: "rootDir",
|
|
@@ -259,10 +252,53 @@ async function getBackendForLocation(projectId, location, backendId) {
|
|
|
259
252
|
}
|
|
260
253
|
}
|
|
261
254
|
exports.getBackendForLocation = getBackendForLocation;
|
|
255
|
+
async function chooseBackends(projectId, backendId, chooseBackendPrompt, force) {
|
|
256
|
+
let { unreachable, backends } = await apphosting.listBackends(projectId, "-");
|
|
257
|
+
if (unreachable && unreachable.length !== 0) {
|
|
258
|
+
(0, utils_1.logWarning)(`The following locations are currently unreachable: ${unreachable.join(",")}.\n` +
|
|
259
|
+
"If your backend is in one of these regions, please try again later.");
|
|
260
|
+
}
|
|
261
|
+
backends = backends.filter((backend) => apphosting.parseBackendName(backend.name).id === backendId);
|
|
262
|
+
if (backends.length === 0) {
|
|
263
|
+
throw new error_1.FirebaseError(`No backend named "${backendId}" found.`);
|
|
264
|
+
}
|
|
265
|
+
if (backends.length === 1) {
|
|
266
|
+
return backends;
|
|
267
|
+
}
|
|
268
|
+
if (force) {
|
|
269
|
+
throw new error_1.FirebaseError(`Force cannot be used because multiple backends were found with ID ${backendId}.`);
|
|
270
|
+
}
|
|
271
|
+
const backendsByDisplay = new Map();
|
|
272
|
+
backends.forEach((backend) => {
|
|
273
|
+
const { location, id } = apphosting.parseBackendName(backend.name);
|
|
274
|
+
backendsByDisplay.set(`${id}(${location})`, backend);
|
|
275
|
+
});
|
|
276
|
+
const chosenBackendDisplays = await (0, prompt_1.promptOnce)({
|
|
277
|
+
name: "backend",
|
|
278
|
+
type: "checkbox",
|
|
279
|
+
message: chooseBackendPrompt,
|
|
280
|
+
choices: Array.from(backendsByDisplay.keys(), (name) => {
|
|
281
|
+
return {
|
|
282
|
+
checked: false,
|
|
283
|
+
name: name,
|
|
284
|
+
value: name,
|
|
285
|
+
};
|
|
286
|
+
}),
|
|
287
|
+
});
|
|
288
|
+
const chosenBackends = [];
|
|
289
|
+
chosenBackendDisplays.forEach((backendDisplay) => {
|
|
290
|
+
const backend = backendsByDisplay.get(backendDisplay);
|
|
291
|
+
if (backend !== undefined) {
|
|
292
|
+
chosenBackends.push(backend);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
return chosenBackends;
|
|
296
|
+
}
|
|
297
|
+
exports.chooseBackends = chooseBackends;
|
|
262
298
|
async function getBackendForAmbiguousLocation(projectId, backendId, locationDisambugationPrompt, force) {
|
|
263
299
|
let { unreachable, backends } = await apphosting.listBackends(projectId, "-");
|
|
264
300
|
if (unreachable && unreachable.length !== 0) {
|
|
265
|
-
(0, utils_1.logWarning)(`The following locations are currently unreachable: ${unreachable}.\n` +
|
|
301
|
+
(0, utils_1.logWarning)(`The following locations are currently unreachable: ${unreachable.join(", ")}.\n` +
|
|
266
302
|
"If your backend is in one of these regions, please try again later.");
|
|
267
303
|
}
|
|
268
304
|
backends = backends.filter((backend) => apphosting.parseBackendName(backend.name).id === backendId);
|
|
@@ -286,3 +322,21 @@ async function getBackendForAmbiguousLocation(projectId, backendId, locationDisa
|
|
|
286
322
|
return backendsByLocation.get(location);
|
|
287
323
|
}
|
|
288
324
|
exports.getBackendForAmbiguousLocation = getBackendForAmbiguousLocation;
|
|
325
|
+
async function getBackend(projectId, backendId) {
|
|
326
|
+
let { unreachable, backends } = await apphosting.listBackends(projectId, "-");
|
|
327
|
+
backends = backends.filter((backend) => apphosting.parseBackendName(backend.name).id === backendId);
|
|
328
|
+
if (backends.length > 1) {
|
|
329
|
+
const locations = backends.map((b) => apphosting.parseBackendName(b.name).location);
|
|
330
|
+
throw new error_1.FirebaseError(`You have multiple backends with the same ${backendId} ID in regions: ${locations.join(", ")}. This is not allowed until we can support more locations. ` +
|
|
331
|
+
"Please delete and recreate any backends that share an ID with another backend.");
|
|
332
|
+
}
|
|
333
|
+
if (backends.length === 1) {
|
|
334
|
+
return backends[0];
|
|
335
|
+
}
|
|
336
|
+
if (unreachable && unreachable.length !== 0) {
|
|
337
|
+
(0, utils_1.logWarning)(`Backends with the following primary regions are unreachable: ${unreachable.join(", ")}.\n` +
|
|
338
|
+
"If your backend is in one of these regions, please try again later.");
|
|
339
|
+
}
|
|
340
|
+
throw new error_1.FirebaseError(`No backend named ${backendId} found.`);
|
|
341
|
+
}
|
|
342
|
+
exports.getBackend = getBackend;
|
package/lib/apphosting/config.js
CHANGED
|
@@ -1,28 +1,29 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
exports.suggestedTestKeyName = exports.overrideChosenEnv = exports.maybeGenerateEmulatorYaml = exports.maybeAddSecretToYaml = exports.upsertEnv = exports.findEnv = exports.store = exports.load = exports.listAppHostingFilesInPath = exports.discoverBackendRoot = exports.APPHOSTING_YAML_FILE_REGEX = exports.APPHOSTING_LOCAL_YAML_FILE = exports.APPHOSTING_EMULATORS_YAML_FILE = exports.APPHOSTING_BASE_YAML_FILE = void 0;
|
|
4
4
|
const path_1 = require("path");
|
|
5
5
|
const fs_1 = require("fs");
|
|
6
6
|
const yaml = require("yaml");
|
|
7
|
+
const clc = require("colorette");
|
|
7
8
|
const fs = require("../fsutils");
|
|
8
9
|
const prompt = require("../prompt");
|
|
9
10
|
const dialogs = require("./secrets/dialogs");
|
|
10
11
|
const yaml_1 = require("./yaml");
|
|
11
|
-
const error_1 = require("../error");
|
|
12
|
-
const utils_1 = require("./utils");
|
|
13
|
-
const secrets_1 = require("./secrets");
|
|
14
12
|
const logger_1 = require("../logger");
|
|
15
|
-
const
|
|
16
|
-
const
|
|
13
|
+
const csm = require("../gcp/secretManager");
|
|
14
|
+
const error_1 = require("../error");
|
|
17
15
|
exports.APPHOSTING_BASE_YAML_FILE = "apphosting.yaml";
|
|
16
|
+
exports.APPHOSTING_EMULATORS_YAML_FILE = "apphosting.emulator.yaml";
|
|
18
17
|
exports.APPHOSTING_LOCAL_YAML_FILE = "apphosting.local.yaml";
|
|
19
18
|
exports.APPHOSTING_YAML_FILE_REGEX = /^apphosting(\.[a-z0-9_]+)?\.yaml$/;
|
|
20
|
-
const SECRET_CONFIG = "Secret";
|
|
21
|
-
const EXPORTABLE_CONFIG = [SECRET_CONFIG];
|
|
22
19
|
function discoverBackendRoot(cwd) {
|
|
23
20
|
let dir = cwd;
|
|
24
|
-
while (
|
|
25
|
-
|
|
21
|
+
while (true) {
|
|
22
|
+
const files = fs.listFiles(dir);
|
|
23
|
+
if (files.some((file) => exports.APPHOSTING_YAML_FILE_REGEX.test(file))) {
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
if (files.includes("firebase.json")) {
|
|
26
27
|
return null;
|
|
27
28
|
}
|
|
28
29
|
const parent = (0, path_1.dirname)(dir);
|
|
@@ -31,7 +32,6 @@ function discoverBackendRoot(cwd) {
|
|
|
31
32
|
}
|
|
32
33
|
dir = parent;
|
|
33
34
|
}
|
|
34
|
-
return dir;
|
|
35
35
|
}
|
|
36
36
|
exports.discoverBackendRoot = discoverBackendRoot;
|
|
37
37
|
function listAppHostingFilesInPath(path) {
|
|
@@ -42,7 +42,18 @@ function listAppHostingFilesInPath(path) {
|
|
|
42
42
|
}
|
|
43
43
|
exports.listAppHostingFilesInPath = listAppHostingFilesInPath;
|
|
44
44
|
function load(yamlPath) {
|
|
45
|
-
|
|
45
|
+
let raw;
|
|
46
|
+
try {
|
|
47
|
+
raw = fs.readFile(yamlPath);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err.code !== "ENOENT") {
|
|
51
|
+
throw new error_1.FirebaseError(`Unexpected error trying to load ${yamlPath}`, {
|
|
52
|
+
original: (0, error_1.getError)(err),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return new yaml.Document();
|
|
56
|
+
}
|
|
46
57
|
return yaml.parseDocument(raw);
|
|
47
58
|
}
|
|
48
59
|
exports.load = load;
|
|
@@ -79,13 +90,13 @@ function upsertEnv(document, env) {
|
|
|
79
90
|
envs.add(envYaml);
|
|
80
91
|
}
|
|
81
92
|
exports.upsertEnv = upsertEnv;
|
|
82
|
-
|
|
83
|
-
|
|
93
|
+
const dynamicDispatch = exports;
|
|
94
|
+
async function maybeAddSecretToYaml(secretName, fileName = exports.APPHOSTING_BASE_YAML_FILE) {
|
|
84
95
|
const backendRoot = dynamicDispatch.discoverBackendRoot(process.cwd());
|
|
85
96
|
let path;
|
|
86
97
|
let projectYaml;
|
|
87
98
|
if (backendRoot) {
|
|
88
|
-
path = (0, path_1.join)(backendRoot,
|
|
99
|
+
path = (0, path_1.join)(backendRoot, fileName);
|
|
89
100
|
projectYaml = dynamicDispatch.load(path);
|
|
90
101
|
}
|
|
91
102
|
else {
|
|
@@ -95,7 +106,7 @@ async function maybeAddSecretToYaml(secretName) {
|
|
|
95
106
|
return;
|
|
96
107
|
}
|
|
97
108
|
const addToYaml = await prompt.confirm({
|
|
98
|
-
message:
|
|
109
|
+
message: `Would you like to add this secret to ${fileName}?`,
|
|
99
110
|
default: true,
|
|
100
111
|
});
|
|
101
112
|
if (!addToYaml) {
|
|
@@ -103,12 +114,12 @@ async function maybeAddSecretToYaml(secretName) {
|
|
|
103
114
|
}
|
|
104
115
|
if (!path) {
|
|
105
116
|
path = await prompt.promptOnce({
|
|
106
|
-
message:
|
|
117
|
+
message: `It looks like you don't have an ${fileName} yet. Where would you like to store it?`,
|
|
107
118
|
default: process.cwd(),
|
|
108
119
|
});
|
|
109
|
-
path = (0, path_1.join)(path,
|
|
120
|
+
path = (0, path_1.join)(path, fileName);
|
|
110
121
|
}
|
|
111
|
-
const envName = await dialogs.envVarForSecret(secretName);
|
|
122
|
+
const envName = await dialogs.envVarForSecret(secretName, fileName === exports.APPHOSTING_EMULATORS_YAML_FILE);
|
|
112
123
|
dynamicDispatch.upsertEnv(projectYaml, {
|
|
113
124
|
variable: envName,
|
|
114
125
|
secret: secretName,
|
|
@@ -116,92 +127,110 @@ async function maybeAddSecretToYaml(secretName) {
|
|
|
116
127
|
dynamicDispatch.store(path, projectYaml);
|
|
117
128
|
}
|
|
118
129
|
exports.maybeAddSecretToYaml = maybeAddSecretToYaml;
|
|
119
|
-
async function
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const localAppHostingConfigPath = (0, path_1.resolve)(backendRoot, exports.APPHOSTING_LOCAL_YAML_FILE);
|
|
138
|
-
if (fs.fileExistsSync(localAppHostingConfigPath)) {
|
|
139
|
-
localAppHostingConfig = await yaml_1.AppHostingYamlConfig.loadFromFile(localAppHostingConfigPath);
|
|
140
|
-
}
|
|
141
|
-
const configToExport = await loadConfigToExportSecrets(cwd, userGivenConfigFile);
|
|
142
|
-
const secretsToExport = Object.entries(configToExport.env)
|
|
143
|
-
.filter(([, env]) => env.secret)
|
|
144
|
-
.map(([variable, env]) => {
|
|
145
|
-
return Object.assign({ variable }, env);
|
|
130
|
+
async function maybeGenerateEmulatorYaml(projectId, repoRoot) {
|
|
131
|
+
const basePath = dynamicDispatch.discoverBackendRoot(repoRoot) || repoRoot;
|
|
132
|
+
if (fs.fileExistsSync((0, path_1.join)(basePath, exports.APPHOSTING_EMULATORS_YAML_FILE))) {
|
|
133
|
+
logger_1.logger.debug("apphosting.emulator.yaml already exists, skipping generation and secrets access prompt");
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
let baseConfig;
|
|
137
|
+
try {
|
|
138
|
+
baseConfig = await yaml_1.AppHostingYamlConfig.loadFromFile((0, path_1.join)(basePath, exports.APPHOSTING_BASE_YAML_FILE));
|
|
139
|
+
}
|
|
140
|
+
catch (_a) {
|
|
141
|
+
baseConfig = yaml_1.AppHostingYamlConfig.empty();
|
|
142
|
+
}
|
|
143
|
+
const createFile = await prompt.confirm({
|
|
144
|
+
message: "The App Hosting emulator uses a file called apphosting.emulator.yaml to override " +
|
|
145
|
+
"values in apphosting.yaml for local testing. This codebase does not have one, would you like " +
|
|
146
|
+
"to create it?",
|
|
147
|
+
default: true,
|
|
146
148
|
});
|
|
147
|
-
if (!
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
localAppHostingConfig.upsertFile(localAppHostingConfigPath);
|
|
159
|
-
logger_1.logger.info(`Wrote secrets as environment variables to ${exports.APPHOSTING_LOCAL_YAML_FILE}.`);
|
|
160
|
-
(0, utils_2.updateOrCreateGitignore)(projectRoot, [exports.APPHOSTING_LOCAL_YAML_FILE]);
|
|
161
|
-
logger_1.logger.info(`${exports.APPHOSTING_LOCAL_YAML_FILE} has been automatically added to your .gitignore.`);
|
|
162
|
-
}
|
|
163
|
-
exports.exportConfig = exportConfig;
|
|
164
|
-
async function loadConfigForEnvironment(envYamlPath, baseYamlPath) {
|
|
165
|
-
const envYamlConfig = await yaml_1.AppHostingYamlConfig.loadFromFile(envYamlPath);
|
|
166
|
-
if (baseYamlPath) {
|
|
167
|
-
const baseConfig = await yaml_1.AppHostingYamlConfig.loadFromFile(baseYamlPath);
|
|
168
|
-
baseConfig.merge(envYamlConfig);
|
|
169
|
-
return baseConfig;
|
|
170
|
-
}
|
|
171
|
-
return envYamlConfig;
|
|
172
|
-
}
|
|
173
|
-
exports.loadConfigForEnvironment = loadConfigForEnvironment;
|
|
174
|
-
async function loadConfigToExportSecrets(cwd, userGivenConfigFile) {
|
|
175
|
-
if (userGivenConfigFile && !exports.APPHOSTING_YAML_FILE_REGEX.test(userGivenConfigFile)) {
|
|
176
|
-
throw new error_1.FirebaseError("Invalid apphosting yaml config file provided. File must be in format: 'apphosting.yaml' or 'apphosting.<environment>.yaml'");
|
|
177
|
-
}
|
|
178
|
-
const allConfigs = getValidConfigs(cwd);
|
|
179
|
-
let userGivenConfigFilePath;
|
|
180
|
-
if (userGivenConfigFile) {
|
|
181
|
-
if (!allConfigs.has(userGivenConfigFile)) {
|
|
182
|
-
throw new error_1.FirebaseError(`The provided app hosting config file "${userGivenConfigFile}" does not exist`);
|
|
149
|
+
if (!createFile) {
|
|
150
|
+
return (0, yaml_1.toEnvList)(baseConfig.env);
|
|
151
|
+
}
|
|
152
|
+
const newEnv = await dynamicDispatch.overrideChosenEnv(projectId, baseConfig.env || {});
|
|
153
|
+
const envList = Object.entries(newEnv);
|
|
154
|
+
if (envList.length) {
|
|
155
|
+
const newYaml = new yaml.Document();
|
|
156
|
+
for (const [variable, env] of envList) {
|
|
157
|
+
dynamicDispatch.upsertEnv(newYaml, Object.assign({ variable }, env));
|
|
183
158
|
}
|
|
184
|
-
|
|
159
|
+
dynamicDispatch.store((0, path_1.join)(basePath, exports.APPHOSTING_EMULATORS_YAML_FILE), newYaml);
|
|
185
160
|
}
|
|
186
161
|
else {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
162
|
+
const sample = "env:\n" +
|
|
163
|
+
"#- variable: ENV_VAR_NAME\n" +
|
|
164
|
+
"# value: plaintext value\n" +
|
|
165
|
+
"#- variable: SECRET_ENV_VAR_NAME\n" +
|
|
166
|
+
"# secret: cloud-secret-manager-id\n";
|
|
167
|
+
(0, fs_1.writeFileSync)((0, path_1.join)(basePath, exports.APPHOSTING_EMULATORS_YAML_FILE), sample);
|
|
168
|
+
}
|
|
169
|
+
return (0, yaml_1.toEnvList)(Object.assign(Object.assign({}, baseConfig.env), newEnv));
|
|
170
|
+
}
|
|
171
|
+
exports.maybeGenerateEmulatorYaml = maybeGenerateEmulatorYaml;
|
|
172
|
+
async function overrideChosenEnv(projectId, env) {
|
|
173
|
+
const names = Object.keys(env);
|
|
174
|
+
if (!names.length) {
|
|
175
|
+
return {};
|
|
176
|
+
}
|
|
177
|
+
const toOverwrite = await prompt.promptOnce({
|
|
178
|
+
type: "checkbox",
|
|
179
|
+
message: "Which environment variables would you like to override?",
|
|
180
|
+
choices: names.map((name) => {
|
|
181
|
+
return { name };
|
|
182
|
+
}),
|
|
183
|
+
});
|
|
184
|
+
if (!projectId && toOverwrite.some((name) => "secret" in env[name])) {
|
|
185
|
+
throw new error_1.FirebaseError(`Need a project ID to overwrite a secret. Either use ${clc.bold("firebase use")} or pass the ${clc.bold("--project")} flag`);
|
|
186
|
+
}
|
|
187
|
+
const newEnv = {};
|
|
188
|
+
for (const name of toOverwrite) {
|
|
189
|
+
if ("value" in env[name]) {
|
|
190
|
+
const newValue = await prompt.promptOnce({
|
|
191
|
+
type: "input",
|
|
192
|
+
message: `What new value would you like for plaintext ${name}?`,
|
|
193
|
+
});
|
|
194
|
+
newEnv[name] = { variable: name, value: newValue };
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
let secretRef;
|
|
198
|
+
let action = "pick-new";
|
|
199
|
+
while (action === "pick-new") {
|
|
200
|
+
secretRef = await prompt.promptOnce({
|
|
201
|
+
type: "input",
|
|
202
|
+
message: `What would you like to name the secret reference for ${name}?`,
|
|
203
|
+
default: suggestedTestKeyName(name),
|
|
204
|
+
});
|
|
205
|
+
if (await csm.secretExists(projectId, secretRef)) {
|
|
206
|
+
action = await prompt.promptOnce({
|
|
207
|
+
type: "list",
|
|
208
|
+
message: "This secret reference already exists, would you like to reuse it or create a new one?",
|
|
209
|
+
choices: [
|
|
210
|
+
{ name: "Reuse it", value: "reuse" },
|
|
211
|
+
{ name: "Create a new one", value: "pick-new" },
|
|
212
|
+
],
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
action = "create";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
newEnv[name] = { variable: name, secret: secretRef };
|
|
220
|
+
if (action === "reuse") {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const secretValue = await prompt.promptOnce({
|
|
224
|
+
type: "password",
|
|
225
|
+
message: `What new value would you like for secret ${name} [input is hidden]?`,
|
|
226
|
+
});
|
|
227
|
+
await csm.createSecret(projectId, secretRef, { [csm.FIREBASE_MANAGED]: "apphosting" });
|
|
228
|
+
await csm.addVersion(projectId, secretRef, secretValue);
|
|
191
229
|
}
|
|
192
|
-
|
|
193
|
-
return await loadConfigForEnvironment(userGivenConfigFilePath, baseFilePath);
|
|
230
|
+
return newEnv;
|
|
194
231
|
}
|
|
195
|
-
exports.
|
|
196
|
-
function
|
|
197
|
-
|
|
198
|
-
if (appHostingConfigPaths.length === 0) {
|
|
199
|
-
throw new error_1.FirebaseError("No apphosting.*.yaml configs found");
|
|
200
|
-
}
|
|
201
|
-
const fileNameToPathMap = new Map();
|
|
202
|
-
for (const path of appHostingConfigPaths) {
|
|
203
|
-
const fileName = (0, path_1.basename)(path);
|
|
204
|
-
fileNameToPathMap.set(fileName, path);
|
|
205
|
-
}
|
|
206
|
-
return fileNameToPathMap;
|
|
232
|
+
exports.overrideChosenEnv = overrideChosenEnv;
|
|
233
|
+
function suggestedTestKeyName(variable) {
|
|
234
|
+
return "test-" + variable.replace(/_/g, "-").toLowerCase();
|
|
207
235
|
}
|
|
236
|
+
exports.suggestedTestKeyName = suggestedTestKeyName;
|
|
@@ -17,18 +17,12 @@ const apphostingPollerOptions = {
|
|
|
17
17
|
maxBackoff: 10000,
|
|
18
18
|
};
|
|
19
19
|
const GIT_COMMIT_SHA_REGEX = /^(?:[0-9a-f]{40}|[0-9a-f]{7})$/;
|
|
20
|
-
async function createRollout(backendId, projectId,
|
|
21
|
-
|
|
22
|
-
if (location === "-" || location === "") {
|
|
23
|
-
backend = await (0, backend_1.getBackendForAmbiguousLocation)(projectId, backendId, "Please select the location of the backend you'd like to roll out:", force);
|
|
24
|
-
location = apphosting.parseBackendName(backend.name).location;
|
|
25
|
-
}
|
|
26
|
-
else {
|
|
27
|
-
backend = await (0, backend_1.getBackendForLocation)(projectId, location, backendId);
|
|
28
|
-
}
|
|
20
|
+
async function createRollout(backendId, projectId, branch, commit, force) {
|
|
21
|
+
const backend = await (0, backend_1.getBackend)(projectId, backendId);
|
|
29
22
|
if (!backend.codebase.repository) {
|
|
30
23
|
throw new error_1.FirebaseError(`Backend ${backendId} is misconfigured due to missing a connected repository. You can delete and recreate your backend using 'firebase apphosting:backends:delete' and 'firebase apphosting:backends:create'.`);
|
|
31
24
|
}
|
|
25
|
+
const { location } = apphosting.parseBackendName(backend.name);
|
|
32
26
|
const { repoLink, owner, repo, readToken } = await (0, devConnect_1.getRepoDetailsFromBackend)(projectId, location, backend.codebase.repository);
|
|
33
27
|
let targetCommit;
|
|
34
28
|
if (branch) {
|
|
@@ -143,8 +143,11 @@ function toUpperSnakeCase(key) {
|
|
|
143
143
|
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
144
144
|
.toUpperCase();
|
|
145
145
|
}
|
|
146
|
-
async function envVarForSecret(secret) {
|
|
147
|
-
|
|
146
|
+
async function envVarForSecret(secret, trimTestPrefix = false) {
|
|
147
|
+
let upper = toUpperSnakeCase(secret);
|
|
148
|
+
if (trimTestPrefix && upper.startsWith("TEST_")) {
|
|
149
|
+
upper = upper.substring("TEST_".length);
|
|
150
|
+
}
|
|
148
151
|
if (upper === secret) {
|
|
149
152
|
try {
|
|
150
153
|
env.validateKey(secret);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getSecretNameParts = exports.fetchSecrets = exports.upsertSecret = exports.grantSecretAccess = exports.serviceAccountsForBackend = exports.toMulti = void 0;
|
|
3
|
+
exports.getSecretNameParts = exports.fetchSecrets = exports.upsertSecret = exports.grantEmailsSecretAccess = exports.grantSecretAccess = exports.serviceAccountsForBackend = exports.toMulti = void 0;
|
|
4
4
|
const error_1 = require("../../error");
|
|
5
5
|
const gcsm = require("../../gcp/secretManager");
|
|
6
6
|
const gcb = require("../../gcp/cloudbuild");
|
|
@@ -57,16 +57,58 @@ async function grantSecretAccess(projectId, projectNumber, secretName, accounts)
|
|
|
57
57
|
catch (err) {
|
|
58
58
|
throw new error_1.FirebaseError(`Failed to get IAM bindings on secret: ${secretName}. Ensure you have the permissions to do so and try again.`, { original: (0, error_1.getError)(err) });
|
|
59
59
|
}
|
|
60
|
+
const updatedBindings = existingBindings.concat(newBindings);
|
|
60
61
|
try {
|
|
61
|
-
const updatedBindings = existingBindings.concat(newBindings);
|
|
62
62
|
await gcsm.setIamPolicy({ projectId, name: secretName }, updatedBindings);
|
|
63
63
|
}
|
|
64
64
|
catch (err) {
|
|
65
|
-
throw new error_1.FirebaseError(`Failed to set IAM bindings ${JSON.stringify(newBindings)} on secret: ${secretName}. Ensure you have the permissions to do so and try again
|
|
65
|
+
throw new error_1.FirebaseError(`Failed to set IAM bindings ${JSON.stringify(newBindings)} on secret: ${secretName}. Ensure you have the permissions to do so and try again. ` +
|
|
66
|
+
"For more information visit https://cloud.google.com/secret-manager/docs/manage-access-to-secrets#required-roles", { original: (0, error_1.getError)(err) });
|
|
66
67
|
}
|
|
67
68
|
utils.logSuccess(`Successfully set IAM bindings on secret ${secretName}.\n`);
|
|
68
69
|
}
|
|
69
70
|
exports.grantSecretAccess = grantSecretAccess;
|
|
71
|
+
async function grantEmailsSecretAccess(projectId, secretNames, emails) {
|
|
72
|
+
const typeGuesses = Object.fromEntries(emails.map((email) => [email, "user"]));
|
|
73
|
+
for (const secretName of secretNames) {
|
|
74
|
+
let existingBindings;
|
|
75
|
+
try {
|
|
76
|
+
existingBindings = (await gcsm.getIamPolicy({ projectId, name: secretName })).bindings || [];
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
throw new error_1.FirebaseError(`Failed to get IAM bindings on secret: ${secretName}. Ensure you have the permissions to do so and try again. ` +
|
|
80
|
+
"For more information visit https://cloud.google.com/secret-manager/docs/manage-access-to-secrets#required-roles", { original: (0, error_1.getError)(err) });
|
|
81
|
+
}
|
|
82
|
+
do {
|
|
83
|
+
try {
|
|
84
|
+
const newBindings = [
|
|
85
|
+
{
|
|
86
|
+
role: "roles/secretmanager.secretAccessor",
|
|
87
|
+
members: Object.entries(typeGuesses).map(([email, type]) => `${type}:${email}`),
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
const updatedBindings = existingBindings.concat(newBindings);
|
|
91
|
+
await gcsm.setIamPolicy({ projectId, name: secretName }, updatedBindings);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
if (!(err instanceof error_1.FirebaseError)) {
|
|
96
|
+
throw new error_1.FirebaseError(`Unexpected error updating IAM bindings on secret: ${secretName}`, {
|
|
97
|
+
original: (0, error_1.getError)(err),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const match = /Principal (.*) is of type "([^"]+)"/.exec(err.message);
|
|
101
|
+
if (!match) {
|
|
102
|
+
throw new error_1.FirebaseError(`Failed to set IAM bindings on secret: ${secretName}. Ensure you have the permissions to do so and try again.`, { original: (0, error_1.getError)(err) });
|
|
103
|
+
}
|
|
104
|
+
typeGuesses[match[1]] = match[2];
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
} while (true);
|
|
108
|
+
utils.logSuccess(`Successfully set IAM bindings on secret ${secretName}.\n`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
exports.grantEmailsSecretAccess = grantEmailsSecretAccess;
|
|
70
112
|
async function upsertSecret(project, secret, location) {
|
|
71
113
|
var _a, _b, _c, _d;
|
|
72
114
|
let existing;
|
package/lib/apphosting/yaml.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.AppHostingYamlConfig = void 0;
|
|
3
|
+
exports.toEnvList = exports.toEnvMap = exports.AppHostingYamlConfig = void 0;
|
|
4
4
|
const path_1 = require("path");
|
|
5
5
|
const utils_1 = require("../utils");
|
|
6
6
|
const config_1 = require("./config");
|
|
@@ -23,7 +23,7 @@ class AppHostingYamlConfig {
|
|
|
23
23
|
config.filename = path.basename(filePath);
|
|
24
24
|
const loadedAppHostingYaml = (_a = (await (0, utils_1.wrappedSafeLoad)(file.source))) !== null && _a !== void 0 ? _a : {};
|
|
25
25
|
if (loadedAppHostingYaml.env) {
|
|
26
|
-
config.env =
|
|
26
|
+
config.env = toEnvMap(loadedAppHostingYaml.env);
|
|
27
27
|
}
|
|
28
28
|
return config;
|
|
29
29
|
}
|
|
@@ -31,12 +31,13 @@ class AppHostingYamlConfig {
|
|
|
31
31
|
return new AppHostingYamlConfig();
|
|
32
32
|
}
|
|
33
33
|
merge(other, allowSecretsToBecomePlaintext = true) {
|
|
34
|
+
var _a;
|
|
34
35
|
if (!allowSecretsToBecomePlaintext) {
|
|
35
36
|
const wereSecrets = Object.entries(this.env)
|
|
36
37
|
.filter(([, env]) => env.secret)
|
|
37
38
|
.map(([key]) => key);
|
|
38
39
|
if (wereSecrets.some((key) => { var _a; return (_a = other.env[key]) === null || _a === void 0 ? void 0 : _a.value; })) {
|
|
39
|
-
throw new error_1.FirebaseError(`Cannot convert secret to plaintext in ${other.filename ?
|
|
40
|
+
throw new error_1.FirebaseError(`Cannot convert secret to plaintext in ${(_a = other.filename) !== null && _a !== void 0 ? _a : "apphosting yaml"}`);
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
this.env = Object.assign(Object.assign({}, this.env), other.env);
|
|
@@ -47,13 +48,23 @@ class AppHostingYamlConfig {
|
|
|
47
48
|
const file = await (0, utils_1.readFileFromDirectory)((0, path_1.dirname)(filePath), (0, path_1.basename)(filePath));
|
|
48
49
|
yamlConfigToWrite = await (0, utils_1.wrappedSafeLoad)(file.source);
|
|
49
50
|
}
|
|
50
|
-
yamlConfigToWrite.env =
|
|
51
|
-
return Object.assign({ variable }, env);
|
|
52
|
-
});
|
|
51
|
+
yamlConfigToWrite.env = toEnvList(this.env);
|
|
53
52
|
(0, config_1.store)(filePath, yaml.parseDocument(jsYaml.dump(yamlConfigToWrite)));
|
|
54
53
|
}
|
|
55
54
|
}
|
|
56
55
|
exports.AppHostingYamlConfig = AppHostingYamlConfig;
|
|
57
|
-
function
|
|
58
|
-
return Object.fromEntries(envs.map((env) =>
|
|
56
|
+
function toEnvMap(envs) {
|
|
57
|
+
return Object.fromEntries(envs.map((env) => {
|
|
58
|
+
const variable = env.variable;
|
|
59
|
+
const tmp = Object.assign({}, env);
|
|
60
|
+
delete env.variable;
|
|
61
|
+
return [variable, tmp];
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
exports.toEnvMap = toEnvMap;
|
|
65
|
+
function toEnvList(envs) {
|
|
66
|
+
return Object.entries(envs).map(([variable, env]) => {
|
|
67
|
+
return Object.assign(Object.assign({}, env), { variable });
|
|
68
|
+
});
|
|
59
69
|
}
|
|
70
|
+
exports.toEnvList = toEnvList;
|
|
@@ -7,7 +7,7 @@ const requireAuth_1 = require("../requireAuth");
|
|
|
7
7
|
const client_1 = require("../appdistribution/client");
|
|
8
8
|
const options_parser_util_1 = require("../appdistribution/options-parser-util");
|
|
9
9
|
exports.command = new command_1.Command("appdistribution:groups:create <displayName> [alias]")
|
|
10
|
-
.description("create
|
|
10
|
+
.description("create an App Distribution group")
|
|
11
11
|
.alias("appdistribution:group:create")
|
|
12
12
|
.before(requireAuth_1.requireAuth)
|
|
13
13
|
.action(async (displayName, alias, options) => {
|
|
@@ -8,7 +8,7 @@ const error_1 = require("../error");
|
|
|
8
8
|
const client_1 = require("../appdistribution/client");
|
|
9
9
|
const options_parser_util_1 = require("../appdistribution/options-parser-util");
|
|
10
10
|
exports.command = new command_1.Command("appdistribution:groups:delete <alias>")
|
|
11
|
-
.description("delete
|
|
11
|
+
.description("delete an App Distribution group")
|
|
12
12
|
.alias("appdistribution:group:delete")
|
|
13
13
|
.before(requireAuth_1.requireAuth)
|
|
14
14
|
.action(async (alias, options) => {
|
|
@@ -11,7 +11,7 @@ const requireAuth_1 = require("../requireAuth");
|
|
|
11
11
|
const utils = require("../utils");
|
|
12
12
|
const Table = require("cli-table3");
|
|
13
13
|
exports.command = new command_1.Command("appdistribution:groups:list")
|
|
14
|
-
.description("list
|
|
14
|
+
.description("list App Distribution groups")
|
|
15
15
|
.alias("appdistribution:group:list")
|
|
16
16
|
.before(requireAuth_1.requireAuth)
|
|
17
17
|
.action(async (options) => {
|
|
@@ -7,7 +7,7 @@ const requireAuth_1 = require("../requireAuth");
|
|
|
7
7
|
const client_1 = require("../appdistribution/client");
|
|
8
8
|
const options_parser_util_1 = require("../appdistribution/options-parser-util");
|
|
9
9
|
exports.command = new command_1.Command("appdistribution:testers:add [emails...]")
|
|
10
|
-
.description("add testers to project (and
|
|
10
|
+
.description("add testers to project (and App Distribution group, if specified via flag)")
|
|
11
11
|
.option("--file <file>", "a path to a file containing a list of tester emails to be added")
|
|
12
12
|
.option("--group-alias <group-alias>", "if specified, the testers are also added to the group identified by this alias")
|
|
13
13
|
.before(requireAuth_1.requireAuth)
|