@configdirector/cli 0.1.0-beta.2 → 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.
@@ -1,14 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { homedir } from "node:os";
3
- import { Command, CommanderError } from "commander";
4
- import { access, mkdir, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import { Command, CommanderError, Option } from "commander";
5
4
  import * as v from "valibot";
5
+ import { access, mkdir, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
6
6
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
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";
12
+ import { randomUUID } from "node:crypto";
13
+ import { text } from "node:stream/consumers";
10
14
  //#region package.json
11
- var version = "0.1.0-beta.2";
15
+ var version = "0.8.0-beta.1";
12
16
  //#endregion
13
17
  //#region src/CliError.ts
14
18
  var CliError = class extends Error {
@@ -38,15 +42,45 @@ const ApiValidationErrorBodySchema = v.object({ errors: v.array(v.object({
38
42
  message: v.string()
39
43
  })) });
40
44
  const ServiceSuspendedBodySchema = v.object({ code: v.literal("SERVICE_SUSPENDED") });
45
+ const RepositoryLimitReachedBodySchema = v.object({ code: v.literal("REPOSITORY_LIMIT_REACHED") });
46
+ const EvaluationNotFoundBodySchema = v.object({
47
+ error: v.string(),
48
+ code: v.picklist([
49
+ "CONFIG_NOT_FOUND",
50
+ "ENVIRONMENT_NOT_FOUND",
51
+ "CONTEXT_NOT_FOUND"
52
+ ])
53
+ });
41
54
  const describeFieldError = (fieldError) => fieldError.name === void 0 ? fieldError.message : `${fieldError.name}: ${fieldError.message}`;
42
55
  var ApiAuthorizationError = class extends CliError {
43
56
  constructor(code, apiMessage) {
44
57
  super(`The Admin API refused the request: ${apiMessage} (${code})`);
58
+ this.code = code;
59
+ }
60
+ };
61
+ const ifPermitted = async (request) => {
62
+ try {
63
+ return await request;
64
+ } catch (error) {
65
+ if (error instanceof ApiAuthorizationError && error.code === "INSUFFICIENT_PERMISSIONS") return;
66
+ throw error;
45
67
  }
46
68
  };
47
69
  var ApiValidationError = class extends CliError {
48
- constructor(errors) {
49
- super(`The Admin API rejected the request:\n${errors.map((error) => ` ${describeFieldError(error)}`).join("\n")}`);
70
+ constructor(fieldErrors) {
71
+ super(`The Admin API rejected the request:\n${fieldErrors.map((error) => ` ${describeFieldError(error)}`).join("\n")}`);
72
+ this.fieldErrors = fieldErrors;
73
+ }
74
+ };
75
+ var EvaluationNotFoundError = class extends CliError {
76
+ constructor(code, apiMessage) {
77
+ super(`The Admin API could not evaluate the config: ${apiMessage} (${code})`);
78
+ this.code = code;
79
+ }
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.");
50
84
  }
51
85
  };
52
86
  var ServiceSuspendedError = class extends CliError {
@@ -66,6 +100,579 @@ var UnexpectedApiResponseError = class extends CliError {
66
100
  }
67
101
  };
68
102
  //#endregion
103
+ //#region src/aligned-columns.ts
104
+ const COLUMN_GAP = " ";
105
+ const widestCellOf = (rows, column) => Math.max(0, ...rows.map((row) => row[column]?.length ?? 0));
106
+ const alignedColumns = (rows) => rows.map((row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widestCellOf(rows, column))).join(COLUMN_GAP));
107
+ //#endregion
108
+ //#region src/admin-api/targeting-rules.ts
109
+ const RuleValueSchema = v.union([
110
+ v.string(),
111
+ v.number(),
112
+ v.boolean()
113
+ ]);
114
+ const attributeConditionEntries = {
115
+ attribute: v.string(),
116
+ trait: v.nullish(v.string()),
117
+ operator: v.string(),
118
+ targetValues: v.array(v.unknown())
119
+ };
120
+ const AttributeConditionSchema = v.object(attributeConditionEntries);
121
+ const ConditionGroupSchema = v.array(AttributeConditionSchema);
122
+ const SegmentConditionSchema = v.object({
123
+ id: v.string(),
124
+ kind: v.literal("segment"),
125
+ operator: v.string(),
126
+ segmentId: v.string()
127
+ });
128
+ const ConditionSchema = v.variant("kind", [v.object({
129
+ id: v.string(),
130
+ kind: v.literal("attribute"),
131
+ ...attributeConditionEntries
132
+ }), SegmentConditionSchema]);
133
+ const PercentageSchema = v.object({
134
+ percentage: v.number(),
135
+ value: RuleValueSchema
136
+ });
137
+ const RuleSchema = v.variant("type", [v.variant("target", [v.object({
138
+ id: v.string(),
139
+ type: v.literal("conditional"),
140
+ conditions: v.array(ConditionSchema),
141
+ target: v.literal("value"),
142
+ value: RuleValueSchema
143
+ }), v.object({
144
+ id: v.string(),
145
+ type: v.literal("conditional"),
146
+ conditions: v.array(ConditionSchema),
147
+ target: v.literal("percentage"),
148
+ percentages: v.array(PercentageSchema)
149
+ })]), v.object({
150
+ id: v.string(),
151
+ type: v.literal("percentage"),
152
+ percentages: v.array(PercentageSchema)
153
+ })]);
154
+ //#endregion
155
+ //#region src/admin-api/config-with-targeting-rules.ts
156
+ const TargetingRulesInEnvironmentSchema = v.object({
157
+ environment: v.object({
158
+ slug: v.string(),
159
+ live: v.boolean()
160
+ }),
161
+ rules: v.array(RuleSchema),
162
+ defaultValue: v.string()
163
+ });
164
+ const NumericBoundSchema = v.object({
165
+ relation: v.string(),
166
+ value: v.number()
167
+ });
168
+ const NumericBoundsSchema = v.object({
169
+ min: v.optional(NumericBoundSchema),
170
+ max: v.optional(NumericBoundSchema)
171
+ });
172
+ const EnumValuesSchema = v.object({
173
+ valueType: v.string(),
174
+ values: v.array(v.string())
175
+ });
176
+ const TimespanRangeSchema = v.object({
177
+ unit: v.string(),
178
+ allowFractions: v.nullish(v.boolean()),
179
+ min: v.number(),
180
+ max: v.number()
181
+ });
182
+ const ConstrainedTypeSchema = v.variant("type", [
183
+ v.object({
184
+ type: v.literal("integer"),
185
+ typeOptions: v.nullish(NumericBoundsSchema)
186
+ }),
187
+ v.object({
188
+ type: v.literal("float"),
189
+ typeOptions: v.nullish(NumericBoundsSchema)
190
+ }),
191
+ v.object({
192
+ type: v.literal("enum"),
193
+ typeOptions: EnumValuesSchema
194
+ }),
195
+ v.object({
196
+ type: v.literal("timespan"),
197
+ typeOptions: TimespanRangeSchema
198
+ }),
199
+ v.object({
200
+ type: v.literal("url"),
201
+ typeOptions: v.nullish(v.object({ secureOnly: v.nullish(v.boolean()) }))
202
+ }),
203
+ v.object({
204
+ type: v.literal("json"),
205
+ typeOptions: v.nullish(v.object({ useSchema: v.optional(v.boolean()) }))
206
+ })
207
+ ]);
208
+ const CONSTRAINED_TYPE_NAMES = new Set(ConstrainedTypeSchema.options.map((option) => option.entries.type.literal));
209
+ const ConfigTypeSchema = v.union([ConstrainedTypeSchema, v.object({ type: v.pipe(v.string(), v.notValues([...CONSTRAINED_TYPE_NAMES])) })]);
210
+ const hasTypeConstraints = (configType) => CONSTRAINED_TYPE_NAMES.has(configType.type);
211
+ const ConfigWithTargetingRulesSchema = v.intersect([v.object({
212
+ key: v.string(),
213
+ description: v.nullish(v.string()),
214
+ role: v.string(),
215
+ lifetime: v.string(),
216
+ state: v.string(),
217
+ client: v.boolean(),
218
+ server: v.boolean(),
219
+ tags: v.array(v.object({ name: v.string() })),
220
+ targets: v.array(TargetingRulesInEnvironmentSchema)
221
+ }), ConfigTypeSchema]);
222
+ //#endregion
223
+ //#region src/old-key-lines.ts
224
+ const oldKeyLines = (requestedKey, currentKey) => requestedKey === currentKey ? [] : [`${requestedKey} is an old key of ${currentKey}.`];
225
+ //#endregion
226
+ //#region src/rule-sentences.ts
227
+ const TRAIT_POINTER_PREFIX = "/";
228
+ const ATTRIBUTE_LABELS = {
229
+ identifier: "context identifier",
230
+ kind: "context kind",
231
+ name: "context name",
232
+ appName: "app name",
233
+ appVersion: "app version"
234
+ };
235
+ const formatPercentage = (value) => Number(value.toFixed(3)).toString();
236
+ const subjectOf = (condition) => {
237
+ if (condition.attribute === "traits" && condition.trait != null) return condition.trait.startsWith(TRAIT_POINTER_PREFIX) ? condition.trait.slice(1) : condition.trait;
238
+ return ATTRIBUTE_LABELS[condition.attribute] ?? condition.attribute;
239
+ };
240
+ const segmentNameOf = (segmentId, segments) => segments[segmentId]?.name ?? segmentId;
241
+ const describeAttributeCondition = (condition) => `${subjectOf(condition)} ${condition.operator} ${condition.targetValues.map(String).join(", ")}`;
242
+ const describeConditionGroup = (group) => group.map(describeAttributeCondition).join(" and ");
243
+ const describeCondition = (condition, segments) => {
244
+ switch (condition.kind) {
245
+ case "attribute": return describeAttributeCondition(condition);
246
+ case "segment": return `${condition.operator} segment ${segmentNameOf(condition.segmentId, segments)}`;
247
+ }
248
+ };
249
+ const describePercentages = (percentages) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${String(percentage.value)}`).join(", ");
250
+ const describeRule = (rule, segments) => {
251
+ if (rule.type === "percentage") return describePercentages(rule.percentages);
252
+ return `${rule.conditions.map((condition) => describeCondition(condition, segments)).join(" and ")}: ${rule.target === "value" ? String(rule.value) : describePercentages(rule.percentages)}`;
253
+ };
254
+ //#endregion
255
+ //#region src/configs/config-text.ts
256
+ const boundsDescriptions = (bounds) => {
257
+ const limits = [bounds?.min, bounds?.max].flatMap((bound) => bound === void 0 ? [] : [`${bound.relation} ${bound.value}`]);
258
+ return limits.length === 0 ? [] : [limits.join(" and ")];
259
+ };
260
+ const enumValuesDescription = ({ valueType, values }) => valueType === "number" ? `one of the numbers ${values.join(", ")}` : `one of ${values.join(", ")}`;
261
+ const timespanRangeDescriptions = (range) => [`${range.min} to ${range.max} ${range.unit}`, ...range.allowFractions ? ["fractions allowed"] : []];
262
+ const typeConstraintDescriptions = (config) => {
263
+ if (!hasTypeConstraints(config)) return [];
264
+ switch (config.type) {
265
+ case "integer":
266
+ case "float": return boundsDescriptions(config.typeOptions);
267
+ case "enum": return [enumValuesDescription(config.typeOptions)];
268
+ case "timespan": return timespanRangeDescriptions(config.typeOptions);
269
+ case "url": return config.typeOptions?.secureOnly ? ["HTTPS only"] : [];
270
+ case "json": return config.typeOptions?.useSchema ? ["checked against a JSON schema"] : [];
271
+ }
272
+ };
273
+ const availabilityDescription = (config) => {
274
+ if (config.client && config.server) return "client and server SDKs";
275
+ if (config.client) return "client-only";
276
+ if (config.server) return "server-only";
277
+ return "served to no SDK";
278
+ };
279
+ const settingLines = (config) => [
280
+ ...config.description ? [`Description: ${config.description}`] : [],
281
+ `Type: ${[config.type, ...typeConstraintDescriptions(config)].join(", ")}`,
282
+ `Role: ${config.role}`,
283
+ `Lifetime: ${config.lifetime}`,
284
+ `Availability: ${availabilityDescription(config)}`,
285
+ `Lifecycle state: ${config.state}`,
286
+ ...config.tags.length === 0 ? [] : [`Tags: ${config.tags.map((tag) => tag.name).join(", ")}`]
287
+ ];
288
+ const environmentHeading = (targetingRules) => targetingRules.environment.live ? `${targetingRules.environment.slug} (live)` : targetingRules.environment.slug;
289
+ const targetingRuleLines = (targetingRules, segments) => [
290
+ environmentHeading(targetingRules),
291
+ ...targetingRules.rules.map((rule, index) => ` ${index + 1}. ${describeRule(rule, segments)}`),
292
+ ` Default targeting rule: ${targetingRules.defaultValue}`
293
+ ];
294
+ const configText = (config, requestedKey, segments) => [
295
+ ...oldKeyLines(requestedKey, config.key),
296
+ config.key,
297
+ ...settingLines(config).map((line) => ` ${line}`),
298
+ ...config.targets.flatMap((targetingRules) => ["", ...targetingRuleLines(targetingRules, segments)]),
299
+ ""
300
+ ].join("\n");
301
+ //#endregion
302
+ //#region src/file-contents.ts
303
+ const readFileIfPresent = async (file) => {
304
+ try {
305
+ return await readFile(file, "utf8");
306
+ } catch (error) {
307
+ if (error.code === "ENOENT") return;
308
+ throw error;
309
+ }
310
+ };
311
+ const readOptionFile = async (argumentLabel, path, workingDirectory) => {
312
+ const file = resolve(workingDirectory, path);
313
+ const contents = await readFileIfPresent(file);
314
+ if (contents === void 0) throw new CliError(`${argumentLabel} names ${file}, which does not exist.`);
315
+ return {
316
+ file,
317
+ contents
318
+ };
319
+ };
320
+ const FILE_ARGUMENT_MARKER = "@";
321
+ const atFileArgumentText = async (argumentLabel, argument, workingDirectory) => {
322
+ if (!argument.startsWith(FILE_ARGUMENT_MARKER)) return argument;
323
+ return (await readOptionFile(argumentLabel, argument.slice(1), workingDirectory)).contents;
324
+ };
325
+ const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
326
+ const parseJson = (contents) => {
327
+ try {
328
+ return JSON.parse(contents);
329
+ } catch {
330
+ return;
331
+ }
332
+ };
333
+ const pathExists = (path) => stat(path).then(() => true, () => false);
334
+ const fileExists = (path) => stat(path).then((stats) => stats.isFile(), () => false);
335
+ //#endregion
336
+ //#region src/configs/new-config.ts
337
+ const CONFIG_TYPES = [
338
+ "boolean",
339
+ "string",
340
+ "integer",
341
+ "float",
342
+ "timespan",
343
+ "enum",
344
+ "url",
345
+ "json"
346
+ ];
347
+ const CONFIG_ROLES = [
348
+ "config",
349
+ "flag",
350
+ "kill-switch",
351
+ "experiment"
352
+ ];
353
+ const CONFIG_LIFETIMES = ["permanent", "temporary"];
354
+ const TIMESPAN_UNITS = [
355
+ "milliseconds",
356
+ "seconds",
357
+ "minutes"
358
+ ];
359
+ const ENUM_VALUE_TYPES = ["string", "number"];
360
+ const DEFAULT_ENUM_VALUE_TYPE = "string";
361
+ const TYPE_CONSTRAINT_OPTION_NAMES = {
362
+ min: "--min",
363
+ max: "--max",
364
+ exclusiveMin: "--exclusive-min",
365
+ exclusiveMax: "--exclusive-max",
366
+ unit: "--unit",
367
+ allowFractions: "--allow-fractions",
368
+ allowedValue: "--allowed-value",
369
+ enumValueType: "--enum-value-type",
370
+ httpsOnly: "--https-only",
371
+ jsonSchema: "--json-schema"
372
+ };
373
+ const availabilityOf = (options) => {
374
+ if (options.clientOnly) return {
375
+ client: true,
376
+ server: false
377
+ };
378
+ if (options.serverOnly) return {
379
+ client: false,
380
+ server: true
381
+ };
382
+ return {
383
+ client: true,
384
+ server: true
385
+ };
386
+ };
387
+ const BOOLEAN_VALUES = new Map([["true", true], ["false", false]]);
388
+ const numberOf = (text) => {
389
+ const value = parseJson(text);
390
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
391
+ };
392
+ const constraintNumberOf = (options, option) => {
393
+ const text = options[option];
394
+ if (text === void 0) return;
395
+ const value = numberOf(text);
396
+ if (value === void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES[option]} takes a number, not ${text}.`);
397
+ return value;
398
+ };
399
+ const boundOf = (options, option, relation) => {
400
+ const value = constraintNumberOf(options, option);
401
+ return value === void 0 ? void 0 : {
402
+ relation,
403
+ value
404
+ };
405
+ };
406
+ const numericBoundsOf = (options) => ({
407
+ min: boundOf(options, "min", ">=") ?? boundOf(options, "exclusiveMin", ">"),
408
+ max: boundOf(options, "max", "<=") ?? boundOf(options, "exclusiveMax", "<")
409
+ });
410
+ const jsonTextOf = async ({ argumentLabel, text, workingDirectory }) => {
411
+ const jsonText = (await atFileArgumentText(argumentLabel, text, workingDirectory)).trim();
412
+ return parseJson(jsonText) === void 0 ? void 0 : jsonText;
413
+ };
414
+ const jsonSchemaOptionsOf = async (options, workingDirectory) => {
415
+ if (options.jsonSchema === void 0) return;
416
+ const { file, contents } = await readOptionFile(TYPE_CONSTRAINT_OPTION_NAMES.jsonSchema, options.jsonSchema, workingDirectory);
417
+ const schema = parseJson(contents);
418
+ if (schema === void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES.jsonSchema} names ${file}, which is not JSON.`);
419
+ return {
420
+ useSchema: true,
421
+ schema
422
+ };
423
+ };
424
+ const timespanRangeOf = async (options) => {
425
+ if (options.unit === void 0 || options.min === void 0 || options.max === void 0) throw new CliError("--type timespan needs --unit, --min and --max.");
426
+ return {
427
+ unit: options.unit,
428
+ min: constraintNumberOf(options, "min"),
429
+ max: constraintNumberOf(options, "max"),
430
+ allowFractions: options.allowFractions
431
+ };
432
+ };
433
+ const NUMERIC_BOUND_OPTIONS = [
434
+ "min",
435
+ "max",
436
+ "exclusiveMin",
437
+ "exclusiveMax"
438
+ ];
439
+ const CONFIG_TYPE_RULES = {
440
+ boolean: {
441
+ constraintOptions: [],
442
+ valueDescription: "true or false",
443
+ valueOf: async ({ text }) => BOOLEAN_VALUES.get(text),
444
+ typeOptionsOf: async () => void 0
445
+ },
446
+ string: {
447
+ constraintOptions: [],
448
+ valueDescription: "text",
449
+ valueOf: async ({ text }) => text,
450
+ typeOptionsOf: async () => void 0
451
+ },
452
+ integer: {
453
+ constraintOptions: NUMERIC_BOUND_OPTIONS,
454
+ valueDescription: "a number",
455
+ valueOf: async ({ text }) => numberOf(text),
456
+ typeOptionsOf: async (options) => ({
457
+ isInteger: true,
458
+ ...numericBoundsOf(options)
459
+ })
460
+ },
461
+ float: {
462
+ constraintOptions: NUMERIC_BOUND_OPTIONS,
463
+ valueDescription: "a number",
464
+ valueOf: async ({ text }) => numberOf(text),
465
+ typeOptionsOf: async (options) => ({
466
+ isInteger: false,
467
+ ...numericBoundsOf(options)
468
+ })
469
+ },
470
+ timespan: {
471
+ constraintOptions: [
472
+ "min",
473
+ "max",
474
+ "unit",
475
+ "allowFractions"
476
+ ],
477
+ valueDescription: "a number",
478
+ valueOf: async ({ text }) => numberOf(text),
479
+ typeOptionsOf: timespanRangeOf
480
+ },
481
+ enum: {
482
+ constraintOptions: ["allowedValue", "enumValueType"],
483
+ valueDescription: "text",
484
+ valueOf: async ({ text }) => text,
485
+ typeOptionsOf: async (options) => ({
486
+ values: options.allowedValue ?? [],
487
+ valueType: options.enumValueType ?? DEFAULT_ENUM_VALUE_TYPE
488
+ })
489
+ },
490
+ url: {
491
+ constraintOptions: ["httpsOnly"],
492
+ valueDescription: "text",
493
+ valueOf: async ({ text }) => text,
494
+ typeOptionsOf: async (options) => ({ secureOnly: options.httpsOnly === true })
495
+ },
496
+ json: {
497
+ constraintOptions: ["jsonSchema"],
498
+ valueDescription: "JSON or @<file>",
499
+ valueOf: jsonTextOf,
500
+ typeOptionsOf: jsonSchemaOptionsOf
501
+ }
502
+ };
503
+ const typeAlternatives = (types) => types.length === 1 ? `${types[0]}` : `${types.slice(0, -1).join(", ")} or ${types.at(-1)}`;
504
+ const typesTaking = (option) => CONFIG_TYPES.filter((type) => CONFIG_TYPE_RULES[type].constraintOptions.includes(option));
505
+ const typeConstraintHelpGroup = (option) => `Options for --type ${typeAlternatives(typesTaking(option))}:`;
506
+ const TYPE_CONSTRAINT_OPTIONS = Object.keys(TYPE_CONSTRAINT_OPTION_NAMES);
507
+ const rejectOptionsOfOtherTypes = (options) => {
508
+ const { constraintOptions } = CONFIG_TYPE_RULES[options.type];
509
+ const otherTypeOption = TYPE_CONSTRAINT_OPTIONS.find((option) => options[option] !== void 0 && !constraintOptions.includes(option));
510
+ if (otherTypeOption !== void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES[otherTypeOption]} cannot be used with --type ${options.type}. It applies to --type ${typeAlternatives(typesTaking(otherTypeOption))}.`);
511
+ };
512
+ const typedValueOf = async (type, argument) => {
513
+ const rules = CONFIG_TYPE_RULES[type];
514
+ const value = await rules.valueOf(argument);
515
+ if (value === void 0) throw new CliError(`${argument.argumentLabel} takes ${rules.valueDescription} with --type ${type}, not ${argument.text}.`);
516
+ return value;
517
+ };
518
+ const VARIATION_SEPARATOR = "=";
519
+ const variationOf = async (type, variation, workingDirectory) => {
520
+ const separatorIndex = variation.indexOf(VARIATION_SEPARATOR);
521
+ const name = variation.slice(0, separatorIndex);
522
+ if (separatorIndex === -1 || name === "") throw new CliError(`--variation takes name=value, not ${variation}.`);
523
+ const text = variation.slice(separatorIndex + 1);
524
+ return {
525
+ name,
526
+ value: await typedValueOf(type, {
527
+ argumentLabel: `--variation ${name}`,
528
+ text,
529
+ workingDirectory
530
+ })
531
+ };
532
+ };
533
+ const variationsOf = async (options, workingDirectory) => {
534
+ const variations = [];
535
+ for (const variation of options.variation ?? []) variations.push(await variationOf(options.type, variation, workingDirectory));
536
+ return variations;
537
+ };
538
+ const newConfigOf = async (options, workingDirectory) => {
539
+ rejectOptionsOfOtherTypes(options);
540
+ return {
541
+ key: options.key,
542
+ description: options.description,
543
+ role: options.role,
544
+ lifetime: options.lifetime,
545
+ type: options.type,
546
+ typeOptions: await CONFIG_TYPE_RULES[options.type].typeOptionsOf(options, workingDirectory),
547
+ ...availabilityOf(options),
548
+ variations: await variationsOf(options, workingDirectory),
549
+ defaultValue: await typedValueOf(options.type, {
550
+ argumentLabel: "--default-value",
551
+ text: options.defaultValue,
552
+ workingDirectory
553
+ })
554
+ };
555
+ };
556
+ //#endregion
557
+ //#region src/admin-api/config-creation.ts
558
+ const CreatedConfigSchema = v.object({
559
+ key: v.string(),
560
+ type: v.string(),
561
+ role: v.string(),
562
+ lifetime: v.string(),
563
+ client: v.boolean(),
564
+ server: v.boolean()
565
+ });
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
580
+ //#region src/admin-api/segments.ts
581
+ const SegmentSchema = v.object({
582
+ id: v.string(),
583
+ key: v.string(),
584
+ name: v.string(),
585
+ usages: v.array(v.unknown())
586
+ });
587
+ const SegmentDefinitionSchema = v.object({
588
+ key: v.string(),
589
+ name: v.string(),
590
+ groups: v.array(ConditionGroupSchema),
591
+ overrides: v.record(v.string(), v.array(ConditionGroupSchema))
592
+ });
593
+ //#endregion
594
+ //#region src/admin-api/targeting-rules-reference.ts
595
+ const TargetingRulesReferenceSchema = v.object({
596
+ notes: v.array(v.string()),
597
+ conditionKinds: v.array(v.string()),
598
+ conditionAttributes: v.array(v.string()),
599
+ contextKindOperators: v.array(v.string()),
600
+ segmentOperators: v.array(v.string()),
601
+ operatorsByTargetType: v.record(v.string(), v.array(v.string()))
602
+ });
603
+ //#endregion
604
+ //#region src/admin-api/config-evaluation.ts
605
+ const CheckedOutcomeSchema = v.picklist(["matched", "not-matched"]);
606
+ const AttributeConditionExplanationSchema = v.object({
607
+ conditionId: v.string(),
608
+ kind: v.literal("attribute"),
609
+ outcome: CheckedOutcomeSchema,
610
+ resolvedValue: v.optional(v.unknown()),
611
+ resolvedType: v.picklist([
612
+ "absent",
613
+ "scalar",
614
+ "array",
615
+ "object",
616
+ "unknown-attribute"
617
+ ])
618
+ });
619
+ const SegmentConditionExplanationSchema = v.object({
620
+ conditionId: v.string(),
621
+ kind: v.literal("segment"),
622
+ outcome: CheckedOutcomeSchema,
623
+ segmentId: v.string(),
624
+ segmentFound: v.boolean(),
625
+ matchedGroupIndex: v.optional(v.number())
626
+ });
627
+ const ConditionExplanationSchema = v.union([
628
+ v.object({
629
+ conditionId: v.string(),
630
+ outcome: v.literal("not-evaluated")
631
+ }),
632
+ AttributeConditionExplanationSchema,
633
+ SegmentConditionExplanationSchema
634
+ ]);
635
+ const BucketSchema = v.object({
636
+ defaultedToPredefinedBucket: v.boolean(),
637
+ assignedPercentage: v.number(),
638
+ shares: v.array(v.object({
639
+ percentageId: v.string(),
640
+ from: v.number(),
641
+ to: v.number()
642
+ })),
643
+ selectedPercentageId: v.optional(v.string())
644
+ });
645
+ const RuleExplanationSchema = v.object({
646
+ ruleId: v.string(),
647
+ outcome: v.picklist([
648
+ "matched",
649
+ "not-matched",
650
+ "not-evaluated",
651
+ "errored"
652
+ ]),
653
+ conditions: v.array(ConditionExplanationSchema),
654
+ bucket: v.optional(BucketSchema)
655
+ });
656
+ const ConfigEvaluationSchema = v.object({
657
+ key: v.string(),
658
+ environment: v.object({ name: v.string() }),
659
+ context: v.nullable(v.object({
660
+ kind: v.string(),
661
+ identifier: v.nullable(v.string())
662
+ })),
663
+ value: v.optional(v.string()),
664
+ servedBy: v.variant("kind", [v.object({
665
+ kind: v.literal("rule"),
666
+ ruleId: v.string()
667
+ }), v.object({ kind: v.literal("default") })]),
668
+ rules: v.array(RuleExplanationSchema),
669
+ targetingRules: v.object({
670
+ rules: v.array(RuleSchema),
671
+ defaultValue: v.string()
672
+ }),
673
+ segments: v.record(v.string(), v.object({ name: v.string() }))
674
+ });
675
+ //#endregion
69
676
  //#region src/admin-api/AdminApiClient.ts
70
677
  const ProjectSchema = v.object({
71
678
  id: v.string(),
@@ -75,6 +682,9 @@ const ProjectSchema = v.object({
75
682
  const configFields = {
76
683
  key: v.string(),
77
684
  deprecatedKeys: v.array(v.object({ key: v.string() })),
685
+ role: v.string(),
686
+ lifetime: v.string(),
687
+ state: v.string(),
78
688
  client: v.boolean(),
79
689
  server: v.boolean()
80
690
  };
@@ -91,6 +701,28 @@ const ConfigSchema = v.variant("type", [EnumConfigSchema, v.object({
91
701
  type: v.pipe(v.string(), v.notValue("enum"))
92
702
  })]);
93
703
  const isEnumConfig = (config) => config.type === "enum";
704
+ const EnvironmentSchema = v.object({
705
+ id: v.string(),
706
+ slug: v.string(),
707
+ name: v.string(),
708
+ live: v.boolean()
709
+ });
710
+ const ConfigActivitySchema = v.object({ latestEvaluations: v.array(v.object({
711
+ environment: v.object({ slug: v.string() }),
712
+ source: v.string(),
713
+ sdkName: v.nullable(v.string()),
714
+ sdkVersion: v.nullable(v.string()),
715
+ time: v.pipe(v.string(), v.isoTimestamp())
716
+ })) });
717
+ const TagSchema = v.object({ id: v.string() });
718
+ const tagFilterQuery = (tagFilter) => {
719
+ switch (tagFilter.form) {
720
+ case "every-config": return "";
721
+ case "tagged": return `?${new URLSearchParams(tagFilter.tagIds.map((tagId) => ["tagIds", tagId]))}`;
722
+ case "untagged": return "?untagged=true";
723
+ }
724
+ };
725
+ const MissingResourceBodySchema = v.strictObject({});
94
726
  const readJsonBody = async (response) => {
95
727
  try {
96
728
  return await response.json();
@@ -102,6 +734,7 @@ const describeStatus = (response) => `HTTP ${response.status}`;
102
734
  const failureFor = (url, response, body) => {
103
735
  switch (response.status) {
104
736
  case 400: {
737
+ if (v.is(RepositoryLimitReachedBodySchema, body)) return new RepositoryLimitReachedError();
105
738
  const validationError = v.safeParse(ApiValidationErrorBodySchema, body);
106
739
  return validationError.success ? new ApiValidationError(validationError.output.errors) : new UnexpectedApiResponseError(url, describeStatus(response));
107
740
  }
@@ -109,6 +742,10 @@ const failureFor = (url, response, body) => {
109
742
  const authorizationError = v.safeParse(ApiAuthorizationErrorBodySchema, body);
110
743
  return authorizationError.success ? new ApiAuthorizationError(authorizationError.output.code, authorizationError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
111
744
  }
745
+ case 404: {
746
+ const notFoundError = v.safeParse(EvaluationNotFoundBodySchema, body);
747
+ return notFoundError.success ? new EvaluationNotFoundError(notFoundError.output.code, notFoundError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
748
+ }
112
749
  case 429: {
113
750
  const retryAfterSeconds = response.headers.get("retry-after");
114
751
  return v.is(ServiceSuspendedBodySchema, body) && retryAfterSeconds !== null ? new ServiceSuspendedError(retryAfterSeconds) : new UnexpectedApiResponseError(url, describeStatus(response));
@@ -116,6 +753,16 @@ const failureFor = (url, response, body) => {
116
753
  default: return new UnexpectedApiResponseError(url, describeStatus(response));
117
754
  }
118
755
  };
756
+ const isMissingResource = (received) => received.response.status === 404 && v.is(MissingResourceBodySchema, received.body);
757
+ const parsedResponseOf = ({ url, response, body }, schema) => {
758
+ if (!response.ok) throw failureFor(url, response, body);
759
+ const parsedBody = v.safeParse(schema, body);
760
+ if (!parsedBody.success) throw new UnexpectedApiResponseError(url, `an ${describeStatus(response)} response the CLI cannot read`);
761
+ return {
762
+ responseBody: body,
763
+ parsedBody: parsedBody.output
764
+ };
765
+ };
119
766
  var AdminApiClient = class {
120
767
  constructor(connection) {
121
768
  this.connection = connection;
@@ -123,51 +770,119 @@ var AdminApiClient = class {
123
770
  listProjects() {
124
771
  return this.get("/v1/projects", v.array(ProjectSchema));
125
772
  }
126
- listConfigs(projectId) {
127
- return this.get(`/v1/projects/${encodeURIComponent(projectId)}/configs`, v.array(ConfigSchema));
773
+ async listConfigs(projectId, tagFilter) {
774
+ const { parsedBody: configs, responseBody } = await this.get(`/v1/projects/${encodeURIComponent(projectId)}/configs${tagFilterQuery(tagFilter)}`, v.array(ConfigSchema));
775
+ const receivedConfigs = v.parse(v.array(v.unknown()), responseBody);
776
+ return configs.map((config, index) => ({
777
+ config,
778
+ receivedConfig: receivedConfigs[index]
779
+ }));
780
+ }
781
+ listEnvironments(projectId) {
782
+ return this.get(`/v1/projects/${encodeURIComponent(projectId)}/environments`, v.array(EnvironmentSchema));
783
+ }
784
+ getConfig(projectId, configKey) {
785
+ return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, ConfigWithTargetingRulesSchema);
786
+ }
787
+ getConfigActivity(projectId, configKey) {
788
+ return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/activity`, ConfigActivitySchema);
789
+ }
790
+ listSegments(projectId) {
791
+ return this.get(`/v1/projects/${encodeURIComponent(projectId)}/segments`, v.array(SegmentSchema));
792
+ }
793
+ getSegment(projectId, segmentKey) {
794
+ return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, SegmentDefinitionSchema);
795
+ }
796
+ async findTagByName(projectId, name) {
797
+ return (await this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/tags/by-name?${new URLSearchParams({ name })}`, TagSchema))?.parsedBody;
798
+ }
799
+ createConfig(projectId, request) {
800
+ return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs`, CreatedConfigSchema, {
801
+ method: "POST",
802
+ body: request
803
+ });
804
+ }
805
+ async evaluateConfig(projectId, configKey, request) {
806
+ const { responseBody, parsedBody } = await this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/evaluate`, ConfigEvaluationSchema, {
807
+ method: "POST",
808
+ body: request
809
+ });
810
+ return {
811
+ evaluation: parsedBody,
812
+ responseBody
813
+ };
814
+ }
815
+ getTargetingRulesReference() {
816
+ return this.get("/v1/targeting-rules-reference", TargetingRulesReferenceSchema);
817
+ }
818
+ async replaceTargetingRules(projectId, configKey, request) {
819
+ return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/targets`, v.unknown(), {
820
+ method: "PUT",
821
+ body: request
822
+ }) === void 0 ? "not-found" : "replaced";
128
823
  }
129
- async get(path, schema) {
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
+ }
839
+ get(path, schema) {
840
+ return this.requestJson(path, schema, { method: "GET" });
841
+ }
842
+ getIfFound(path, schema) {
843
+ return this.requestIfFound(path, schema, { method: "GET" });
844
+ }
845
+ async requestIfFound(path, schema, request) {
846
+ const received = await this.send(path, request);
847
+ return isMissingResource(received) ? void 0 : parsedResponseOf(received, schema);
848
+ }
849
+ async requestJson(path, schema, request) {
850
+ return parsedResponseOf(await this.send(path, request), schema);
851
+ }
852
+ async send(path, request) {
130
853
  const url = `${this.connection.baseUrl}${path}`;
131
- const response = await this.fetchResponse(url);
132
- const body = await readJsonBody(response);
133
- if (!response.ok) throw failureFor(url, response, body);
134
- const parsedBody = v.safeParse(schema, body);
135
- if (!parsedBody.success) throw new UnexpectedApiResponseError(url, `an ${describeStatus(response)} response the CLI cannot read`);
136
- return parsedBody.output;
137
- }
138
- async fetchResponse(url) {
854
+ const response = await this.fetchResponse(url, request);
855
+ return {
856
+ url,
857
+ response,
858
+ body: await readJsonBody(response)
859
+ };
860
+ }
861
+ async fetchResponse(url, request) {
862
+ const headers = {
863
+ Accept: "application/json",
864
+ Authorization: `Bearer ${this.connection.token}`,
865
+ "User-Agent": `configdirector-cli/${version}`,
866
+ "X-SourceClient": "cli"
867
+ };
139
868
  try {
140
- return await fetch(url, { headers: {
141
- Accept: "application/json",
142
- Authorization: `Bearer ${this.connection.token}`,
143
- "User-Agent": `configdirector-cli/${version}`,
144
- "X-SourceClient": "cli"
145
- } });
869
+ return await fetch(url, "body" in request ? {
870
+ method: request.method,
871
+ headers: {
872
+ ...headers,
873
+ "Content-Type": "application/json"
874
+ },
875
+ body: JSON.stringify(request.body)
876
+ } : {
877
+ method: request.method,
878
+ headers
879
+ });
146
880
  } catch (error) {
147
881
  throw new ApiUnreachableError(this.connection.baseUrl, error);
148
882
  }
149
883
  }
150
884
  };
151
885
  //#endregion
152
- //#region src/file-contents.ts
153
- const readFileIfPresent = async (file) => {
154
- try {
155
- return await readFile(file, "utf8");
156
- } catch (error) {
157
- if (error.code === "ENOENT") return;
158
- throw error;
159
- }
160
- };
161
- const parseJson = (contents) => {
162
- try {
163
- return JSON.parse(contents);
164
- } catch {
165
- return;
166
- }
167
- };
168
- const pathExists = (path) => stat(path).then(() => true, () => false);
169
- const fileExists = (path) => stat(path).then((stats) => stats.isFile(), () => false);
170
- //#endregion
171
886
  //#region src/saved-api-token.ts
172
887
  const SavedApiTokenSchema = v.object({ token: v.string() });
173
888
  const platformConfigDirectory = (invocation) => {
@@ -210,22 +925,6 @@ const configuredApiToken = async (context) => {
210
925
  return token;
211
926
  };
212
927
  //#endregion
213
- //#region src/project-selection.ts
214
- const requireProjectSlug = (context) => {
215
- if (context.projectSlug === void 0) throw new CliError("Name the project with --project <slug> or CONFIGDIRECTOR_PROJECT.");
216
- return context.projectSlug;
217
- };
218
- const requireLinkedProjectSlug = (context, settings) => {
219
- const slug = context.projectSlug ?? settings?.project;
220
- if (slug === void 0) throw new CliError("Name the project with --project <slug>, CONFIGDIRECTOR_PROJECT, or `project` in configdirector.json, which `configdirector init` writes.");
221
- return slug;
222
- };
223
- const findProjectBySlug = async (client, slug) => {
224
- const project = (await client.listProjects()).find((listedProject) => listedProject.slug === slug);
225
- if (project === void 0) throw new CliError(`No project in the API token's organization has the slug "${slug}".`);
226
- return project;
227
- };
228
- //#endregion
229
928
  //#region src/project-settings.ts
230
929
  const ProjectSettingsSchema = v.object({
231
930
  project: v.optional(v.string()),
@@ -247,6 +946,406 @@ const readProjectSettings = async (workingDirectory) => {
247
946
  return settings.output;
248
947
  };
249
948
  //#endregion
949
+ //#region src/project-selection.ts
950
+ const requireProjectSlug = (context) => {
951
+ if (context.projectSlug === void 0) throw new CliError("Name the project with --project <slug> or CONFIGDIRECTOR_PROJECT.");
952
+ return context.projectSlug;
953
+ };
954
+ const requireLinkedProjectSlug = (context, settings) => {
955
+ const slug = context.projectSlug ?? settings?.project;
956
+ if (slug === void 0) throw new CliError("Name the project with --project <slug>, CONFIGDIRECTOR_PROJECT, or `project` in configdirector.json, which `configdirector init` writes.");
957
+ return slug;
958
+ };
959
+ const findProjectBySlug = async (client, slug) => {
960
+ const { parsedBody: projects } = await client.listProjects();
961
+ const project = projects.find((listedProject) => listedProject.slug === slug);
962
+ if (project === void 0) throw new CliError(`No project in the API token's organization has the slug "${slug}".`);
963
+ return project;
964
+ };
965
+ const connectToLinkedProject = async (context) => {
966
+ const projectSlug = requireLinkedProjectSlug(context, await readProjectSettings(context.invocation.workingDirectory));
967
+ const client = new AdminApiClient({
968
+ baseUrl: context.baseUrl,
969
+ token: await configuredApiToken(context)
970
+ });
971
+ return {
972
+ client,
973
+ project: await findProjectBySlug(client, projectSlug)
974
+ };
975
+ };
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
983
+ //#region src/commands/configs.ts
984
+ const LIFECYCLE_STATES = [
985
+ "new",
986
+ "in-use",
987
+ "settled",
988
+ "archive-ready",
989
+ "stale"
990
+ ];
991
+ const tagIdNamed = async (client, project, name) => {
992
+ const tag = await client.findTagByName(project.id, name);
993
+ if (tag === void 0) throw new CliError(`No tag in ${project.slug} has the name "${name}".`);
994
+ return tag.id;
995
+ };
996
+ const tagIdsNamed = async (client, project, names) => {
997
+ const tagIds = [];
998
+ for (const name of names) tagIds.push(await tagIdNamed(client, project, name));
999
+ return tagIds;
1000
+ };
1001
+ const tagFilterOf = async (client, project, options) => {
1002
+ if (options.untagged) return { form: "untagged" };
1003
+ if (options.tag === void 0) return { form: "every-config" };
1004
+ return {
1005
+ form: "tagged",
1006
+ tagIds: await tagIdsNamed(client, project, options.tag)
1007
+ };
1008
+ };
1009
+ const configsInState = (listedConfigs, state) => state === void 0 ? listedConfigs : listedConfigs.filter((listedConfig) => listedConfig.config.state === state);
1010
+ const summaryColumnsOf = (config) => [
1011
+ config.key,
1012
+ config.type,
1013
+ config.role,
1014
+ config.lifetime,
1015
+ config.state
1016
+ ];
1017
+ const configLines = (projectSlug, configs) => configs.length === 0 ? [`No configs found in ${projectSlug}.`] : alignedColumns(configs.map(summaryColumnsOf));
1018
+ const listConfigs = async (context, _commandArguments, options) => {
1019
+ const { client, project } = await connectToLinkedProject(context);
1020
+ const listedConfigs = configsInState(await client.listConfigs(project.id, await tagFilterOf(client, project, options)), options.state);
1021
+ return {
1022
+ json: listedConfigs.map((listedConfig) => listedConfig.receivedConfig),
1023
+ text: [...configLines(project.slug, listedConfigs.map((listedConfig) => listedConfig.config)), ""].join("\n"),
1024
+ exitCode: ExitCode.success
1025
+ };
1026
+ };
1027
+ const referencesSegments = (config) => config.targets.some((targetingRules) => targetingRules.rules.some((rule) => rule.type === "conditional" && rule.conditions.some((condition) => condition.kind === "segment")));
1028
+ const segmentNamesFor = async (client, project, config) => {
1029
+ if (!referencesSegments(config)) return {};
1030
+ const segments = await ifPermitted(client.listSegments(project.id));
1031
+ return Object.fromEntries((segments?.parsedBody ?? []).map((segment) => [segment.id, segment]));
1032
+ };
1033
+ const getConfig = async (context, [configKey]) => {
1034
+ const { client, project } = await connectToLinkedProject(context);
1035
+ const receivedConfig = await client.getConfig(project.id, configKey);
1036
+ if (receivedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1037
+ const { parsedBody: config, responseBody } = receivedConfig;
1038
+ return {
1039
+ json: responseBody,
1040
+ text: configText(config, configKey, await segmentNamesFor(client, project, config)),
1041
+ exitCode: ExitCode.success
1042
+ };
1043
+ };
1044
+ const createdConfigLines = (projectSlug, createdConfig, newConfig) => {
1045
+ const settings = [
1046
+ createdConfig.type,
1047
+ createdConfig.role,
1048
+ createdConfig.lifetime,
1049
+ availabilityDescription(createdConfig)
1050
+ ];
1051
+ return [`Created ${createdConfig.key} in ${projectSlug}: ${settings.join(", ")}.`, `Default targeting rule in every environment: ${String(newConfig.defaultValue)}`];
1052
+ };
1053
+ const keyTakenLines = (projectSlug, configKey, existingConfig) => [`${projectSlug} already has a config with the key ${configKey}, so no config was created:`, ...alignedColumns([summaryColumnsOf(existingConfig)])];
1054
+ const rejectsKey = (error) => error instanceof ApiValidationError && error.fieldErrors.some((fieldError) => fieldError.name === "key");
1055
+ const requestCreation = async (client, project, request) => {
1056
+ try {
1057
+ const { parsedBody: createdConfig, responseBody } = await client.createConfig(project.id, request);
1058
+ return {
1059
+ outcome: "created",
1060
+ createdConfig,
1061
+ responseBody
1062
+ };
1063
+ } catch (error) {
1064
+ if (!rejectsKey(error)) throw error;
1065
+ const existingConfig = await client.getConfig(project.id, request.key);
1066
+ if (existingConfig === void 0) throw error;
1067
+ return {
1068
+ outcome: "key-taken",
1069
+ existingConfig: existingConfig.parsedBody,
1070
+ responseBody: existingConfig.responseBody
1071
+ };
1072
+ }
1073
+ };
1074
+ const creationOutput = (project, newConfig, creation) => {
1075
+ switch (creation.outcome) {
1076
+ case "created": return {
1077
+ json: creation.responseBody,
1078
+ text: [...createdConfigLines(project.slug, creation.createdConfig, newConfig), ""].join("\n"),
1079
+ exitCode: ExitCode.success
1080
+ };
1081
+ case "key-taken": return {
1082
+ json: creation.responseBody,
1083
+ text: [...keyTakenLines(project.slug, newConfig.key, creation.existingConfig), ""].join("\n"),
1084
+ exitCode: ExitCode.problemsFound
1085
+ };
1086
+ }
1087
+ };
1088
+ const createConfig = async (context, _commandArguments, options) => {
1089
+ const newConfig = await newConfigOf(options, context.invocation.workingDirectory);
1090
+ const { client, project } = await connectToLinkedProject(context);
1091
+ return creationOutput(project, newConfig, await requestCreation(client, project, {
1092
+ ...newConfig,
1093
+ tagIds: await tagIdsNamed(client, project, options.tag ?? [])
1094
+ }));
1095
+ };
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
+ };
1104
+ };
1105
+ const sdkDescription = (evaluation) => [evaluation.sdkName ?? "unknown SDK", ...evaluation.sdkVersion === null ? [] : [evaluation.sdkVersion]].join(" ");
1106
+ const activityLines = (configKey, activity) => activity.latestEvaluations.length === 0 ? [`No SDK evaluations of ${configKey} found.`] : alignedColumns(activity.latestEvaluations.map((evaluation) => [
1107
+ evaluation.environment.slug,
1108
+ evaluation.source,
1109
+ sdkDescription(evaluation),
1110
+ utcMinute(evaluation.time)
1111
+ ]));
1112
+ const showConfigActivity = async (context, [configKey]) => {
1113
+ const { client, project } = await connectToLinkedProject(context);
1114
+ const receivedActivity = await client.getConfigActivity(project.id, configKey);
1115
+ if (receivedActivity === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1116
+ return {
1117
+ json: receivedActivity.responseBody,
1118
+ text: [...activityLines(configKey, receivedActivity.parsedBody), ""].join("\n"),
1119
+ exitCode: ExitCode.success
1120
+ };
1121
+ };
1122
+ //#endregion
1123
+ //#region src/commands/environments.ts
1124
+ const environmentColumns = (environment) => environment.live ? [
1125
+ environment.slug,
1126
+ environment.name,
1127
+ "live"
1128
+ ] : [environment.slug, environment.name];
1129
+ const environmentLines = (projectSlug, environments) => environments.length === 0 ? [`No environments found in ${projectSlug}.`] : alignedColumns(environments.map(environmentColumns));
1130
+ const listEnvironments = async (context) => {
1131
+ const { client, project } = await connectToLinkedProject(context);
1132
+ const { parsedBody: environments, responseBody } = await client.listEnvironments(project.id);
1133
+ return {
1134
+ json: responseBody,
1135
+ text: [...environmentLines(project.slug, environments), ""].join("\n"),
1136
+ exitCode: ExitCode.success
1137
+ };
1138
+ };
1139
+ //#endregion
1140
+ //#region src/environment-selection.ts
1141
+ const environmentSlugsSentence = (environments) => environments.length === 0 ? "The project has no environments." : `Its environments are ${environments.map((environment) => environment.slug).join(", ")}.`;
1142
+ const findEnvironmentBySlug = async (client, project, slug) => {
1143
+ const { parsedBody: environments } = await client.listEnvironments(project.id);
1144
+ const environment = environments.find((listedEnvironment) => listedEnvironment.slug === slug);
1145
+ if (environment === void 0) throw new CliError(`No environment in ${project.slug} has the slug "${slug}". ${environmentSlugsSentence(environments)}`);
1146
+ return environment;
1147
+ };
1148
+ //#endregion
1149
+ //#region src/evaluate/context-form.ts
1150
+ const DEFAULT_CONTEXT_KIND = "user";
1151
+ const TYPED_TRAIT_MARKER = ":";
1152
+ const traitUsageError = (trait) => new CliError(`--context-trait ${trait} is not a trait. Write name=value for a string, or name:=<json> for a typed value, as in plan=pro or beta:=true.`);
1153
+ const parseTraitJson = (trait, json) => {
1154
+ try {
1155
+ return JSON.parse(json);
1156
+ } catch {
1157
+ throw new CliError(`--context-trait ${trait} has a value after := that is not JSON. Write name=value for a string.`);
1158
+ }
1159
+ };
1160
+ const traitEntryOf = (trait) => {
1161
+ const separatorIndex = trait.indexOf("=");
1162
+ if (separatorIndex === -1) throw traitUsageError(trait);
1163
+ const isTyped = trait[separatorIndex - 1] === TYPED_TRAIT_MARKER;
1164
+ const name = trait.slice(0, isTyped ? separatorIndex - 1 : separatorIndex);
1165
+ if (name === "") throw traitUsageError(trait);
1166
+ const value = trait.slice(separatorIndex + 1);
1167
+ return [name, isTyped ? parseTraitJson(trait, value) : value];
1168
+ };
1169
+ const contextJsonUsageError = () => new CliError("--context-json takes the context as a JSON object, as in {\"kind\":\"user\",\"identifier\":\"123\"}, or @<file>.");
1170
+ const contextOfJson = async (contextJson, workingDirectory) => {
1171
+ const context = parseJson(await atFileArgumentText("--context-json", contextJson, workingDirectory));
1172
+ if (!isJsonObject(context)) throw contextJsonUsageError();
1173
+ return context;
1174
+ };
1175
+ const inlineContextOf = (options) => ({
1176
+ kind: options.contextKind ?? DEFAULT_CONTEXT_KIND,
1177
+ identifier: options.contextId,
1178
+ name: options.contextName,
1179
+ traits: options.contextTrait === void 0 ? void 0 : Object.fromEntries(options.contextTrait.map(traitEntryOf))
1180
+ });
1181
+ const SAVED_CONTEXT_SEPARATOR = ":";
1182
+ const savedContextReferenceOf = (savedContext) => {
1183
+ const separatorIndex = savedContext.indexOf(SAVED_CONTEXT_SEPARATOR);
1184
+ const kind = savedContext.slice(0, separatorIndex);
1185
+ const identifier = savedContext.slice(separatorIndex + 1);
1186
+ if (separatorIndex === -1 || kind === "" || identifier === "") throw new CliError(`--saved-context takes <kind>:<identifier>, as in user:123, not ${savedContext}.`);
1187
+ return {
1188
+ kind,
1189
+ identifier
1190
+ };
1191
+ };
1192
+ const givenInlineOptionNames = (options) => [
1193
+ ...options.contextKind === void 0 ? [] : ["--context-kind"],
1194
+ ...options.contextId === void 0 ? [] : ["--context-id"],
1195
+ ...options.contextName === void 0 ? [] : ["--context-name"],
1196
+ ...options.contextTrait === void 0 ? [] : ["--context-trait"]
1197
+ ];
1198
+ const givenContextDoors = (options) => {
1199
+ const { savedContext, contextJson } = options;
1200
+ const inlineOptionNames = givenInlineOptionNames(options);
1201
+ return [
1202
+ ...savedContext === void 0 ? [] : [{
1203
+ optionNames: ["--saved-context"],
1204
+ contextForm: async () => ({
1205
+ form: "saved",
1206
+ reference: savedContextReferenceOf(savedContext)
1207
+ })
1208
+ }],
1209
+ ...contextJson === void 0 ? [] : [{
1210
+ optionNames: ["--context-json"],
1211
+ contextForm: async (workingDirectory) => ({
1212
+ form: "inline",
1213
+ context: await contextOfJson(contextJson, workingDirectory)
1214
+ })
1215
+ }],
1216
+ ...inlineOptionNames.length === 0 ? [] : [{
1217
+ optionNames: inlineOptionNames,
1218
+ contextForm: async () => ({
1219
+ form: "inline",
1220
+ context: inlineContextOf(options)
1221
+ })
1222
+ }]
1223
+ ];
1224
+ };
1225
+ const contextFormOf = async (options, workingDirectory) => {
1226
+ const [door, ...otherDoors] = givenContextDoors(options);
1227
+ if (door === void 0) return { form: "empty" };
1228
+ if (otherDoors.length > 0) throw new CliError(`${door.optionNames.join(", ")} cannot be combined with ${otherDoors.flatMap((otherDoor) => otherDoor.optionNames).join(", ")}. Give the context one way: --saved-context, --context-json, or the --context-kind, --context-id, --context-name and --context-trait options.`);
1229
+ return door.contextForm(workingDirectory);
1230
+ };
1231
+ //#endregion
1232
+ //#region src/evaluate/evaluation-text.ts
1233
+ const RESOLVED_VALUE_DESCRIPTIONS = {
1234
+ absent: () => "absent",
1235
+ "unknown-attribute": () => "unknown to the evaluator",
1236
+ scalar: (resolvedValue) => JSON.stringify(resolvedValue),
1237
+ array: (resolvedValue) => JSON.stringify(resolvedValue),
1238
+ object: (resolvedValue) => JSON.stringify(resolvedValue)
1239
+ };
1240
+ const contextDescription = (evaluation, contextForm) => {
1241
+ const evaluatedContext = evaluation.context;
1242
+ if (evaluatedContext === null) return "an empty context";
1243
+ if (evaluatedContext.identifier === null) return `a ${evaluatedContext.kind} context without an identifier`;
1244
+ const identity = `${evaluatedContext.kind} ${evaluatedContext.identifier}`;
1245
+ return contextForm.form === "saved" ? `saved context ${identity}` : identity;
1246
+ };
1247
+ const servedByDescription = (evaluation) => {
1248
+ const { servedBy } = evaluation;
1249
+ switch (servedBy.kind) {
1250
+ case "rule": return `served by rule ${evaluation.rules.findIndex((rule) => rule.ruleId === servedBy.ruleId) + 1}`;
1251
+ case "default": return "served by the default targeting rule";
1252
+ }
1253
+ };
1254
+ const describeShareRange = (from, to) => `${formatPercentage(from)} to ${formatPercentage(to)}`;
1255
+ const bucketPlacement = (bucket) => {
1256
+ const assigned = `bucket ${bucket.assignedPercentage.toFixed(1)}`;
1257
+ const selectedShare = bucket.shares.find((share) => share.percentageId === bucket.selectedPercentageId);
1258
+ if (selectedShare !== void 0) return `${assigned} is in ${describeShareRange(selectedShare.from, selectedShare.to)}`;
1259
+ return `${assigned} is outside ${describeShareRange(0, bucket.shares.at(-1)?.to ?? 0)}`;
1260
+ };
1261
+ const describeBucket = (bucket) => bucket.defaultedToPredefinedBucket ? `no identifier, so ${bucketPlacement(bucket)}` : bucketPlacement(bucket);
1262
+ const withBucket = (outcome, bucket) => bucket === void 0 ? outcome : `${outcome}: ${describeBucket(bucket)}`;
1263
+ const conditionsOf = (rule) => rule?.type === "conditional" ? rule.conditions : [];
1264
+ const segmentMismatchEvidence = (explanation) => {
1265
+ if (!explanation.segmentFound) return "the segment was not found";
1266
+ return explanation.matchedGroupIndex === void 0 ? "no group matched" : `group ${explanation.matchedGroupIndex + 1} matched`;
1267
+ };
1268
+ const mismatchEvidence = (failingCondition) => {
1269
+ switch (failingCondition.kind) {
1270
+ case "attribute": return `it is ${RESOLVED_VALUE_DESCRIPTIONS[failingCondition.resolvedType](failingCondition.resolvedValue)}`;
1271
+ case "segment": return segmentMismatchEvidence(failingCondition);
1272
+ }
1273
+ };
1274
+ const isFailingCondition = (condition) => condition.outcome === "not-matched";
1275
+ const failingConditionDescription = (explanation, conditions, segments) => {
1276
+ const failingCondition = explanation.conditions.find(isFailingCondition);
1277
+ const condition = conditions.find((ruleCondition) => ruleCondition.id === failingCondition?.conditionId);
1278
+ return failingCondition === void 0 || condition === void 0 ? void 0 : `${describeCondition(condition, segments)}, but ${mismatchEvidence(failingCondition)}`;
1279
+ };
1280
+ const describeNotMatched = (explanation, conditions, segments) => {
1281
+ const failingCondition = failingConditionDescription(explanation, conditions, segments);
1282
+ return failingCondition === void 0 ? withBucket("not matched", explanation.bucket) : `not matched: ${failingCondition}`;
1283
+ };
1284
+ const RULE_OUTCOME_DESCRIPTIONS = {
1285
+ matched: (explanation) => withBucket("matched", explanation.bucket),
1286
+ "not-matched": describeNotMatched,
1287
+ "not-evaluated": () => "not evaluated",
1288
+ errored: () => "errored"
1289
+ };
1290
+ const ruleLines = (evaluation) => {
1291
+ const rulesById = new Map(evaluation.targetingRules.rules.map((rule) => [rule.id, rule]));
1292
+ return alignedColumns(evaluation.rules.map((explanation, index) => {
1293
+ const rule = rulesById.get(explanation.ruleId);
1294
+ return [`${index + 1}. ${rule === void 0 ? explanation.ruleId : describeRule(rule, evaluation.segments)}`, RULE_OUTCOME_DESCRIPTIONS[explanation.outcome](explanation, conditionsOf(rule), evaluation.segments)];
1295
+ }));
1296
+ };
1297
+ const evaluationText = (evaluation, requested) => [
1298
+ ...oldKeyLines(requested.configKey, evaluation.key),
1299
+ `${evaluation.key} in ${evaluation.environment.name} for ${contextDescription(evaluation, requested.contextForm)}`,
1300
+ "",
1301
+ `${evaluation.value ?? "no value"}, ${servedByDescription(evaluation)}`,
1302
+ "",
1303
+ ...ruleLines(evaluation),
1304
+ `Default targeting rule: ${evaluation.targetingRules.defaultValue}`,
1305
+ ""
1306
+ ].join("\n");
1307
+ //#endregion
1308
+ //#region src/commands/evaluate.ts
1309
+ const contextFieldsOf = (contextForm) => {
1310
+ switch (contextForm.form) {
1311
+ case "inline": return { context: contextForm.context };
1312
+ case "saved": return { savedContext: contextForm.reference };
1313
+ case "empty": return {};
1314
+ }
1315
+ };
1316
+ const NOT_FOUND_MESSAGES = {
1317
+ CONFIG_NOT_FOUND: ({ project, configKey }) => `No config in ${project.slug} has the key "${configKey}".`,
1318
+ ENVIRONMENT_NOT_FOUND: ({ project, environment }) => `No environment in ${project.slug} has the slug "${environment.slug}" any more.`,
1319
+ CONTEXT_NOT_FOUND: ({ environment, contextForm }, error) => contextForm.form === "saved" ? `No context saved in ${environment.slug} has the context kind ${contextForm.reference.kind} and the identifier "${contextForm.reference.identifier}".` : error.message
1320
+ };
1321
+ const requestEvaluation = async (client, requested) => {
1322
+ try {
1323
+ return await client.evaluateConfig(requested.project.id, requested.configKey, {
1324
+ environmentId: requested.environment.id,
1325
+ ...contextFieldsOf(requested.contextForm)
1326
+ });
1327
+ } catch (error) {
1328
+ if (error instanceof EvaluationNotFoundError) throw new CliError(NOT_FOUND_MESSAGES[error.code](requested, error));
1329
+ throw error;
1330
+ }
1331
+ };
1332
+ const evaluate = async (context, [configKey], options) => {
1333
+ const contextForm = await contextFormOf(options, context.invocation.workingDirectory);
1334
+ const { client, project } = await connectToLinkedProject(context);
1335
+ const requested = {
1336
+ project,
1337
+ configKey,
1338
+ environment: await findEnvironmentBySlug(client, project, options.environment),
1339
+ contextForm
1340
+ };
1341
+ const { evaluation, responseBody } = await requestEvaluation(client, requested);
1342
+ return {
1343
+ json: responseBody,
1344
+ text: evaluationText(evaluation, requested),
1345
+ exitCode: ExitCode.success
1346
+ };
1347
+ };
1348
+ //#endregion
250
1349
  //#region src/commands/init.ts
251
1350
  const refuseExistingFile = async (file) => {
252
1351
  if (await access(file).then(() => true, () => false)) throw new CliError(`${file} already exists. init never overwrites it; edit the file instead.`);
@@ -350,9 +1449,66 @@ const login = async (context) => {
350
1449
  };
351
1450
  };
352
1451
  //#endregion
1452
+ //#region src/commands/projects.ts
1453
+ const projectLines = (projects) => projects.length === 0 ? ["No projects found."] : alignedColumns(projects.map((project) => [project.slug, project.name]));
1454
+ const listProjects = async (context) => {
1455
+ const { parsedBody: projects, responseBody } = await new AdminApiClient({
1456
+ baseUrl: context.baseUrl,
1457
+ token: await configuredApiToken(context)
1458
+ }).listProjects();
1459
+ return {
1460
+ json: responseBody,
1461
+ text: [...projectLines(projects), ""].join("\n"),
1462
+ exitCode: ExitCode.success
1463
+ };
1464
+ };
1465
+ //#endregion
353
1466
  //#region src/plural.ts
354
1467
  const plural = (count, singular, pluralForm) => count === 1 ? singular : pluralForm;
355
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
356
1512
  //#region src/scan/source-languages.ts
357
1513
  const languageOfExtension = {
358
1514
  ".js": "javascript",
@@ -824,6 +1980,7 @@ const scanRootAt = async (workingDirectory, path) => {
824
1980
  description: path ?? "the current directory"
825
1981
  };
826
1982
  };
1983
+ const scanRootDirectory = (root) => root.isDirectory ? root.path : dirname(root.path);
827
1984
  const gitignoreRulesIn = async (directory) => {
828
1985
  const contents = await readFileIfPresent(join(directory, ".gitignore"));
829
1986
  return contents === void 0 ? [] : [{
@@ -1077,6 +2234,10 @@ const DART_RE_EXPORT_DIRECTIVES = ["export"];
1077
2234
  const SWIFT_PACKAGE_MANIFEST = "Package.swift";
1078
2235
  const SWIFT_PACKAGE_TARGETS_DIRECTORIES = ["Sources", "Tests"];
1079
2236
  const XCODE_PROJECT_EXTENSION = ".xcodeproj";
2237
+ const JVM_SOURCE_EXTENSIONS = [".kt", ".java"];
2238
+ const CSHARP_PROJECT_EXTENSION = ".csproj";
2239
+ const CSHARP_SOURCE_EXTENSION = ".cs";
2240
+ const CSHARP_BUILD_OUTPUT_DIRECTORIES = ["bin", "obj"];
1080
2241
  const stringAt = (code, index) => code.token(index)?.kind === "string" ? plainStringContent(code, {
1081
2242
  first: index,
1082
2243
  last: index
@@ -1245,6 +2406,63 @@ const goPackageNameIn = (code) => {
1245
2406
  const name = code.token(1);
1246
2407
  return code.isIdentifier(0, "package") && name?.kind === "identifier" ? name.text : void 0;
1247
2408
  };
2409
+ const isCapitalized = (name) => name.charAt(0) >= "A" && name.charAt(0) <= "Z";
2410
+ const capitalizedNamesIn = (code) => new Set(code.tokens.flatMap((token) => token.kind === "identifier" && isCapitalized(token.text) ? [token.text] : []));
2411
+ const dottedNameAt = (code, start) => {
2412
+ const segments = [];
2413
+ let index = start;
2414
+ for (let token = code.token(index); token?.kind === "identifier"; token = code.token(index)) {
2415
+ segments.push(token.text);
2416
+ if (!code.isPunctuation(index + 1, ".")) break;
2417
+ index += 2;
2418
+ }
2419
+ return {
2420
+ segments,
2421
+ endsWithWildcard: code.isPunctuation(index - 1, ".") && code.isPunctuation(index, "*")
2422
+ };
2423
+ };
2424
+ const jvmPackagePathIn = (code) => {
2425
+ const packageIndex = code.tokens.findIndex((_token, index) => code.startsLine(index) && code.isIdentifier(index, "package"));
2426
+ return packageIndex === -1 ? [] : dottedNameAt(code, packageIndex + 1).segments;
2427
+ };
2428
+ const jvmImportsIn = (code) => code.tokens.flatMap((_token, index) => {
2429
+ if (!code.startsLine(index) || !code.isIdentifier(index, "import")) return [];
2430
+ const isStaticImport = code.isIdentifier(index + 1, "static");
2431
+ const { segments, endsWithWildcard } = dottedNameAt(code, isStaticImport ? index + 2 : index + 1);
2432
+ return [endsWithWildcard && !isStaticImport ? {
2433
+ kind: "wholePackage",
2434
+ packagePath: segments
2435
+ } : {
2436
+ kind: "classOrMember",
2437
+ path: segments
2438
+ }];
2439
+ });
2440
+ const jvmSourceRootOf = (directory, packagePath) => {
2441
+ const sourceRoot = ancestorDirectory(directory, packagePath.length);
2442
+ return join(sourceRoot, ...packagePath) === directory ? sourceRoot : void 0;
2443
+ };
2444
+ const jvmClassFileCandidates = (sourceRoot, importPath) => importPath.flatMap((_segment, index) => {
2445
+ const classPath = join(sourceRoot, ...importPath.slice(0, importPath.length - index));
2446
+ return JVM_SOURCE_EXTENSIONS.map((extension) => classPath + extension);
2447
+ });
2448
+ const csharpProjectRootDeclaredIn = async (directory) => {
2449
+ return (await readdir(directory, { withFileTypes: true }).catch(() => [])).some((entry) => entry.isFile() && extname(entry.name) === CSHARP_PROJECT_EXTENSION) ? directory : void 0;
2450
+ };
2451
+ const isSearchedForHolderClasses = (directoryName) => !CSHARP_BUILD_OUTPUT_DIRECTORIES.includes(directoryName) && !EXCLUDED_DIRECTORY_NAMES.has(directoryName);
2452
+ const csharpFilesUnder = async (directory) => {
2453
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
2454
+ const files = entries.filter((entry) => entry.isFile() && extname(entry.name) === CSHARP_SOURCE_EXTENSION).map((entry) => join(directory, entry.name));
2455
+ const filesOfSubdirectories = await Promise.all(entries.filter((entry) => entry.isDirectory() && isSearchedForHolderClasses(entry.name)).map((entry) => csharpFilesUnder(join(directory, entry.name))));
2456
+ return [...files, ...filesOfSubdirectories.flat()];
2457
+ };
2458
+ const filesOfCsharpClassName = (files) => {
2459
+ const filesOfClassName = /* @__PURE__ */ new Map();
2460
+ for (const file of files) {
2461
+ const className = basename(file, CSHARP_SOURCE_EXTENSION);
2462
+ filesOfClassName.set(className, [...filesOfClassName.get(className) ?? [], file]);
2463
+ }
2464
+ return filesOfClassName;
2465
+ };
1248
2466
  const goPackageNameOfFile = async (file) => {
1249
2467
  const contents = await readFileWithinSizeLimit(file);
1250
2468
  return contents === void 0 ? void 0 : goPackageNameIn(new SourceCode(contents, "go"));
@@ -1253,14 +2471,17 @@ var LocalImports = class {
1253
2471
  dartPackages;
1254
2472
  goModules;
1255
2473
  swiftProjects;
2474
+ csharpProjectRoots;
1256
2475
  pathAliases;
1257
- goFilesOfDirectory = /* @__PURE__ */ new Map();
2476
+ filesOfDirectory = /* @__PURE__ */ new Map();
2477
+ csharpClassFilesOfProject = /* @__PURE__ */ new Map();
1258
2478
  fileExistence = /* @__PURE__ */ new Map();
1259
2479
  constructor(highestDirectory) {
1260
2480
  this.highestDirectory = highestDirectory;
1261
2481
  this.dartPackages = new NearestDeclarations(highestDirectory, dartPackageDeclaredIn);
1262
2482
  this.goModules = new NearestDeclarations(highestDirectory, goModuleDeclaredIn);
1263
2483
  this.swiftProjects = new NearestDeclarations(highestDirectory, swiftProjectDeclaredIn);
2484
+ this.csharpProjectRoots = new NearestDeclarations(highestDirectory, csharpProjectRootDeclaredIn);
1264
2485
  this.pathAliases = new PathAliases(highestDirectory);
1265
2486
  }
1266
2487
  filesImportedBy(code, file) {
@@ -1287,6 +2508,18 @@ var LocalImports = class {
1287
2508
  case "csharp": return Promise.resolve([]);
1288
2509
  }
1289
2510
  }
2511
+ holderClassFilesReferencedBy(code, file) {
2512
+ switch (code.language) {
2513
+ case "kotlin":
2514
+ case "java": return this.jvmHolderClassFilesReferencedBy(code, file);
2515
+ case "csharp": return this.csharpHolderClassFilesReferencedBy(code, file);
2516
+ case "javascript":
2517
+ case "python":
2518
+ case "dart":
2519
+ case "go":
2520
+ case "swift": return Promise.resolve([]);
2521
+ }
2522
+ }
1290
2523
  filesReExportedBy(code, file) {
1291
2524
  switch (code.language) {
1292
2525
  case "javascript": return this.javascriptFilesAt(javascriptReExportSpecifiers(code), file);
@@ -1380,11 +2613,53 @@ var LocalImports = class {
1380
2613
  case "xcodeProject": return project.root;
1381
2614
  }
1382
2615
  }
1383
- goFilesIn(directory) {
1384
- const filesListedBefore = this.goFilesOfDirectory.get(directory);
2616
+ async jvmHolderClassFilesReferencedBy(code, file) {
2617
+ const directory = dirname(file);
2618
+ const classNames = capitalizedNamesIn(code);
2619
+ const sourceRoot = jvmSourceRootOf(directory, jvmPackagePathIn(code));
2620
+ return [...sourceRoot === void 0 ? [] : await this.jvmClassFilesImportedFrom(sourceRoot, jvmImportsIn(code), classNames), ...await this.jvmClassFilesIn(directory, classNames)];
2621
+ }
2622
+ async jvmClassFilesImportedFrom(sourceRoot, imports, classNames) {
2623
+ const files = [];
2624
+ for (const jvmImport of imports) switch (jvmImport.kind) {
2625
+ case "classOrMember": {
2626
+ const importedFile = await this.firstExistingFile(jvmClassFileCandidates(sourceRoot, jvmImport.path));
2627
+ if (importedFile !== void 0) files.push(importedFile);
2628
+ break;
2629
+ }
2630
+ case "wholePackage":
2631
+ files.push(...await this.jvmClassFilesIn(join(sourceRoot, ...jvmImport.packagePath), classNames));
2632
+ break;
2633
+ }
2634
+ return files;
2635
+ }
2636
+ async jvmClassFilesIn(directory, classNames) {
2637
+ return (await this.filesIn(directory)).filter((file) => {
2638
+ const extension = extname(file);
2639
+ return JVM_SOURCE_EXTENSIONS.includes(extension) && classNames.has(basename(file, extension));
2640
+ });
2641
+ }
2642
+ async csharpHolderClassFilesReferencedBy(code, file) {
2643
+ const projectRoot = await this.csharpProjectRoots.declarationAbove(file);
2644
+ if (projectRoot === void 0) return [];
2645
+ const filesOfClassName = await this.csharpClassFilesIn(projectRoot);
2646
+ return [...capitalizedNamesIn(code)].flatMap((className) => filesOfClassName.get(className) ?? []);
2647
+ }
2648
+ csharpClassFilesIn(projectRoot) {
2649
+ const classFilesListedBefore = this.csharpClassFilesOfProject.get(projectRoot);
2650
+ if (classFilesListedBefore !== void 0) return classFilesListedBefore;
2651
+ const classFiles = csharpFilesUnder(projectRoot).then(filesOfCsharpClassName);
2652
+ this.csharpClassFilesOfProject.set(projectRoot, classFiles);
2653
+ return classFiles;
2654
+ }
2655
+ async goFilesIn(directory) {
2656
+ return (await this.filesIn(directory)).filter((file) => extname(file) === ".go");
2657
+ }
2658
+ filesIn(directory) {
2659
+ const filesListedBefore = this.filesOfDirectory.get(directory);
1385
2660
  if (filesListedBefore !== void 0) return filesListedBefore;
1386
- const files = readdir(directory, { withFileTypes: true }).then((entries) => entries.filter((entry) => entry.isFile() && extname(entry.name) === ".go").map((entry) => join(directory, entry.name)), () => []);
1387
- this.goFilesOfDirectory.set(directory, files);
2661
+ const files = readdir(directory, { withFileTypes: true }).then((entries) => entries.filter((entry) => entry.isFile()).map((entry) => join(directory, entry.name)), () => []);
2662
+ this.filesOfDirectory.set(directory, files);
1388
2663
  return files;
1389
2664
  }
1390
2665
  };
@@ -2457,19 +3732,24 @@ const sdkFamiliesUsedIn = (highestDirectory) => {
2457
3732
  }
2458
3733
  }
2459
3734
  };
2460
- const familiesMarkedThroughImports = async (file, code) => {
2461
- const importedPaths = await localImports.filesImportedBy(code, file.path);
2462
- return (await Promise.all(importedPaths.map((importedPath) => familiesReachedThrough(importedPath, RE_EXPORT_STEPS_FOLLOWED)))).flat();
3735
+ const familiesReachedThroughEach = async (paths) => {
3736
+ return (await Promise.all(paths.map((path) => familiesReachedThrough(path, RE_EXPORT_STEPS_FOLLOWED)))).flat();
2463
3737
  };
3738
+ const familiesMarkedThroughImports = async (file, code) => familiesReachedThroughEach(await localImports.filesImportedBy(code, file.path));
3739
+ const familiesMarkedInHolderClasses = async (file, code) => familiesReachedThroughEach(await localImports.holderClassFilesReferencedBy(code, file.path));
2464
3740
  const familiesMarkedInSamePackageOrTarget = async (file, code) => {
2465
3741
  const familiesFoundBefore = familiesMarkedInPackageOrTargetOfFile.get(file.path);
2466
3742
  if (familiesFoundBefore !== void 0) return familiesFoundBefore;
2467
3743
  const filesOfPackageOrTarget = await localImports.filesOfSamePackageOrTarget(code, file.path);
2468
- const families = Promise.all(filesOfPackageOrTarget.map((fileOfPackageOrTarget) => familiesReachedThrough(fileOfPackageOrTarget, RE_EXPORT_STEPS_FOLLOWED))).then((familiesOfFiles) => familiesOfFiles.flat());
3744
+ const families = familiesReachedThroughEach(filesOfPackageOrTarget);
2469
3745
  for (const fileOfPackageOrTarget of filesOfPackageOrTarget) familiesMarkedInPackageOrTargetOfFile.set(fileOfPackageOrTarget, families);
2470
3746
  return families;
2471
3747
  };
2472
- const familiesMarkedByOtherFiles = async (file, code) => [...await familiesMarkedThroughImports(file, code), ...await familiesMarkedInSamePackageOrTarget(file, code)];
3748
+ const familiesMarkedByOtherFiles = async (file, code) => [
3749
+ ...await familiesMarkedThroughImports(file, code),
3750
+ ...await familiesMarkedInSamePackageOrTarget(file, code),
3751
+ ...await familiesMarkedInHolderClasses(file, code)
3752
+ ];
2473
3753
  return async (file, code) => {
2474
3754
  const familiesForExtension = familiesOfExtension(file.extension);
2475
3755
  const markedHere = familiesWithMarkerIn(file.contents, familiesForExtension);
@@ -2533,17 +3813,16 @@ const scanCodeReferences = async (scope, projectKeys) => {
2533
3813
  };
2534
3814
  //#endregion
2535
3815
  //#region src/project-scan.ts
2536
- const scanProject = async (context, path) => {
3816
+ const scanProject = async (context, root) => {
2537
3817
  const { workingDirectory } = context.invocation;
2538
3818
  const settings = await readProjectSettings(workingDirectory);
2539
3819
  const projectSlug = requireLinkedProjectSlug(context, settings);
2540
- const root = await scanRootAt(workingDirectory, path);
2541
3820
  const client = new AdminApiClient({
2542
3821
  baseUrl: context.baseUrl,
2543
3822
  token: await configuredApiToken(context)
2544
3823
  });
2545
3824
  const project = await findProjectBySlug(client, projectSlug);
2546
- const configs = await client.listConfigs(project.id);
3825
+ const configs = (await client.listConfigs(project.id, { form: "every-config" })).map((listedConfig) => listedConfig.config);
2547
3826
  const projectKeys = new Set(configs.flatMap((config) => [config.key, ...config.deprecatedKeys.map((deprecatedKey) => deprecatedKey.key)]));
2548
3827
  const codeReferenceScan = await scanCodeReferences({
2549
3828
  root,
@@ -2552,14 +3831,107 @@ const scanProject = async (context, path) => {
2552
3831
  exclude: settings?.exclude ?? []
2553
3832
  }, projectKeys);
2554
3833
  return {
3834
+ client,
2555
3835
  project,
2556
3836
  configs,
2557
3837
  ignoredKeys: new Set(settings?.ignoreKeys ?? []),
2558
- scannedLocation: root.description,
2559
3838
  codeReferenceScan
2560
3839
  };
2561
3840
  };
2562
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
2563
3935
  //#region src/commands/scan.ts
2564
3936
  const referenceJson = (reference) => {
2565
3937
  switch (reference.kind) {
@@ -2584,8 +3956,7 @@ const referenceJson = (reference) => {
2584
3956
  };
2585
3957
  }
2586
3958
  };
2587
- const scanJson = (projectSlug, scan) => ({
2588
- project: projectSlug,
3959
+ const codeReferenceScanJson = (scan) => ({
2589
3960
  filesScanned: scan.filesScanned,
2590
3961
  references: scan.references.map(referenceJson),
2591
3962
  dynamicReferences: scan.dynamicReferences.map((reference) => ({
@@ -2596,6 +3967,10 @@ const scanJson = (projectSlug, scan) => ({
2596
3967
  argument: reference.argument
2597
3968
  }))
2598
3969
  });
3970
+ const scanJson = (projectSlug, scan) => ({
3971
+ project: projectSlug,
3972
+ ...codeReferenceScanJson(scan)
3973
+ });
2599
3974
  const DYNAMIC_REFERENCES_HEADING = "Dynamic references (calls whose key is not a string literal)";
2600
3975
  const withCollapsedWhitespace = (text) => {
2601
3976
  let collapsed = "";
@@ -2633,11 +4008,169 @@ const scanText = (scan, scannedLocation) => {
2633
4008
  counts
2634
4009
  ].join("\n\n")}\n`;
2635
4010
  };
2636
- const scan = async (context, [path]) => {
2637
- const { project, scannedLocation, codeReferenceScan } = await scanProject(context, path);
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);
2638
4037
  return {
2639
4038
  json: scanJson(project.slug, codeReferenceScan),
2640
- text: scanText(codeReferenceScan, scannedLocation),
4039
+ text: scanText(codeReferenceScan, root.description),
4040
+ exitCode: ExitCode.success
4041
+ };
4042
+ };
4043
+ //#endregion
4044
+ //#region src/segments/segment-text.ts
4045
+ const conditionGroupLines = (heading, groups) => [
4046
+ "",
4047
+ heading,
4048
+ ...groups.map((group, index) => ` ${index + 1}. ${describeConditionGroup(group)}`)
4049
+ ];
4050
+ const segmentText = (segment, environmentSlugs) => [
4051
+ segment.key,
4052
+ ` Name: ${segment.name}`,
4053
+ ...conditionGroupLines("Project definition", segment.groups),
4054
+ ...Object.entries(segment.overrides).flatMap(([environmentId, groups]) => conditionGroupLines(`Override in ${environmentSlugs.get(environmentId) ?? environmentId}`, groups)),
4055
+ ""
4056
+ ].join("\n");
4057
+ //#endregion
4058
+ //#region src/commands/segments.ts
4059
+ const usageDescription = (segment) => `used by ${segment.usages.length} ${plural(segment.usages.length, "config", "configs")}`;
4060
+ const segmentLines = (projectSlug, segments) => segments.length === 0 ? [`No segments found in ${projectSlug}.`] : alignedColumns(segments.map((segment) => [
4061
+ segment.key,
4062
+ segment.name,
4063
+ usageDescription(segment)
4064
+ ]));
4065
+ const listSegments = async (context) => {
4066
+ const { client, project } = await connectToLinkedProject(context);
4067
+ const { parsedBody: segments, responseBody } = await client.listSegments(project.id);
4068
+ return {
4069
+ json: responseBody,
4070
+ text: [...segmentLines(project.slug, segments), ""].join("\n"),
4071
+ exitCode: ExitCode.success
4072
+ };
4073
+ };
4074
+ const environmentSlugsFor = async (client, project, segment) => {
4075
+ if (Object.keys(segment.overrides).length === 0) return /* @__PURE__ */ new Map();
4076
+ const environments = await ifPermitted(client.listEnvironments(project.id));
4077
+ return new Map((environments?.parsedBody ?? []).map((environment) => [environment.id, environment.slug]));
4078
+ };
4079
+ const getSegment = async (context, [segmentKey]) => {
4080
+ const { client, project } = await connectToLinkedProject(context);
4081
+ const receivedSegment = await client.getSegment(project.id, segmentKey);
4082
+ if (receivedSegment === void 0) throw new CliError(`No segment in ${project.slug} has the key "${segmentKey}".`);
4083
+ const { parsedBody: segment, responseBody } = receivedSegment;
4084
+ return {
4085
+ json: responseBody,
4086
+ text: segmentText(segment, await environmentSlugsFor(client, project, segment)),
4087
+ exitCode: ExitCode.success
4088
+ };
4089
+ };
4090
+ //#endregion
4091
+ //#region src/targeting-rules/reference-text.ts
4092
+ const headedListLines = (heading, items) => [heading, ...items.map((item) => ` ${item}`)];
4093
+ const operatorsByTargetTypeLines = (operatorsByTargetType) => ["Operators per target type:", ...Object.entries(operatorsByTargetType).flatMap(([targetType, operators]) => [` ${targetType}:`, ...operators.map((operator) => ` ${operator}`)])];
4094
+ const targetingRulesReferenceText = (reference) => [
4095
+ ...reference.notes.flatMap((note) => [note, ""]),
4096
+ ...headedListLines("Condition kinds:", reference.conditionKinds),
4097
+ "",
4098
+ ...headedListLines("Attributes a condition can test:", reference.conditionAttributes),
4099
+ "",
4100
+ ...headedListLines("Context kind operators:", reference.contextKindOperators),
4101
+ "",
4102
+ ...headedListLines("Segment operators:", reference.segmentOperators),
4103
+ "",
4104
+ ...operatorsByTargetTypeLines(reference.operatorsByTargetType),
4105
+ ""
4106
+ ].join("\n");
4107
+ //#endregion
4108
+ //#region src/targeting-rules/targeting-rules-file.ts
4109
+ const STANDARD_INPUT_ARGUMENT = "-";
4110
+ const readRulesSource = async (rulesArgument, invocation) => {
4111
+ if (rulesArgument === STANDARD_INPUT_ARGUMENT) return {
4112
+ origin: "--rules reads stdin",
4113
+ contents: await text(invocation.stdin)
4114
+ };
4115
+ const { file, contents } = await readOptionFile("--rules", rulesArgument, invocation.workingDirectory);
4116
+ return {
4117
+ origin: `--rules names ${file}`,
4118
+ contents
4119
+ };
4120
+ };
4121
+ const readTargetingRulesFile = async (rulesArgument, invocation) => {
4122
+ const { origin, contents } = await readRulesSource(rulesArgument, invocation);
4123
+ const targetingRules = parseJson(contents);
4124
+ if (!isJsonObject(targetingRules)) throw new CliError(`${origin}, which does not hold a JSON object. It takes {"defaultValue": ..., "rules": [...]}, like an entry of targets in configs get --json.`);
4125
+ return targetingRules;
4126
+ };
4127
+ const withId = (entry) => entry.id === void 0 ? {
4128
+ id: randomUUID(),
4129
+ ...entry
4130
+ } : entry;
4131
+ const conditionOrPercentageWithId = (conditionOrPercentage) => isJsonObject(conditionOrPercentage) ? withId(conditionOrPercentage) : conditionOrPercentage;
4132
+ const conditionsOrPercentagesWithIds = (rule, field) => {
4133
+ const conditionsOrPercentages = rule[field];
4134
+ return Array.isArray(conditionsOrPercentages) ? { [field]: conditionsOrPercentages.map(conditionOrPercentageWithId) } : {};
4135
+ };
4136
+ const ruleWithIds = (rule) => isJsonObject(rule) ? {
4137
+ ...withId(rule),
4138
+ ...conditionsOrPercentagesWithIds(rule, "conditions"),
4139
+ ...conditionsOrPercentagesWithIds(rule, "percentages")
4140
+ } : rule;
4141
+ const withGeneratedIds = (targetingRules) => Array.isArray(targetingRules.rules) ? {
4142
+ ...targetingRules,
4143
+ rules: targetingRules.rules.map(ruleWithIds)
4144
+ } : targetingRules;
4145
+ //#endregion
4146
+ //#region src/commands/targeting-rules.ts
4147
+ const replacedRulesLine = (configKey, environment, targetingRules) => {
4148
+ const ruleCount = Array.isArray(targetingRules.rules) ? targetingRules.rules.length : 0;
4149
+ return `Replaced the targeting rules of ${configKey} in ${environment.slug} with ${ruleCount} ${plural(ruleCount, "rule", "rules")} and the default targeting rule value ${String(targetingRules.defaultValue)}.`;
4150
+ };
4151
+ const setTargetingRules = async (context, [configKey], options) => {
4152
+ const targetingRules = withGeneratedIds(await readTargetingRulesFile(options.rules, context.invocation));
4153
+ const { client, project } = await connectToLinkedProject(context);
4154
+ const environment = await findEnvironmentBySlug(client, project, options.environment);
4155
+ const request = {
4156
+ ...targetingRules,
4157
+ environmentId: environment.id
4158
+ };
4159
+ if (await client.replaceTargetingRules(project.id, configKey, request) === "not-found") throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
4160
+ return {
4161
+ json: request,
4162
+ text: [replacedRulesLine(configKey, environment, targetingRules), ""].join("\n"),
4163
+ exitCode: ExitCode.success
4164
+ };
4165
+ };
4166
+ const showTargetingRulesReference = async (context) => {
4167
+ const { parsedBody: reference, responseBody } = await new AdminApiClient({
4168
+ baseUrl: context.baseUrl,
4169
+ token: await configuredApiToken(context)
4170
+ }).getTargetingRulesReference();
4171
+ return {
4172
+ json: responseBody,
4173
+ text: targetingRulesReferenceText(reference),
2641
4174
  exitCode: ExitCode.success
2642
4175
  };
2643
4176
  };
@@ -2836,14 +4369,15 @@ const validateText = (projectSlug, scan, scannedLocation, check) => {
2836
4369
  }), countsText(scan, check)].join("\n\n")}\n`;
2837
4370
  };
2838
4371
  const validate = async (context, [path]) => {
2839
- const { project, configs, ignoredKeys, scannedLocation, codeReferenceScan } = await scanProject(context, path);
4372
+ const root = await scanRootAt(context.invocation.workingDirectory, path);
4373
+ const { project, configs, ignoredKeys, codeReferenceScan } = await scanProject(context, root);
2840
4374
  const check = checkCodeReferences(codeReferenceScan.references, configs, ignoredKeys);
2841
4375
  return {
2842
4376
  json: {
2843
4377
  ...scanJson(project.slug, codeReferenceScan),
2844
4378
  findings: check.findings.map(findingJson)
2845
4379
  },
2846
- text: validateText(project.slug, codeReferenceScan, scannedLocation, check),
4380
+ text: validateText(project.slug, codeReferenceScan, root.description, check),
2847
4381
  exitCode: check.findings.length === 0 ? ExitCode.success : ExitCode.problemsFound
2848
4382
  };
2849
4383
  };
@@ -2855,6 +4389,7 @@ const withoutTrailingSlashes = (url) => {
2855
4389
  while (trimmedUrl.endsWith("/")) trimmedUrl = trimmedUrl.slice(0, -1);
2856
4390
  return trimmedUrl;
2857
4391
  };
4392
+ const repeatedOption = (value, values) => [...values ?? [], value];
2858
4393
  const runCommand = async (invocation, command, actionArguments) => {
2859
4394
  const parsedCommand = actionArguments.at(-1);
2860
4395
  const options = parsedCommand.optsWithGlobals();
@@ -2863,7 +4398,7 @@ const runCommand = async (invocation, command, actionArguments) => {
2863
4398
  baseUrl: withoutTrailingSlashes(options.baseUrl ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_BASE_URL") ?? DEFAULT_BASE_URL),
2864
4399
  tokenOption: options.token,
2865
4400
  projectSlug: options.project ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_PROJECT")
2866
- }, parsedCommand.processedArgs);
4401
+ }, parsedCommand.processedArgs, parsedCommand.opts());
2867
4402
  invocation.stdout.write(options.json ? `${JSON.stringify(output.json, null, 2)}\n` : output.text);
2868
4403
  return output.exitCode;
2869
4404
  };
@@ -2887,8 +4422,26 @@ const run = async (invocation) => {
2887
4422
  });
2888
4423
  program.command("login").description("save an API token for later runs, after checking it can list projects").action(commandAction(login));
2889
4424
  program.command("init").description("write configdirector.json for a project in the current directory").action(commandAction(init));
2890
- 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));
2891
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));
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));
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));
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));
4430
+ const configsCommand = program.command("configs").description("list, show, create and archive a project's configs");
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));
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));
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));
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));
4436
+ const segmentsCommand = program.command("segments").description("list a project's segments and show one segment");
4437
+ segmentsCommand.command("list").description("list the project's segments with how many configs use each").action(commandAction(listSegments));
4438
+ segmentsCommand.command("get").argument("<key>", "the segment's key").description("show a segment's condition groups and its environment overrides").action(commandAction(getSegment));
4439
+ const targetingRulesCommand = program.command("targeting-rules").description("replace a config's targeting rules in one environment and show the format they take");
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));
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));
2892
4445
  try {
2893
4446
  await program.parseAsync(invocation.commandLineArguments, { from: "user" });
2894
4447
  return exitCode;