@configdirector/cli 0.5.0-beta.1 → 0.9.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 CHANGED
@@ -4,6 +4,37 @@ All notable changes to `@configdirector/cli` are documented here. The format fol
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.0-beta.1] - 2026-10-18
8
+
9
+ ### Added
10
+
11
+ - `configdirector scan --json` prints, and `scan --upload` sends, a snippet with each direct and indirect reference: its line and the 2 lines before and after it, so the dashboard can show the code around the reference. A snippet stops at the start and end of the file, leaves out line endings, and cuts a line longer than 250 characters to its first 250, listing it in `truncatedLines`. Dynamic references have no snippet, and the text output is unchanged. `schema/scan-output.schema.json` describes the `snippet` field.
12
+ - `--context-lines <number>` sets how many lines before and after each reference the snippet includes, from 0 to 5; `--context-lines 0` includes the reference line alone. `--no-snippets` sends no snippet. Giving both, or a `--context-lines` that is not a whole number from 0 to 5, exits with 2.
13
+ - When the project's Code snippets setting is off, `scan --upload` sends no snippets and ends with `Code snippets were not sent: this project does not store them.` `scan` without `--upload` still prints them with `--json`.
14
+
15
+ ## [0.8.0-beta.1] - 2026-10-18
16
+
17
+ ### Added
18
+
19
+ - `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`.
20
+ - `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.
21
+
22
+ ## [0.7.0-beta.1] - 2026-10-17
23
+
24
+ ### Added
25
+
26
+ - `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.
27
+ - 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.
28
+ - 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.
29
+ - 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`.
30
+
31
+ ## [0.6.0-beta.1] - 2026-10-17
32
+
33
+ ### Added
34
+
35
+ - `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.
36
+ - `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.
37
+
7
38
  ## [0.5.0-beta.1] - 2026-10-17
8
39
 
9
40
  ### 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, and replaces a config's targeting rules in an environment.
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,51 @@ 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
+ Each direct and indirect reference in the JSON also carries a `snippet`: its line and the 2 lines before and after it, without line endings, with any line longer than 250 characters cut to its first 250. `--context-lines <number>` includes from 0 to 5 lines on each side, and `--no-snippets` leaves every snippet out; giving both exits with 2. Dynamic references have no snippet, and the text output shows none.
58
+
59
+ ## Upload the scan to ConfigDirector
60
+
61
+ From the repository root, on the default branch:
62
+
63
+ ```bash
64
+ configdirector scan --upload
65
+ ```
66
+
67
+ `--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.
68
+
69
+ 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.
70
+
71
+ 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.
72
+
73
+ Each direct and indirect reference is sent with its snippet, so the dashboard can show the code around it; `--no-snippets` sends no code, and `--context-lines` sets how many lines are sent. When the project's Code snippets setting is off, `scan --upload` sends no snippets and ends with `Code snippets were not sent: this project does not store them.`
74
+
75
+ 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.
76
+
77
+ ## List and remove scanned repositories
78
+
79
+ ```bash
80
+ configdirector repositories list
81
+ ```
82
+
83
+ `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:
84
+
85
+ ```txt
86
+ ConfigDirector/cli 4d40f50 2026-10-17 10:00 UTC 12 references 1 dynamic reference
87
+ acme/storefront 9f3e2a1 2026-10-16 09:12 UTC 1 reference 0 dynamic references
88
+
89
+ web-app has 2 of the 50 scanned repositories a project can hold.
90
+ ```
91
+
92
+ 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`.
93
+
94
+ ```bash
95
+ configdirector repositories remove acme/storefront
96
+ ```
97
+
98
+ 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.
99
+
100
+ `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.
101
+
57
102
  ## Check the keys the code reads
58
103
 
59
104
  From the repository root, or as a CI step:
@@ -217,6 +262,22 @@ configdirector targeting-rules set new-checkout --environment production --rules
217
262
 
218
263
  `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
264
 
265
+ ## Archive a config
266
+
267
+ ```bash
268
+ configdirector configs archive new-checkout
269
+ ```
270
+
271
+ `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:
272
+
273
+ ```txt
274
+ Archived new-checkout in web-app. It can be restored from the dashboard for 30 days.
275
+ ```
276
+
277
+ 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.
278
+
279
+ `configs archive` needs `projects:read` and `configs:archive`. The change shows in the audit log with the source CLI.
280
+
220
281
  ## Options
221
282
 
222
283
  | Option | Environment variable | Meaning |
@@ -226,7 +287,7 @@ configdirector targeting-rules set new-checkout --environment production --rules
226
287
  | `--project <slug>` | `CONFIGDIRECTOR_PROJECT` | Project slug. Without either, every command that reads a project uses the `project` in `configdirector.json`. |
227
288
  | `--json` | | Print JSON on stdout. Errors always go to stderr. |
228
289
 
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 tag. A CI job can therefore tell a wrong config key from an outage.
290
+ 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
291
 
231
292
  ## License
232
293
 
@@ -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.5.0-beta.1";
15
+ var version = "0.9.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(),
@@ -656,7 +677,8 @@ const ConfigEvaluationSchema = v.object({
656
677
  const ProjectSchema = v.object({
657
678
  id: v.string(),
658
679
  name: v.string(),
659
- slug: v.string()
680
+ slug: v.string(),
681
+ storeCodeSnippets: v.optional(v.boolean(), true)
660
682
  });
661
683
  const configFields = {
662
684
  key: v.string(),
@@ -713,6 +735,7 @@ const describeStatus = (response) => `HTTP ${response.status}`;
713
735
  const failureFor = (url, response, body) => {
714
736
  switch (response.status) {
715
737
  case 400: {
738
+ if (v.is(RepositoryLimitReachedBodySchema, body)) return new RepositoryLimitReachedError();
716
739
  const validationError = v.safeParse(ApiValidationErrorBodySchema, body);
717
740
  return validationError.success ? new ApiValidationError(validationError.output.errors) : new UnexpectedApiResponseError(url, describeStatus(response));
718
741
  }
@@ -799,6 +822,21 @@ var AdminApiClient = class {
799
822
  body: request
800
823
  }) === void 0 ? "not-found" : "replaced";
801
824
  }
825
+ uploadRepositoryScan(projectId, repositoryName, upload) {
826
+ return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryName)}`, ScannedRepositorySchema, {
827
+ method: "PUT",
828
+ body: upload
829
+ });
830
+ }
831
+ listScannedRepositories(projectId) {
832
+ return this.get(`/v1/projects/${encodeURIComponent(projectId)}/repositories`, v.array(ListedScannedRepositorySchema));
833
+ }
834
+ async removeScannedRepository(projectId, repositoryName) {
835
+ return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryName)}`, v.unknown(), { method: "DELETE" }) === void 0 ? "not-found" : "removed";
836
+ }
837
+ async archiveConfig(projectId, configKey) {
838
+ return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, v.unknown(), { method: "DELETE" }) === void 0 ? "not-found" : "archived";
839
+ }
802
840
  get(path, schema) {
803
841
  return this.requestJson(path, schema, { method: "GET" });
804
842
  }
@@ -829,13 +867,16 @@ var AdminApiClient = class {
829
867
  "X-SourceClient": "cli"
830
868
  };
831
869
  try {
832
- return await fetch(url, request.method === "GET" ? { headers } : {
870
+ return await fetch(url, "body" in request ? {
833
871
  method: request.method,
834
872
  headers: {
835
873
  ...headers,
836
874
  "Content-Type": "application/json"
837
875
  },
838
876
  body: JSON.stringify(request.body)
877
+ } : {
878
+ method: request.method,
879
+ headers
839
880
  });
840
881
  } catch (error) {
841
882
  throw new ApiUnreachableError(this.connection.baseUrl, error);
@@ -934,6 +975,12 @@ const connectToLinkedProject = async (context) => {
934
975
  };
935
976
  };
936
977
  //#endregion
978
+ //#region src/utc-minute.ts
979
+ const utcMinute = (time) => {
980
+ const isoTime = new Date(time).toISOString();
981
+ return `${isoTime.slice(0, 10)} ${isoTime.slice(11, 16)} UTC`;
982
+ };
983
+ //#endregion
937
984
  //#region src/commands/configs.ts
938
985
  const LIFECYCLE_STATES = [
939
986
  "new",
@@ -1047,11 +1094,16 @@ const createConfig = async (context, _commandArguments, options) => {
1047
1094
  tagIds: await tagIdsNamed(client, project, options.tag ?? [])
1048
1095
  }));
1049
1096
  };
1050
- const sdkDescription = (evaluation) => [evaluation.sdkName ?? "unknown SDK", ...evaluation.sdkVersion === null ? [] : [evaluation.sdkVersion]].join(" ");
1051
- const utcMinute = (time) => {
1052
- const isoTime = new Date(time).toISOString();
1053
- return `${isoTime.slice(0, 10)} ${isoTime.slice(11, 16)} UTC`;
1097
+ const archiveConfig = async (context, [configKey]) => {
1098
+ const { client, project } = await connectToLinkedProject(context);
1099
+ if (await client.archiveConfig(project.id, configKey) === "not-found") throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1100
+ return {
1101
+ json: { archivedConfigKey: configKey },
1102
+ text: [`Archived ${configKey} in ${project.slug}. It can be restored from the dashboard for 30 days.`, ""].join("\n"),
1103
+ exitCode: ExitCode.success
1104
+ };
1054
1105
  };
1106
+ const sdkDescription = (evaluation) => [evaluation.sdkName ?? "unknown SDK", ...evaluation.sdkVersion === null ? [] : [evaluation.sdkVersion]].join(" ");
1055
1107
  const activityLines = (configKey, activity) => activity.latestEvaluations.length === 0 ? [`No SDK evaluations of ${configKey} found.`] : alignedColumns(activity.latestEvaluations.map((evaluation) => [
1056
1108
  evaluation.environment.slug,
1057
1109
  evaluation.source,
@@ -1415,6 +1467,49 @@ const listProjects = async (context) => {
1415
1467
  //#region src/plural.ts
1416
1468
  const plural = (count, singular, pluralForm) => count === 1 ? singular : pluralForm;
1417
1469
  //#endregion
1470
+ //#region src/scanned-repository/short-commit.ts
1471
+ const SHORT_COMMIT_LENGTH = 7;
1472
+ const shortCommit = (commit) => commit.slice(0, SHORT_COMMIT_LENGTH);
1473
+ //#endregion
1474
+ //#region src/commands/repositories.ts
1475
+ const MAX_SCANNED_REPOSITORIES_PER_PROJECT = 50;
1476
+ const commitDescription = (commit) => commit === null ? "no commit" : shortCommit(commit);
1477
+ const scannedRepositoryColumnsOf = (repository) => [
1478
+ repository.name,
1479
+ commitDescription(repository.commit),
1480
+ utcMinute(repository.scannedAt),
1481
+ `${repository.referenceCount} ${plural(repository.referenceCount, "reference", "references")}`,
1482
+ `${repository.dynamicReferenceCount} ${plural(repository.dynamicReferenceCount, "dynamic reference", "dynamic references")}`
1483
+ ];
1484
+ 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.`] : [
1485
+ ...alignedColumns(repositories.map(scannedRepositoryColumnsOf)),
1486
+ "",
1487
+ `${projectSlug} has ${repositories.length} of the ${MAX_SCANNED_REPOSITORIES_PER_PROJECT} scanned repositories a project can hold.`
1488
+ ];
1489
+ const listScannedRepositories = async (context) => {
1490
+ const { client, project } = await connectToLinkedProject(context);
1491
+ const { parsedBody: repositories, responseBody } = await client.listScannedRepositories(project.id);
1492
+ return {
1493
+ json: responseBody,
1494
+ text: [...scannedRepositoryLines(project.slug, repositories), ""].join("\n"),
1495
+ exitCode: ExitCode.success
1496
+ };
1497
+ };
1498
+ const scannedRepositoryNamesSentence = (repositories) => repositories.length === 0 ? "The project has no scanned repositories." : `Its scanned repositories are ${repositories.map((repository) => repository.name).join(", ")}.`;
1499
+ const scannedRepositoryNotFoundError = async (client, project, repositoryName) => {
1500
+ const repositories = await ifPermitted(client.listScannedRepositories(project.id));
1501
+ return new CliError([`No scanned repository in ${project.slug} has the name "${repositoryName}".`, ...repositories === void 0 ? [] : [scannedRepositoryNamesSentence(repositories.parsedBody)]].join(" "));
1502
+ };
1503
+ const removeScannedRepository = async (context, [repositoryName]) => {
1504
+ const { client, project } = await connectToLinkedProject(context);
1505
+ if (await client.removeScannedRepository(project.id, repositoryName) === "not-found") throw await scannedRepositoryNotFoundError(client, project, repositoryName);
1506
+ return {
1507
+ json: { removedRepository: repositoryName },
1508
+ text: [`Removed ${repositoryName} and its code references from ${project.slug}.`, ""].join("\n"),
1509
+ exitCode: ExitCode.success
1510
+ };
1511
+ };
1512
+ //#endregion
1418
1513
  //#region src/scan/source-languages.ts
1419
1514
  const languageOfExtension = {
1420
1515
  ".js": "javascript",
@@ -1436,6 +1531,25 @@ const languageOfExtension = {
1436
1531
  ".go": "go"
1437
1532
  };
1438
1533
  const isSourceExtension = (extension) => Object.hasOwn(languageOfExtension, extension);
1534
+ const snippetLanguageOfExtension = {
1535
+ ".js": "javascript",
1536
+ ".jsx": "jsx",
1537
+ ".mjs": "javascript",
1538
+ ".cjs": "javascript",
1539
+ ".ts": "typescript",
1540
+ ".tsx": "tsx",
1541
+ ".mts": "typescript",
1542
+ ".cts": "typescript",
1543
+ ".vue": "vue",
1544
+ ".svelte": "svelte",
1545
+ ".dart": "dart",
1546
+ ".swift": "swift",
1547
+ ".kt": "kotlin",
1548
+ ".java": "java",
1549
+ ".py": "python",
1550
+ ".cs": "csharp",
1551
+ ".go": "go"
1552
+ };
1439
1553
  const languageSyntax = {
1440
1554
  javascript: {
1441
1555
  lineComment: "//",
@@ -1886,6 +2000,7 @@ const scanRootAt = async (workingDirectory, path) => {
1886
2000
  description: path ?? "the current directory"
1887
2001
  };
1888
2002
  };
2003
+ const scanRootDirectory = (root) => root.isDirectory ? root.path : dirname(root.path);
1889
2004
  const gitignoreRulesIn = async (directory) => {
1890
2005
  const contents = await readFileIfPresent(join(directory, ".gitignore"));
1891
2006
  return contents === void 0 ? [] : [{
@@ -3492,6 +3607,43 @@ const sdkCallMatches = (code, callShapes) => {
3492
3607
  return matches;
3493
3608
  };
3494
3609
  //#endregion
3610
+ //#region src/scan/snippets.ts
3611
+ const MAXIMUM_LINE_LENGTH = 250;
3612
+ const linesOf = (contents) => {
3613
+ const lines = contents.split("\n");
3614
+ return lines.at(-1) === "" ? lines.slice(0, -1) : lines;
3615
+ };
3616
+ const withoutCarriageReturn = (line) => line.endsWith("\r") ? line.slice(0, -1) : line;
3617
+ const snippetOfLines = (fileLines, line, contextLines, language) => {
3618
+ const firstLine = Math.max(1, line - contextLines);
3619
+ const lastLine = Math.min(fileLines.length, line + contextLines);
3620
+ const lines = [];
3621
+ const truncatedLines = [];
3622
+ for (let lineNumber = firstLine; lineNumber <= lastLine; lineNumber += 1) {
3623
+ const text = withoutCarriageReturn(fileLines[lineNumber - 1]);
3624
+ if (text.length > MAXIMUM_LINE_LENGTH) truncatedLines.push(lineNumber);
3625
+ lines.push(text.slice(0, MAXIMUM_LINE_LENGTH));
3626
+ }
3627
+ return {
3628
+ firstLine,
3629
+ lines,
3630
+ language,
3631
+ truncatedLines
3632
+ };
3633
+ };
3634
+ const snippetCutterFor = (file, cut) => {
3635
+ switch (cut.kind) {
3636
+ case "no-snippets": return () => void 0;
3637
+ case "around-each-reference": {
3638
+ let fileLines;
3639
+ return (line) => {
3640
+ fileLines ??= linesOf(file.contents);
3641
+ return snippetOfLines(fileLines, line, cut.contextLines, snippetLanguageOfExtension[file.extension]);
3642
+ };
3643
+ }
3644
+ }
3645
+ };
3646
+ //#endregion
3495
3647
  //#region src/scan/test-files.ts
3496
3648
  const TEST_DIRECTORY_NAMES = new Set([
3497
3649
  "test",
@@ -3549,12 +3701,13 @@ const matchesIn = (file, code, callShapes, projectKeys, constantNameOfToken) =>
3549
3701
  };
3550
3702
  const matchOutcomes = (matches, constantIndex) => {
3551
3703
  const constantsReadByCalls = new Set(matches.flatMap((match) => match.kind === "named" && constantIndex.keyOf(match.name) !== void 0 ? [match.name] : []));
3552
- return matches.map(({ file, ...match }) => {
3704
+ return matches.map(({ file, snippet, ...match }) => {
3553
3705
  switch (match.kind) {
3554
3706
  case "direct": return {
3555
3707
  file,
3556
3708
  kind: "direct",
3557
- reference: match.reference
3709
+ reference: match.reference,
3710
+ snippet
3558
3711
  };
3559
3712
  case "dynamic": return {
3560
3713
  file,
@@ -3573,7 +3726,8 @@ const matchOutcomes = (matches, constantIndex) => {
3573
3726
  reference: {
3574
3727
  ...match.reference,
3575
3728
  key
3576
- }
3729
+ },
3730
+ snippet
3577
3731
  };
3578
3732
  }
3579
3733
  case "indirect": return match.constantName !== void 0 && constantsReadByCalls.has(match.constantName) ? {
@@ -3582,7 +3736,8 @@ const matchOutcomes = (matches, constantIndex) => {
3582
3736
  } : {
3583
3737
  file,
3584
3738
  kind: "indirect",
3585
- reference: match.reference
3739
+ reference: match.reference,
3740
+ snippet
3586
3741
  };
3587
3742
  }
3588
3743
  });
@@ -3667,7 +3822,7 @@ const sdkFamiliesUsedIn = (highestDirectory) => {
3667
3822
  return usedFamilies;
3668
3823
  };
3669
3824
  };
3670
- const scanCodeReferences = async (scope, projectKeys) => {
3825
+ const scanCodeReferences = async (scope, projectKeys, snippetCut) => {
3671
3826
  const scan = {
3672
3827
  filesScanned: 0,
3673
3828
  references: [],
@@ -3686,9 +3841,11 @@ const scanCodeReferences = async (scope, projectKeys) => {
3686
3841
  const constantAssignments = constantAssignmentsIn(code);
3687
3842
  for (const assignment of constantAssignments) constantIndex.add(assignment);
3688
3843
  const constantNameOfToken = new Map(constantAssignments.map((assignment) => [assignment.tokenIndex, assignment.name]));
3844
+ const snippetAround = snippetCutterFor(file, snippetCut);
3689
3845
  matches.push(...matchesIn(file, code, callShapes, projectKeys, constantNameOfToken).map((match) => ({
3690
3846
  ...match,
3691
- file: file.displayPath
3847
+ file: file.displayPath,
3848
+ snippet: snippetAround(match.reference.line)
3692
3849
  })));
3693
3850
  }
3694
3851
  for (const outcome of matchOutcomes(matches, constantIndex)) switch (outcome.kind) {
@@ -3696,14 +3853,16 @@ const scanCodeReferences = async (scope, projectKeys) => {
3696
3853
  scan.references.push({
3697
3854
  ...outcome.reference,
3698
3855
  kind: "direct",
3699
- file: outcome.file
3856
+ file: outcome.file,
3857
+ snippet: outcome.snippet
3700
3858
  });
3701
3859
  break;
3702
3860
  case "indirect":
3703
3861
  scan.references.push({
3704
3862
  ...outcome.reference,
3705
3863
  kind: "indirect",
3706
- file: outcome.file
3864
+ file: outcome.file,
3865
+ snippet: outcome.snippet
3707
3866
  });
3708
3867
  break;
3709
3868
  case "dynamic":
@@ -3718,11 +3877,10 @@ const scanCodeReferences = async (scope, projectKeys) => {
3718
3877
  };
3719
3878
  //#endregion
3720
3879
  //#region src/project-scan.ts
3721
- const scanProject = async (context, path) => {
3880
+ const connectToScannableProject = async (context) => {
3722
3881
  const { workingDirectory } = context.invocation;
3723
3882
  const settings = await readProjectSettings(workingDirectory);
3724
3883
  const projectSlug = requireLinkedProjectSlug(context, settings);
3725
- const root = await scanRootAt(workingDirectory, path);
3726
3884
  const client = new AdminApiClient({
3727
3885
  baseUrl: context.baseUrl,
3728
3886
  token: await configuredApiToken(context)
@@ -3730,18 +3888,110 @@ const scanProject = async (context, path) => {
3730
3888
  const project = await findProjectBySlug(client, projectSlug);
3731
3889
  const configs = (await client.listConfigs(project.id, { form: "every-config" })).map((listedConfig) => listedConfig.config);
3732
3890
  const projectKeys = new Set(configs.flatMap((config) => [config.key, ...config.deprecatedKeys.map((deprecatedKey) => deprecatedKey.key)]));
3733
- const codeReferenceScan = await scanCodeReferences({
3734
- root,
3735
- workingDirectory,
3736
- include: settings?.include ?? "everyFile",
3737
- exclude: settings?.exclude ?? []
3738
- }, projectKeys);
3739
3891
  return {
3892
+ client,
3740
3893
  project,
3741
3894
  configs,
3742
3895
  ignoredKeys: new Set(settings?.ignoreKeys ?? []),
3743
- scannedLocation: root.description,
3744
- codeReferenceScan
3896
+ codeReferencesIn: (root, snippetCut) => scanCodeReferences({
3897
+ root,
3898
+ workingDirectory,
3899
+ include: settings?.include ?? "everyFile",
3900
+ exclude: settings?.exclude ?? []
3901
+ }, projectKeys, snippetCut)
3902
+ };
3903
+ };
3904
+ //#endregion
3905
+ //#region src/scanned-repository/git-checkout.ts
3906
+ const execFileAsync = promisify(execFile);
3907
+ const firstLineOf = (text) => text.trim().split("\n")[0] ?? "";
3908
+ const gitOutput = async (directory, environmentVariables, gitArguments) => {
3909
+ try {
3910
+ const { stdout } = await execFileAsync("git", gitArguments, {
3911
+ cwd: directory,
3912
+ env: { ...environmentVariables }
3913
+ });
3914
+ return {
3915
+ kind: "printed",
3916
+ text: stdout.trim()
3917
+ };
3918
+ } catch (error) {
3919
+ const failure = error;
3920
+ return failure.code === "ENOENT" ? { kind: "git-missing" } : {
3921
+ kind: "failed",
3922
+ message: firstLineOf(failure.stderr ?? "")
3923
+ };
3924
+ }
3925
+ };
3926
+ const readGitCheckout = async (directory, environmentVariables) => {
3927
+ const workTree = await gitOutput(directory, environmentVariables, ["rev-parse", "--is-inside-work-tree"]);
3928
+ switch (workTree.kind) {
3929
+ case "git-missing": return { kind: "git-missing" };
3930
+ case "failed": return {
3931
+ kind: "no-repository",
3932
+ gitMessage: workTree.message
3933
+ };
3934
+ case "printed": break;
3935
+ }
3936
+ const remoteUrl = await gitOutput(directory, environmentVariables, [
3937
+ "remote",
3938
+ "get-url",
3939
+ "origin"
3940
+ ]);
3941
+ if (remoteUrl.kind !== "printed") return { kind: "no-origin-remote" };
3942
+ const commit = await gitOutput(directory, environmentVariables, ["rev-parse", "HEAD"]);
3943
+ return {
3944
+ kind: "origin-remote",
3945
+ remoteUrl: remoteUrl.text,
3946
+ commit: commit.kind === "printed" ? commit.text : ""
3947
+ };
3948
+ };
3949
+ //#endregion
3950
+ //#region src/scanned-repository/repository-identity.ts
3951
+ const URL_SCHEME_SEPARATOR = "://";
3952
+ const GIT_SUFFIX = ".git";
3953
+ const HTTP_PROTOCOLS = ["http:", "https:"];
3954
+ const carriesHttpCredentials = (url) => HTTP_PROTOCOLS.includes(url.protocol) && (url.username !== "" || url.password !== "");
3955
+ const withoutCredentials = (url) => {
3956
+ const urlWithoutCredentials = new URL(url);
3957
+ urlWithoutCredentials.username = "";
3958
+ urlWithoutCredentials.password = "";
3959
+ return urlWithoutCredentials.href;
3960
+ };
3961
+ const originRemoteOf = (remoteUrl) => {
3962
+ if (!remoteUrl.includes(URL_SCHEME_SEPARATOR)) return {
3963
+ path: remoteUrl.slice(remoteUrl.indexOf(":") + 1),
3964
+ uploadedUrl: remoteUrl
3965
+ };
3966
+ const url = new URL(remoteUrl);
3967
+ return {
3968
+ path: url.pathname.slice(1),
3969
+ uploadedUrl: carriesHttpCredentials(url) ? withoutCredentials(url) : remoteUrl
3970
+ };
3971
+ };
3972
+ const repositoryNameOf = (remote) => remote.path.endsWith(GIT_SUFFIX) ? remote.path.slice(0, -4) : remote.path;
3973
+ const unnamedCheckoutReason = (checkout, scannedLocation) => {
3974
+ switch (checkout.kind) {
3975
+ case "git-missing": return "git is not installed or not on the PATH";
3976
+ case "no-repository": return `git cannot read a repository in ${scannedLocation} (${checkout.gitMessage})`;
3977
+ case "no-origin-remote": return `the git repository in ${scannedLocation} has no remote named origin`;
3978
+ }
3979
+ };
3980
+ 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>.`);
3981
+ const repositoryIdentityOf = (checkout, repositoryOption, scannedLocation) => {
3982
+ if (checkout.kind === "origin-remote") {
3983
+ const remote = originRemoteOf(checkout.remoteUrl);
3984
+ return {
3985
+ name: repositoryOption ?? repositoryNameOf(remote),
3986
+ remoteUrl: remote.uploadedUrl,
3987
+ commit: checkout.commit
3988
+ };
3989
+ }
3990
+ if (repositoryOption === void 0) throw unnamedRepositoryError(checkout, scannedLocation);
3991
+ return {
3992
+ name: repositoryOption,
3993
+ remoteUrl: "",
3994
+ commit: ""
3745
3995
  };
3746
3996
  };
3747
3997
  //#endregion
@@ -3758,19 +4008,20 @@ const referenceJson = (reference) => {
3758
4008
  ...reference.argument === void 0 ? {} : { argument: reference.argument },
3759
4009
  ...reference.inCodeDefault === void 0 ? {} : { default: reference.inCodeDefault },
3760
4010
  ...reference.getterType === void 0 ? {} : { getterType: reference.getterType },
3761
- test: reference.test
4011
+ test: reference.test,
4012
+ ...reference.snippet === void 0 ? {} : { snippet: reference.snippet }
3762
4013
  };
3763
4014
  case "indirect": return {
3764
4015
  key: reference.key,
3765
4016
  file: reference.file,
3766
4017
  line: reference.line,
3767
4018
  kind: reference.kind,
3768
- test: reference.test
4019
+ test: reference.test,
4020
+ ...reference.snippet === void 0 ? {} : { snippet: reference.snippet }
3769
4021
  };
3770
4022
  }
3771
4023
  };
3772
- const scanJson = (projectSlug, scan) => ({
3773
- project: projectSlug,
4024
+ const codeReferenceScanJson = (scan) => ({
3774
4025
  filesScanned: scan.filesScanned,
3775
4026
  references: scan.references.map(referenceJson),
3776
4027
  dynamicReferences: scan.dynamicReferences.map((reference) => ({
@@ -3781,6 +4032,10 @@ const scanJson = (projectSlug, scan) => ({
3781
4032
  argument: reference.argument
3782
4033
  }))
3783
4034
  });
4035
+ const scanJson = (projectSlug, scan) => ({
4036
+ project: projectSlug,
4037
+ ...codeReferenceScanJson(scan)
4038
+ });
3784
4039
  const DYNAMIC_REFERENCES_HEADING = "Dynamic references (calls whose key is not a string literal)";
3785
4040
  const withCollapsedWhitespace = (text) => {
3786
4041
  let collapsed = "";
@@ -3818,11 +4073,62 @@ const scanText = (scan, scannedLocation) => {
3818
4073
  counts
3819
4074
  ].join("\n\n")}\n`;
3820
4075
  };
3821
- const scan = async (context, [path]) => {
3822
- const { project, scannedLocation, codeReferenceScan } = await scanProject(context, path);
4076
+ const shortCommitText = (commit) => commit === null ? "" : ` at ${shortCommit(commit)}`;
4077
+ 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.`;
4078
+ const snippetsSentTo = (project, requestedSnippetCut) => project.storeCodeSnippets ? {
4079
+ kind: "as-requested",
4080
+ snippetCut: requestedSnippetCut
4081
+ } : {
4082
+ kind: "project-stores-none",
4083
+ snippetCut: { kind: "no-snippets" }
4084
+ };
4085
+ const snippetsSentText = (snippetsSent) => {
4086
+ switch (snippetsSent.kind) {
4087
+ case "as-requested": return "";
4088
+ case "project-stores-none": return "Code snippets were not sent: this project does not store them.\n";
4089
+ }
4090
+ };
4091
+ const uploadScan = async (context, root, repositoryOption, requestedSnippetCut) => {
4092
+ const repository = repositoryIdentityOf(await readGitCheckout(scanRootDirectory(root), context.invocation.environmentVariables), repositoryOption, root.description);
4093
+ const { client, project, codeReferencesIn } = await connectToScannableProject(context);
4094
+ const snippetsSent = snippetsSentTo(project, requestedSnippetCut);
4095
+ const codeReferenceScan = await codeReferencesIn(root, snippetsSent.snippetCut);
4096
+ 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.`);
4097
+ const configKeyReferences = codeReferenceScan.references.filter((reference) => fitsKeyGrammar(reference.key));
4098
+ const leftOutCount = codeReferenceScan.references.length - configKeyReferences.length;
4099
+ const { parsedBody: scannedRepository, responseBody } = await client.uploadRepositoryScan(project.id, repository.name, {
4100
+ remoteUrl: repository.remoteUrl,
4101
+ commit: repository.commit,
4102
+ ...codeReferenceScanJson({
4103
+ ...codeReferenceScan,
4104
+ references: configKeyReferences
4105
+ })
4106
+ });
4107
+ return {
4108
+ json: responseBody,
4109
+ text: `${scanText(codeReferenceScan, root.description)}\n${uploadLine(project.slug, scannedRepository, leftOutCount)}\n` + snippetsSentText(snippetsSent),
4110
+ exitCode: ExitCode.success
4111
+ };
4112
+ };
4113
+ const contextLinesOf = (text) => {
4114
+ if (text === void 0) return 2;
4115
+ const contextLines = /^\d+$/.test(text) ? Number(text) : void 0;
4116
+ if (contextLines === void 0 || contextLines > 5) throw new CliError(`--context-lines takes a whole number from 0 to 5, not ${text}.`);
4117
+ return contextLines;
4118
+ };
4119
+ const snippetCutOf = (options) => options.snippets ? {
4120
+ kind: "around-each-reference",
4121
+ contextLines: contextLinesOf(options.contextLines)
4122
+ } : { kind: "no-snippets" };
4123
+ const scan = async (context, [path], options) => {
4124
+ const snippetCut = snippetCutOf(options);
4125
+ const root = await scanRootAt(context.invocation.workingDirectory, path);
4126
+ if (options.upload) return uploadScan(context, root, options.repository, snippetCut);
4127
+ const { project, codeReferencesIn } = await connectToScannableProject(context);
4128
+ const codeReferenceScan = await codeReferencesIn(root, snippetCut);
3823
4129
  return {
3824
4130
  json: scanJson(project.slug, codeReferenceScan),
3825
- text: scanText(codeReferenceScan, scannedLocation),
4131
+ text: scanText(codeReferenceScan, root.description),
3826
4132
  exitCode: ExitCode.success
3827
4133
  };
3828
4134
  };
@@ -4155,14 +4461,16 @@ const validateText = (projectSlug, scan, scannedLocation, check) => {
4155
4461
  }), countsText(scan, check)].join("\n\n")}\n`;
4156
4462
  };
4157
4463
  const validate = async (context, [path]) => {
4158
- const { project, configs, ignoredKeys, scannedLocation, codeReferenceScan } = await scanProject(context, path);
4464
+ const root = await scanRootAt(context.invocation.workingDirectory, path);
4465
+ const { project, configs, ignoredKeys, codeReferencesIn } = await connectToScannableProject(context);
4466
+ const codeReferenceScan = await codeReferencesIn(root, { kind: "no-snippets" });
4159
4467
  const check = checkCodeReferences(codeReferenceScan.references, configs, ignoredKeys);
4160
4468
  return {
4161
4469
  json: {
4162
4470
  ...scanJson(project.slug, codeReferenceScan),
4163
4471
  findings: check.findings.map(findingJson)
4164
4472
  },
4165
- text: validateText(project.slug, codeReferenceScan, scannedLocation, check),
4473
+ text: validateText(project.slug, codeReferenceScan, root.description, check),
4166
4474
  exitCode: check.findings.length === 0 ? ExitCode.success : ExitCode.problemsFound
4167
4475
  };
4168
4476
  };
@@ -4207,22 +4515,26 @@ const run = async (invocation) => {
4207
4515
  });
4208
4516
  program.command("login").description("save an API token for later runs, after checking it can list projects").action(commandAction(login));
4209
4517
  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));
4518
+ 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)").addOption(new Option("--context-lines <number>", `how many lines of code to include before and after each reference, from 0 to 5 (default: 2)`).conflicts("snippets")).option("--no-snippets", "leave out the lines of code around each reference").action(commandAction(scan));
4211
4519
  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
4520
  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
4521
  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
4522
  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 create a project's configs");
4523
+ const configsCommand = program.command("configs").description("list, show, create and archive a project's configs");
4216
4524
  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
4525
  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
4526
  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
4527
  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));
4528
+ 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
4529
  const segmentsCommand = program.command("segments").description("list a project's segments and show one segment");
4221
4530
  segmentsCommand.command("list").description("list the project's segments with how many configs use each").action(commandAction(listSegments));
4222
4531
  segmentsCommand.command("get").argument("<key>", "the segment's key").description("show a segment's condition groups and its environment overrides").action(commandAction(getSegment));
4223
4532
  const targetingRulesCommand = program.command("targeting-rules").description("replace a config's targeting rules in one environment and show the format they take");
4224
4533
  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
4534
  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));
4535
+ const repositoriesCommand = program.command("repositories").description("list a project's scanned repositories and remove one");
4536
+ 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));
4537
+ 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
4538
  try {
4227
4539
  await program.parseAsync(invocation.commandLineArguments, { from: "user" });
4228
4540
  return exitCode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@configdirector/cli",
3
- "version": "0.5.0-beta.1",
3
+ "version": "0.9.0-beta.1",
4
4
  "description": "Command line tool for ConfigDirector, the managed configuration and feature flag service.",
5
5
  "license": "MIT",
6
6
  "author": "ConfigDirector",
@@ -149,6 +149,9 @@
149
149
  },
150
150
  "test": {
151
151
  "$ref": "#/$defs/test"
152
+ },
153
+ "snippet": {
154
+ "$ref": "#/$defs/snippet"
152
155
  }
153
156
  }
154
157
  },
@@ -174,6 +177,58 @@
174
177
  "test": {
175
178
  "description": "Whether the file is in a test directory or has a test name.",
176
179
  "type": "boolean"
180
+ },
181
+ "snippet": {
182
+ "$ref": "#/$defs/snippet"
183
+ }
184
+ }
185
+ },
186
+ "snippet": {
187
+ "description": "The reference line and the lines around it, as many before and after it as --context-lines asks for and the file holds. Absent with --no-snippets, and in `configdirector validate --json` output.",
188
+ "type": "object",
189
+ "required": ["firstLine", "lines", "language", "truncatedLines"],
190
+ "additionalProperties": false,
191
+ "properties": {
192
+ "firstLine": {
193
+ "description": "The number of the snippet's first line in the file, starting at 1.",
194
+ "type": "integer",
195
+ "minimum": 1
196
+ },
197
+ "lines": {
198
+ "description": "The lines, in order, without their line endings. A line longer than 250 characters is cut to its first 250.",
199
+ "type": "array",
200
+ "minItems": 1,
201
+ "maxItems": 11,
202
+ "items": {
203
+ "type": "string",
204
+ "maxLength": 250
205
+ }
206
+ },
207
+ "language": {
208
+ "description": "The language to highlight the lines as, from the file's extension.",
209
+ "enum": [
210
+ "typescript",
211
+ "tsx",
212
+ "javascript",
213
+ "jsx",
214
+ "vue",
215
+ "svelte",
216
+ "dart",
217
+ "swift",
218
+ "kotlin",
219
+ "java",
220
+ "python",
221
+ "csharp",
222
+ "go"
223
+ ]
224
+ },
225
+ "truncatedLines": {
226
+ "description": "The numbers of the lines that were cut to 250 characters, empty when none was.",
227
+ "type": "array",
228
+ "items": {
229
+ "type": "integer",
230
+ "minimum": 1
231
+ }
177
232
  }
178
233
  }
179
234
  },