@configdirector/cli 0.5.0-beta.1 → 0.8.0-beta.1
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/CHANGELOG.md +23 -0
- package/README.md +59 -2
- package/dist/configdirector.mjs +237 -18
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ All notable changes to `@configdirector/cli` are documented here. The format fol
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.8.0-beta.1] - 2026-10-18
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `configdirector repositories list` lists the repositories whose scan was uploaded to the project, one per line, with the commit, when it was scanned in UTC, and how many references and dynamic references it holds, and ends with how many scanned repositories the project has out of the 50 it can hold, as in `web-app has 2 of the 50 scanned repositories a project can hold.` A repository uploaded without a commit shows `no commit`. With `--json`, it prints the Admin API's response as received. It needs `projects:read` and `code-references:read`.
|
|
12
|
+
- `configdirector repositories remove <name>` removes a scanned repository and all its references from the project, so a repository that was renamed or retired stops counting toward the 50, and the dashboard stops using its references to show where each config is read and which configs no repository reads. It asks for no confirmation and prints `Removed acme/storefront and its code references from web-app.`; `--json` prints `{ "removedRepository": "acme/storefront" }` with the name as given. When no scanned repository in the project has the name, it exits with 2 and lists the names the project has. It needs `projects:read` and `code-references:update`, and `code-references:read` to list the names. Removals do not appear in the audit log.
|
|
13
|
+
|
|
14
|
+
## [0.7.0-beta.1] - 2026-10-17
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `configdirector scan --upload` sends the scan to the project, so the dashboard can show where each config is read and which configs no repository reads. Each upload replaces all the references the project held for the repository. The CLI does not check which branch is checked out, so run it on pushes to the default branch, and keep `validate` for pull requests.
|
|
19
|
+
- The repository is named after the path of its git remote `origin`, without the host and `.git`, so `https://github.com/acme/shop.git`, `git@github.com:acme/shop.git` and `ssh://git@github.com/acme/shop.git` all upload to `acme/shop`. The upload also sends the remote's URL and the commit checked out, both read by running `git`; the user name and password of an `https` remote, such as the job token GitLab CI puts there, are removed first. `--repository <name>` names the repository yourself. Without it, `scan --upload` exits with 2 when `git` is not installed, when the directory is not in a git repository, or when that repository has no `origin` remote; with it, each of those cases uploads with no remote URL and no commit. A repository with no commit yet uploads with no commit.
|
|
20
|
+
- The upload holds the references and dynamic references `scan --json` prints, whatever `ignoreKeys` lists, except a reference to a key that cannot be a config key, which is left out and counted. The output is the scan's, then a line such as `Uploaded the scan of acme/shop at 4d40f50 to web-app: 12 references and 1 dynamic reference, with 0 left out for a key that cannot be a config key.` With `--json`, it prints the Admin API's response as received.
|
|
21
|
+
- A scan that finds no source file exits with 2 and uploads nothing, so a wrong path or `include` never empties the repository's references; a scan that reads files and finds no reference uploads an empty set. When the project already holds its maximum of scanned repositories, `scan --upload` exits with 2 and names `configdirector repositories remove`. It needs `projects:read`, `configs:read` and `code-references:update`.
|
|
22
|
+
|
|
23
|
+
## [0.6.0-beta.1] - 2026-10-17
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- `configdirector configs archive <key>` archives a config: it is removed from the project and from what SDKs receive, so applications get their in-code default value for it. It can be restored from the dashboard for 30 days, and after that it is deleted for good. The key can be the config's current key or an old one.
|
|
28
|
+
- `configs archive` asks for no confirmation and does not check whether the code still reads the key, so run `configdirector scan` first. It prints `Archived new-checkout in web-app. It can be restored from the dashboard for 30 days.`, and `--json` prints `{ "archivedConfigKey": "new-checkout" }` with the key as given, as the MCP server's `archive_config` tool does. It exits with 2 when no config in the project has the key, which includes a config already archived. It needs `projects:read` and `configs:archive`. The change shows in the audit log with the source CLI.
|
|
29
|
+
|
|
7
30
|
## [0.5.0-beta.1] - 2026-10-17
|
|
8
31
|
|
|
9
32
|
### Added
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ConfigDirector CLI
|
|
2
2
|
|
|
3
|
-
`configdirector` is the command line tool for [ConfigDirector](https://www.configdirector.com), the managed configuration and feature flag service. It is in beta: commands and output may change before 1.0. This release signs in, links a repository to a project, lists where the repository's code reads config keys, checks those keys against the project's configs, shows the value a context gets for a config and why, reads a project's environments, configs, targeting rules and segments, creates configs,
|
|
3
|
+
`configdirector` is the command line tool for [ConfigDirector](https://www.configdirector.com), the managed configuration and feature flag service. It is in beta: commands and output may change before 1.0. This release signs in, links a repository to a project, lists where the repository's code reads config keys, checks those keys against the project's configs, shows the value a context gets for a config and why, reads a project's environments, configs, targeting rules and segments, creates configs, replaces a config's targeting rules in an environment, archives configs, uploads the repository's code references to the project, and lists and removes the project's scanned repositories.
|
|
4
4
|
|
|
5
5
|
The [CLI page of the ConfigDirector docs](https://docs.configdirector.com/integrations/cli) covers the API token, CI setup for GitHub Actions and GitLab, and what each problem `validate` reports means for your build.
|
|
6
6
|
|
|
@@ -54,6 +54,47 @@ The scan skips what `.gitignore` ignores, the `node_modules`, `vendor`, `build`,
|
|
|
54
54
|
|
|
55
55
|
With `--json`, `scan` prints the references for scripts and CI; `schema/scan-output.schema.json` in the package describes that output. Each reference has a `kind`, `direct` or `indirect`, and a direct reference resolved from a constant carries the constant as `argument`.
|
|
56
56
|
|
|
57
|
+
## Upload the scan to ConfigDirector
|
|
58
|
+
|
|
59
|
+
From the repository root, on the default branch:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
configdirector scan --upload
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`--upload` sends the scan to the project, so the dashboard can show where each config is read and which configs no repository reads. Each upload replaces all the references the project held for the repository. The CLI does not check which branch is checked out, so run it on pushes to the default branch, and keep `validate` for pull requests.
|
|
66
|
+
|
|
67
|
+
The repository is named after the path of its git remote `origin`, without the host and `.git`: `https://github.com/acme/shop.git`, `git@github.com:acme/shop.git` and `ssh://git@github.com/acme/shop.git` all upload to `acme/shop`, and a GitLab subgroup keeps its whole path, such as `group/subgroup/app`. The upload also sends the remote's URL, without the user name and password of an `https` remote, such as the job token GitLab CI puts there, and the commit checked out, both read by running `git`. `--repository <name>` names the repository yourself. Without it, `scan --upload` exits with 2 when `git` is not installed, when the directory is not in a git repository, or when that repository has no `origin` remote; with it, each of those cases uploads with no remote URL and no commit. A repository with no commit yet uploads with no commit.
|
|
68
|
+
|
|
69
|
+
The upload holds the references and dynamic references `scan --json` prints, whatever `ignoreKeys` lists, except a reference to a key that cannot be a config key, such as one shorter than 4 characters, which is left out and counted. The output is the scan's, then a line naming the repository, the commit, the project, how many references were sent and how many were left out. A scan that finds no source file exits with 2 and uploads nothing, so a wrong path or `include` never empties the repository's references; a scan that reads files and finds no reference uploads an empty set. With `--json`, it prints the Admin API's response as received.
|
|
70
|
+
|
|
71
|
+
A project holds at most 50 scanned repositories, and one upload holds at most 10,000 references, dynamic references included; going over either exits with 2. `scan --upload` needs `projects:read`, `configs:read` and `code-references:update`. Uploads do not appear in the audit log.
|
|
72
|
+
|
|
73
|
+
## List and remove scanned repositories
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
configdirector repositories list
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`repositories list` lists the repositories whose scan was uploaded to the project, one per line, with the commit, when it was scanned in UTC, and how many references and dynamic references it holds, and ends with how many scanned repositories the project has out of the 50 it can hold:
|
|
80
|
+
|
|
81
|
+
```txt
|
|
82
|
+
ConfigDirector/cli 4d40f50 2026-10-17 10:00 UTC 12 references 1 dynamic reference
|
|
83
|
+
acme/storefront 9f3e2a1 2026-10-16 09:12 UTC 1 reference 0 dynamic references
|
|
84
|
+
|
|
85
|
+
web-app has 2 of the 50 scanned repositories a project can hold.
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A repository uploaded without a commit shows `no commit`. With `--json`, it prints the Admin API's response as received, which also holds each repository's remote URL and how many files its scan read. `repositories list` needs `projects:read` and `code-references:read`.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
configdirector repositories remove acme/storefront
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Nothing removes a scanned repository on its own: a repository that is renamed or retired keeps its last upload, keeps counting toward the 50, and the dashboard keeps using its references to show where each config is read and which configs no repository reads. `repositories remove <name>` removes the repository and all its references from the project, with no confirmation, and prints `Removed acme/storefront and its code references from web-app.` To rename a repository, remove the old name and upload under the new one; a removed repository that uploads again is added back. When no scanned repository in the project has the name, it exits with 2 and lists the names the project has. With `--json`, it prints `{ "removedRepository": "acme/storefront" }`, with the name as given.
|
|
95
|
+
|
|
96
|
+
`repositories remove` needs `projects:read` and `code-references:update`, and `code-references:read` to list the names when none matches. Removals do not appear in the audit log.
|
|
97
|
+
|
|
57
98
|
## Check the keys the code reads
|
|
58
99
|
|
|
59
100
|
From the repository root, or as a CI step:
|
|
@@ -217,6 +258,22 @@ configdirector targeting-rules set new-checkout --environment production --rules
|
|
|
217
258
|
|
|
218
259
|
`targeting-rules set` needs `projects:read`, `environments:read` and `config-targeting-rules:update`. `targeting-rules reference` needs only a valid token and no project.
|
|
219
260
|
|
|
261
|
+
## Archive a config
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
configdirector configs archive new-checkout
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`configs archive` archives a config: it is removed from the project and from what SDKs receive, so applications get their in-code default value for it. It can be restored from the dashboard for 30 days, and after that it is deleted for good:
|
|
268
|
+
|
|
269
|
+
```txt
|
|
270
|
+
Archived new-checkout in web-app. It can be restored from the dashboard for 30 days.
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
It asks for no confirmation and does not check whether the code still reads the key, so run `configdirector scan` first. A renamed config can be archived by an old key. When no config in the project has the key, which includes a config already archived, it exits with 2. With `--json`, it prints `{ "archivedConfigKey": "new-checkout" }`, with the key as given.
|
|
274
|
+
|
|
275
|
+
`configs archive` needs `projects:read` and `configs:archive`. The change shows in the audit log with the source CLI.
|
|
276
|
+
|
|
220
277
|
## Options
|
|
221
278
|
|
|
222
279
|
| Option | Environment variable | Meaning |
|
|
@@ -226,7 +283,7 @@ configdirector targeting-rules set new-checkout --environment production --rules
|
|
|
226
283
|
| `--project <slug>` | `CONFIGDIRECTOR_PROJECT` | Project slug. Without either, every command that reads a project uses the `project` in `configdirector.json`. |
|
|
227
284
|
| `--json` | | Print JSON on stdout. Errors always go to stderr. |
|
|
228
285
|
|
|
229
|
-
The command exits with 0 when it succeeds, 1 when `validate` found a problem or `configs create` found a config that already has the key, and 2 when it could not run: a usage mistake, a rejected token, a suspended service, an unreachable API, or something the command names that does not exist, such as a config key, an environment, a saved context, a segment key or a
|
|
286
|
+
The command exits with 0 when it succeeds, 1 when `validate` found a problem or `configs create` found a config that already has the key, and 2 when it could not run: a usage mistake, a rejected token, a suspended service, an unreachable API, or something the command names that does not exist, such as a config key, an environment, a saved context, a segment key, a tag or a scanned repository. A CI job can therefore tell a wrong config key from an outage.
|
|
230
287
|
|
|
231
288
|
## License
|
|
232
289
|
|
package/dist/configdirector.mjs
CHANGED
|
@@ -7,10 +7,12 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep }
|
|
|
7
7
|
import { createInterface } from "node:readline";
|
|
8
8
|
import ignore from "ignore";
|
|
9
9
|
import picomatch from "picomatch";
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { promisify } from "node:util";
|
|
10
12
|
import { randomUUID } from "node:crypto";
|
|
11
13
|
import { text } from "node:stream/consumers";
|
|
12
14
|
//#region package.json
|
|
13
|
-
var version = "0.
|
|
15
|
+
var version = "0.8.0-beta.1";
|
|
14
16
|
//#endregion
|
|
15
17
|
//#region src/CliError.ts
|
|
16
18
|
var CliError = class extends Error {
|
|
@@ -40,6 +42,7 @@ const ApiValidationErrorBodySchema = v.object({ errors: v.array(v.object({
|
|
|
40
42
|
message: v.string()
|
|
41
43
|
})) });
|
|
42
44
|
const ServiceSuspendedBodySchema = v.object({ code: v.literal("SERVICE_SUSPENDED") });
|
|
45
|
+
const RepositoryLimitReachedBodySchema = v.object({ code: v.literal("REPOSITORY_LIMIT_REACHED") });
|
|
43
46
|
const EvaluationNotFoundBodySchema = v.object({
|
|
44
47
|
error: v.string(),
|
|
45
48
|
code: v.picklist([
|
|
@@ -75,6 +78,11 @@ var EvaluationNotFoundError = class extends CliError {
|
|
|
75
78
|
this.code = code;
|
|
76
79
|
}
|
|
77
80
|
};
|
|
81
|
+
var RepositoryLimitReachedError = class extends CliError {
|
|
82
|
+
constructor() {
|
|
83
|
+
super("The project already holds its maximum of scanned repositories, so the Admin API refused the upload (REPOSITORY_LIMIT_REACHED). Remove a repository it no longer needs with `configdirector repositories remove <name>`, then upload again.");
|
|
84
|
+
}
|
|
85
|
+
};
|
|
78
86
|
var ServiceSuspendedError = class extends CliError {
|
|
79
87
|
constructor(retryAfterSeconds) {
|
|
80
88
|
super(`Your organization's service is suspended, so ConfigDirector refuses Admin API requests. The dashboard's billing page shows why and how to reinstate it. Retry after ${retryAfterSeconds} seconds.`);
|
|
@@ -556,6 +564,19 @@ const CreatedConfigSchema = v.object({
|
|
|
556
564
|
server: v.boolean()
|
|
557
565
|
});
|
|
558
566
|
//#endregion
|
|
567
|
+
//#region src/admin-api/scanned-repositories.ts
|
|
568
|
+
const scannedRepositoryFields = {
|
|
569
|
+
name: v.string(),
|
|
570
|
+
commit: v.pipe(v.string(), v.transform((commit) => commit === "" ? null : commit)),
|
|
571
|
+
referenceCount: v.number(),
|
|
572
|
+
dynamicReferenceCount: v.number()
|
|
573
|
+
};
|
|
574
|
+
const ScannedRepositorySchema = v.object(scannedRepositoryFields);
|
|
575
|
+
const ListedScannedRepositorySchema = v.object({
|
|
576
|
+
...scannedRepositoryFields,
|
|
577
|
+
scannedAt: v.pipe(v.string(), v.isoTimestamp())
|
|
578
|
+
});
|
|
579
|
+
//#endregion
|
|
559
580
|
//#region src/admin-api/segments.ts
|
|
560
581
|
const SegmentSchema = v.object({
|
|
561
582
|
id: v.string(),
|
|
@@ -713,6 +734,7 @@ const describeStatus = (response) => `HTTP ${response.status}`;
|
|
|
713
734
|
const failureFor = (url, response, body) => {
|
|
714
735
|
switch (response.status) {
|
|
715
736
|
case 400: {
|
|
737
|
+
if (v.is(RepositoryLimitReachedBodySchema, body)) return new RepositoryLimitReachedError();
|
|
716
738
|
const validationError = v.safeParse(ApiValidationErrorBodySchema, body);
|
|
717
739
|
return validationError.success ? new ApiValidationError(validationError.output.errors) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
718
740
|
}
|
|
@@ -799,6 +821,21 @@ var AdminApiClient = class {
|
|
|
799
821
|
body: request
|
|
800
822
|
}) === void 0 ? "not-found" : "replaced";
|
|
801
823
|
}
|
|
824
|
+
uploadRepositoryScan(projectId, repositoryName, upload) {
|
|
825
|
+
return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryName)}`, ScannedRepositorySchema, {
|
|
826
|
+
method: "PUT",
|
|
827
|
+
body: upload
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
listScannedRepositories(projectId) {
|
|
831
|
+
return this.get(`/v1/projects/${encodeURIComponent(projectId)}/repositories`, v.array(ListedScannedRepositorySchema));
|
|
832
|
+
}
|
|
833
|
+
async removeScannedRepository(projectId, repositoryName) {
|
|
834
|
+
return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryName)}`, v.unknown(), { method: "DELETE" }) === void 0 ? "not-found" : "removed";
|
|
835
|
+
}
|
|
836
|
+
async archiveConfig(projectId, configKey) {
|
|
837
|
+
return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, v.unknown(), { method: "DELETE" }) === void 0 ? "not-found" : "archived";
|
|
838
|
+
}
|
|
802
839
|
get(path, schema) {
|
|
803
840
|
return this.requestJson(path, schema, { method: "GET" });
|
|
804
841
|
}
|
|
@@ -829,13 +866,16 @@ var AdminApiClient = class {
|
|
|
829
866
|
"X-SourceClient": "cli"
|
|
830
867
|
};
|
|
831
868
|
try {
|
|
832
|
-
return await fetch(url,
|
|
869
|
+
return await fetch(url, "body" in request ? {
|
|
833
870
|
method: request.method,
|
|
834
871
|
headers: {
|
|
835
872
|
...headers,
|
|
836
873
|
"Content-Type": "application/json"
|
|
837
874
|
},
|
|
838
875
|
body: JSON.stringify(request.body)
|
|
876
|
+
} : {
|
|
877
|
+
method: request.method,
|
|
878
|
+
headers
|
|
839
879
|
});
|
|
840
880
|
} catch (error) {
|
|
841
881
|
throw new ApiUnreachableError(this.connection.baseUrl, error);
|
|
@@ -934,6 +974,12 @@ const connectToLinkedProject = async (context) => {
|
|
|
934
974
|
};
|
|
935
975
|
};
|
|
936
976
|
//#endregion
|
|
977
|
+
//#region src/utc-minute.ts
|
|
978
|
+
const utcMinute = (time) => {
|
|
979
|
+
const isoTime = new Date(time).toISOString();
|
|
980
|
+
return `${isoTime.slice(0, 10)} ${isoTime.slice(11, 16)} UTC`;
|
|
981
|
+
};
|
|
982
|
+
//#endregion
|
|
937
983
|
//#region src/commands/configs.ts
|
|
938
984
|
const LIFECYCLE_STATES = [
|
|
939
985
|
"new",
|
|
@@ -1047,11 +1093,16 @@ const createConfig = async (context, _commandArguments, options) => {
|
|
|
1047
1093
|
tagIds: await tagIdsNamed(client, project, options.tag ?? [])
|
|
1048
1094
|
}));
|
|
1049
1095
|
};
|
|
1050
|
-
const
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
return
|
|
1096
|
+
const archiveConfig = async (context, [configKey]) => {
|
|
1097
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1098
|
+
if (await client.archiveConfig(project.id, configKey) === "not-found") throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
1099
|
+
return {
|
|
1100
|
+
json: { archivedConfigKey: configKey },
|
|
1101
|
+
text: [`Archived ${configKey} in ${project.slug}. It can be restored from the dashboard for 30 days.`, ""].join("\n"),
|
|
1102
|
+
exitCode: ExitCode.success
|
|
1103
|
+
};
|
|
1054
1104
|
};
|
|
1105
|
+
const sdkDescription = (evaluation) => [evaluation.sdkName ?? "unknown SDK", ...evaluation.sdkVersion === null ? [] : [evaluation.sdkVersion]].join(" ");
|
|
1055
1106
|
const activityLines = (configKey, activity) => activity.latestEvaluations.length === 0 ? [`No SDK evaluations of ${configKey} found.`] : alignedColumns(activity.latestEvaluations.map((evaluation) => [
|
|
1056
1107
|
evaluation.environment.slug,
|
|
1057
1108
|
evaluation.source,
|
|
@@ -1415,6 +1466,49 @@ const listProjects = async (context) => {
|
|
|
1415
1466
|
//#region src/plural.ts
|
|
1416
1467
|
const plural = (count, singular, pluralForm) => count === 1 ? singular : pluralForm;
|
|
1417
1468
|
//#endregion
|
|
1469
|
+
//#region src/scanned-repository/short-commit.ts
|
|
1470
|
+
const SHORT_COMMIT_LENGTH = 7;
|
|
1471
|
+
const shortCommit = (commit) => commit.slice(0, SHORT_COMMIT_LENGTH);
|
|
1472
|
+
//#endregion
|
|
1473
|
+
//#region src/commands/repositories.ts
|
|
1474
|
+
const MAX_SCANNED_REPOSITORIES_PER_PROJECT = 50;
|
|
1475
|
+
const commitDescription = (commit) => commit === null ? "no commit" : shortCommit(commit);
|
|
1476
|
+
const scannedRepositoryColumnsOf = (repository) => [
|
|
1477
|
+
repository.name,
|
|
1478
|
+
commitDescription(repository.commit),
|
|
1479
|
+
utcMinute(repository.scannedAt),
|
|
1480
|
+
`${repository.referenceCount} ${plural(repository.referenceCount, "reference", "references")}`,
|
|
1481
|
+
`${repository.dynamicReferenceCount} ${plural(repository.dynamicReferenceCount, "dynamic reference", "dynamic references")}`
|
|
1482
|
+
];
|
|
1483
|
+
const scannedRepositoryLines = (projectSlug, repositories) => repositories.length === 0 ? [`No scanned repositories found in ${projectSlug}. Run configdirector scan --upload in a repository to upload its code references.`] : [
|
|
1484
|
+
...alignedColumns(repositories.map(scannedRepositoryColumnsOf)),
|
|
1485
|
+
"",
|
|
1486
|
+
`${projectSlug} has ${repositories.length} of the ${MAX_SCANNED_REPOSITORIES_PER_PROJECT} scanned repositories a project can hold.`
|
|
1487
|
+
];
|
|
1488
|
+
const listScannedRepositories = async (context) => {
|
|
1489
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1490
|
+
const { parsedBody: repositories, responseBody } = await client.listScannedRepositories(project.id);
|
|
1491
|
+
return {
|
|
1492
|
+
json: responseBody,
|
|
1493
|
+
text: [...scannedRepositoryLines(project.slug, repositories), ""].join("\n"),
|
|
1494
|
+
exitCode: ExitCode.success
|
|
1495
|
+
};
|
|
1496
|
+
};
|
|
1497
|
+
const scannedRepositoryNamesSentence = (repositories) => repositories.length === 0 ? "The project has no scanned repositories." : `Its scanned repositories are ${repositories.map((repository) => repository.name).join(", ")}.`;
|
|
1498
|
+
const scannedRepositoryNotFoundError = async (client, project, repositoryName) => {
|
|
1499
|
+
const repositories = await ifPermitted(client.listScannedRepositories(project.id));
|
|
1500
|
+
return new CliError([`No scanned repository in ${project.slug} has the name "${repositoryName}".`, ...repositories === void 0 ? [] : [scannedRepositoryNamesSentence(repositories.parsedBody)]].join(" "));
|
|
1501
|
+
};
|
|
1502
|
+
const removeScannedRepository = async (context, [repositoryName]) => {
|
|
1503
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1504
|
+
if (await client.removeScannedRepository(project.id, repositoryName) === "not-found") throw await scannedRepositoryNotFoundError(client, project, repositoryName);
|
|
1505
|
+
return {
|
|
1506
|
+
json: { removedRepository: repositoryName },
|
|
1507
|
+
text: [`Removed ${repositoryName} and its code references from ${project.slug}.`, ""].join("\n"),
|
|
1508
|
+
exitCode: ExitCode.success
|
|
1509
|
+
};
|
|
1510
|
+
};
|
|
1511
|
+
//#endregion
|
|
1418
1512
|
//#region src/scan/source-languages.ts
|
|
1419
1513
|
const languageOfExtension = {
|
|
1420
1514
|
".js": "javascript",
|
|
@@ -1886,6 +1980,7 @@ const scanRootAt = async (workingDirectory, path) => {
|
|
|
1886
1980
|
description: path ?? "the current directory"
|
|
1887
1981
|
};
|
|
1888
1982
|
};
|
|
1983
|
+
const scanRootDirectory = (root) => root.isDirectory ? root.path : dirname(root.path);
|
|
1889
1984
|
const gitignoreRulesIn = async (directory) => {
|
|
1890
1985
|
const contents = await readFileIfPresent(join(directory, ".gitignore"));
|
|
1891
1986
|
return contents === void 0 ? [] : [{
|
|
@@ -3718,11 +3813,10 @@ const scanCodeReferences = async (scope, projectKeys) => {
|
|
|
3718
3813
|
};
|
|
3719
3814
|
//#endregion
|
|
3720
3815
|
//#region src/project-scan.ts
|
|
3721
|
-
const scanProject = async (context,
|
|
3816
|
+
const scanProject = async (context, root) => {
|
|
3722
3817
|
const { workingDirectory } = context.invocation;
|
|
3723
3818
|
const settings = await readProjectSettings(workingDirectory);
|
|
3724
3819
|
const projectSlug = requireLinkedProjectSlug(context, settings);
|
|
3725
|
-
const root = await scanRootAt(workingDirectory, path);
|
|
3726
3820
|
const client = new AdminApiClient({
|
|
3727
3821
|
baseUrl: context.baseUrl,
|
|
3728
3822
|
token: await configuredApiToken(context)
|
|
@@ -3737,14 +3831,107 @@ const scanProject = async (context, path) => {
|
|
|
3737
3831
|
exclude: settings?.exclude ?? []
|
|
3738
3832
|
}, projectKeys);
|
|
3739
3833
|
return {
|
|
3834
|
+
client,
|
|
3740
3835
|
project,
|
|
3741
3836
|
configs,
|
|
3742
3837
|
ignoredKeys: new Set(settings?.ignoreKeys ?? []),
|
|
3743
|
-
scannedLocation: root.description,
|
|
3744
3838
|
codeReferenceScan
|
|
3745
3839
|
};
|
|
3746
3840
|
};
|
|
3747
3841
|
//#endregion
|
|
3842
|
+
//#region src/scanned-repository/git-checkout.ts
|
|
3843
|
+
const execFileAsync = promisify(execFile);
|
|
3844
|
+
const firstLineOf = (text) => text.trim().split("\n")[0] ?? "";
|
|
3845
|
+
const gitOutput = async (directory, environmentVariables, gitArguments) => {
|
|
3846
|
+
try {
|
|
3847
|
+
const { stdout } = await execFileAsync("git", gitArguments, {
|
|
3848
|
+
cwd: directory,
|
|
3849
|
+
env: { ...environmentVariables }
|
|
3850
|
+
});
|
|
3851
|
+
return {
|
|
3852
|
+
kind: "printed",
|
|
3853
|
+
text: stdout.trim()
|
|
3854
|
+
};
|
|
3855
|
+
} catch (error) {
|
|
3856
|
+
const failure = error;
|
|
3857
|
+
return failure.code === "ENOENT" ? { kind: "git-missing" } : {
|
|
3858
|
+
kind: "failed",
|
|
3859
|
+
message: firstLineOf(failure.stderr ?? "")
|
|
3860
|
+
};
|
|
3861
|
+
}
|
|
3862
|
+
};
|
|
3863
|
+
const readGitCheckout = async (directory, environmentVariables) => {
|
|
3864
|
+
const workTree = await gitOutput(directory, environmentVariables, ["rev-parse", "--is-inside-work-tree"]);
|
|
3865
|
+
switch (workTree.kind) {
|
|
3866
|
+
case "git-missing": return { kind: "git-missing" };
|
|
3867
|
+
case "failed": return {
|
|
3868
|
+
kind: "no-repository",
|
|
3869
|
+
gitMessage: workTree.message
|
|
3870
|
+
};
|
|
3871
|
+
case "printed": break;
|
|
3872
|
+
}
|
|
3873
|
+
const remoteUrl = await gitOutput(directory, environmentVariables, [
|
|
3874
|
+
"remote",
|
|
3875
|
+
"get-url",
|
|
3876
|
+
"origin"
|
|
3877
|
+
]);
|
|
3878
|
+
if (remoteUrl.kind !== "printed") return { kind: "no-origin-remote" };
|
|
3879
|
+
const commit = await gitOutput(directory, environmentVariables, ["rev-parse", "HEAD"]);
|
|
3880
|
+
return {
|
|
3881
|
+
kind: "origin-remote",
|
|
3882
|
+
remoteUrl: remoteUrl.text,
|
|
3883
|
+
commit: commit.kind === "printed" ? commit.text : ""
|
|
3884
|
+
};
|
|
3885
|
+
};
|
|
3886
|
+
//#endregion
|
|
3887
|
+
//#region src/scanned-repository/repository-identity.ts
|
|
3888
|
+
const URL_SCHEME_SEPARATOR = "://";
|
|
3889
|
+
const GIT_SUFFIX = ".git";
|
|
3890
|
+
const HTTP_PROTOCOLS = ["http:", "https:"];
|
|
3891
|
+
const carriesHttpCredentials = (url) => HTTP_PROTOCOLS.includes(url.protocol) && (url.username !== "" || url.password !== "");
|
|
3892
|
+
const withoutCredentials = (url) => {
|
|
3893
|
+
const urlWithoutCredentials = new URL(url);
|
|
3894
|
+
urlWithoutCredentials.username = "";
|
|
3895
|
+
urlWithoutCredentials.password = "";
|
|
3896
|
+
return urlWithoutCredentials.href;
|
|
3897
|
+
};
|
|
3898
|
+
const originRemoteOf = (remoteUrl) => {
|
|
3899
|
+
if (!remoteUrl.includes(URL_SCHEME_SEPARATOR)) return {
|
|
3900
|
+
path: remoteUrl.slice(remoteUrl.indexOf(":") + 1),
|
|
3901
|
+
uploadedUrl: remoteUrl
|
|
3902
|
+
};
|
|
3903
|
+
const url = new URL(remoteUrl);
|
|
3904
|
+
return {
|
|
3905
|
+
path: url.pathname.slice(1),
|
|
3906
|
+
uploadedUrl: carriesHttpCredentials(url) ? withoutCredentials(url) : remoteUrl
|
|
3907
|
+
};
|
|
3908
|
+
};
|
|
3909
|
+
const repositoryNameOf = (remote) => remote.path.endsWith(GIT_SUFFIX) ? remote.path.slice(0, -4) : remote.path;
|
|
3910
|
+
const unnamedCheckoutReason = (checkout, scannedLocation) => {
|
|
3911
|
+
switch (checkout.kind) {
|
|
3912
|
+
case "git-missing": return "git is not installed or not on the PATH";
|
|
3913
|
+
case "no-repository": return `git cannot read a repository in ${scannedLocation} (${checkout.gitMessage})`;
|
|
3914
|
+
case "no-origin-remote": return `the git repository in ${scannedLocation} has no remote named origin`;
|
|
3915
|
+
}
|
|
3916
|
+
};
|
|
3917
|
+
const unnamedRepositoryError = (checkout, scannedLocation) => new CliError(`scan --upload names the repository after its git remote origin, but ${unnamedCheckoutReason(checkout, scannedLocation)}. Name the repository with --repository <name>.`);
|
|
3918
|
+
const repositoryIdentityOf = (checkout, repositoryOption, scannedLocation) => {
|
|
3919
|
+
if (checkout.kind === "origin-remote") {
|
|
3920
|
+
const remote = originRemoteOf(checkout.remoteUrl);
|
|
3921
|
+
return {
|
|
3922
|
+
name: repositoryOption ?? repositoryNameOf(remote),
|
|
3923
|
+
remoteUrl: remote.uploadedUrl,
|
|
3924
|
+
commit: checkout.commit
|
|
3925
|
+
};
|
|
3926
|
+
}
|
|
3927
|
+
if (repositoryOption === void 0) throw unnamedRepositoryError(checkout, scannedLocation);
|
|
3928
|
+
return {
|
|
3929
|
+
name: repositoryOption,
|
|
3930
|
+
remoteUrl: "",
|
|
3931
|
+
commit: ""
|
|
3932
|
+
};
|
|
3933
|
+
};
|
|
3934
|
+
//#endregion
|
|
3748
3935
|
//#region src/commands/scan.ts
|
|
3749
3936
|
const referenceJson = (reference) => {
|
|
3750
3937
|
switch (reference.kind) {
|
|
@@ -3769,8 +3956,7 @@ const referenceJson = (reference) => {
|
|
|
3769
3956
|
};
|
|
3770
3957
|
}
|
|
3771
3958
|
};
|
|
3772
|
-
const
|
|
3773
|
-
project: projectSlug,
|
|
3959
|
+
const codeReferenceScanJson = (scan) => ({
|
|
3774
3960
|
filesScanned: scan.filesScanned,
|
|
3775
3961
|
references: scan.references.map(referenceJson),
|
|
3776
3962
|
dynamicReferences: scan.dynamicReferences.map((reference) => ({
|
|
@@ -3781,6 +3967,10 @@ const scanJson = (projectSlug, scan) => ({
|
|
|
3781
3967
|
argument: reference.argument
|
|
3782
3968
|
}))
|
|
3783
3969
|
});
|
|
3970
|
+
const scanJson = (projectSlug, scan) => ({
|
|
3971
|
+
project: projectSlug,
|
|
3972
|
+
...codeReferenceScanJson(scan)
|
|
3973
|
+
});
|
|
3784
3974
|
const DYNAMIC_REFERENCES_HEADING = "Dynamic references (calls whose key is not a string literal)";
|
|
3785
3975
|
const withCollapsedWhitespace = (text) => {
|
|
3786
3976
|
let collapsed = "";
|
|
@@ -3818,11 +4008,35 @@ const scanText = (scan, scannedLocation) => {
|
|
|
3818
4008
|
counts
|
|
3819
4009
|
].join("\n\n")}\n`;
|
|
3820
4010
|
};
|
|
3821
|
-
const
|
|
3822
|
-
|
|
4011
|
+
const shortCommitText = (commit) => commit === null ? "" : ` at ${shortCommit(commit)}`;
|
|
4012
|
+
const uploadLine = (projectSlug, repository, leftOutCount) => `Uploaded the scan of ${repository.name}${shortCommitText(repository.commit)} to ${projectSlug}: ${repository.referenceCount} ${plural(repository.referenceCount, "reference", "references")} and ${repository.dynamicReferenceCount} ${plural(repository.dynamicReferenceCount, "dynamic reference", "dynamic references")}, with ${leftOutCount} left out for a key that cannot be a config key.`;
|
|
4013
|
+
const uploadScan = async (context, root, repositoryOption) => {
|
|
4014
|
+
const repository = repositoryIdentityOf(await readGitCheckout(scanRootDirectory(root), context.invocation.environmentVariables), repositoryOption, root.description);
|
|
4015
|
+
const { client, project, codeReferenceScan } = await scanProject(context, root);
|
|
4016
|
+
if (codeReferenceScan.filesScanned === 0) throw new CliError(`No source files to scan in ${root.description}, so nothing was uploaded and ${project.slug} keeps its code references for ${repository.name}. Check the path given to scan, and include and exclude in configdirector.json.`);
|
|
4017
|
+
const configKeyReferences = codeReferenceScan.references.filter((reference) => fitsKeyGrammar(reference.key));
|
|
4018
|
+
const leftOutCount = codeReferenceScan.references.length - configKeyReferences.length;
|
|
4019
|
+
const { parsedBody: scannedRepository, responseBody } = await client.uploadRepositoryScan(project.id, repository.name, {
|
|
4020
|
+
remoteUrl: repository.remoteUrl,
|
|
4021
|
+
commit: repository.commit,
|
|
4022
|
+
...codeReferenceScanJson({
|
|
4023
|
+
...codeReferenceScan,
|
|
4024
|
+
references: configKeyReferences
|
|
4025
|
+
})
|
|
4026
|
+
});
|
|
4027
|
+
return {
|
|
4028
|
+
json: responseBody,
|
|
4029
|
+
text: `${scanText(codeReferenceScan, root.description)}\n${uploadLine(project.slug, scannedRepository, leftOutCount)}\n`,
|
|
4030
|
+
exitCode: ExitCode.success
|
|
4031
|
+
};
|
|
4032
|
+
};
|
|
4033
|
+
const scan = async (context, [path], options) => {
|
|
4034
|
+
const root = await scanRootAt(context.invocation.workingDirectory, path);
|
|
4035
|
+
if (options.upload) return uploadScan(context, root, options.repository);
|
|
4036
|
+
const { project, codeReferenceScan } = await scanProject(context, root);
|
|
3823
4037
|
return {
|
|
3824
4038
|
json: scanJson(project.slug, codeReferenceScan),
|
|
3825
|
-
text: scanText(codeReferenceScan,
|
|
4039
|
+
text: scanText(codeReferenceScan, root.description),
|
|
3826
4040
|
exitCode: ExitCode.success
|
|
3827
4041
|
};
|
|
3828
4042
|
};
|
|
@@ -4155,14 +4369,15 @@ const validateText = (projectSlug, scan, scannedLocation, check) => {
|
|
|
4155
4369
|
}), countsText(scan, check)].join("\n\n")}\n`;
|
|
4156
4370
|
};
|
|
4157
4371
|
const validate = async (context, [path]) => {
|
|
4158
|
-
const
|
|
4372
|
+
const root = await scanRootAt(context.invocation.workingDirectory, path);
|
|
4373
|
+
const { project, configs, ignoredKeys, codeReferenceScan } = await scanProject(context, root);
|
|
4159
4374
|
const check = checkCodeReferences(codeReferenceScan.references, configs, ignoredKeys);
|
|
4160
4375
|
return {
|
|
4161
4376
|
json: {
|
|
4162
4377
|
...scanJson(project.slug, codeReferenceScan),
|
|
4163
4378
|
findings: check.findings.map(findingJson)
|
|
4164
4379
|
},
|
|
4165
|
-
text: validateText(project.slug, codeReferenceScan,
|
|
4380
|
+
text: validateText(project.slug, codeReferenceScan, root.description, check),
|
|
4166
4381
|
exitCode: check.findings.length === 0 ? ExitCode.success : ExitCode.problemsFound
|
|
4167
4382
|
};
|
|
4168
4383
|
};
|
|
@@ -4207,22 +4422,26 @@ const run = async (invocation) => {
|
|
|
4207
4422
|
});
|
|
4208
4423
|
program.command("login").description("save an API token for later runs, after checking it can list projects").action(commandAction(login));
|
|
4209
4424
|
program.command("init").description("write configdirector.json for a project in the current directory").action(commandAction(init));
|
|
4210
|
-
program.command("scan").argument("[path]", "directory or file to scan (default: the current directory)").description("list where the code reads config keys through a ConfigDirector SDK").action(commandAction(scan));
|
|
4425
|
+
program.command("scan").argument("[path]", "directory or file to scan (default: the current directory)").description("list where the code reads config keys through a ConfigDirector SDK").option("--upload", "send the code references to the project, replacing those it holds for this repository").option("--repository <name>", "the repository name to upload under (default: the path of the git remote origin, such as ConfigDirector/cli)").action(commandAction(scan));
|
|
4211
4426
|
program.command("validate").argument("[path]", "directory or file to check (default: the current directory)").description("check every config key the code reads against the project's configs, exiting 1 when one is wrong").action(commandAction(validate));
|
|
4212
4427
|
program.command("evaluate").argument("<key>", "the config's key").description("show the value a context gets for a config in an environment, and why").requiredOption("--environment <slug>", "slug of the environment whose targeting rules to evaluate (required)").option("--context-kind <kind>", "context kind of the context to evaluate for (default: user)").option("--context-id <identifier>", "identifier of the context").option("--context-name <name>", "name of the context").option("--context-trait <name=value>", "a trait of the context, as a string, or name:=<json> for a number, boolean, array or object; repeatable", repeatedOption).option("--context-json <json>", "the whole context as a JSON object, or @<file> to read it from a file").option("--saved-context <kind:identifier>", "a context ConfigDirector saved from SDK evaluations in the environment, such as user:123").addHelpText("after", "\nGive the context with the --context-* options, with --context-json, or with --saved-context. Without any of them, the config is evaluated for an empty context, as an SDK given none would.").action(commandAction(evaluate));
|
|
4213
4428
|
program.command("projects").description("list the projects of the API token's organization").command("list").description("list the organization's projects, whatever project is selected").action(commandAction(listProjects));
|
|
4214
4429
|
program.command("environments").description("list the project's environments").command("list").description("list the project's environments with their slugs, marking the live ones").action(commandAction(listEnvironments));
|
|
4215
|
-
const configsCommand = program.command("configs").description("list, show and
|
|
4430
|
+
const configsCommand = program.command("configs").description("list, show, create and archive a project's configs");
|
|
4216
4431
|
configsCommand.command("list").description("list the project's configs with their type, role, lifetime and lifecycle state").option("--tag <name>", "list only the configs that have this tag; repeatable, for configs with any of them", repeatedOption).addOption(new Option("--untagged", "list only the configs without tags").conflicts("tag")).addOption(new Option("--state <state>", "list only the configs in this lifecycle state").choices(LIFECYCLE_STATES)).action(commandAction(listConfigs));
|
|
4217
4432
|
configsCommand.command("get").argument("<key>", "the config's key, or an old key of it").description("show a config's settings and its targeting rules in every environment").action(commandAction(getConfig));
|
|
4218
4433
|
configsCommand.command("activity").argument("<key>", "the config's key, or an old key of it").description("show when SDKs last evaluated a config, per environment, SDK key kind, SDK name and SDK version").action(commandAction(showConfigActivity));
|
|
4219
4434
|
configsCommand.command("create").description("create a config from options that mirror the Admin API's fields").requiredOption("--key <key>", "the config's key (required)").option("--description <text>", "a description shown with the config in the dashboard").addOption(new Option("--type <type>", "the type of the config's values (required)").choices(CONFIG_TYPES).makeOptionMandatory()).addOption(new Option("--role <role>", "what the config is for (required)").choices(CONFIG_ROLES).makeOptionMandatory()).addOption(new Option("--lifetime <lifetime>", "whether the config is expected to be removed (required)").choices(CONFIG_LIFETIMES).makeOptionMandatory()).requiredOption("--default-value <value>", "the default targeting rule value every environment starts with, in the type given by --type; not the in-code default value an application passes to the SDK (required)").option("--client-only", "serve the config to client SDKs only (default: client and server SDKs)").addOption(new Option("--server-only", "serve the config to server SDKs only (default: client and server SDKs)").conflicts("clientOnly")).option("--tag <name>", "assign the project's tag with this name; repeatable", repeatedOption).option("--variation <name=value>", "a variation of an experiment, its value in the type given by --type; repeatable", repeatedOption).addOption(new Option("--min <number>", "the smallest value allowed, included").helpGroup(typeConstraintHelpGroup("min"))).addOption(new Option("--max <number>", "the largest value allowed, included").helpGroup(typeConstraintHelpGroup("max"))).addOption(new Option("--exclusive-min <number>", "values must be greater than this number").conflicts("min").helpGroup(typeConstraintHelpGroup("exclusiveMin"))).addOption(new Option("--exclusive-max <number>", "values must be less than this number").conflicts("max").helpGroup(typeConstraintHelpGroup("exclusiveMax"))).addOption(new Option("--unit <unit>", "the unit of the number the SDKs receive (required)").choices(TIMESPAN_UNITS).helpGroup(typeConstraintHelpGroup("unit"))).addOption(new Option("--allow-fractions", "allow values with a fraction of the unit").helpGroup(typeConstraintHelpGroup("allowFractions"))).addOption(new Option("--allowed-value <value>", "a value the config can take; repeatable").argParser(repeatedOption).helpGroup(typeConstraintHelpGroup("allowedValue"))).addOption(new Option("--enum-value-type <type>", "whether the allowed values are strings or numbers (default: string)").choices(ENUM_VALUE_TYPES).helpGroup(typeConstraintHelpGroup("enumValueType"))).addOption(new Option("--https-only", "allow only HTTPS URLs").helpGroup(typeConstraintHelpGroup("httpsOnly"))).addOption(new Option("--json-schema <file>", "a file holding the JSON Schema every value must match").helpGroup(typeConstraintHelpGroup("jsonSchema"))).action(commandAction(createConfig));
|
|
4435
|
+
configsCommand.command("archive").argument("<key>", "the config's key, or an old key of it").description("archive a config, so SDKs stop receiving it; the dashboard can restore it for 30 days").addHelpText("after", "\nThe config is archived with no confirmation and no check of whether the code still reads its key, so run configdirector scan first. Applications that still read it get their in-code default value.").action(commandAction(archiveConfig));
|
|
4220
4436
|
const segmentsCommand = program.command("segments").description("list a project's segments and show one segment");
|
|
4221
4437
|
segmentsCommand.command("list").description("list the project's segments with how many configs use each").action(commandAction(listSegments));
|
|
4222
4438
|
segmentsCommand.command("get").argument("<key>", "the segment's key").description("show a segment's condition groups and its environment overrides").action(commandAction(getSegment));
|
|
4223
4439
|
const targetingRulesCommand = program.command("targeting-rules").description("replace a config's targeting rules in one environment and show the format they take");
|
|
4224
4440
|
targetingRulesCommand.command("set").argument("<key>", "the config's key, or an old key of it").description("replace a config's targeting rules in one environment with the rules of a JSON file").requiredOption("--environment <slug>", "slug of the environment whose targeting rules to replace (required)").requiredOption("--rules <file>", "a JSON file holding defaultValue and rules as the Admin API takes them, or - to read it from stdin (required)").addHelpText("after", "\nThe file holds {\"defaultValue\": ..., \"rules\": [...]}, like an entry of targets in configs get --json, so you can edit that output and write it back. Every rule of the environment is replaced. A rule, condition or percentage without an id gets a new one. configdirector targeting-rules reference lists the condition kinds, attributes and operators.").action(commandAction(setTargetingRules));
|
|
4225
4441
|
targetingRulesCommand.command("reference").description("show the format targeting rules take: the condition kinds, the attributes a condition can test and the operators").action(commandAction(showTargetingRulesReference));
|
|
4442
|
+
const repositoriesCommand = program.command("repositories").description("list a project's scanned repositories and remove one");
|
|
4443
|
+
repositoriesCommand.command("list").description("list the repositories whose scan was uploaded to the project, with their commit, scan time and reference counts").action(commandAction(listScannedRepositories));
|
|
4444
|
+
repositoriesCommand.command("remove").argument("<name>", "the repository's name, as repositories list shows it, such as ConfigDirector/cli").description("remove a scanned repository and its code references from the project").addHelpText("after", "\nThe repository is removed with no confirmation, and the dashboard stops showing its code references. The next scan --upload from it adds it back.").action(commandAction(removeScannedRepository));
|
|
4226
4445
|
try {
|
|
4227
4446
|
await program.parseAsync(invocation.commandLineArguments, { from: "user" });
|
|
4228
4447
|
return exitCode;
|
package/package.json
CHANGED