@configdirector/cli 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to `@configdirector/cli` are documented here. The format fol
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.20.0] - 2026-11-06
8
+
9
+ ### Changed
10
+
11
+ - `configdirector evaluate` says when a percentage rollout was skipped because the context has no identifier. The rule's line ends with `rollout skipped: the context has no identifier`, where it showed `not matched` with no reason, both for a percentage rule and for a conditional rule that serves a percentage rollout once its conditions matched. The rules after it show `not evaluated` and the context gets the default targeting rule value, as before. `--json` prints the Admin API's response as received, as before.
12
+
13
+ ## [0.19.0] - 2026-11-04
14
+
15
+ ### Added
16
+
17
+ - `configdirector configs runs <key> --environment <slug>` lists an experiment's runs in an environment, newest first: when each started and ended, how it ended, and, per rollout, how many distinct contexts each variation reached against the share configured when the run started, and whether the split matches those shares. `--json` prints the Admin API's response as received.
18
+ - `configdirector configs ship <key> --environment <slug> --variation <name or id>` serves one variation to everyone in the environment and ends its run: in one save of the targeting rules, the default targeting rule value becomes the variation's value, every percentage rollout is removed, including one a conditional rule serves, and a conditional rule serving a single value stays. `configdirector configs stop <key> --environment <slug>` ends the run without choosing a variation, keeping the default targeting rule value. Both print the rules they saved, ask for no confirmation and treat a live environment like any other. They exit with 2 when ConfigDirector refuses the change, such as a stop on a config that is not an experiment or with no percentage rollout saved in the environment, and `configs ship` exits with 2 listing the variations when `--variation` matches none, or several with the same name, and saying so when the config has no variations.
19
+
7
20
  ## [0.18.1] - 2026-11-03
8
21
 
9
22
  ### Fixed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ConfigDirector CLI
2
2
 
3
- `configdirector` is the command line tool for [ConfigDirector](https://www.configdirector.com), the managed configuration and feature flag service. Commands and output may change before 1.0. This release signs in to one or more organizations, links a repository to a project, lists where the repository's code reads config keys, checks those keys against the project's configs, shows the value a context gets for a config and why, reads a project's environments, configs, targeting rules and segments, creates and copies configs, replaces a config's targeting rules in an environment, archives configs, creates, updates and deletes segments, uploads the repository's code references to the project, and lists and removes the project's scanned repositories.
3
+ `configdirector` is the command line tool for [ConfigDirector](https://www.configdirector.com), the managed configuration and feature flag service. Commands and output may change before 1.0. This release signs in to one or more organizations, links a repository to a project, lists where the repository's code reads config keys, checks those keys against the project's configs, shows the value a context gets for a config and why, reads a project's environments, configs, targeting rules and segments, creates and copies configs, replaces a config's targeting rules in an environment, lists an experiment's runs and ships or stops them, archives configs, creates, updates and deletes segments, uploads the repository's code references to the project, and lists and removes the project's scanned repositories.
4
4
 
5
5
  The [CLI page of the ConfigDirector docs](https://docs.configdirector.com/integrations/cli) covers the API token, CI setup for GitHub Actions and GitLab, and what each problem `validate` reports means for your build.
6
6
 
@@ -159,7 +159,7 @@ true, served by rule 2
159
159
  Default targeting rule: false
160
160
  ```
161
161
 
162
- A rule that did not match names the first condition that failed and the context's value for it, as in `plan is one of pro, team, but it is "free"`, or the bucket a percentage rollout put the context in. A rule after the one that served the value is not evaluated, and a rule that could not be evaluated is shown as errored.
162
+ A rule that did not match names the first condition that failed and the context's value for it, as in `plan is one of pro, team, but it is "free"`, or the bucket a percentage rollout put the context in. A context without an identifier gets no bucket: the first percentage rollout it reaches shows `rollout skipped: the context has no identifier`, and the context gets the default targeting rule value. A rule after the one that served the value, or after a skipped rollout, is not evaluated, and a rule that could not be evaluated is shown as errored.
163
163
 
164
164
  `--environment` takes the environment's slug and is required. Give the context one of three ways:
165
165
 
@@ -318,6 +318,35 @@ configdirector targeting-rules set new-checkout --environment production --rules
318
318
 
319
319
  `targeting-rules set` needs `projects:read`, `environments:read` and `config-targeting-rules:update`. `targeting-rules reference` needs only a valid token and no project.
320
320
 
321
+ ## Experiment runs
322
+
323
+ ```bash
324
+ configdirector configs runs checkout-cta-copy --environment production
325
+ configdirector configs ship checkout-cta-copy --environment production --variation treatment-a
326
+ configdirector configs stop checkout-cta-copy --environment production
327
+ ```
328
+
329
+ A run is a period during which an experiment's targeting rules in one environment split traffic between its variations. `configs runs` lists the experiment's runs in an environment, newest first, with how many distinct contexts each variation reached, called exposures, against the share configured when the run started:
330
+
331
+ ```txt
332
+ Runs of checkout-cta-copy in production (live), newest first:
333
+
334
+ Running since 2026-11-03 12:00 UTC
335
+ Rollout: everyone not caught by a rule above
336
+ control (Start free trial) 412 exposures configured 50%
337
+ treatment-a (Try it free for 14 days) 398 exposures configured 50%
338
+ 810 contexts exposed, 3201 evaluations. The split matches the configured shares.
339
+ ```
340
+
341
+ - `configs ship` serves one variation to everyone in the environment and ends the run. `--variation` takes its name or its ID. The default targeting rule value becomes the variation's value, every percentage rollout is removed, including one a conditional rule serves, and a conditional rule serving a single value stays.
342
+ - `configs stop` ends the run without choosing a variation: every percentage rollout is removed, including one a conditional rule serves, and the default targeting rule value and any conditional rule serving a single value stay.
343
+
344
+ Both print the rules they saved, ask for no confirmation and treat a live environment like any other, so check the environment before you run them.
345
+
346
+ `configs ship` exits with 2 when `--variation` matches none of the experiment's variations, or several with the same name, and lists them with their IDs, and when the config has no variations, since only an experiment has them. `configs stop` exits with 2 when the config is not an experiment or the environment holds no percentage rollout. All three exit with 2 when no config has the key or no environment has the slug. With `--json`, they print the Admin API's response as received.
347
+
348
+ `configs runs` needs `projects:read`, `environments:read` and `configs:read`. `configs ship` also needs `config-targeting-rules:update`, and `configs stop` needs `projects:read`, `environments:read` and `config-targeting-rules:update`. Shipped and stopped runs show in the audit log with the source CLI.
349
+
321
350
  ## Change segments
322
351
 
323
352
  ```bash
@@ -14,7 +14,7 @@ import { promisify } from "node:util";
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { text } from "node:stream/consumers";
16
16
  //#region package.json
17
- var version = "0.18.1";
17
+ var version = "0.20.0";
18
18
  //#endregion
19
19
  //#region src/CliError.ts
20
20
  var CliError = class extends Error {
@@ -121,6 +121,36 @@ const CopyPlanSchema = v.object({
121
121
  });
122
122
  const CopyPlanResponseSchema = v.object({ plan: CopyPlanSchema });
123
123
  //#endregion
124
+ //#region src/admin-api/experiment-runs.ts
125
+ const TimestampSchema = v.pipe(v.string(), v.isoTimestamp());
126
+ const RolloutExposuresSchema = v.object({
127
+ ruleId: v.string(),
128
+ exposures: v.number(),
129
+ evaluations: v.number(),
130
+ verdict: v.string(),
131
+ variations: v.array(v.object({
132
+ variationId: v.string(),
133
+ percentage: v.number(),
134
+ exposures: v.number()
135
+ }))
136
+ });
137
+ const ExperimentRunSchema = v.object({
138
+ startedAt: TimestampSchema,
139
+ endedAt: v.optional(TimestampSchema),
140
+ outcome: v.optional(v.string()),
141
+ shippedVariationId: v.optional(v.string()),
142
+ rollouts: v.array(v.object({
143
+ ruleId: v.string(),
144
+ conditions: v.optional(v.string())
145
+ })),
146
+ exposures: v.object({
147
+ rollouts: v.array(RolloutExposuresSchema),
148
+ evaluationsWithoutIdentifier: v.number(),
149
+ evaluationsFromSdksNotReportingExposures: v.number()
150
+ })
151
+ });
152
+ const ExperimentRunsSchema = v.array(ExperimentRunSchema);
153
+ //#endregion
124
154
  //#region src/admin-api/config-creation.ts
125
155
  const CreatedConfigSchema = v.object({
126
156
  key: v.string(),
@@ -217,7 +247,8 @@ const RuleSchema = v.variant("type", [v.variant("target", [v.object({
217
247
  //#region src/admin-api/variations.ts
218
248
  const VariationSchema = v.object({
219
249
  id: v.string(),
220
- name: v.nullish(v.string())
250
+ name: v.nullish(v.string()),
251
+ value: RuleValueSchema
221
252
  });
222
253
  //#endregion
223
254
  //#region src/admin-api/config-with-targeting-rules.ts
@@ -368,7 +399,6 @@ const ConditionExplanationSchema = v.union([
368
399
  SegmentConditionExplanationSchema
369
400
  ]);
370
401
  const BucketSchema = v.object({
371
- defaultedToPredefinedBucket: v.boolean(),
372
402
  assignedPercentage: v.number(),
373
403
  shares: v.array(v.object({
374
404
  percentageId: v.string(),
@@ -386,7 +416,8 @@ const RuleExplanationSchema = v.object({
386
416
  "errored"
387
417
  ]),
388
418
  conditions: v.array(ConditionExplanationSchema),
389
- bucket: v.optional(BucketSchema)
419
+ bucket: v.optional(BucketSchema),
420
+ rolloutSkippedNoIdentifier: v.boolean()
390
421
  });
391
422
  const ConfigEvaluationSchema = v.object({
392
423
  key: v.string(),
@@ -594,6 +625,21 @@ var AdminApiClient = class {
594
625
  body: request
595
626
  });
596
627
  }
628
+ listRuns(projectId, configKey, environmentId) {
629
+ return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/runs`, ExperimentRunsSchema);
630
+ }
631
+ shipVariation(projectId, configKey, environmentId, variationId) {
632
+ return this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/ship`, ConfigWithTargetingRulesSchema, {
633
+ method: "POST",
634
+ body: { variationId }
635
+ });
636
+ }
637
+ stopRun(projectId, configKey, environmentId) {
638
+ return this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/stop`, ConfigWithTargetingRulesSchema, {
639
+ method: "POST",
640
+ body: {}
641
+ });
642
+ }
597
643
  async evaluateConfig(projectId, configKey, request) {
598
644
  const { responseBody, parsedBody } = await this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/evaluate`, ConfigEvaluationSchema, {
599
645
  method: "POST",
@@ -1018,6 +1064,7 @@ const describeCondition = (condition, segments) => {
1018
1064
  }
1019
1065
  };
1020
1066
  const valueWithVariationName = (value, variationName) => variationName ? `${variationName} (${String(value)})` : String(value);
1067
+ const describeVariation = (variation) => valueWithVariationName(variation.value, variation.name);
1021
1068
  const describeValue = (value, variationId, variations) => valueWithVariationName(value, variations.find((variation) => variation.id === variationId)?.name);
1022
1069
  const describePercentages = (percentages, variations) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${describeValue(percentage.value, percentage.variationId, variations)}`).join(", ");
1023
1070
  const describeRule = (rule, segments, variations) => {
@@ -1405,6 +1452,85 @@ const newConfigOf = async (options, workingDirectory) => {
1405
1452
  };
1406
1453
  };
1407
1454
  //#endregion
1455
+ //#region src/configs/run-text.ts
1456
+ const OUTCOME_LABELS = {
1457
+ shipped: "Shipped",
1458
+ stopped: "Stopped",
1459
+ replaced: "Replaced"
1460
+ };
1461
+ const VERDICT_SENTENCES = {
1462
+ matches: "The split matches the configured shares.",
1463
+ mismatch: "The split does not match the configured shares. This usually means some contexts evaluate without a stable identifier, or a rule above the split catches part of the traffic. Results from this run are not reliable.",
1464
+ "needs-more-exposures": "The run needs more exposures before the split can be checked against the configured shares."
1465
+ };
1466
+ const variationDescription = (variationId, variations) => {
1467
+ const variation = variations.find((candidate) => candidate.id === variationId);
1468
+ return variation && describeVariation(variation);
1469
+ };
1470
+ const runHeadline = (run, variations) => {
1471
+ if (run.outcome === void 0 || run.endedAt === void 0) return `Running since ${utcMinute(run.startedAt)}`;
1472
+ const outcomeLabel = OUTCOME_LABELS[run.outcome] ?? run.outcome;
1473
+ const shippedVariation = run.shippedVariationId && variationDescription(run.shippedVariationId, variations);
1474
+ return `${shippedVariation ? `${outcomeLabel} ${shippedVariation}` : outcomeLabel}, ${utcMinute(run.startedAt)} to ${utcMinute(run.endedAt)}`;
1475
+ };
1476
+ const rolloutAudience = (run, rollout) => run.rollouts.find((candidate) => candidate.ruleId === rollout.ruleId)?.conditions ?? "everyone not caught by a rule above";
1477
+ const rolloutSummary = (rollout) => `${rollout.exposures} ${plural(rollout.exposures, "context exposed", "contexts exposed")}, ${rollout.evaluations} ${plural(rollout.evaluations, "evaluation", "evaluations")}. ` + (VERDICT_SENTENCES[rollout.verdict] ?? rollout.verdict);
1478
+ const rolloutLines = (run, rollout, variations) => [
1479
+ `Rollout: ${rolloutAudience(run, rollout)}`,
1480
+ ...alignedColumns(rollout.variations.map((variation) => [
1481
+ variationDescription(variation.variationId, variations) ?? "a variation no longer listed",
1482
+ `${variation.exposures} ${plural(variation.exposures, "exposure", "exposures")}`,
1483
+ `configured ${formatPercentage(variation.percentage)}%`
1484
+ ])).map((line) => ` ${line}`),
1485
+ ` ${rolloutSummary(rollout)}`
1486
+ ];
1487
+ const uncountedEvaluationsLines = (evaluations, one, several) => {
1488
+ if (evaluations === 0) return [];
1489
+ return [evaluations === 1 ? `1 evaluation ${one}` : `${evaluations} evaluations ${several}`];
1490
+ };
1491
+ const runLines = (run, variations) => [runHeadline(run, variations), ...[
1492
+ ...run.exposures.rollouts.flatMap((rollout) => rolloutLines(run, rollout, variations)),
1493
+ ...uncountedEvaluationsLines(run.exposures.evaluationsFromSdksNotReportingExposures, "came from an SDK version that does not report exposures yet. It is not counted above; update that SDK to count it.", "came from SDK versions that do not report exposures yet. They are not counted above; update those SDKs to count them."),
1494
+ ...uncountedEvaluationsLines(run.exposures.evaluationsWithoutIdentifier, "came from a context without an identifier and was served the default targeting rule value. It skips the split and is not an exposure.", "came from contexts without an identifier and were served the default targeting rule value. They skip the split and are not exposures.")
1495
+ ].map((line) => ` ${line}`)];
1496
+ const runsText = (config, requestedKey, environment, runs) => {
1497
+ const environmentName = environmentLabel(environment);
1498
+ return [
1499
+ ...oldKeyLines(requestedKey, config.key),
1500
+ ...runs.length === 0 ? [`No runs of ${config.key} in ${environmentName}.`] : [`Runs of ${config.key} in ${environmentName}, newest first:`, ...runs.flatMap((run) => ["", ...runLines(run, config.variations)])],
1501
+ ""
1502
+ ].join("\n");
1503
+ };
1504
+ const savedRulesText = ({ config, requestedKey, environment, segments }, headline) => [
1505
+ ...oldKeyLines(requestedKey, config.key),
1506
+ headline,
1507
+ "",
1508
+ ...config.targets.filter((targetingRules) => targetingRules.environment.slug === environment.slug).flatMap((targetingRules) => targetingRuleLines(targetingRules, segments, config.variations)),
1509
+ ""
1510
+ ].join("\n");
1511
+ const shippedVariationText = (savedRules, variation) => savedRulesText(savedRules, `Shipped ${describeVariation(variation)} in ${environmentLabel(savedRules.environment)}.`);
1512
+ const stoppedRunText = (savedRules) => savedRulesText(savedRules, `Stopped the run of ${savedRules.config.key} in ${environmentLabel(savedRules.environment)}.`);
1513
+ //#endregion
1514
+ //#region src/configs/variation-selection.ts
1515
+ const variationListing = (variations) => alignedColumns(variations.map((variation) => [describeVariation(variation), variation.id])).map((line) => ` ${line}`);
1516
+ const findVariationToShip = (config, nameOrId) => {
1517
+ if (config.variations.length === 0) throw new CliError(`${config.key} has no variations to ship. Only an experiment has variations.`);
1518
+ const matches = config.variations.filter((variation) => variation.id === nameOrId || variation.name === nameOrId);
1519
+ const [onlyMatch, ...otherMatches] = matches;
1520
+ if (onlyMatch === void 0) throw new CliError([`${config.key} has no variation named or with the id "${nameOrId}". Its variations are:`, ...variationListing(config.variations)].join("\n"));
1521
+ if (otherMatches.length > 0) throw new CliError([`Several variations of ${config.key} are named "${nameOrId}". Give the id of the one to ship:`, ...variationListing(matches)].join("\n"));
1522
+ return onlyMatch;
1523
+ };
1524
+ //#endregion
1525
+ //#region src/environment-selection.ts
1526
+ const environmentSlugsSentence = (environments) => environments.length === 0 ? "The project has no environments." : `Its environments are ${environments.map((environment) => environment.slug).join(", ")}.`;
1527
+ const findEnvironmentBySlug = async (client, project, slug) => {
1528
+ const { parsedBody: environments } = await client.listEnvironments(project.id);
1529
+ const environment = environments.find((listedEnvironment) => listedEnvironment.slug === slug);
1530
+ if (environment === void 0) throw new CliError(`No environment in ${project.slug} has the slug "${slug}". ${environmentSlugsSentence(environments)}`);
1531
+ return environment;
1532
+ };
1533
+ //#endregion
1408
1534
  //#region src/project-selection.ts
1409
1535
  const requireProjectSlug = (context) => {
1410
1536
  if (context.projectSlug === void 0) throw new CliError("Name the project with --project <slug> or CONFIGDIRECTOR_PROJECT.");
@@ -1666,6 +1792,55 @@ const copyConfig = async (context, [configKey], options) => {
1666
1792
  exitCode: ExitCode.success
1667
1793
  };
1668
1794
  };
1795
+ const showConfigRuns = async (context, [configKey], options) => {
1796
+ const { client, project } = await connectToLinkedProject(context);
1797
+ const environment = await findEnvironmentBySlug(client, project, options.environment);
1798
+ const receivedConfig = await client.getConfig(project.id, configKey);
1799
+ const receivedRuns = receivedConfig && await client.listRuns(project.id, configKey, environment.id);
1800
+ if (receivedConfig === void 0 || receivedRuns === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1801
+ return {
1802
+ json: receivedRuns.responseBody,
1803
+ text: runsText(receivedConfig.parsedBody, configKey, environment, receivedRuns.parsedBody),
1804
+ exitCode: ExitCode.success
1805
+ };
1806
+ };
1807
+ const shipVariation = async (context, [configKey], options) => {
1808
+ const { client, project } = await connectToLinkedProject(context);
1809
+ const environment = await findEnvironmentBySlug(client, project, options.environment);
1810
+ const receivedConfig = await client.getConfig(project.id, configKey);
1811
+ if (receivedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1812
+ const variation = findVariationToShip(receivedConfig.parsedBody, options.variation);
1813
+ const savedConfig = await client.shipVariation(project.id, configKey, environment.id, variation.id);
1814
+ if (savedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1815
+ const segments = await segmentNamesFor(client, project, savedConfig.parsedBody);
1816
+ return {
1817
+ json: savedConfig.responseBody,
1818
+ text: shippedVariationText({
1819
+ config: savedConfig.parsedBody,
1820
+ requestedKey: configKey,
1821
+ environment,
1822
+ segments
1823
+ }, variation),
1824
+ exitCode: ExitCode.success
1825
+ };
1826
+ };
1827
+ const stopRun = async (context, [configKey], options) => {
1828
+ const { client, project } = await connectToLinkedProject(context);
1829
+ const environment = await findEnvironmentBySlug(client, project, options.environment);
1830
+ const savedConfig = await client.stopRun(project.id, configKey, environment.id);
1831
+ if (savedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1832
+ const segments = await segmentNamesFor(client, project, savedConfig.parsedBody);
1833
+ return {
1834
+ json: savedConfig.responseBody,
1835
+ text: stoppedRunText({
1836
+ config: savedConfig.parsedBody,
1837
+ requestedKey: configKey,
1838
+ environment,
1839
+ segments
1840
+ }),
1841
+ exitCode: ExitCode.success
1842
+ };
1843
+ };
1669
1844
  const archiveConfig = async (context, [configKey]) => {
1670
1845
  const { client, project } = await connectToLinkedProject(context);
1671
1846
  if (await client.archiveConfig(project.id, configKey) === "not-found") throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
@@ -1721,15 +1896,6 @@ const listEnvironments = async (context, _commandArguments, options) => {
1721
1896
  };
1722
1897
  };
1723
1898
  //#endregion
1724
- //#region src/environment-selection.ts
1725
- const environmentSlugsSentence = (environments) => environments.length === 0 ? "The project has no environments." : `Its environments are ${environments.map((environment) => environment.slug).join(", ")}.`;
1726
- const findEnvironmentBySlug = async (client, project, slug) => {
1727
- const { parsedBody: environments } = await client.listEnvironments(project.id);
1728
- const environment = environments.find((listedEnvironment) => listedEnvironment.slug === slug);
1729
- if (environment === void 0) throw new CliError(`No environment in ${project.slug} has the slug "${slug}". ${environmentSlugsSentence(environments)}`);
1730
- return environment;
1731
- };
1732
- //#endregion
1733
1899
  //#region src/evaluate/context-form.ts
1734
1900
  const DEFAULT_CONTEXT_KIND = "user";
1735
1901
  const TYPED_TRAIT_MARKER = ":";
@@ -1843,8 +2009,7 @@ const bucketPlacement = (bucket) => {
1843
2009
  if (selectedShare !== void 0) return `${assigned} is in ${describeShareRange(selectedShare.from, selectedShare.to)}`;
1844
2010
  return `${assigned} is outside ${describeShareRange(0, bucket.shares.at(-1)?.to ?? 0)}`;
1845
2011
  };
1846
- const describeBucket = (bucket) => bucket.defaultedToPredefinedBucket ? `no identifier, so ${bucketPlacement(bucket)}` : bucketPlacement(bucket);
1847
- const withBucket = (outcome, bucket) => bucket === void 0 ? outcome : `${outcome}: ${describeBucket(bucket)}`;
2012
+ const withBucket = (outcome, bucket) => bucket === void 0 ? outcome : `${outcome}: ${bucketPlacement(bucket)}`;
1848
2013
  const conditionsOf = (rule) => rule?.type === "conditional" ? rule.conditions : [];
1849
2014
  const segmentMismatchEvidence = (explanation) => {
1850
2015
  if (!explanation.segmentFound) return "the segment was not found";
@@ -1866,17 +2031,19 @@ const describeNotMatched = (explanation, conditions, segments) => {
1866
2031
  const failingCondition = failingConditionDescription(explanation, conditions, segments);
1867
2032
  return failingCondition === void 0 ? withBucket("not matched", explanation.bucket) : `not matched: ${failingCondition}`;
1868
2033
  };
2034
+ const evaluatorRuleOutcomeOf = (explanation) => explanation.rolloutSkippedNoIdentifier ? "rollout-skipped-no-identifier" : explanation.outcome;
1869
2035
  const RULE_OUTCOME_DESCRIPTIONS = {
1870
2036
  matched: (explanation) => withBucket("matched", explanation.bucket),
1871
2037
  "not-matched": describeNotMatched,
1872
2038
  "not-evaluated": () => "not evaluated",
1873
- errored: () => "errored"
2039
+ errored: () => "errored",
2040
+ "rollout-skipped-no-identifier": () => "rollout skipped: the context has no identifier"
1874
2041
  };
1875
2042
  const ruleLines = (evaluation) => {
1876
2043
  const rulesById = new Map(evaluation.targetingRules.rules.map((rule) => [rule.id, rule]));
1877
2044
  return alignedColumns(evaluation.rules.map((explanation, index) => {
1878
2045
  const rule = rulesById.get(explanation.ruleId);
1879
- return [`${index + 1}. ${rule === void 0 ? explanation.ruleId : describeRule(rule, evaluation.segments, evaluation.variations)}`, RULE_OUTCOME_DESCRIPTIONS[explanation.outcome](explanation, conditionsOf(rule), evaluation.segments)];
2046
+ return [`${index + 1}. ${rule === void 0 ? explanation.ruleId : describeRule(rule, evaluation.segments, evaluation.variations)}`, RULE_OUTCOME_DESCRIPTIONS[evaluatorRuleOutcomeOf(explanation)](explanation, conditionsOf(rule), evaluation.segments)];
1880
2047
  }));
1881
2048
  };
1882
2049
  const evaluationText = (evaluation, requested) => [
@@ -5258,12 +5425,15 @@ const run = async (invocation) => {
5258
5425
  program.command("api-token").description("describe the API token a command here would use").command("get").description("show the token's organization, name, permissions and expiration, and where it came from; never the token itself").action(commandAction(getApiToken));
5259
5426
  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").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listProjects));
5260
5427
  program.command("environments").description("list the project's environments").command("list").description("list the project's environments with their slugs, marking the live ones").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listEnvironments));
5261
- const configsCommand = program.command("configs").description("list, show, create, copy and archive a project's configs");
5428
+ const configsCommand = program.command("configs").description("list, show, create, copy and archive a project's configs, and list, ship and stop an experiment's runs");
5262
5429
  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)).option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listConfigs));
5263
5430
  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));
5264
5431
  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").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(showConfigActivity));
5265
5432
  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 with --role config, while a feature flag and an experiment are always temporary and a kill switch always permanent").choices(CONFIG_LIFETIMES)).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("--new-tag <name>", "create a tag with this name in the project's tag catalog and assign it; 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));
5266
5433
  configsCommand.command("copy").argument("<key>", "the key of the config to copy, or an old key of it").description("copy a config under a new key, in its project or in another project of the organization, and print the plan").requiredOption("--key <key>", "the copy's key (required)").option("--to <project slug>", "slug of the project to copy the config into, another project of the organization (default: the config's project)").option("--environment <destination slug>=<source slug>", "the source environment whose targeting rules an environment of the destination project starts with, needed for each destination environment with no source environment of its slug; repeatable", repeatedOption).option("--dry-run", "print the plan without creating anything").addHelpText("after", "\nThe copy gets the config's role, lifetime, type, constraints, variations, SDK availability, description and tags, and every environment's targeting rules and default targeting rule value. It starts as a new config, with no old keys, evaluations or code references, and shares nothing with the config it came from. An archived config cannot be copied; restore it first. In another project, environments are matched by slug, tags by name and segments by key: a source environment with no environment of its slug there is skipped, and an environment there with no source environment of its slug needs --environment. A targeting rule that uses a segment the other project doesn't have is dropped. A tag it doesn't have is created when the API token can create tags, and skipped otherwise. The plan names every skipped environment, dropped rule and tag; --dry-run shows it without creating anything.").action(commandAction(copyConfig));
5434
+ configsCommand.command("runs").argument("<key>", "the experiment's key, or an old key of it").description("list an experiment's runs in an environment with their exposures, newest first").requiredOption("--environment <slug>", "slug of the environment to list the runs of (required)").action(commandAction(showConfigRuns));
5435
+ configsCommand.command("ship").argument("<key>", "the experiment's key, or an old key of it").description("serve one variation of an experiment to everyone in an environment, ending its run").requiredOption("--environment <slug>", "slug of the environment to ship the variation in (required)").requiredOption("--variation <variation>", "the name or id of the variation to ship (required)").addHelpText("after", "\nIn one targeting rules save, the default targeting rule value becomes the variation's value, every percentage rollout is removed, including one a conditional rule serves, and a conditional rule serving a single value stays. It asks for no confirmation and treats a live environment like any other.").action(commandAction(shipVariation));
5436
+ configsCommand.command("stop").argument("<key>", "the experiment's key, or an old key of it").description("end an experiment's run in an environment without choosing a variation").requiredOption("--environment <slug>", "slug of the environment to stop the run in (required)").addHelpText("after", "\nIn one targeting rules save, every percentage rollout is removed, including one a conditional rule serves, the default targeting rule value stays, and a conditional rule serving a single value stays. It asks for no confirmation and treats a live environment like any other. When the config is not an experiment, or no percentage rollout is saved in the environment, there is no run to stop, and the command exits with 2.").action(commandAction(stopRun));
5267
5437
  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));
5268
5438
  const segmentsCommand = program.command("segments").description("list, show, create, update and delete a project's segments");
5269
5439
  segmentsCommand.command("list").description("list the project's segments with how many configs use each").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listSegments));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@configdirector/cli",
3
- "version": "0.18.1",
3
+ "version": "0.20.0",
4
4
  "description": "Command line tool for ConfigDirector, the managed configuration and feature flag service.",
5
5
  "license": "MIT",
6
6
  "author": "ConfigDirector",