@configdirector/cli 0.1.0-beta.1 → 0.5.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,16 @@
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 { randomUUID } from "node:crypto";
11
+ import { text } from "node:stream/consumers";
10
12
  //#region package.json
11
- var version = "0.1.0-beta.1";
13
+ var version = "0.5.0-beta.1";
12
14
  //#endregion
13
15
  //#region src/CliError.ts
14
16
  var CliError = class extends Error {
@@ -38,15 +40,39 @@ const ApiValidationErrorBodySchema = v.object({ errors: v.array(v.object({
38
40
  message: v.string()
39
41
  })) });
40
42
  const ServiceSuspendedBodySchema = v.object({ code: v.literal("SERVICE_SUSPENDED") });
43
+ const EvaluationNotFoundBodySchema = v.object({
44
+ error: v.string(),
45
+ code: v.picklist([
46
+ "CONFIG_NOT_FOUND",
47
+ "ENVIRONMENT_NOT_FOUND",
48
+ "CONTEXT_NOT_FOUND"
49
+ ])
50
+ });
41
51
  const describeFieldError = (fieldError) => fieldError.name === void 0 ? fieldError.message : `${fieldError.name}: ${fieldError.message}`;
42
52
  var ApiAuthorizationError = class extends CliError {
43
53
  constructor(code, apiMessage) {
44
54
  super(`The Admin API refused the request: ${apiMessage} (${code})`);
55
+ this.code = code;
56
+ }
57
+ };
58
+ const ifPermitted = async (request) => {
59
+ try {
60
+ return await request;
61
+ } catch (error) {
62
+ if (error instanceof ApiAuthorizationError && error.code === "INSUFFICIENT_PERMISSIONS") return;
63
+ throw error;
45
64
  }
46
65
  };
47
66
  var ApiValidationError = class extends CliError {
48
- constructor(errors) {
49
- super(`The Admin API rejected the request:\n${errors.map((error) => ` ${describeFieldError(error)}`).join("\n")}`);
67
+ constructor(fieldErrors) {
68
+ super(`The Admin API rejected the request:\n${fieldErrors.map((error) => ` ${describeFieldError(error)}`).join("\n")}`);
69
+ this.fieldErrors = fieldErrors;
70
+ }
71
+ };
72
+ var EvaluationNotFoundError = class extends CliError {
73
+ constructor(code, apiMessage) {
74
+ super(`The Admin API could not evaluate the config: ${apiMessage} (${code})`);
75
+ this.code = code;
50
76
  }
51
77
  };
52
78
  var ServiceSuspendedError = class extends CliError {
@@ -66,6 +92,566 @@ var UnexpectedApiResponseError = class extends CliError {
66
92
  }
67
93
  };
68
94
  //#endregion
95
+ //#region src/aligned-columns.ts
96
+ const COLUMN_GAP = " ";
97
+ const widestCellOf = (rows, column) => Math.max(0, ...rows.map((row) => row[column]?.length ?? 0));
98
+ const alignedColumns = (rows) => rows.map((row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widestCellOf(rows, column))).join(COLUMN_GAP));
99
+ //#endregion
100
+ //#region src/admin-api/targeting-rules.ts
101
+ const RuleValueSchema = v.union([
102
+ v.string(),
103
+ v.number(),
104
+ v.boolean()
105
+ ]);
106
+ const attributeConditionEntries = {
107
+ attribute: v.string(),
108
+ trait: v.nullish(v.string()),
109
+ operator: v.string(),
110
+ targetValues: v.array(v.unknown())
111
+ };
112
+ const AttributeConditionSchema = v.object(attributeConditionEntries);
113
+ const ConditionGroupSchema = v.array(AttributeConditionSchema);
114
+ const SegmentConditionSchema = v.object({
115
+ id: v.string(),
116
+ kind: v.literal("segment"),
117
+ operator: v.string(),
118
+ segmentId: v.string()
119
+ });
120
+ const ConditionSchema = v.variant("kind", [v.object({
121
+ id: v.string(),
122
+ kind: v.literal("attribute"),
123
+ ...attributeConditionEntries
124
+ }), SegmentConditionSchema]);
125
+ const PercentageSchema = v.object({
126
+ percentage: v.number(),
127
+ value: RuleValueSchema
128
+ });
129
+ const RuleSchema = v.variant("type", [v.variant("target", [v.object({
130
+ id: v.string(),
131
+ type: v.literal("conditional"),
132
+ conditions: v.array(ConditionSchema),
133
+ target: v.literal("value"),
134
+ value: RuleValueSchema
135
+ }), v.object({
136
+ id: v.string(),
137
+ type: v.literal("conditional"),
138
+ conditions: v.array(ConditionSchema),
139
+ target: v.literal("percentage"),
140
+ percentages: v.array(PercentageSchema)
141
+ })]), v.object({
142
+ id: v.string(),
143
+ type: v.literal("percentage"),
144
+ percentages: v.array(PercentageSchema)
145
+ })]);
146
+ //#endregion
147
+ //#region src/admin-api/config-with-targeting-rules.ts
148
+ const TargetingRulesInEnvironmentSchema = v.object({
149
+ environment: v.object({
150
+ slug: v.string(),
151
+ live: v.boolean()
152
+ }),
153
+ rules: v.array(RuleSchema),
154
+ defaultValue: v.string()
155
+ });
156
+ const NumericBoundSchema = v.object({
157
+ relation: v.string(),
158
+ value: v.number()
159
+ });
160
+ const NumericBoundsSchema = v.object({
161
+ min: v.optional(NumericBoundSchema),
162
+ max: v.optional(NumericBoundSchema)
163
+ });
164
+ const EnumValuesSchema = v.object({
165
+ valueType: v.string(),
166
+ values: v.array(v.string())
167
+ });
168
+ const TimespanRangeSchema = v.object({
169
+ unit: v.string(),
170
+ allowFractions: v.nullish(v.boolean()),
171
+ min: v.number(),
172
+ max: v.number()
173
+ });
174
+ const ConstrainedTypeSchema = v.variant("type", [
175
+ v.object({
176
+ type: v.literal("integer"),
177
+ typeOptions: v.nullish(NumericBoundsSchema)
178
+ }),
179
+ v.object({
180
+ type: v.literal("float"),
181
+ typeOptions: v.nullish(NumericBoundsSchema)
182
+ }),
183
+ v.object({
184
+ type: v.literal("enum"),
185
+ typeOptions: EnumValuesSchema
186
+ }),
187
+ v.object({
188
+ type: v.literal("timespan"),
189
+ typeOptions: TimespanRangeSchema
190
+ }),
191
+ v.object({
192
+ type: v.literal("url"),
193
+ typeOptions: v.nullish(v.object({ secureOnly: v.nullish(v.boolean()) }))
194
+ }),
195
+ v.object({
196
+ type: v.literal("json"),
197
+ typeOptions: v.nullish(v.object({ useSchema: v.optional(v.boolean()) }))
198
+ })
199
+ ]);
200
+ const CONSTRAINED_TYPE_NAMES = new Set(ConstrainedTypeSchema.options.map((option) => option.entries.type.literal));
201
+ const ConfigTypeSchema = v.union([ConstrainedTypeSchema, v.object({ type: v.pipe(v.string(), v.notValues([...CONSTRAINED_TYPE_NAMES])) })]);
202
+ const hasTypeConstraints = (configType) => CONSTRAINED_TYPE_NAMES.has(configType.type);
203
+ const ConfigWithTargetingRulesSchema = v.intersect([v.object({
204
+ key: v.string(),
205
+ description: v.nullish(v.string()),
206
+ role: v.string(),
207
+ lifetime: v.string(),
208
+ state: v.string(),
209
+ client: v.boolean(),
210
+ server: v.boolean(),
211
+ tags: v.array(v.object({ name: v.string() })),
212
+ targets: v.array(TargetingRulesInEnvironmentSchema)
213
+ }), ConfigTypeSchema]);
214
+ //#endregion
215
+ //#region src/old-key-lines.ts
216
+ const oldKeyLines = (requestedKey, currentKey) => requestedKey === currentKey ? [] : [`${requestedKey} is an old key of ${currentKey}.`];
217
+ //#endregion
218
+ //#region src/rule-sentences.ts
219
+ const TRAIT_POINTER_PREFIX = "/";
220
+ const ATTRIBUTE_LABELS = {
221
+ identifier: "context identifier",
222
+ kind: "context kind",
223
+ name: "context name",
224
+ appName: "app name",
225
+ appVersion: "app version"
226
+ };
227
+ const formatPercentage = (value) => Number(value.toFixed(3)).toString();
228
+ const subjectOf = (condition) => {
229
+ if (condition.attribute === "traits" && condition.trait != null) return condition.trait.startsWith(TRAIT_POINTER_PREFIX) ? condition.trait.slice(1) : condition.trait;
230
+ return ATTRIBUTE_LABELS[condition.attribute] ?? condition.attribute;
231
+ };
232
+ const segmentNameOf = (segmentId, segments) => segments[segmentId]?.name ?? segmentId;
233
+ const describeAttributeCondition = (condition) => `${subjectOf(condition)} ${condition.operator} ${condition.targetValues.map(String).join(", ")}`;
234
+ const describeConditionGroup = (group) => group.map(describeAttributeCondition).join(" and ");
235
+ const describeCondition = (condition, segments) => {
236
+ switch (condition.kind) {
237
+ case "attribute": return describeAttributeCondition(condition);
238
+ case "segment": return `${condition.operator} segment ${segmentNameOf(condition.segmentId, segments)}`;
239
+ }
240
+ };
241
+ const describePercentages = (percentages) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${String(percentage.value)}`).join(", ");
242
+ const describeRule = (rule, segments) => {
243
+ if (rule.type === "percentage") return describePercentages(rule.percentages);
244
+ return `${rule.conditions.map((condition) => describeCondition(condition, segments)).join(" and ")}: ${rule.target === "value" ? String(rule.value) : describePercentages(rule.percentages)}`;
245
+ };
246
+ //#endregion
247
+ //#region src/configs/config-text.ts
248
+ const boundsDescriptions = (bounds) => {
249
+ const limits = [bounds?.min, bounds?.max].flatMap((bound) => bound === void 0 ? [] : [`${bound.relation} ${bound.value}`]);
250
+ return limits.length === 0 ? [] : [limits.join(" and ")];
251
+ };
252
+ const enumValuesDescription = ({ valueType, values }) => valueType === "number" ? `one of the numbers ${values.join(", ")}` : `one of ${values.join(", ")}`;
253
+ const timespanRangeDescriptions = (range) => [`${range.min} to ${range.max} ${range.unit}`, ...range.allowFractions ? ["fractions allowed"] : []];
254
+ const typeConstraintDescriptions = (config) => {
255
+ if (!hasTypeConstraints(config)) return [];
256
+ switch (config.type) {
257
+ case "integer":
258
+ case "float": return boundsDescriptions(config.typeOptions);
259
+ case "enum": return [enumValuesDescription(config.typeOptions)];
260
+ case "timespan": return timespanRangeDescriptions(config.typeOptions);
261
+ case "url": return config.typeOptions?.secureOnly ? ["HTTPS only"] : [];
262
+ case "json": return config.typeOptions?.useSchema ? ["checked against a JSON schema"] : [];
263
+ }
264
+ };
265
+ const availabilityDescription = (config) => {
266
+ if (config.client && config.server) return "client and server SDKs";
267
+ if (config.client) return "client-only";
268
+ if (config.server) return "server-only";
269
+ return "served to no SDK";
270
+ };
271
+ const settingLines = (config) => [
272
+ ...config.description ? [`Description: ${config.description}`] : [],
273
+ `Type: ${[config.type, ...typeConstraintDescriptions(config)].join(", ")}`,
274
+ `Role: ${config.role}`,
275
+ `Lifetime: ${config.lifetime}`,
276
+ `Availability: ${availabilityDescription(config)}`,
277
+ `Lifecycle state: ${config.state}`,
278
+ ...config.tags.length === 0 ? [] : [`Tags: ${config.tags.map((tag) => tag.name).join(", ")}`]
279
+ ];
280
+ const environmentHeading = (targetingRules) => targetingRules.environment.live ? `${targetingRules.environment.slug} (live)` : targetingRules.environment.slug;
281
+ const targetingRuleLines = (targetingRules, segments) => [
282
+ environmentHeading(targetingRules),
283
+ ...targetingRules.rules.map((rule, index) => ` ${index + 1}. ${describeRule(rule, segments)}`),
284
+ ` Default targeting rule: ${targetingRules.defaultValue}`
285
+ ];
286
+ const configText = (config, requestedKey, segments) => [
287
+ ...oldKeyLines(requestedKey, config.key),
288
+ config.key,
289
+ ...settingLines(config).map((line) => ` ${line}`),
290
+ ...config.targets.flatMap((targetingRules) => ["", ...targetingRuleLines(targetingRules, segments)]),
291
+ ""
292
+ ].join("\n");
293
+ //#endregion
294
+ //#region src/file-contents.ts
295
+ const readFileIfPresent = async (file) => {
296
+ try {
297
+ return await readFile(file, "utf8");
298
+ } catch (error) {
299
+ if (error.code === "ENOENT") return;
300
+ throw error;
301
+ }
302
+ };
303
+ const readOptionFile = async (argumentLabel, path, workingDirectory) => {
304
+ const file = resolve(workingDirectory, path);
305
+ const contents = await readFileIfPresent(file);
306
+ if (contents === void 0) throw new CliError(`${argumentLabel} names ${file}, which does not exist.`);
307
+ return {
308
+ file,
309
+ contents
310
+ };
311
+ };
312
+ const FILE_ARGUMENT_MARKER = "@";
313
+ const atFileArgumentText = async (argumentLabel, argument, workingDirectory) => {
314
+ if (!argument.startsWith(FILE_ARGUMENT_MARKER)) return argument;
315
+ return (await readOptionFile(argumentLabel, argument.slice(1), workingDirectory)).contents;
316
+ };
317
+ const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
318
+ const parseJson = (contents) => {
319
+ try {
320
+ return JSON.parse(contents);
321
+ } catch {
322
+ return;
323
+ }
324
+ };
325
+ const pathExists = (path) => stat(path).then(() => true, () => false);
326
+ const fileExists = (path) => stat(path).then((stats) => stats.isFile(), () => false);
327
+ //#endregion
328
+ //#region src/configs/new-config.ts
329
+ const CONFIG_TYPES = [
330
+ "boolean",
331
+ "string",
332
+ "integer",
333
+ "float",
334
+ "timespan",
335
+ "enum",
336
+ "url",
337
+ "json"
338
+ ];
339
+ const CONFIG_ROLES = [
340
+ "config",
341
+ "flag",
342
+ "kill-switch",
343
+ "experiment"
344
+ ];
345
+ const CONFIG_LIFETIMES = ["permanent", "temporary"];
346
+ const TIMESPAN_UNITS = [
347
+ "milliseconds",
348
+ "seconds",
349
+ "minutes"
350
+ ];
351
+ const ENUM_VALUE_TYPES = ["string", "number"];
352
+ const DEFAULT_ENUM_VALUE_TYPE = "string";
353
+ const TYPE_CONSTRAINT_OPTION_NAMES = {
354
+ min: "--min",
355
+ max: "--max",
356
+ exclusiveMin: "--exclusive-min",
357
+ exclusiveMax: "--exclusive-max",
358
+ unit: "--unit",
359
+ allowFractions: "--allow-fractions",
360
+ allowedValue: "--allowed-value",
361
+ enumValueType: "--enum-value-type",
362
+ httpsOnly: "--https-only",
363
+ jsonSchema: "--json-schema"
364
+ };
365
+ const availabilityOf = (options) => {
366
+ if (options.clientOnly) return {
367
+ client: true,
368
+ server: false
369
+ };
370
+ if (options.serverOnly) return {
371
+ client: false,
372
+ server: true
373
+ };
374
+ return {
375
+ client: true,
376
+ server: true
377
+ };
378
+ };
379
+ const BOOLEAN_VALUES = new Map([["true", true], ["false", false]]);
380
+ const numberOf = (text) => {
381
+ const value = parseJson(text);
382
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
383
+ };
384
+ const constraintNumberOf = (options, option) => {
385
+ const text = options[option];
386
+ if (text === void 0) return;
387
+ const value = numberOf(text);
388
+ if (value === void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES[option]} takes a number, not ${text}.`);
389
+ return value;
390
+ };
391
+ const boundOf = (options, option, relation) => {
392
+ const value = constraintNumberOf(options, option);
393
+ return value === void 0 ? void 0 : {
394
+ relation,
395
+ value
396
+ };
397
+ };
398
+ const numericBoundsOf = (options) => ({
399
+ min: boundOf(options, "min", ">=") ?? boundOf(options, "exclusiveMin", ">"),
400
+ max: boundOf(options, "max", "<=") ?? boundOf(options, "exclusiveMax", "<")
401
+ });
402
+ const jsonTextOf = async ({ argumentLabel, text, workingDirectory }) => {
403
+ const jsonText = (await atFileArgumentText(argumentLabel, text, workingDirectory)).trim();
404
+ return parseJson(jsonText) === void 0 ? void 0 : jsonText;
405
+ };
406
+ const jsonSchemaOptionsOf = async (options, workingDirectory) => {
407
+ if (options.jsonSchema === void 0) return;
408
+ const { file, contents } = await readOptionFile(TYPE_CONSTRAINT_OPTION_NAMES.jsonSchema, options.jsonSchema, workingDirectory);
409
+ const schema = parseJson(contents);
410
+ if (schema === void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES.jsonSchema} names ${file}, which is not JSON.`);
411
+ return {
412
+ useSchema: true,
413
+ schema
414
+ };
415
+ };
416
+ const timespanRangeOf = async (options) => {
417
+ if (options.unit === void 0 || options.min === void 0 || options.max === void 0) throw new CliError("--type timespan needs --unit, --min and --max.");
418
+ return {
419
+ unit: options.unit,
420
+ min: constraintNumberOf(options, "min"),
421
+ max: constraintNumberOf(options, "max"),
422
+ allowFractions: options.allowFractions
423
+ };
424
+ };
425
+ const NUMERIC_BOUND_OPTIONS = [
426
+ "min",
427
+ "max",
428
+ "exclusiveMin",
429
+ "exclusiveMax"
430
+ ];
431
+ const CONFIG_TYPE_RULES = {
432
+ boolean: {
433
+ constraintOptions: [],
434
+ valueDescription: "true or false",
435
+ valueOf: async ({ text }) => BOOLEAN_VALUES.get(text),
436
+ typeOptionsOf: async () => void 0
437
+ },
438
+ string: {
439
+ constraintOptions: [],
440
+ valueDescription: "text",
441
+ valueOf: async ({ text }) => text,
442
+ typeOptionsOf: async () => void 0
443
+ },
444
+ integer: {
445
+ constraintOptions: NUMERIC_BOUND_OPTIONS,
446
+ valueDescription: "a number",
447
+ valueOf: async ({ text }) => numberOf(text),
448
+ typeOptionsOf: async (options) => ({
449
+ isInteger: true,
450
+ ...numericBoundsOf(options)
451
+ })
452
+ },
453
+ float: {
454
+ constraintOptions: NUMERIC_BOUND_OPTIONS,
455
+ valueDescription: "a number",
456
+ valueOf: async ({ text }) => numberOf(text),
457
+ typeOptionsOf: async (options) => ({
458
+ isInteger: false,
459
+ ...numericBoundsOf(options)
460
+ })
461
+ },
462
+ timespan: {
463
+ constraintOptions: [
464
+ "min",
465
+ "max",
466
+ "unit",
467
+ "allowFractions"
468
+ ],
469
+ valueDescription: "a number",
470
+ valueOf: async ({ text }) => numberOf(text),
471
+ typeOptionsOf: timespanRangeOf
472
+ },
473
+ enum: {
474
+ constraintOptions: ["allowedValue", "enumValueType"],
475
+ valueDescription: "text",
476
+ valueOf: async ({ text }) => text,
477
+ typeOptionsOf: async (options) => ({
478
+ values: options.allowedValue ?? [],
479
+ valueType: options.enumValueType ?? DEFAULT_ENUM_VALUE_TYPE
480
+ })
481
+ },
482
+ url: {
483
+ constraintOptions: ["httpsOnly"],
484
+ valueDescription: "text",
485
+ valueOf: async ({ text }) => text,
486
+ typeOptionsOf: async (options) => ({ secureOnly: options.httpsOnly === true })
487
+ },
488
+ json: {
489
+ constraintOptions: ["jsonSchema"],
490
+ valueDescription: "JSON or @<file>",
491
+ valueOf: jsonTextOf,
492
+ typeOptionsOf: jsonSchemaOptionsOf
493
+ }
494
+ };
495
+ const typeAlternatives = (types) => types.length === 1 ? `${types[0]}` : `${types.slice(0, -1).join(", ")} or ${types.at(-1)}`;
496
+ const typesTaking = (option) => CONFIG_TYPES.filter((type) => CONFIG_TYPE_RULES[type].constraintOptions.includes(option));
497
+ const typeConstraintHelpGroup = (option) => `Options for --type ${typeAlternatives(typesTaking(option))}:`;
498
+ const TYPE_CONSTRAINT_OPTIONS = Object.keys(TYPE_CONSTRAINT_OPTION_NAMES);
499
+ const rejectOptionsOfOtherTypes = (options) => {
500
+ const { constraintOptions } = CONFIG_TYPE_RULES[options.type];
501
+ const otherTypeOption = TYPE_CONSTRAINT_OPTIONS.find((option) => options[option] !== void 0 && !constraintOptions.includes(option));
502
+ 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))}.`);
503
+ };
504
+ const typedValueOf = async (type, argument) => {
505
+ const rules = CONFIG_TYPE_RULES[type];
506
+ const value = await rules.valueOf(argument);
507
+ if (value === void 0) throw new CliError(`${argument.argumentLabel} takes ${rules.valueDescription} with --type ${type}, not ${argument.text}.`);
508
+ return value;
509
+ };
510
+ const VARIATION_SEPARATOR = "=";
511
+ const variationOf = async (type, variation, workingDirectory) => {
512
+ const separatorIndex = variation.indexOf(VARIATION_SEPARATOR);
513
+ const name = variation.slice(0, separatorIndex);
514
+ if (separatorIndex === -1 || name === "") throw new CliError(`--variation takes name=value, not ${variation}.`);
515
+ const text = variation.slice(separatorIndex + 1);
516
+ return {
517
+ name,
518
+ value: await typedValueOf(type, {
519
+ argumentLabel: `--variation ${name}`,
520
+ text,
521
+ workingDirectory
522
+ })
523
+ };
524
+ };
525
+ const variationsOf = async (options, workingDirectory) => {
526
+ const variations = [];
527
+ for (const variation of options.variation ?? []) variations.push(await variationOf(options.type, variation, workingDirectory));
528
+ return variations;
529
+ };
530
+ const newConfigOf = async (options, workingDirectory) => {
531
+ rejectOptionsOfOtherTypes(options);
532
+ return {
533
+ key: options.key,
534
+ description: options.description,
535
+ role: options.role,
536
+ lifetime: options.lifetime,
537
+ type: options.type,
538
+ typeOptions: await CONFIG_TYPE_RULES[options.type].typeOptionsOf(options, workingDirectory),
539
+ ...availabilityOf(options),
540
+ variations: await variationsOf(options, workingDirectory),
541
+ defaultValue: await typedValueOf(options.type, {
542
+ argumentLabel: "--default-value",
543
+ text: options.defaultValue,
544
+ workingDirectory
545
+ })
546
+ };
547
+ };
548
+ //#endregion
549
+ //#region src/admin-api/config-creation.ts
550
+ const CreatedConfigSchema = v.object({
551
+ key: v.string(),
552
+ type: v.string(),
553
+ role: v.string(),
554
+ lifetime: v.string(),
555
+ client: v.boolean(),
556
+ server: v.boolean()
557
+ });
558
+ //#endregion
559
+ //#region src/admin-api/segments.ts
560
+ const SegmentSchema = v.object({
561
+ id: v.string(),
562
+ key: v.string(),
563
+ name: v.string(),
564
+ usages: v.array(v.unknown())
565
+ });
566
+ const SegmentDefinitionSchema = v.object({
567
+ key: v.string(),
568
+ name: v.string(),
569
+ groups: v.array(ConditionGroupSchema),
570
+ overrides: v.record(v.string(), v.array(ConditionGroupSchema))
571
+ });
572
+ //#endregion
573
+ //#region src/admin-api/targeting-rules-reference.ts
574
+ const TargetingRulesReferenceSchema = v.object({
575
+ notes: v.array(v.string()),
576
+ conditionKinds: v.array(v.string()),
577
+ conditionAttributes: v.array(v.string()),
578
+ contextKindOperators: v.array(v.string()),
579
+ segmentOperators: v.array(v.string()),
580
+ operatorsByTargetType: v.record(v.string(), v.array(v.string()))
581
+ });
582
+ //#endregion
583
+ //#region src/admin-api/config-evaluation.ts
584
+ const CheckedOutcomeSchema = v.picklist(["matched", "not-matched"]);
585
+ const AttributeConditionExplanationSchema = v.object({
586
+ conditionId: v.string(),
587
+ kind: v.literal("attribute"),
588
+ outcome: CheckedOutcomeSchema,
589
+ resolvedValue: v.optional(v.unknown()),
590
+ resolvedType: v.picklist([
591
+ "absent",
592
+ "scalar",
593
+ "array",
594
+ "object",
595
+ "unknown-attribute"
596
+ ])
597
+ });
598
+ const SegmentConditionExplanationSchema = v.object({
599
+ conditionId: v.string(),
600
+ kind: v.literal("segment"),
601
+ outcome: CheckedOutcomeSchema,
602
+ segmentId: v.string(),
603
+ segmentFound: v.boolean(),
604
+ matchedGroupIndex: v.optional(v.number())
605
+ });
606
+ const ConditionExplanationSchema = v.union([
607
+ v.object({
608
+ conditionId: v.string(),
609
+ outcome: v.literal("not-evaluated")
610
+ }),
611
+ AttributeConditionExplanationSchema,
612
+ SegmentConditionExplanationSchema
613
+ ]);
614
+ const BucketSchema = v.object({
615
+ defaultedToPredefinedBucket: v.boolean(),
616
+ assignedPercentage: v.number(),
617
+ shares: v.array(v.object({
618
+ percentageId: v.string(),
619
+ from: v.number(),
620
+ to: v.number()
621
+ })),
622
+ selectedPercentageId: v.optional(v.string())
623
+ });
624
+ const RuleExplanationSchema = v.object({
625
+ ruleId: v.string(),
626
+ outcome: v.picklist([
627
+ "matched",
628
+ "not-matched",
629
+ "not-evaluated",
630
+ "errored"
631
+ ]),
632
+ conditions: v.array(ConditionExplanationSchema),
633
+ bucket: v.optional(BucketSchema)
634
+ });
635
+ const ConfigEvaluationSchema = v.object({
636
+ key: v.string(),
637
+ environment: v.object({ name: v.string() }),
638
+ context: v.nullable(v.object({
639
+ kind: v.string(),
640
+ identifier: v.nullable(v.string())
641
+ })),
642
+ value: v.optional(v.string()),
643
+ servedBy: v.variant("kind", [v.object({
644
+ kind: v.literal("rule"),
645
+ ruleId: v.string()
646
+ }), v.object({ kind: v.literal("default") })]),
647
+ rules: v.array(RuleExplanationSchema),
648
+ targetingRules: v.object({
649
+ rules: v.array(RuleSchema),
650
+ defaultValue: v.string()
651
+ }),
652
+ segments: v.record(v.string(), v.object({ name: v.string() }))
653
+ });
654
+ //#endregion
69
655
  //#region src/admin-api/AdminApiClient.ts
70
656
  const ProjectSchema = v.object({
71
657
  id: v.string(),
@@ -75,6 +661,9 @@ const ProjectSchema = v.object({
75
661
  const configFields = {
76
662
  key: v.string(),
77
663
  deprecatedKeys: v.array(v.object({ key: v.string() })),
664
+ role: v.string(),
665
+ lifetime: v.string(),
666
+ state: v.string(),
78
667
  client: v.boolean(),
79
668
  server: v.boolean()
80
669
  };
@@ -91,6 +680,28 @@ const ConfigSchema = v.variant("type", [EnumConfigSchema, v.object({
91
680
  type: v.pipe(v.string(), v.notValue("enum"))
92
681
  })]);
93
682
  const isEnumConfig = (config) => config.type === "enum";
683
+ const EnvironmentSchema = v.object({
684
+ id: v.string(),
685
+ slug: v.string(),
686
+ name: v.string(),
687
+ live: v.boolean()
688
+ });
689
+ const ConfigActivitySchema = v.object({ latestEvaluations: v.array(v.object({
690
+ environment: v.object({ slug: v.string() }),
691
+ source: v.string(),
692
+ sdkName: v.nullable(v.string()),
693
+ sdkVersion: v.nullable(v.string()),
694
+ time: v.pipe(v.string(), v.isoTimestamp())
695
+ })) });
696
+ const TagSchema = v.object({ id: v.string() });
697
+ const tagFilterQuery = (tagFilter) => {
698
+ switch (tagFilter.form) {
699
+ case "every-config": return "";
700
+ case "tagged": return `?${new URLSearchParams(tagFilter.tagIds.map((tagId) => ["tagIds", tagId]))}`;
701
+ case "untagged": return "?untagged=true";
702
+ }
703
+ };
704
+ const MissingResourceBodySchema = v.strictObject({});
94
705
  const readJsonBody = async (response) => {
95
706
  try {
96
707
  return await response.json();
@@ -109,6 +720,10 @@ const failureFor = (url, response, body) => {
109
720
  const authorizationError = v.safeParse(ApiAuthorizationErrorBodySchema, body);
110
721
  return authorizationError.success ? new ApiAuthorizationError(authorizationError.output.code, authorizationError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
111
722
  }
723
+ case 404: {
724
+ const notFoundError = v.safeParse(EvaluationNotFoundBodySchema, body);
725
+ return notFoundError.success ? new EvaluationNotFoundError(notFoundError.output.code, notFoundError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
726
+ }
112
727
  case 429: {
113
728
  const retryAfterSeconds = response.headers.get("retry-after");
114
729
  return v.is(ServiceSuspendedBodySchema, body) && retryAfterSeconds !== null ? new ServiceSuspendedError(retryAfterSeconds) : new UnexpectedApiResponseError(url, describeStatus(response));
@@ -116,6 +731,16 @@ const failureFor = (url, response, body) => {
116
731
  default: return new UnexpectedApiResponseError(url, describeStatus(response));
117
732
  }
118
733
  };
734
+ const isMissingResource = (received) => received.response.status === 404 && v.is(MissingResourceBodySchema, received.body);
735
+ const parsedResponseOf = ({ url, response, body }, schema) => {
736
+ if (!response.ok) throw failureFor(url, response, body);
737
+ const parsedBody = v.safeParse(schema, body);
738
+ if (!parsedBody.success) throw new UnexpectedApiResponseError(url, `an ${describeStatus(response)} response the CLI cannot read`);
739
+ return {
740
+ responseBody: body,
741
+ parsedBody: parsedBody.output
742
+ };
743
+ };
119
744
  var AdminApiClient = class {
120
745
  constructor(connection) {
121
746
  this.connection = connection;
@@ -123,51 +748,101 @@ var AdminApiClient = class {
123
748
  listProjects() {
124
749
  return this.get("/v1/projects", v.array(ProjectSchema));
125
750
  }
126
- listConfigs(projectId) {
127
- return this.get(`/v1/projects/${encodeURIComponent(projectId)}/configs`, v.array(ConfigSchema));
751
+ async listConfigs(projectId, tagFilter) {
752
+ const { parsedBody: configs, responseBody } = await this.get(`/v1/projects/${encodeURIComponent(projectId)}/configs${tagFilterQuery(tagFilter)}`, v.array(ConfigSchema));
753
+ const receivedConfigs = v.parse(v.array(v.unknown()), responseBody);
754
+ return configs.map((config, index) => ({
755
+ config,
756
+ receivedConfig: receivedConfigs[index]
757
+ }));
758
+ }
759
+ listEnvironments(projectId) {
760
+ return this.get(`/v1/projects/${encodeURIComponent(projectId)}/environments`, v.array(EnvironmentSchema));
761
+ }
762
+ getConfig(projectId, configKey) {
763
+ return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, ConfigWithTargetingRulesSchema);
764
+ }
765
+ getConfigActivity(projectId, configKey) {
766
+ return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/activity`, ConfigActivitySchema);
767
+ }
768
+ listSegments(projectId) {
769
+ return this.get(`/v1/projects/${encodeURIComponent(projectId)}/segments`, v.array(SegmentSchema));
770
+ }
771
+ getSegment(projectId, segmentKey) {
772
+ return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, SegmentDefinitionSchema);
773
+ }
774
+ async findTagByName(projectId, name) {
775
+ return (await this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/tags/by-name?${new URLSearchParams({ name })}`, TagSchema))?.parsedBody;
776
+ }
777
+ createConfig(projectId, request) {
778
+ return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs`, CreatedConfigSchema, {
779
+ method: "POST",
780
+ body: request
781
+ });
782
+ }
783
+ async evaluateConfig(projectId, configKey, request) {
784
+ const { responseBody, parsedBody } = await this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/evaluate`, ConfigEvaluationSchema, {
785
+ method: "POST",
786
+ body: request
787
+ });
788
+ return {
789
+ evaluation: parsedBody,
790
+ responseBody
791
+ };
792
+ }
793
+ getTargetingRulesReference() {
794
+ return this.get("/v1/targeting-rules-reference", TargetingRulesReferenceSchema);
795
+ }
796
+ async replaceTargetingRules(projectId, configKey, request) {
797
+ return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/targets`, v.unknown(), {
798
+ method: "PUT",
799
+ body: request
800
+ }) === void 0 ? "not-found" : "replaced";
801
+ }
802
+ get(path, schema) {
803
+ return this.requestJson(path, schema, { method: "GET" });
804
+ }
805
+ getIfFound(path, schema) {
806
+ return this.requestIfFound(path, schema, { method: "GET" });
807
+ }
808
+ async requestIfFound(path, schema, request) {
809
+ const received = await this.send(path, request);
810
+ return isMissingResource(received) ? void 0 : parsedResponseOf(received, schema);
811
+ }
812
+ async requestJson(path, schema, request) {
813
+ return parsedResponseOf(await this.send(path, request), schema);
128
814
  }
129
- async get(path, schema) {
815
+ async send(path, request) {
130
816
  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) {
817
+ const response = await this.fetchResponse(url, request);
818
+ return {
819
+ url,
820
+ response,
821
+ body: await readJsonBody(response)
822
+ };
823
+ }
824
+ async fetchResponse(url, request) {
825
+ const headers = {
826
+ Accept: "application/json",
827
+ Authorization: `Bearer ${this.connection.token}`,
828
+ "User-Agent": `configdirector-cli/${version}`,
829
+ "X-SourceClient": "cli"
830
+ };
139
831
  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
- } });
832
+ return await fetch(url, request.method === "GET" ? { headers } : {
833
+ method: request.method,
834
+ headers: {
835
+ ...headers,
836
+ "Content-Type": "application/json"
837
+ },
838
+ body: JSON.stringify(request.body)
839
+ });
146
840
  } catch (error) {
147
841
  throw new ApiUnreachableError(this.connection.baseUrl, error);
148
842
  }
149
843
  }
150
844
  };
151
845
  //#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
846
  //#region src/saved-api-token.ts
172
847
  const SavedApiTokenSchema = v.object({ token: v.string() });
173
848
  const platformConfigDirectory = (invocation) => {
@@ -210,22 +885,6 @@ const configuredApiToken = async (context) => {
210
885
  return token;
211
886
  };
212
887
  //#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
888
  //#region src/project-settings.ts
230
889
  const ProjectSettingsSchema = v.object({
231
890
  project: v.optional(v.string()),
@@ -247,6 +906,395 @@ const readProjectSettings = async (workingDirectory) => {
247
906
  return settings.output;
248
907
  };
249
908
  //#endregion
909
+ //#region src/project-selection.ts
910
+ const requireProjectSlug = (context) => {
911
+ if (context.projectSlug === void 0) throw new CliError("Name the project with --project <slug> or CONFIGDIRECTOR_PROJECT.");
912
+ return context.projectSlug;
913
+ };
914
+ const requireLinkedProjectSlug = (context, settings) => {
915
+ const slug = context.projectSlug ?? settings?.project;
916
+ if (slug === void 0) throw new CliError("Name the project with --project <slug>, CONFIGDIRECTOR_PROJECT, or `project` in configdirector.json, which `configdirector init` writes.");
917
+ return slug;
918
+ };
919
+ const findProjectBySlug = async (client, slug) => {
920
+ const { parsedBody: projects } = await client.listProjects();
921
+ const project = projects.find((listedProject) => listedProject.slug === slug);
922
+ if (project === void 0) throw new CliError(`No project in the API token's organization has the slug "${slug}".`);
923
+ return project;
924
+ };
925
+ const connectToLinkedProject = async (context) => {
926
+ const projectSlug = requireLinkedProjectSlug(context, await readProjectSettings(context.invocation.workingDirectory));
927
+ const client = new AdminApiClient({
928
+ baseUrl: context.baseUrl,
929
+ token: await configuredApiToken(context)
930
+ });
931
+ return {
932
+ client,
933
+ project: await findProjectBySlug(client, projectSlug)
934
+ };
935
+ };
936
+ //#endregion
937
+ //#region src/commands/configs.ts
938
+ const LIFECYCLE_STATES = [
939
+ "new",
940
+ "in-use",
941
+ "settled",
942
+ "archive-ready",
943
+ "stale"
944
+ ];
945
+ const tagIdNamed = async (client, project, name) => {
946
+ const tag = await client.findTagByName(project.id, name);
947
+ if (tag === void 0) throw new CliError(`No tag in ${project.slug} has the name "${name}".`);
948
+ return tag.id;
949
+ };
950
+ const tagIdsNamed = async (client, project, names) => {
951
+ const tagIds = [];
952
+ for (const name of names) tagIds.push(await tagIdNamed(client, project, name));
953
+ return tagIds;
954
+ };
955
+ const tagFilterOf = async (client, project, options) => {
956
+ if (options.untagged) return { form: "untagged" };
957
+ if (options.tag === void 0) return { form: "every-config" };
958
+ return {
959
+ form: "tagged",
960
+ tagIds: await tagIdsNamed(client, project, options.tag)
961
+ };
962
+ };
963
+ const configsInState = (listedConfigs, state) => state === void 0 ? listedConfigs : listedConfigs.filter((listedConfig) => listedConfig.config.state === state);
964
+ const summaryColumnsOf = (config) => [
965
+ config.key,
966
+ config.type,
967
+ config.role,
968
+ config.lifetime,
969
+ config.state
970
+ ];
971
+ const configLines = (projectSlug, configs) => configs.length === 0 ? [`No configs found in ${projectSlug}.`] : alignedColumns(configs.map(summaryColumnsOf));
972
+ const listConfigs = async (context, _commandArguments, options) => {
973
+ const { client, project } = await connectToLinkedProject(context);
974
+ const listedConfigs = configsInState(await client.listConfigs(project.id, await tagFilterOf(client, project, options)), options.state);
975
+ return {
976
+ json: listedConfigs.map((listedConfig) => listedConfig.receivedConfig),
977
+ text: [...configLines(project.slug, listedConfigs.map((listedConfig) => listedConfig.config)), ""].join("\n"),
978
+ exitCode: ExitCode.success
979
+ };
980
+ };
981
+ const referencesSegments = (config) => config.targets.some((targetingRules) => targetingRules.rules.some((rule) => rule.type === "conditional" && rule.conditions.some((condition) => condition.kind === "segment")));
982
+ const segmentNamesFor = async (client, project, config) => {
983
+ if (!referencesSegments(config)) return {};
984
+ const segments = await ifPermitted(client.listSegments(project.id));
985
+ return Object.fromEntries((segments?.parsedBody ?? []).map((segment) => [segment.id, segment]));
986
+ };
987
+ const getConfig = async (context, [configKey]) => {
988
+ const { client, project } = await connectToLinkedProject(context);
989
+ const receivedConfig = await client.getConfig(project.id, configKey);
990
+ if (receivedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
991
+ const { parsedBody: config, responseBody } = receivedConfig;
992
+ return {
993
+ json: responseBody,
994
+ text: configText(config, configKey, await segmentNamesFor(client, project, config)),
995
+ exitCode: ExitCode.success
996
+ };
997
+ };
998
+ const createdConfigLines = (projectSlug, createdConfig, newConfig) => {
999
+ const settings = [
1000
+ createdConfig.type,
1001
+ createdConfig.role,
1002
+ createdConfig.lifetime,
1003
+ availabilityDescription(createdConfig)
1004
+ ];
1005
+ return [`Created ${createdConfig.key} in ${projectSlug}: ${settings.join(", ")}.`, `Default targeting rule in every environment: ${String(newConfig.defaultValue)}`];
1006
+ };
1007
+ const keyTakenLines = (projectSlug, configKey, existingConfig) => [`${projectSlug} already has a config with the key ${configKey}, so no config was created:`, ...alignedColumns([summaryColumnsOf(existingConfig)])];
1008
+ const rejectsKey = (error) => error instanceof ApiValidationError && error.fieldErrors.some((fieldError) => fieldError.name === "key");
1009
+ const requestCreation = async (client, project, request) => {
1010
+ try {
1011
+ const { parsedBody: createdConfig, responseBody } = await client.createConfig(project.id, request);
1012
+ return {
1013
+ outcome: "created",
1014
+ createdConfig,
1015
+ responseBody
1016
+ };
1017
+ } catch (error) {
1018
+ if (!rejectsKey(error)) throw error;
1019
+ const existingConfig = await client.getConfig(project.id, request.key);
1020
+ if (existingConfig === void 0) throw error;
1021
+ return {
1022
+ outcome: "key-taken",
1023
+ existingConfig: existingConfig.parsedBody,
1024
+ responseBody: existingConfig.responseBody
1025
+ };
1026
+ }
1027
+ };
1028
+ const creationOutput = (project, newConfig, creation) => {
1029
+ switch (creation.outcome) {
1030
+ case "created": return {
1031
+ json: creation.responseBody,
1032
+ text: [...createdConfigLines(project.slug, creation.createdConfig, newConfig), ""].join("\n"),
1033
+ exitCode: ExitCode.success
1034
+ };
1035
+ case "key-taken": return {
1036
+ json: creation.responseBody,
1037
+ text: [...keyTakenLines(project.slug, newConfig.key, creation.existingConfig), ""].join("\n"),
1038
+ exitCode: ExitCode.problemsFound
1039
+ };
1040
+ }
1041
+ };
1042
+ const createConfig = async (context, _commandArguments, options) => {
1043
+ const newConfig = await newConfigOf(options, context.invocation.workingDirectory);
1044
+ const { client, project } = await connectToLinkedProject(context);
1045
+ return creationOutput(project, newConfig, await requestCreation(client, project, {
1046
+ ...newConfig,
1047
+ tagIds: await tagIdsNamed(client, project, options.tag ?? [])
1048
+ }));
1049
+ };
1050
+ const sdkDescription = (evaluation) => [evaluation.sdkName ?? "unknown SDK", ...evaluation.sdkVersion === null ? [] : [evaluation.sdkVersion]].join(" ");
1051
+ const utcMinute = (time) => {
1052
+ const isoTime = new Date(time).toISOString();
1053
+ return `${isoTime.slice(0, 10)} ${isoTime.slice(11, 16)} UTC`;
1054
+ };
1055
+ const activityLines = (configKey, activity) => activity.latestEvaluations.length === 0 ? [`No SDK evaluations of ${configKey} found.`] : alignedColumns(activity.latestEvaluations.map((evaluation) => [
1056
+ evaluation.environment.slug,
1057
+ evaluation.source,
1058
+ sdkDescription(evaluation),
1059
+ utcMinute(evaluation.time)
1060
+ ]));
1061
+ const showConfigActivity = async (context, [configKey]) => {
1062
+ const { client, project } = await connectToLinkedProject(context);
1063
+ const receivedActivity = await client.getConfigActivity(project.id, configKey);
1064
+ if (receivedActivity === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1065
+ return {
1066
+ json: receivedActivity.responseBody,
1067
+ text: [...activityLines(configKey, receivedActivity.parsedBody), ""].join("\n"),
1068
+ exitCode: ExitCode.success
1069
+ };
1070
+ };
1071
+ //#endregion
1072
+ //#region src/commands/environments.ts
1073
+ const environmentColumns = (environment) => environment.live ? [
1074
+ environment.slug,
1075
+ environment.name,
1076
+ "live"
1077
+ ] : [environment.slug, environment.name];
1078
+ const environmentLines = (projectSlug, environments) => environments.length === 0 ? [`No environments found in ${projectSlug}.`] : alignedColumns(environments.map(environmentColumns));
1079
+ const listEnvironments = async (context) => {
1080
+ const { client, project } = await connectToLinkedProject(context);
1081
+ const { parsedBody: environments, responseBody } = await client.listEnvironments(project.id);
1082
+ return {
1083
+ json: responseBody,
1084
+ text: [...environmentLines(project.slug, environments), ""].join("\n"),
1085
+ exitCode: ExitCode.success
1086
+ };
1087
+ };
1088
+ //#endregion
1089
+ //#region src/environment-selection.ts
1090
+ const environmentSlugsSentence = (environments) => environments.length === 0 ? "The project has no environments." : `Its environments are ${environments.map((environment) => environment.slug).join(", ")}.`;
1091
+ const findEnvironmentBySlug = async (client, project, slug) => {
1092
+ const { parsedBody: environments } = await client.listEnvironments(project.id);
1093
+ const environment = environments.find((listedEnvironment) => listedEnvironment.slug === slug);
1094
+ if (environment === void 0) throw new CliError(`No environment in ${project.slug} has the slug "${slug}". ${environmentSlugsSentence(environments)}`);
1095
+ return environment;
1096
+ };
1097
+ //#endregion
1098
+ //#region src/evaluate/context-form.ts
1099
+ const DEFAULT_CONTEXT_KIND = "user";
1100
+ const TYPED_TRAIT_MARKER = ":";
1101
+ 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.`);
1102
+ const parseTraitJson = (trait, json) => {
1103
+ try {
1104
+ return JSON.parse(json);
1105
+ } catch {
1106
+ throw new CliError(`--context-trait ${trait} has a value after := that is not JSON. Write name=value for a string.`);
1107
+ }
1108
+ };
1109
+ const traitEntryOf = (trait) => {
1110
+ const separatorIndex = trait.indexOf("=");
1111
+ if (separatorIndex === -1) throw traitUsageError(trait);
1112
+ const isTyped = trait[separatorIndex - 1] === TYPED_TRAIT_MARKER;
1113
+ const name = trait.slice(0, isTyped ? separatorIndex - 1 : separatorIndex);
1114
+ if (name === "") throw traitUsageError(trait);
1115
+ const value = trait.slice(separatorIndex + 1);
1116
+ return [name, isTyped ? parseTraitJson(trait, value) : value];
1117
+ };
1118
+ const contextJsonUsageError = () => new CliError("--context-json takes the context as a JSON object, as in {\"kind\":\"user\",\"identifier\":\"123\"}, or @<file>.");
1119
+ const contextOfJson = async (contextJson, workingDirectory) => {
1120
+ const context = parseJson(await atFileArgumentText("--context-json", contextJson, workingDirectory));
1121
+ if (!isJsonObject(context)) throw contextJsonUsageError();
1122
+ return context;
1123
+ };
1124
+ const inlineContextOf = (options) => ({
1125
+ kind: options.contextKind ?? DEFAULT_CONTEXT_KIND,
1126
+ identifier: options.contextId,
1127
+ name: options.contextName,
1128
+ traits: options.contextTrait === void 0 ? void 0 : Object.fromEntries(options.contextTrait.map(traitEntryOf))
1129
+ });
1130
+ const SAVED_CONTEXT_SEPARATOR = ":";
1131
+ const savedContextReferenceOf = (savedContext) => {
1132
+ const separatorIndex = savedContext.indexOf(SAVED_CONTEXT_SEPARATOR);
1133
+ const kind = savedContext.slice(0, separatorIndex);
1134
+ const identifier = savedContext.slice(separatorIndex + 1);
1135
+ if (separatorIndex === -1 || kind === "" || identifier === "") throw new CliError(`--saved-context takes <kind>:<identifier>, as in user:123, not ${savedContext}.`);
1136
+ return {
1137
+ kind,
1138
+ identifier
1139
+ };
1140
+ };
1141
+ const givenInlineOptionNames = (options) => [
1142
+ ...options.contextKind === void 0 ? [] : ["--context-kind"],
1143
+ ...options.contextId === void 0 ? [] : ["--context-id"],
1144
+ ...options.contextName === void 0 ? [] : ["--context-name"],
1145
+ ...options.contextTrait === void 0 ? [] : ["--context-trait"]
1146
+ ];
1147
+ const givenContextDoors = (options) => {
1148
+ const { savedContext, contextJson } = options;
1149
+ const inlineOptionNames = givenInlineOptionNames(options);
1150
+ return [
1151
+ ...savedContext === void 0 ? [] : [{
1152
+ optionNames: ["--saved-context"],
1153
+ contextForm: async () => ({
1154
+ form: "saved",
1155
+ reference: savedContextReferenceOf(savedContext)
1156
+ })
1157
+ }],
1158
+ ...contextJson === void 0 ? [] : [{
1159
+ optionNames: ["--context-json"],
1160
+ contextForm: async (workingDirectory) => ({
1161
+ form: "inline",
1162
+ context: await contextOfJson(contextJson, workingDirectory)
1163
+ })
1164
+ }],
1165
+ ...inlineOptionNames.length === 0 ? [] : [{
1166
+ optionNames: inlineOptionNames,
1167
+ contextForm: async () => ({
1168
+ form: "inline",
1169
+ context: inlineContextOf(options)
1170
+ })
1171
+ }]
1172
+ ];
1173
+ };
1174
+ const contextFormOf = async (options, workingDirectory) => {
1175
+ const [door, ...otherDoors] = givenContextDoors(options);
1176
+ if (door === void 0) return { form: "empty" };
1177
+ 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.`);
1178
+ return door.contextForm(workingDirectory);
1179
+ };
1180
+ //#endregion
1181
+ //#region src/evaluate/evaluation-text.ts
1182
+ const RESOLVED_VALUE_DESCRIPTIONS = {
1183
+ absent: () => "absent",
1184
+ "unknown-attribute": () => "unknown to the evaluator",
1185
+ scalar: (resolvedValue) => JSON.stringify(resolvedValue),
1186
+ array: (resolvedValue) => JSON.stringify(resolvedValue),
1187
+ object: (resolvedValue) => JSON.stringify(resolvedValue)
1188
+ };
1189
+ const contextDescription = (evaluation, contextForm) => {
1190
+ const evaluatedContext = evaluation.context;
1191
+ if (evaluatedContext === null) return "an empty context";
1192
+ if (evaluatedContext.identifier === null) return `a ${evaluatedContext.kind} context without an identifier`;
1193
+ const identity = `${evaluatedContext.kind} ${evaluatedContext.identifier}`;
1194
+ return contextForm.form === "saved" ? `saved context ${identity}` : identity;
1195
+ };
1196
+ const servedByDescription = (evaluation) => {
1197
+ const { servedBy } = evaluation;
1198
+ switch (servedBy.kind) {
1199
+ case "rule": return `served by rule ${evaluation.rules.findIndex((rule) => rule.ruleId === servedBy.ruleId) + 1}`;
1200
+ case "default": return "served by the default targeting rule";
1201
+ }
1202
+ };
1203
+ const describeShareRange = (from, to) => `${formatPercentage(from)} to ${formatPercentage(to)}`;
1204
+ const bucketPlacement = (bucket) => {
1205
+ const assigned = `bucket ${bucket.assignedPercentage.toFixed(1)}`;
1206
+ const selectedShare = bucket.shares.find((share) => share.percentageId === bucket.selectedPercentageId);
1207
+ if (selectedShare !== void 0) return `${assigned} is in ${describeShareRange(selectedShare.from, selectedShare.to)}`;
1208
+ return `${assigned} is outside ${describeShareRange(0, bucket.shares.at(-1)?.to ?? 0)}`;
1209
+ };
1210
+ const describeBucket = (bucket) => bucket.defaultedToPredefinedBucket ? `no identifier, so ${bucketPlacement(bucket)}` : bucketPlacement(bucket);
1211
+ const withBucket = (outcome, bucket) => bucket === void 0 ? outcome : `${outcome}: ${describeBucket(bucket)}`;
1212
+ const conditionsOf = (rule) => rule?.type === "conditional" ? rule.conditions : [];
1213
+ const segmentMismatchEvidence = (explanation) => {
1214
+ if (!explanation.segmentFound) return "the segment was not found";
1215
+ return explanation.matchedGroupIndex === void 0 ? "no group matched" : `group ${explanation.matchedGroupIndex + 1} matched`;
1216
+ };
1217
+ const mismatchEvidence = (failingCondition) => {
1218
+ switch (failingCondition.kind) {
1219
+ case "attribute": return `it is ${RESOLVED_VALUE_DESCRIPTIONS[failingCondition.resolvedType](failingCondition.resolvedValue)}`;
1220
+ case "segment": return segmentMismatchEvidence(failingCondition);
1221
+ }
1222
+ };
1223
+ const isFailingCondition = (condition) => condition.outcome === "not-matched";
1224
+ const failingConditionDescription = (explanation, conditions, segments) => {
1225
+ const failingCondition = explanation.conditions.find(isFailingCondition);
1226
+ const condition = conditions.find((ruleCondition) => ruleCondition.id === failingCondition?.conditionId);
1227
+ return failingCondition === void 0 || condition === void 0 ? void 0 : `${describeCondition(condition, segments)}, but ${mismatchEvidence(failingCondition)}`;
1228
+ };
1229
+ const describeNotMatched = (explanation, conditions, segments) => {
1230
+ const failingCondition = failingConditionDescription(explanation, conditions, segments);
1231
+ return failingCondition === void 0 ? withBucket("not matched", explanation.bucket) : `not matched: ${failingCondition}`;
1232
+ };
1233
+ const RULE_OUTCOME_DESCRIPTIONS = {
1234
+ matched: (explanation) => withBucket("matched", explanation.bucket),
1235
+ "not-matched": describeNotMatched,
1236
+ "not-evaluated": () => "not evaluated",
1237
+ errored: () => "errored"
1238
+ };
1239
+ const ruleLines = (evaluation) => {
1240
+ const rulesById = new Map(evaluation.targetingRules.rules.map((rule) => [rule.id, rule]));
1241
+ return alignedColumns(evaluation.rules.map((explanation, index) => {
1242
+ const rule = rulesById.get(explanation.ruleId);
1243
+ return [`${index + 1}. ${rule === void 0 ? explanation.ruleId : describeRule(rule, evaluation.segments)}`, RULE_OUTCOME_DESCRIPTIONS[explanation.outcome](explanation, conditionsOf(rule), evaluation.segments)];
1244
+ }));
1245
+ };
1246
+ const evaluationText = (evaluation, requested) => [
1247
+ ...oldKeyLines(requested.configKey, evaluation.key),
1248
+ `${evaluation.key} in ${evaluation.environment.name} for ${contextDescription(evaluation, requested.contextForm)}`,
1249
+ "",
1250
+ `${evaluation.value ?? "no value"}, ${servedByDescription(evaluation)}`,
1251
+ "",
1252
+ ...ruleLines(evaluation),
1253
+ `Default targeting rule: ${evaluation.targetingRules.defaultValue}`,
1254
+ ""
1255
+ ].join("\n");
1256
+ //#endregion
1257
+ //#region src/commands/evaluate.ts
1258
+ const contextFieldsOf = (contextForm) => {
1259
+ switch (contextForm.form) {
1260
+ case "inline": return { context: contextForm.context };
1261
+ case "saved": return { savedContext: contextForm.reference };
1262
+ case "empty": return {};
1263
+ }
1264
+ };
1265
+ const NOT_FOUND_MESSAGES = {
1266
+ CONFIG_NOT_FOUND: ({ project, configKey }) => `No config in ${project.slug} has the key "${configKey}".`,
1267
+ ENVIRONMENT_NOT_FOUND: ({ project, environment }) => `No environment in ${project.slug} has the slug "${environment.slug}" any more.`,
1268
+ 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
1269
+ };
1270
+ const requestEvaluation = async (client, requested) => {
1271
+ try {
1272
+ return await client.evaluateConfig(requested.project.id, requested.configKey, {
1273
+ environmentId: requested.environment.id,
1274
+ ...contextFieldsOf(requested.contextForm)
1275
+ });
1276
+ } catch (error) {
1277
+ if (error instanceof EvaluationNotFoundError) throw new CliError(NOT_FOUND_MESSAGES[error.code](requested, error));
1278
+ throw error;
1279
+ }
1280
+ };
1281
+ const evaluate = async (context, [configKey], options) => {
1282
+ const contextForm = await contextFormOf(options, context.invocation.workingDirectory);
1283
+ const { client, project } = await connectToLinkedProject(context);
1284
+ const requested = {
1285
+ project,
1286
+ configKey,
1287
+ environment: await findEnvironmentBySlug(client, project, options.environment),
1288
+ contextForm
1289
+ };
1290
+ const { evaluation, responseBody } = await requestEvaluation(client, requested);
1291
+ return {
1292
+ json: responseBody,
1293
+ text: evaluationText(evaluation, requested),
1294
+ exitCode: ExitCode.success
1295
+ };
1296
+ };
1297
+ //#endregion
250
1298
  //#region src/commands/init.ts
251
1299
  const refuseExistingFile = async (file) => {
252
1300
  if (await access(file).then(() => true, () => false)) throw new CliError(`${file} already exists. init never overwrites it; edit the file instead.`);
@@ -350,6 +1398,20 @@ const login = async (context) => {
350
1398
  };
351
1399
  };
352
1400
  //#endregion
1401
+ //#region src/commands/projects.ts
1402
+ const projectLines = (projects) => projects.length === 0 ? ["No projects found."] : alignedColumns(projects.map((project) => [project.slug, project.name]));
1403
+ const listProjects = async (context) => {
1404
+ const { parsedBody: projects, responseBody } = await new AdminApiClient({
1405
+ baseUrl: context.baseUrl,
1406
+ token: await configuredApiToken(context)
1407
+ }).listProjects();
1408
+ return {
1409
+ json: responseBody,
1410
+ text: [...projectLines(projects), ""].join("\n"),
1411
+ exitCode: ExitCode.success
1412
+ };
1413
+ };
1414
+ //#endregion
353
1415
  //#region src/plural.ts
354
1416
  const plural = (count, singular, pluralForm) => count === 1 ? singular : pluralForm;
355
1417
  //#endregion
@@ -1077,6 +2139,10 @@ const DART_RE_EXPORT_DIRECTIVES = ["export"];
1077
2139
  const SWIFT_PACKAGE_MANIFEST = "Package.swift";
1078
2140
  const SWIFT_PACKAGE_TARGETS_DIRECTORIES = ["Sources", "Tests"];
1079
2141
  const XCODE_PROJECT_EXTENSION = ".xcodeproj";
2142
+ const JVM_SOURCE_EXTENSIONS = [".kt", ".java"];
2143
+ const CSHARP_PROJECT_EXTENSION = ".csproj";
2144
+ const CSHARP_SOURCE_EXTENSION = ".cs";
2145
+ const CSHARP_BUILD_OUTPUT_DIRECTORIES = ["bin", "obj"];
1080
2146
  const stringAt = (code, index) => code.token(index)?.kind === "string" ? plainStringContent(code, {
1081
2147
  first: index,
1082
2148
  last: index
@@ -1245,6 +2311,63 @@ const goPackageNameIn = (code) => {
1245
2311
  const name = code.token(1);
1246
2312
  return code.isIdentifier(0, "package") && name?.kind === "identifier" ? name.text : void 0;
1247
2313
  };
2314
+ const isCapitalized = (name) => name.charAt(0) >= "A" && name.charAt(0) <= "Z";
2315
+ const capitalizedNamesIn = (code) => new Set(code.tokens.flatMap((token) => token.kind === "identifier" && isCapitalized(token.text) ? [token.text] : []));
2316
+ const dottedNameAt = (code, start) => {
2317
+ const segments = [];
2318
+ let index = start;
2319
+ for (let token = code.token(index); token?.kind === "identifier"; token = code.token(index)) {
2320
+ segments.push(token.text);
2321
+ if (!code.isPunctuation(index + 1, ".")) break;
2322
+ index += 2;
2323
+ }
2324
+ return {
2325
+ segments,
2326
+ endsWithWildcard: code.isPunctuation(index - 1, ".") && code.isPunctuation(index, "*")
2327
+ };
2328
+ };
2329
+ const jvmPackagePathIn = (code) => {
2330
+ const packageIndex = code.tokens.findIndex((_token, index) => code.startsLine(index) && code.isIdentifier(index, "package"));
2331
+ return packageIndex === -1 ? [] : dottedNameAt(code, packageIndex + 1).segments;
2332
+ };
2333
+ const jvmImportsIn = (code) => code.tokens.flatMap((_token, index) => {
2334
+ if (!code.startsLine(index) || !code.isIdentifier(index, "import")) return [];
2335
+ const isStaticImport = code.isIdentifier(index + 1, "static");
2336
+ const { segments, endsWithWildcard } = dottedNameAt(code, isStaticImport ? index + 2 : index + 1);
2337
+ return [endsWithWildcard && !isStaticImport ? {
2338
+ kind: "wholePackage",
2339
+ packagePath: segments
2340
+ } : {
2341
+ kind: "classOrMember",
2342
+ path: segments
2343
+ }];
2344
+ });
2345
+ const jvmSourceRootOf = (directory, packagePath) => {
2346
+ const sourceRoot = ancestorDirectory(directory, packagePath.length);
2347
+ return join(sourceRoot, ...packagePath) === directory ? sourceRoot : void 0;
2348
+ };
2349
+ const jvmClassFileCandidates = (sourceRoot, importPath) => importPath.flatMap((_segment, index) => {
2350
+ const classPath = join(sourceRoot, ...importPath.slice(0, importPath.length - index));
2351
+ return JVM_SOURCE_EXTENSIONS.map((extension) => classPath + extension);
2352
+ });
2353
+ const csharpProjectRootDeclaredIn = async (directory) => {
2354
+ return (await readdir(directory, { withFileTypes: true }).catch(() => [])).some((entry) => entry.isFile() && extname(entry.name) === CSHARP_PROJECT_EXTENSION) ? directory : void 0;
2355
+ };
2356
+ const isSearchedForHolderClasses = (directoryName) => !CSHARP_BUILD_OUTPUT_DIRECTORIES.includes(directoryName) && !EXCLUDED_DIRECTORY_NAMES.has(directoryName);
2357
+ const csharpFilesUnder = async (directory) => {
2358
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
2359
+ const files = entries.filter((entry) => entry.isFile() && extname(entry.name) === CSHARP_SOURCE_EXTENSION).map((entry) => join(directory, entry.name));
2360
+ const filesOfSubdirectories = await Promise.all(entries.filter((entry) => entry.isDirectory() && isSearchedForHolderClasses(entry.name)).map((entry) => csharpFilesUnder(join(directory, entry.name))));
2361
+ return [...files, ...filesOfSubdirectories.flat()];
2362
+ };
2363
+ const filesOfCsharpClassName = (files) => {
2364
+ const filesOfClassName = /* @__PURE__ */ new Map();
2365
+ for (const file of files) {
2366
+ const className = basename(file, CSHARP_SOURCE_EXTENSION);
2367
+ filesOfClassName.set(className, [...filesOfClassName.get(className) ?? [], file]);
2368
+ }
2369
+ return filesOfClassName;
2370
+ };
1248
2371
  const goPackageNameOfFile = async (file) => {
1249
2372
  const contents = await readFileWithinSizeLimit(file);
1250
2373
  return contents === void 0 ? void 0 : goPackageNameIn(new SourceCode(contents, "go"));
@@ -1253,14 +2376,17 @@ var LocalImports = class {
1253
2376
  dartPackages;
1254
2377
  goModules;
1255
2378
  swiftProjects;
2379
+ csharpProjectRoots;
1256
2380
  pathAliases;
1257
- goFilesOfDirectory = /* @__PURE__ */ new Map();
2381
+ filesOfDirectory = /* @__PURE__ */ new Map();
2382
+ csharpClassFilesOfProject = /* @__PURE__ */ new Map();
1258
2383
  fileExistence = /* @__PURE__ */ new Map();
1259
2384
  constructor(highestDirectory) {
1260
2385
  this.highestDirectory = highestDirectory;
1261
2386
  this.dartPackages = new NearestDeclarations(highestDirectory, dartPackageDeclaredIn);
1262
2387
  this.goModules = new NearestDeclarations(highestDirectory, goModuleDeclaredIn);
1263
2388
  this.swiftProjects = new NearestDeclarations(highestDirectory, swiftProjectDeclaredIn);
2389
+ this.csharpProjectRoots = new NearestDeclarations(highestDirectory, csharpProjectRootDeclaredIn);
1264
2390
  this.pathAliases = new PathAliases(highestDirectory);
1265
2391
  }
1266
2392
  filesImportedBy(code, file) {
@@ -1287,6 +2413,18 @@ var LocalImports = class {
1287
2413
  case "csharp": return Promise.resolve([]);
1288
2414
  }
1289
2415
  }
2416
+ holderClassFilesReferencedBy(code, file) {
2417
+ switch (code.language) {
2418
+ case "kotlin":
2419
+ case "java": return this.jvmHolderClassFilesReferencedBy(code, file);
2420
+ case "csharp": return this.csharpHolderClassFilesReferencedBy(code, file);
2421
+ case "javascript":
2422
+ case "python":
2423
+ case "dart":
2424
+ case "go":
2425
+ case "swift": return Promise.resolve([]);
2426
+ }
2427
+ }
1290
2428
  filesReExportedBy(code, file) {
1291
2429
  switch (code.language) {
1292
2430
  case "javascript": return this.javascriptFilesAt(javascriptReExportSpecifiers(code), file);
@@ -1380,11 +2518,53 @@ var LocalImports = class {
1380
2518
  case "xcodeProject": return project.root;
1381
2519
  }
1382
2520
  }
1383
- goFilesIn(directory) {
1384
- const filesListedBefore = this.goFilesOfDirectory.get(directory);
2521
+ async jvmHolderClassFilesReferencedBy(code, file) {
2522
+ const directory = dirname(file);
2523
+ const classNames = capitalizedNamesIn(code);
2524
+ const sourceRoot = jvmSourceRootOf(directory, jvmPackagePathIn(code));
2525
+ return [...sourceRoot === void 0 ? [] : await this.jvmClassFilesImportedFrom(sourceRoot, jvmImportsIn(code), classNames), ...await this.jvmClassFilesIn(directory, classNames)];
2526
+ }
2527
+ async jvmClassFilesImportedFrom(sourceRoot, imports, classNames) {
2528
+ const files = [];
2529
+ for (const jvmImport of imports) switch (jvmImport.kind) {
2530
+ case "classOrMember": {
2531
+ const importedFile = await this.firstExistingFile(jvmClassFileCandidates(sourceRoot, jvmImport.path));
2532
+ if (importedFile !== void 0) files.push(importedFile);
2533
+ break;
2534
+ }
2535
+ case "wholePackage":
2536
+ files.push(...await this.jvmClassFilesIn(join(sourceRoot, ...jvmImport.packagePath), classNames));
2537
+ break;
2538
+ }
2539
+ return files;
2540
+ }
2541
+ async jvmClassFilesIn(directory, classNames) {
2542
+ return (await this.filesIn(directory)).filter((file) => {
2543
+ const extension = extname(file);
2544
+ return JVM_SOURCE_EXTENSIONS.includes(extension) && classNames.has(basename(file, extension));
2545
+ });
2546
+ }
2547
+ async csharpHolderClassFilesReferencedBy(code, file) {
2548
+ const projectRoot = await this.csharpProjectRoots.declarationAbove(file);
2549
+ if (projectRoot === void 0) return [];
2550
+ const filesOfClassName = await this.csharpClassFilesIn(projectRoot);
2551
+ return [...capitalizedNamesIn(code)].flatMap((className) => filesOfClassName.get(className) ?? []);
2552
+ }
2553
+ csharpClassFilesIn(projectRoot) {
2554
+ const classFilesListedBefore = this.csharpClassFilesOfProject.get(projectRoot);
2555
+ if (classFilesListedBefore !== void 0) return classFilesListedBefore;
2556
+ const classFiles = csharpFilesUnder(projectRoot).then(filesOfCsharpClassName);
2557
+ this.csharpClassFilesOfProject.set(projectRoot, classFiles);
2558
+ return classFiles;
2559
+ }
2560
+ async goFilesIn(directory) {
2561
+ return (await this.filesIn(directory)).filter((file) => extname(file) === ".go");
2562
+ }
2563
+ filesIn(directory) {
2564
+ const filesListedBefore = this.filesOfDirectory.get(directory);
1385
2565
  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);
2566
+ const files = readdir(directory, { withFileTypes: true }).then((entries) => entries.filter((entry) => entry.isFile()).map((entry) => join(directory, entry.name)), () => []);
2567
+ this.filesOfDirectory.set(directory, files);
1388
2568
  return files;
1389
2569
  }
1390
2570
  };
@@ -2457,19 +3637,24 @@ const sdkFamiliesUsedIn = (highestDirectory) => {
2457
3637
  }
2458
3638
  }
2459
3639
  };
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();
3640
+ const familiesReachedThroughEach = async (paths) => {
3641
+ return (await Promise.all(paths.map((path) => familiesReachedThrough(path, RE_EXPORT_STEPS_FOLLOWED)))).flat();
2463
3642
  };
3643
+ const familiesMarkedThroughImports = async (file, code) => familiesReachedThroughEach(await localImports.filesImportedBy(code, file.path));
3644
+ const familiesMarkedInHolderClasses = async (file, code) => familiesReachedThroughEach(await localImports.holderClassFilesReferencedBy(code, file.path));
2464
3645
  const familiesMarkedInSamePackageOrTarget = async (file, code) => {
2465
3646
  const familiesFoundBefore = familiesMarkedInPackageOrTargetOfFile.get(file.path);
2466
3647
  if (familiesFoundBefore !== void 0) return familiesFoundBefore;
2467
3648
  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());
3649
+ const families = familiesReachedThroughEach(filesOfPackageOrTarget);
2469
3650
  for (const fileOfPackageOrTarget of filesOfPackageOrTarget) familiesMarkedInPackageOrTargetOfFile.set(fileOfPackageOrTarget, families);
2470
3651
  return families;
2471
3652
  };
2472
- const familiesMarkedByOtherFiles = async (file, code) => [...await familiesMarkedThroughImports(file, code), ...await familiesMarkedInSamePackageOrTarget(file, code)];
3653
+ const familiesMarkedByOtherFiles = async (file, code) => [
3654
+ ...await familiesMarkedThroughImports(file, code),
3655
+ ...await familiesMarkedInSamePackageOrTarget(file, code),
3656
+ ...await familiesMarkedInHolderClasses(file, code)
3657
+ ];
2473
3658
  return async (file, code) => {
2474
3659
  const familiesForExtension = familiesOfExtension(file.extension);
2475
3660
  const markedHere = familiesWithMarkerIn(file.contents, familiesForExtension);
@@ -2543,7 +3728,7 @@ const scanProject = async (context, path) => {
2543
3728
  token: await configuredApiToken(context)
2544
3729
  });
2545
3730
  const project = await findProjectBySlug(client, projectSlug);
2546
- const configs = await client.listConfigs(project.id);
3731
+ const configs = (await client.listConfigs(project.id, { form: "every-config" })).map((listedConfig) => listedConfig.config);
2547
3732
  const projectKeys = new Set(configs.flatMap((config) => [config.key, ...config.deprecatedKeys.map((deprecatedKey) => deprecatedKey.key)]));
2548
3733
  const codeReferenceScan = await scanCodeReferences({
2549
3734
  root,
@@ -2642,6 +3827,140 @@ const scan = async (context, [path]) => {
2642
3827
  };
2643
3828
  };
2644
3829
  //#endregion
3830
+ //#region src/segments/segment-text.ts
3831
+ const conditionGroupLines = (heading, groups) => [
3832
+ "",
3833
+ heading,
3834
+ ...groups.map((group, index) => ` ${index + 1}. ${describeConditionGroup(group)}`)
3835
+ ];
3836
+ const segmentText = (segment, environmentSlugs) => [
3837
+ segment.key,
3838
+ ` Name: ${segment.name}`,
3839
+ ...conditionGroupLines("Project definition", segment.groups),
3840
+ ...Object.entries(segment.overrides).flatMap(([environmentId, groups]) => conditionGroupLines(`Override in ${environmentSlugs.get(environmentId) ?? environmentId}`, groups)),
3841
+ ""
3842
+ ].join("\n");
3843
+ //#endregion
3844
+ //#region src/commands/segments.ts
3845
+ const usageDescription = (segment) => `used by ${segment.usages.length} ${plural(segment.usages.length, "config", "configs")}`;
3846
+ const segmentLines = (projectSlug, segments) => segments.length === 0 ? [`No segments found in ${projectSlug}.`] : alignedColumns(segments.map((segment) => [
3847
+ segment.key,
3848
+ segment.name,
3849
+ usageDescription(segment)
3850
+ ]));
3851
+ const listSegments = async (context) => {
3852
+ const { client, project } = await connectToLinkedProject(context);
3853
+ const { parsedBody: segments, responseBody } = await client.listSegments(project.id);
3854
+ return {
3855
+ json: responseBody,
3856
+ text: [...segmentLines(project.slug, segments), ""].join("\n"),
3857
+ exitCode: ExitCode.success
3858
+ };
3859
+ };
3860
+ const environmentSlugsFor = async (client, project, segment) => {
3861
+ if (Object.keys(segment.overrides).length === 0) return /* @__PURE__ */ new Map();
3862
+ const environments = await ifPermitted(client.listEnvironments(project.id));
3863
+ return new Map((environments?.parsedBody ?? []).map((environment) => [environment.id, environment.slug]));
3864
+ };
3865
+ const getSegment = async (context, [segmentKey]) => {
3866
+ const { client, project } = await connectToLinkedProject(context);
3867
+ const receivedSegment = await client.getSegment(project.id, segmentKey);
3868
+ if (receivedSegment === void 0) throw new CliError(`No segment in ${project.slug} has the key "${segmentKey}".`);
3869
+ const { parsedBody: segment, responseBody } = receivedSegment;
3870
+ return {
3871
+ json: responseBody,
3872
+ text: segmentText(segment, await environmentSlugsFor(client, project, segment)),
3873
+ exitCode: ExitCode.success
3874
+ };
3875
+ };
3876
+ //#endregion
3877
+ //#region src/targeting-rules/reference-text.ts
3878
+ const headedListLines = (heading, items) => [heading, ...items.map((item) => ` ${item}`)];
3879
+ const operatorsByTargetTypeLines = (operatorsByTargetType) => ["Operators per target type:", ...Object.entries(operatorsByTargetType).flatMap(([targetType, operators]) => [` ${targetType}:`, ...operators.map((operator) => ` ${operator}`)])];
3880
+ const targetingRulesReferenceText = (reference) => [
3881
+ ...reference.notes.flatMap((note) => [note, ""]),
3882
+ ...headedListLines("Condition kinds:", reference.conditionKinds),
3883
+ "",
3884
+ ...headedListLines("Attributes a condition can test:", reference.conditionAttributes),
3885
+ "",
3886
+ ...headedListLines("Context kind operators:", reference.contextKindOperators),
3887
+ "",
3888
+ ...headedListLines("Segment operators:", reference.segmentOperators),
3889
+ "",
3890
+ ...operatorsByTargetTypeLines(reference.operatorsByTargetType),
3891
+ ""
3892
+ ].join("\n");
3893
+ //#endregion
3894
+ //#region src/targeting-rules/targeting-rules-file.ts
3895
+ const STANDARD_INPUT_ARGUMENT = "-";
3896
+ const readRulesSource = async (rulesArgument, invocation) => {
3897
+ if (rulesArgument === STANDARD_INPUT_ARGUMENT) return {
3898
+ origin: "--rules reads stdin",
3899
+ contents: await text(invocation.stdin)
3900
+ };
3901
+ const { file, contents } = await readOptionFile("--rules", rulesArgument, invocation.workingDirectory);
3902
+ return {
3903
+ origin: `--rules names ${file}`,
3904
+ contents
3905
+ };
3906
+ };
3907
+ const readTargetingRulesFile = async (rulesArgument, invocation) => {
3908
+ const { origin, contents } = await readRulesSource(rulesArgument, invocation);
3909
+ const targetingRules = parseJson(contents);
3910
+ 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.`);
3911
+ return targetingRules;
3912
+ };
3913
+ const withId = (entry) => entry.id === void 0 ? {
3914
+ id: randomUUID(),
3915
+ ...entry
3916
+ } : entry;
3917
+ const conditionOrPercentageWithId = (conditionOrPercentage) => isJsonObject(conditionOrPercentage) ? withId(conditionOrPercentage) : conditionOrPercentage;
3918
+ const conditionsOrPercentagesWithIds = (rule, field) => {
3919
+ const conditionsOrPercentages = rule[field];
3920
+ return Array.isArray(conditionsOrPercentages) ? { [field]: conditionsOrPercentages.map(conditionOrPercentageWithId) } : {};
3921
+ };
3922
+ const ruleWithIds = (rule) => isJsonObject(rule) ? {
3923
+ ...withId(rule),
3924
+ ...conditionsOrPercentagesWithIds(rule, "conditions"),
3925
+ ...conditionsOrPercentagesWithIds(rule, "percentages")
3926
+ } : rule;
3927
+ const withGeneratedIds = (targetingRules) => Array.isArray(targetingRules.rules) ? {
3928
+ ...targetingRules,
3929
+ rules: targetingRules.rules.map(ruleWithIds)
3930
+ } : targetingRules;
3931
+ //#endregion
3932
+ //#region src/commands/targeting-rules.ts
3933
+ const replacedRulesLine = (configKey, environment, targetingRules) => {
3934
+ const ruleCount = Array.isArray(targetingRules.rules) ? targetingRules.rules.length : 0;
3935
+ 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)}.`;
3936
+ };
3937
+ const setTargetingRules = async (context, [configKey], options) => {
3938
+ const targetingRules = withGeneratedIds(await readTargetingRulesFile(options.rules, context.invocation));
3939
+ const { client, project } = await connectToLinkedProject(context);
3940
+ const environment = await findEnvironmentBySlug(client, project, options.environment);
3941
+ const request = {
3942
+ ...targetingRules,
3943
+ environmentId: environment.id
3944
+ };
3945
+ if (await client.replaceTargetingRules(project.id, configKey, request) === "not-found") throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
3946
+ return {
3947
+ json: request,
3948
+ text: [replacedRulesLine(configKey, environment, targetingRules), ""].join("\n"),
3949
+ exitCode: ExitCode.success
3950
+ };
3951
+ };
3952
+ const showTargetingRulesReference = async (context) => {
3953
+ const { parsedBody: reference, responseBody } = await new AdminApiClient({
3954
+ baseUrl: context.baseUrl,
3955
+ token: await configuredApiToken(context)
3956
+ }).getTargetingRulesReference();
3957
+ return {
3958
+ json: responseBody,
3959
+ text: targetingRulesReferenceText(reference),
3960
+ exitCode: ExitCode.success
3961
+ };
3962
+ };
3963
+ //#endregion
2645
3964
  //#region src/validate/findings.ts
2646
3965
  const servedValueTypesOfConfigTypes = new Map([
2647
3966
  ["boolean", "boolean"],
@@ -2855,6 +4174,7 @@ const withoutTrailingSlashes = (url) => {
2855
4174
  while (trimmedUrl.endsWith("/")) trimmedUrl = trimmedUrl.slice(0, -1);
2856
4175
  return trimmedUrl;
2857
4176
  };
4177
+ const repeatedOption = (value, values) => [...values ?? [], value];
2858
4178
  const runCommand = async (invocation, command, actionArguments) => {
2859
4179
  const parsedCommand = actionArguments.at(-1);
2860
4180
  const options = parsedCommand.optsWithGlobals();
@@ -2863,7 +4183,7 @@ const runCommand = async (invocation, command, actionArguments) => {
2863
4183
  baseUrl: withoutTrailingSlashes(options.baseUrl ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_BASE_URL") ?? DEFAULT_BASE_URL),
2864
4184
  tokenOption: options.token,
2865
4185
  projectSlug: options.project ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_PROJECT")
2866
- }, parsedCommand.processedArgs);
4186
+ }, parsedCommand.processedArgs, parsedCommand.opts());
2867
4187
  invocation.stdout.write(options.json ? `${JSON.stringify(output.json, null, 2)}\n` : output.text);
2868
4188
  return output.exitCode;
2869
4189
  };
@@ -2889,6 +4209,20 @@ const run = async (invocation) => {
2889
4209
  program.command("init").description("write configdirector.json for a project in the current directory").action(commandAction(init));
2890
4210
  program.command("scan").argument("[path]", "directory or file to scan (default: the current directory)").description("list where the code reads config keys through a ConfigDirector SDK").action(commandAction(scan));
2891
4211
  program.command("validate").argument("[path]", "directory or file to check (default: the current directory)").description("check every config key the code reads against the project's configs, exiting 1 when one is wrong").action(commandAction(validate));
4212
+ program.command("evaluate").argument("<key>", "the config's key").description("show the value a context gets for a config in an environment, and why").requiredOption("--environment <slug>", "slug of the environment whose targeting rules to evaluate (required)").option("--context-kind <kind>", "context kind of the context to evaluate for (default: user)").option("--context-id <identifier>", "identifier of the context").option("--context-name <name>", "name of the context").option("--context-trait <name=value>", "a trait of the context, as a string, or name:=<json> for a number, boolean, array or object; repeatable", repeatedOption).option("--context-json <json>", "the whole context as a JSON object, or @<file> to read it from a file").option("--saved-context <kind:identifier>", "a context ConfigDirector saved from SDK evaluations in the environment, such as user:123").addHelpText("after", "\nGive the context with the --context-* options, with --context-json, or with --saved-context. Without any of them, the config is evaluated for an empty context, as an SDK given none would.").action(commandAction(evaluate));
4213
+ program.command("projects").description("list the projects of the API token's organization").command("list").description("list the organization's projects, whatever project is selected").action(commandAction(listProjects));
4214
+ program.command("environments").description("list the project's environments").command("list").description("list the project's environments with their slugs, marking the live ones").action(commandAction(listEnvironments));
4215
+ const configsCommand = program.command("configs").description("list, show and create a project's configs");
4216
+ configsCommand.command("list").description("list the project's configs with their type, role, lifetime and lifecycle state").option("--tag <name>", "list only the configs that have this tag; repeatable, for configs with any of them", repeatedOption).addOption(new Option("--untagged", "list only the configs without tags").conflicts("tag")).addOption(new Option("--state <state>", "list only the configs in this lifecycle state").choices(LIFECYCLE_STATES)).action(commandAction(listConfigs));
4217
+ configsCommand.command("get").argument("<key>", "the config's key, or an old key of it").description("show a config's settings and its targeting rules in every environment").action(commandAction(getConfig));
4218
+ configsCommand.command("activity").argument("<key>", "the config's key, or an old key of it").description("show when SDKs last evaluated a config, per environment, SDK key kind, SDK name and SDK version").action(commandAction(showConfigActivity));
4219
+ 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));
4220
+ const segmentsCommand = program.command("segments").description("list a project's segments and show one segment");
4221
+ segmentsCommand.command("list").description("list the project's segments with how many configs use each").action(commandAction(listSegments));
4222
+ segmentsCommand.command("get").argument("<key>", "the segment's key").description("show a segment's condition groups and its environment overrides").action(commandAction(getSegment));
4223
+ const targetingRulesCommand = program.command("targeting-rules").description("replace a config's targeting rules in one environment and show the format they take");
4224
+ targetingRulesCommand.command("set").argument("<key>", "the config's key, or an old key of it").description("replace a config's targeting rules in one environment with the rules of a JSON file").requiredOption("--environment <slug>", "slug of the environment whose targeting rules to replace (required)").requiredOption("--rules <file>", "a JSON file holding defaultValue and rules as the Admin API takes them, or - to read it from stdin (required)").addHelpText("after", "\nThe file holds {\"defaultValue\": ..., \"rules\": [...]}, like an entry of targets in configs get --json, so you can edit that output and write it back. Every rule of the environment is replaced. A rule, condition or percentage without an id gets a new one. configdirector targeting-rules reference lists the condition kinds, attributes and operators.").action(commandAction(setTargetingRules));
4225
+ 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));
2892
4226
  try {
2893
4227
  await program.parseAsync(invocation.commandLineArguments, { from: "user" });
2894
4228
  return exitCode;