@configdirector/cli 0.8.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,14 @@ 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
+
7
15
  ## [0.8.0-beta.1] - 2026-10-18
8
16
 
9
17
  ### Added
package/README.md CHANGED
@@ -54,6 +54,8 @@ 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
+
57
59
  ## Upload the scan to ConfigDirector
58
60
 
59
61
  From the repository root, on the default branch:
@@ -68,6 +70,8 @@ The repository is named after the path of its git remote `origin`, without the h
68
70
 
69
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.
70
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
+
71
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.
72
76
 
73
77
  ## List and remove scanned repositories
@@ -12,7 +12,7 @@ import { promisify } from "node:util";
12
12
  import { randomUUID } from "node:crypto";
13
13
  import { text } from "node:stream/consumers";
14
14
  //#region package.json
15
- var version = "0.8.0-beta.1";
15
+ var version = "0.9.0-beta.1";
16
16
  //#endregion
17
17
  //#region src/CliError.ts
18
18
  var CliError = class extends Error {
@@ -677,7 +677,8 @@ const ConfigEvaluationSchema = v.object({
677
677
  const ProjectSchema = v.object({
678
678
  id: v.string(),
679
679
  name: v.string(),
680
- slug: v.string()
680
+ slug: v.string(),
681
+ storeCodeSnippets: v.optional(v.boolean(), true)
681
682
  });
682
683
  const configFields = {
683
684
  key: v.string(),
@@ -1530,6 +1531,25 @@ const languageOfExtension = {
1530
1531
  ".go": "go"
1531
1532
  };
1532
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
+ };
1533
1553
  const languageSyntax = {
1534
1554
  javascript: {
1535
1555
  lineComment: "//",
@@ -3587,6 +3607,43 @@ const sdkCallMatches = (code, callShapes) => {
3587
3607
  return matches;
3588
3608
  };
3589
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
3590
3647
  //#region src/scan/test-files.ts
3591
3648
  const TEST_DIRECTORY_NAMES = new Set([
3592
3649
  "test",
@@ -3644,12 +3701,13 @@ const matchesIn = (file, code, callShapes, projectKeys, constantNameOfToken) =>
3644
3701
  };
3645
3702
  const matchOutcomes = (matches, constantIndex) => {
3646
3703
  const constantsReadByCalls = new Set(matches.flatMap((match) => match.kind === "named" && constantIndex.keyOf(match.name) !== void 0 ? [match.name] : []));
3647
- return matches.map(({ file, ...match }) => {
3704
+ return matches.map(({ file, snippet, ...match }) => {
3648
3705
  switch (match.kind) {
3649
3706
  case "direct": return {
3650
3707
  file,
3651
3708
  kind: "direct",
3652
- reference: match.reference
3709
+ reference: match.reference,
3710
+ snippet
3653
3711
  };
3654
3712
  case "dynamic": return {
3655
3713
  file,
@@ -3668,7 +3726,8 @@ const matchOutcomes = (matches, constantIndex) => {
3668
3726
  reference: {
3669
3727
  ...match.reference,
3670
3728
  key
3671
- }
3729
+ },
3730
+ snippet
3672
3731
  };
3673
3732
  }
3674
3733
  case "indirect": return match.constantName !== void 0 && constantsReadByCalls.has(match.constantName) ? {
@@ -3677,7 +3736,8 @@ const matchOutcomes = (matches, constantIndex) => {
3677
3736
  } : {
3678
3737
  file,
3679
3738
  kind: "indirect",
3680
- reference: match.reference
3739
+ reference: match.reference,
3740
+ snippet
3681
3741
  };
3682
3742
  }
3683
3743
  });
@@ -3762,7 +3822,7 @@ const sdkFamiliesUsedIn = (highestDirectory) => {
3762
3822
  return usedFamilies;
3763
3823
  };
3764
3824
  };
3765
- const scanCodeReferences = async (scope, projectKeys) => {
3825
+ const scanCodeReferences = async (scope, projectKeys, snippetCut) => {
3766
3826
  const scan = {
3767
3827
  filesScanned: 0,
3768
3828
  references: [],
@@ -3781,9 +3841,11 @@ const scanCodeReferences = async (scope, projectKeys) => {
3781
3841
  const constantAssignments = constantAssignmentsIn(code);
3782
3842
  for (const assignment of constantAssignments) constantIndex.add(assignment);
3783
3843
  const constantNameOfToken = new Map(constantAssignments.map((assignment) => [assignment.tokenIndex, assignment.name]));
3844
+ const snippetAround = snippetCutterFor(file, snippetCut);
3784
3845
  matches.push(...matchesIn(file, code, callShapes, projectKeys, constantNameOfToken).map((match) => ({
3785
3846
  ...match,
3786
- file: file.displayPath
3847
+ file: file.displayPath,
3848
+ snippet: snippetAround(match.reference.line)
3787
3849
  })));
3788
3850
  }
3789
3851
  for (const outcome of matchOutcomes(matches, constantIndex)) switch (outcome.kind) {
@@ -3791,14 +3853,16 @@ const scanCodeReferences = async (scope, projectKeys) => {
3791
3853
  scan.references.push({
3792
3854
  ...outcome.reference,
3793
3855
  kind: "direct",
3794
- file: outcome.file
3856
+ file: outcome.file,
3857
+ snippet: outcome.snippet
3795
3858
  });
3796
3859
  break;
3797
3860
  case "indirect":
3798
3861
  scan.references.push({
3799
3862
  ...outcome.reference,
3800
3863
  kind: "indirect",
3801
- file: outcome.file
3864
+ file: outcome.file,
3865
+ snippet: outcome.snippet
3802
3866
  });
3803
3867
  break;
3804
3868
  case "dynamic":
@@ -3813,7 +3877,7 @@ const scanCodeReferences = async (scope, projectKeys) => {
3813
3877
  };
3814
3878
  //#endregion
3815
3879
  //#region src/project-scan.ts
3816
- const scanProject = async (context, root) => {
3880
+ const connectToScannableProject = async (context) => {
3817
3881
  const { workingDirectory } = context.invocation;
3818
3882
  const settings = await readProjectSettings(workingDirectory);
3819
3883
  const projectSlug = requireLinkedProjectSlug(context, settings);
@@ -3824,18 +3888,17 @@ const scanProject = async (context, root) => {
3824
3888
  const project = await findProjectBySlug(client, projectSlug);
3825
3889
  const configs = (await client.listConfigs(project.id, { form: "every-config" })).map((listedConfig) => listedConfig.config);
3826
3890
  const projectKeys = new Set(configs.flatMap((config) => [config.key, ...config.deprecatedKeys.map((deprecatedKey) => deprecatedKey.key)]));
3827
- const codeReferenceScan = await scanCodeReferences({
3828
- root,
3829
- workingDirectory,
3830
- include: settings?.include ?? "everyFile",
3831
- exclude: settings?.exclude ?? []
3832
- }, projectKeys);
3833
3891
  return {
3834
3892
  client,
3835
3893
  project,
3836
3894
  configs,
3837
3895
  ignoredKeys: new Set(settings?.ignoreKeys ?? []),
3838
- codeReferenceScan
3896
+ codeReferencesIn: (root, snippetCut) => scanCodeReferences({
3897
+ root,
3898
+ workingDirectory,
3899
+ include: settings?.include ?? "everyFile",
3900
+ exclude: settings?.exclude ?? []
3901
+ }, projectKeys, snippetCut)
3839
3902
  };
3840
3903
  };
3841
3904
  //#endregion
@@ -3945,14 +4008,16 @@ const referenceJson = (reference) => {
3945
4008
  ...reference.argument === void 0 ? {} : { argument: reference.argument },
3946
4009
  ...reference.inCodeDefault === void 0 ? {} : { default: reference.inCodeDefault },
3947
4010
  ...reference.getterType === void 0 ? {} : { getterType: reference.getterType },
3948
- test: reference.test
4011
+ test: reference.test,
4012
+ ...reference.snippet === void 0 ? {} : { snippet: reference.snippet }
3949
4013
  };
3950
4014
  case "indirect": return {
3951
4015
  key: reference.key,
3952
4016
  file: reference.file,
3953
4017
  line: reference.line,
3954
4018
  kind: reference.kind,
3955
- test: reference.test
4019
+ test: reference.test,
4020
+ ...reference.snippet === void 0 ? {} : { snippet: reference.snippet }
3956
4021
  };
3957
4022
  }
3958
4023
  };
@@ -4010,9 +4075,24 @@ const scanText = (scan, scannedLocation) => {
4010
4075
  };
4011
4076
  const shortCommitText = (commit) => commit === null ? "" : ` at ${shortCommit(commit)}`;
4012
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.`;
4013
- const uploadScan = async (context, root, repositoryOption) => {
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) => {
4014
4092
  const repository = repositoryIdentityOf(await readGitCheckout(scanRootDirectory(root), context.invocation.environmentVariables), repositoryOption, root.description);
4015
- const { client, project, codeReferenceScan } = await scanProject(context, root);
4093
+ const { client, project, codeReferencesIn } = await connectToScannableProject(context);
4094
+ const snippetsSent = snippetsSentTo(project, requestedSnippetCut);
4095
+ const codeReferenceScan = await codeReferencesIn(root, snippetsSent.snippetCut);
4016
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.`);
4017
4097
  const configKeyReferences = codeReferenceScan.references.filter((reference) => fitsKeyGrammar(reference.key));
4018
4098
  const leftOutCount = codeReferenceScan.references.length - configKeyReferences.length;
@@ -4026,14 +4106,26 @@ const uploadScan = async (context, root, repositoryOption) => {
4026
4106
  });
4027
4107
  return {
4028
4108
  json: responseBody,
4029
- text: `${scanText(codeReferenceScan, root.description)}\n${uploadLine(project.slug, scannedRepository, leftOutCount)}\n`,
4109
+ text: `${scanText(codeReferenceScan, root.description)}\n${uploadLine(project.slug, scannedRepository, leftOutCount)}\n` + snippetsSentText(snippetsSent),
4030
4110
  exitCode: ExitCode.success
4031
4111
  };
4032
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" };
4033
4123
  const scan = async (context, [path], options) => {
4124
+ const snippetCut = snippetCutOf(options);
4034
4125
  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);
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);
4037
4129
  return {
4038
4130
  json: scanJson(project.slug, codeReferenceScan),
4039
4131
  text: scanText(codeReferenceScan, root.description),
@@ -4370,7 +4462,8 @@ const validateText = (projectSlug, scan, scannedLocation, check) => {
4370
4462
  };
4371
4463
  const validate = async (context, [path]) => {
4372
4464
  const root = await scanRootAt(context.invocation.workingDirectory, path);
4373
- const { project, configs, ignoredKeys, codeReferenceScan } = await scanProject(context, root);
4465
+ const { project, configs, ignoredKeys, codeReferencesIn } = await connectToScannableProject(context);
4466
+ const codeReferenceScan = await codeReferencesIn(root, { kind: "no-snippets" });
4374
4467
  const check = checkCodeReferences(codeReferenceScan.references, configs, ignoredKeys);
4375
4468
  return {
4376
4469
  json: {
@@ -4422,7 +4515,7 @@ const run = async (invocation) => {
4422
4515
  });
4423
4516
  program.command("login").description("save an API token for later runs, after checking it can list projects").action(commandAction(login));
4424
4517
  program.command("init").description("write configdirector.json for a project in the current directory").action(commandAction(init));
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));
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));
4426
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));
4427
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));
4428
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@configdirector/cli",
3
- "version": "0.8.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
  },