@configdirector/cli 0.19.0 → 0.21.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,29 @@ All notable changes to `@configdirector/cli` are documented here. The format fol
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.21.0] - 2026-11-10
8
+
9
+ ### Added
10
+
11
+ - `configdirector metrics list` lists the project's metrics, each an event name your application sends with `track`, with the kinds it reports, its direction and how many experiments use it, and `configdirector metrics get <event name>` shows one metric with each experiment that uses it, its role there, and the environments where a run is in progress. `configdirector metrics create --event-name <event name> --kind <kind> --direction <direction>`, with `--kind` repeatable and an optional `--description`, creates a metric; `configdirector metrics update <event name>` changes its kinds, direction or description; and `configdirector metrics delete <event name>` deletes it and detaches it from its experiments. They exit with 2, listing the experiments, when ConfigDirector refuses to remove a kind an experiment's primary metric decides on, or to delete a metric an experiment with a run in progress uses. `--json` prints the Admin API's response as received, `metrics get --json` the metric as the Admin API listed it for its event name, and `metrics delete --json` `{ "deletedEventName": ... }`.
12
+ - `configdirector configs runs` shows the results of the metrics an experiment measures under each rollout: per metric kind, each variation's value against the control, with the lift and the range it is likely in, the chance to beat control, the chance to be best with three or more variations, and the verdict, likely better, likely worse or no clear difference. It says when a rollout needs more exposures before the variations can be compared and how many contexts were exposed to more than one variation, that results can be up to 25 minutes behind on a running run, and when an ended run's final results were taken. Each run also shows its hypothesis and the note it ended with, and lists the events sent with `track` during the run that no metric measures. `--json` prints the Admin API's response as received, as before.
13
+ - `configdirector configs ship` and `configs stop` take `--note <text>`, up to 1,000 characters on why, which the ended run keeps.
14
+ - `configdirector configs create` takes `--hypothesis <text>`, what an experiment is expected to do, which each of its runs keeps as it was when the run started.
15
+
16
+ ### Changed
17
+
18
+ - `configdirector configs copy --help` lists the hypothesis among what a copy gets.
19
+
20
+ ### Fixed
21
+
22
+ - `configdirector configs runs` describes the evaluations it leaves out of the exposures as coming from anonymous contexts: in a server SDK, a context without an identifier skips the split and is served the default targeting rule value, and any other anonymous context sees its share of the split without being stored. It said every such evaluation was served the default targeting rule value.
23
+
24
+ ## [0.20.0] - 2026-11-06
25
+
26
+ ### Changed
27
+
28
+ - `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.
29
+
7
30
  ## [0.19.0] - 2026-11-04
8
31
 
9
32
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ConfigDirector CLI
2
2
 
3
- `configdirector` is the command line tool for [ConfigDirector](https://www.configdirector.com), the managed configuration and feature flag service. 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.
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, segments and metrics, creates and copies configs, replaces a config's targeting rules in an environment, lists an experiment's runs with their results and ships or stops them, archives configs, creates, updates and deletes segments and metrics, 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
 
@@ -181,6 +181,8 @@ configdirector configs get <key>
181
181
  configdirector configs activity <key>
182
182
  configdirector segments list
183
183
  configdirector segments get <key>
184
+ configdirector metrics list
185
+ configdirector metrics get <event name>
184
186
  ```
185
187
 
186
188
  `projects list` lists your organization's projects with their slugs and names, whichever project is selected. The other commands read the project from `--project`, `CONFIGDIRECTOR_PROJECT` or `configdirector.json`, as `scan` does:
@@ -190,6 +192,7 @@ configdirector segments get <key>
190
192
  - `configs get <key>` shows the config's settings, including its type's constraints, then each environment's targeting rules in order, written as `evaluate` writes them, with the default targeting rule value last. An old key works, and the output then starts with a line naming the current key.
191
193
  - `configs activity <key>` shows when SDKs last evaluated the config, one line per environment, SDK key kind, SDK name and SDK version, with the time in UTC.
192
194
  - `segments list` lists the segments with how many configs use each, and `segments get <key>` shows a segment's condition groups and its environment overrides.
195
+ - `metrics list` lists the metrics with their kinds, their direction and how many experiments use each, and `metrics get <event name>` shows a metric's kinds and direction, and each experiment that uses it, with its role and the environments of its runs in progress.
193
196
 
194
197
  ```txt
195
198
  new-checkout
@@ -213,11 +216,11 @@ production (live)
213
216
 
214
217
  On an experiment, each value comes after the name of its variation, as in `40% get control (Start free trial), 60% get treatment-a (Try it free for 14 days)`. `evaluate` names the variation it served the same way, as in `treatment-a (Try it free for 14 days), served by rule 2`, and `configs create` names the variation of the default targeting rule value it starts with.
215
218
 
216
- `projects list`, `environments list`, `configs list`, `configs activity` and `segments list` print a header row that names their columns above their lines, such as `KEY TYPE ROLE LIFETIME STATE` for `configs list`, and `--no-headers` leaves it out. An empty list prints only a sentence, such as `No configs found in web-app.`
219
+ `projects list`, `environments list`, `configs list`, `configs activity`, `segments list` and `metrics list` print a header row that names their columns above their lines, such as `KEY TYPE ROLE LIFETIME STATE` for `configs list`, and `--no-headers` leaves it out. An empty list prints only a sentence, such as `No configs found in web-app.`
217
220
 
218
- With `--json`, each prints the Admin API's response as received, except `configs list --state`, which prints only the configs in that state. A key no config or segment has exits with 2.
221
+ With `--json`, each prints the Admin API's response as received, except `configs list --state`, which prints only the configs in that state, and `metrics get`, which prints the metric as the Admin API listed it for its event name. A key no config or segment has, or an event name no metric has, exits with 2.
219
222
 
220
- `projects list` needs `projects:read`. The others need `projects:read` to find the project, and `environments list` needs `environments:read`, `configs list`, `configs get` and `configs activity` need `configs:read`, plus `tags:read` for `--tag`, and `segments list` and `segments get` need `segments:read`. Without `segments:read`, `configs get` shows a segment condition's segment ID instead of its name; without `environments:read`, `segments get` names an override's environment by its ID instead of its slug.
223
+ `projects list` needs `projects:read`. The others need `projects:read` to find the project, and `environments list` needs `environments:read`, `configs list`, `configs get` and `configs activity` need `configs:read`, plus `tags:read` for `--tag`, `segments list` and `segments get` need `segments:read`, and `metrics list` and `metrics get` need `metrics:read`. Without `segments:read`, `configs get` shows a segment condition's segment ID instead of its name; without `environments:read`, `segments get` names an override's environment by its ID instead of its slug, and `metrics get` a run's environment.
221
224
 
222
225
  ## Create a config
223
226
 
@@ -234,7 +237,7 @@ Default targeting rule in every environment: false
234
237
 
235
238
  - `--key`, `--type` (`boolean`, `string`, `integer`, `float`, `timespan`, `enum`, `url` or `json`), `--role` (`config`, `flag`, `kill-switch` or `experiment`) and `--default-value` are required. `--default-value` is the default targeting rule value every environment starts with, not the in-code default value an application passes to the SDK.
236
239
  - `--lifetime` (`temporary` or `permanent`) is required for the `config` role. The role decides the lifetime of the others, so you can leave it out: a feature flag and an experiment are always temporary, and a kill switch is always permanent. A lifetime the role does not have is a usage error.
237
- - `--description <text>`; `--client-only` or `--server-only`, without which client and server SDKs both receive the config; and `--variation <name=value>`, repeatable, for an experiment's variations.
240
+ - `--description <text>`; `--client-only` or `--server-only`, without which client and server SDKs both receive the config; `--variation <name=value>`, repeatable, for an experiment's variations; and `--hypothesis <text>`, what an experiment is expected to do, which each of its runs keeps as it was when the run started.
238
241
  - `--tag <name>`, repeatable, names a tag the project already has, and `--new-tag <name>`, repeatable, creates a tag with that name in the project's tag catalog. The config gets the tags of both, at most 20 in all. Every `--tag` name is looked up before any tag is created, so a misspelled `--tag` creates nothing; a name no tag has, a `--new-tag` name a tag already has, a `--new-tag` name given twice and a name given with both options exit with 2. When a later step fails, such as a key that is taken, the tags already created stay in the catalog, and the command names them so you can pass them with `--tag` next time.
239
242
  - The values of `--default-value` and `--variation` are read in the config's type: `true` or `false`, a number for an integer, float or timespan, the text as written for a string, enum or URL, and JSON for a JSON config, with `@file` reading it from a file. A value that does not fit the type is a usage error.
240
243
  - Constraints, each accepted only by its types: `--min` and `--max` (a value may equal them) for an integer, float or timespan; `--exclusive-min` and `--exclusive-max` for an integer or float; `--unit` (`milliseconds`, `seconds` or `minutes`) and `--allow-fractions` for a timespan, which needs `--unit`, `--min` and `--max`; `--allowed-value`, repeatable, and `--enum-value-type string|number` (default `string`) for an enum; `--https-only` for a URL, without which any HTTP or HTTPS URL is accepted; and `--json-schema <file>` for a JSON config. An option of another type is a usage error naming the types it belongs to. A timespan without `--unit`, `--min` or `--max` is a usage error too.
@@ -326,22 +329,33 @@ configdirector configs ship checkout-cta-copy --environment production --variati
326
329
  configdirector configs stop checkout-cta-copy --environment production
327
330
  ```
328
331
 
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:
332
+ 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, and the results of the metrics the experiment measures:
330
333
 
331
334
  ```txt
332
335
  Runs of checkout-cta-copy in production (live), newest first:
333
336
 
334
337
  Running since 2026-11-03 12:00 UTC
338
+ Hypothesis: Urgency copy raises checkout completion by 5%
335
339
  Rollout: everyone not caught by a rule above
336
340
  control (Start free trial) 412 exposures configured 50%
337
341
  treatment-a (Try it free for 14 days) 398 exposures configured 50%
338
342
  810 contexts exposed, 3201 evaluations. The split matches the configured shares.
343
+ Primary metric: checkout_completed, conversion, higher is better
344
+ control (Start free trial) 2.9% control
345
+ treatment-a (Try it free for 14 days) 4.5% +55% (+12% to +98%) >99% chance to beat control likely better
346
+ Guardrail: support_ticket_opened, count per context, lower is better
347
+ control (Start free trial) 0.12 control
348
+ treatment-a (Try it free for 14 days) 0.13 +8% (-10% to +29%) 22% chance to beat control no clear difference
349
+ Events with no metric: add_to_cart (340 contexts)
350
+ Results can be up to 25 minutes behind.
339
351
  ```
340
352
 
353
+ Each kind a metric reports gets a line with the metric's role, its event name, the kind and its direction, then one row per variation, the control first: its value, and for every other variation the lift against the control with the range it is likely in, the chance to beat control and the verdict, `likely better`, `likely worse` or `no clear difference`. With three or more variations, each row also shows the variation's chance to be best. Until every variation has enough exposures, the rollout says the run needs more exposures and shows the values alone. An ended run's last line says when its final results were taken, once they are in. A run also shows its hypothesis and the note it ended with, and lists the events sent with `track` during the run that no metric measures.
354
+
341
355
  - `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
356
  - `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
357
 
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.
358
+ Both take `--note <text>`, up to 1,000 characters on why, which the ended run keeps. 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
359
 
346
360
  `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
361
 
@@ -400,6 +414,24 @@ configdirector segments delete beta-testers
400
414
 
401
415
  `segments create` needs `projects:read` and `segments:create`, `segments update` needs `projects:read`, `segments:read` and `segments:update`, and `segments delete` needs `projects:read` and `segments:delete`. Without `environments:read`, they name environments by ID instead of by slug. The change shows in the audit log with the source CLI.
402
416
 
417
+ ## Change metrics
418
+
419
+ ```bash
420
+ configdirector metrics create --event-name checkout_completed --kind conversion --kind value-per-context --direction higher-is-better
421
+ configdirector metrics update checkout_completed --direction lower-is-better
422
+ configdirector metrics delete checkout_completed
423
+ ```
424
+
425
+ A metric is what an experiment measures: an event name the application sends with the SDK's `track` call, the numbers it reports and whether a higher or a lower number is better. A project has one metric per event name, and the commands name a metric by its event name. Attaching metrics to an experiment happens on its Settings tab in the dashboard, or through the Admin API or the MCP server.
426
+
427
+ - `metrics create` needs `--event-name`, exactly as the application passes it to `track`; `--kind`, repeatable: `conversion`, `count-per-context` or `value-per-context`; and `--direction`: `higher-is-better` or `lower-is-better`. `--description <text>` is optional. It prints `Created metric checkout_completed in web-app.`, then the metric as `metrics get` shows it.
428
+ - `metrics update <event name>` changes what its options name: `--kind`, repeated, replaces the kinds, and `--direction` and `--description` replace theirs; `--description ""` removes the description. A metric keeps its event name. Removing a kind an experiment's primary metric decides on is refused, and the command exits with 2 listing those experiments.
429
+ - `metrics delete <event name>` deletes the metric and detaches it from the experiments that use it, and prints `Deleted metric checkout_completed from web-app.`; `--json` prints `{ "deletedEventName": "checkout_completed" }` with the event name as given. While an experiment that uses the metric has a run in progress, it is not deleted: the command exits with 2 and lists each experiment with the environments of its runs.
430
+
431
+ None of them asks for confirmation. An event name no metric has exits with 2 for `update` and `delete`, and so does a metric ConfigDirector refuses, such as a second metric for an event name. With `--json`, `create` and `update` print the metric as the Admin API returned it.
432
+
433
+ `metrics create` needs `projects:read` and `metrics:create`, `metrics update` needs `projects:read`, `metrics:read` and `metrics:update`, and `metrics delete` needs `projects:read`, `metrics:read` and `metrics:delete`. Without `environments:read`, `metrics update` and `metrics delete` name environments by ID instead of by slug. The change shows in the audit log with the source CLI.
434
+
403
435
  ## Archive a config
404
436
 
405
437
  ```bash
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { setDefaultAutoSelectFamilyAttemptTimeout } from "node:net";
3
3
  import { homedir } from "node:os";
4
- import { Command, CommanderError, Option } from "commander";
4
+ import { Command, CommanderError, InvalidArgumentError, Option } from "commander";
5
5
  import { setTimeout } from "node:timers/promises";
6
6
  import * as v from "valibot";
7
7
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -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.19.0";
17
+ var version = "0.21.0";
18
18
  //#endregion
19
19
  //#region src/CliError.ts
20
20
  var CliError = class extends Error {
@@ -134,11 +134,50 @@ const RolloutExposuresSchema = v.object({
134
134
  exposures: v.number()
135
135
  }))
136
136
  });
137
+ const VariationResultSchema = v.object({
138
+ variationId: v.string(),
139
+ value: v.number(),
140
+ lift: v.optional(v.number()),
141
+ interval: v.optional(v.object({
142
+ low: v.number(),
143
+ high: v.number()
144
+ })),
145
+ chanceToBeatControl: v.optional(v.number()),
146
+ chanceToBeBest: v.optional(v.number()),
147
+ verdict: v.optional(v.string())
148
+ });
149
+ const MetricKindResultSchema = v.object({
150
+ kind: v.string(),
151
+ rollouts: v.array(v.object({
152
+ ruleId: v.string(),
153
+ variations: v.array(VariationResultSchema)
154
+ }))
155
+ });
156
+ const MetricResultSchema = v.object({
157
+ eventName: v.string(),
158
+ direction: v.string(),
159
+ kinds: v.array(MetricKindResultSchema)
160
+ });
161
+ const RolloutResultsSchema = v.object({
162
+ ruleId: v.string(),
163
+ controlVariationId: v.string(),
164
+ state: v.string(),
165
+ crossovers: v.number()
166
+ });
167
+ const ResultsSchema = v.object({
168
+ asOf: v.optional(TimestampSchema),
169
+ rollouts: v.array(RolloutResultsSchema),
170
+ primary: v.array(MetricResultSchema),
171
+ secondary: v.array(MetricResultSchema),
172
+ guardrail: v.array(MetricResultSchema)
173
+ });
137
174
  const ExperimentRunSchema = v.object({
138
175
  startedAt: TimestampSchema,
139
176
  endedAt: v.optional(TimestampSchema),
140
177
  outcome: v.optional(v.string()),
141
178
  shippedVariationId: v.optional(v.string()),
179
+ hypothesis: v.optional(v.string()),
180
+ note: v.optional(v.string()),
142
181
  rollouts: v.array(v.object({
143
182
  ruleId: v.string(),
144
183
  conditions: v.optional(v.string())
@@ -147,10 +186,48 @@ const ExperimentRunSchema = v.object({
147
186
  rollouts: v.array(RolloutExposuresSchema),
148
187
  evaluationsWithoutIdentifier: v.number(),
149
188
  evaluationsFromSdksNotReportingExposures: v.number()
150
- })
189
+ }),
190
+ trackedEvents: v.object({ events: v.array(v.object({
191
+ name: v.string(),
192
+ contexts: v.number()
193
+ })) }),
194
+ results: ResultsSchema
151
195
  });
152
196
  const ExperimentRunsSchema = v.array(ExperimentRunSchema);
153
197
  //#endregion
198
+ //#region src/admin-api/metrics.ts
199
+ const OpenRunsSchema = v.array(v.object({ environmentId: v.string() }));
200
+ const MetricUsageSchema = v.object({
201
+ configKey: v.string(),
202
+ role: v.string(),
203
+ primaryKind: v.nullable(v.string()),
204
+ openRuns: OpenRunsSchema
205
+ });
206
+ const MetricSchema = v.object({
207
+ id: v.string(),
208
+ eventName: v.string(),
209
+ description: v.nullable(v.string()),
210
+ direction: v.string(),
211
+ kinds: v.array(v.string()),
212
+ usages: v.array(MetricUsageSchema)
213
+ });
214
+ const DecidingExperimentSchema = v.object({
215
+ configKey: v.string(),
216
+ primaryKind: v.string()
217
+ });
218
+ const MetricKindInUseBodySchema = v.object({
219
+ code: v.literal("METRIC_KIND_IN_USE"),
220
+ usages: v.array(DecidingExperimentSchema)
221
+ });
222
+ const ExperimentWithOpenRunsSchema = v.object({
223
+ configKey: v.string(),
224
+ openRuns: OpenRunsSchema
225
+ });
226
+ const MetricInUseBodySchema = v.object({
227
+ code: v.literal("METRIC_IN_USE"),
228
+ usages: v.array(ExperimentWithOpenRunsSchema)
229
+ });
230
+ //#endregion
154
231
  //#region src/admin-api/config-creation.ts
155
232
  const CreatedConfigSchema = v.object({
156
233
  key: v.string(),
@@ -399,7 +476,6 @@ const ConditionExplanationSchema = v.union([
399
476
  SegmentConditionExplanationSchema
400
477
  ]);
401
478
  const BucketSchema = v.object({
402
- defaultedToPredefinedBucket: v.boolean(),
403
479
  assignedPercentage: v.number(),
404
480
  shares: v.array(v.object({
405
481
  percentageId: v.string(),
@@ -417,7 +493,8 @@ const RuleExplanationSchema = v.object({
417
493
  "errored"
418
494
  ]),
419
495
  conditions: v.array(ConditionExplanationSchema),
420
- bucket: v.optional(BucketSchema)
496
+ bucket: v.optional(BucketSchema),
497
+ rolloutSkippedNoIdentifier: v.boolean()
421
498
  });
422
499
  const ConfigEvaluationSchema = v.object({
423
500
  key: v.string(),
@@ -547,6 +624,28 @@ const segmentDeletionOutcomeOf = (received) => {
547
624
  if (!received.response.ok) throw failureFor(received.url, received.response, received.body);
548
625
  return { outcome: "deleted" };
549
626
  };
627
+ const isMetricKindInUse = (received) => received.response.status === 409 && v.is(MetricKindInUseBodySchema, received.body);
628
+ const metricUpdateOutcomeOf = (received) => {
629
+ if (isMissingResource(received)) return { outcome: "not-found" };
630
+ if (isMetricKindInUse(received)) return {
631
+ outcome: "kind-in-use",
632
+ decidingExperiments: received.body.usages
633
+ };
634
+ return {
635
+ outcome: "updated",
636
+ metric: parsedResponseOf(received, MetricSchema)
637
+ };
638
+ };
639
+ const isMetricInUse = (received) => received.response.status === 409 && v.is(MetricInUseBodySchema, received.body);
640
+ const metricDeletionOutcomeOf = (received) => {
641
+ if (isMissingResource(received)) return { outcome: "not-found" };
642
+ if (isMetricInUse(received)) return {
643
+ outcome: "in-use",
644
+ experimentsWithOpenRuns: received.body.usages
645
+ };
646
+ if (!received.response.ok) throw failureFor(received.url, received.response, received.body);
647
+ return { outcome: "deleted" };
648
+ };
550
649
  const parsedResponseOf = ({ url, response, body }, schema) => {
551
650
  if (!response.ok) throw failureFor(url, response, body);
552
651
  const parsedBody = v.safeParse(schema, body);
@@ -604,6 +703,33 @@ var AdminApiClient = class {
604
703
  async deleteSegment(projectId, segmentKey) {
605
704
  return segmentDeletionOutcomeOf(await this.send(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, { method: "DELETE" }));
606
705
  }
706
+ listMetrics(projectId) {
707
+ return this.get(`/v1/projects/${encodeURIComponent(projectId)}/metrics`, v.array(MetricSchema));
708
+ }
709
+ async findMetricByEventName(projectId, eventName) {
710
+ const { parsedBody: metrics, responseBody } = await this.get(`/v1/projects/${encodeURIComponent(projectId)}/metrics?${new URLSearchParams({ eventName })}`, v.array(MetricSchema));
711
+ const index = metrics.findIndex((metric) => metric.eventName === eventName);
712
+ const metric = metrics[index];
713
+ return metric === void 0 ? void 0 : {
714
+ parsedBody: metric,
715
+ responseBody: v.parse(v.array(v.unknown()), responseBody)[index]
716
+ };
717
+ }
718
+ createMetric(projectId, request) {
719
+ return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/metrics`, MetricSchema, {
720
+ method: "POST",
721
+ body: request
722
+ });
723
+ }
724
+ async updateMetric(projectId, metricId, request) {
725
+ return metricUpdateOutcomeOf(await this.send(`/v1/projects/${encodeURIComponent(projectId)}/metrics/${encodeURIComponent(metricId)}`, {
726
+ method: "PATCH",
727
+ body: request
728
+ }));
729
+ }
730
+ async deleteMetric(projectId, metricId) {
731
+ return metricDeletionOutcomeOf(await this.send(`/v1/projects/${encodeURIComponent(projectId)}/metrics/${encodeURIComponent(metricId)}`, { method: "DELETE" }));
732
+ }
607
733
  async findTagByName(projectId, name) {
608
734
  return (await this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/tags/by-name?${new URLSearchParams({ name })}`, TagSchema))?.parsedBody;
609
735
  }
@@ -628,16 +754,16 @@ var AdminApiClient = class {
628
754
  listRuns(projectId, configKey, environmentId) {
629
755
  return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/runs`, ExperimentRunsSchema);
630
756
  }
631
- shipVariation(projectId, configKey, environmentId, variationId) {
757
+ shipVariation(projectId, configKey, environmentId, request) {
632
758
  return this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/ship`, ConfigWithTargetingRulesSchema, {
633
759
  method: "POST",
634
- body: { variationId }
760
+ body: request
635
761
  });
636
762
  }
637
- stopRun(projectId, configKey, environmentId) {
763
+ stopRun(projectId, configKey, environmentId, request) {
638
764
  return this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/stop`, ConfigWithTargetingRulesSchema, {
639
765
  method: "POST",
640
- body: {}
766
+ body: request
641
767
  });
642
768
  }
643
769
  async evaluateConfig(projectId, configKey, request) {
@@ -1065,6 +1191,11 @@ const describeCondition = (condition, segments) => {
1065
1191
  };
1066
1192
  const valueWithVariationName = (value, variationName) => variationName ? `${variationName} (${String(value)})` : String(value);
1067
1193
  const describeVariation = (variation) => valueWithVariationName(variation.value, variation.name);
1194
+ const describeVariationById = (variationId, variations) => {
1195
+ const variation = variations.find((candidate) => candidate.id === variationId);
1196
+ return variation && describeVariation(variation);
1197
+ };
1198
+ const describeVariationOrNoLongerListed = (variationId, variations) => describeVariationById(variationId, variations) ?? "a variation no longer listed";
1068
1199
  const describeValue = (value, variationId, variations) => valueWithVariationName(value, variations.find((variation) => variation.id === variationId)?.name);
1069
1200
  const describePercentages = (percentages, variations) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${describeValue(percentage.value, percentage.variationId, variations)}`).join(", ");
1070
1201
  const describeRule = (rule, segments, variations) => {
@@ -1444,6 +1575,7 @@ const newConfigOf = async (options, workingDirectory) => {
1444
1575
  typeOptions: await CONFIG_TYPE_RULES[options.type].typeOptionsOf(options, workingDirectory),
1445
1576
  ...availabilityOf(options),
1446
1577
  variations: await variationsOf(options, workingDirectory),
1578
+ hypothesis: options.hypothesis,
1447
1579
  defaultValue: await typedValueOf(options.type, {
1448
1580
  argumentLabel: "--default-value",
1449
1581
  text: options.defaultValue,
@@ -1452,6 +1584,170 @@ const newConfigOf = async (options, workingDirectory) => {
1452
1584
  };
1453
1585
  };
1454
1586
  //#endregion
1587
+ //#region src/metrics/metric-words.ts
1588
+ const METRIC_KINDS = [
1589
+ "conversion",
1590
+ "count-per-context",
1591
+ "value-per-context"
1592
+ ];
1593
+ const METRIC_DIRECTIONS = ["higher-is-better", "lower-is-better"];
1594
+ const METRIC_ROLES = [
1595
+ "primary",
1596
+ "secondary",
1597
+ "guardrail"
1598
+ ];
1599
+ const isOneOf = (values, candidate) => values.includes(candidate);
1600
+ const isMetricKind = (kind) => isOneOf(METRIC_KINDS, kind);
1601
+ const KIND_WORDS = {
1602
+ conversion: "conversion",
1603
+ "count-per-context": "count per context",
1604
+ "value-per-context": "value per context"
1605
+ };
1606
+ const DIRECTION_WORDS = {
1607
+ "higher-is-better": "higher is better",
1608
+ "lower-is-better": "lower is better"
1609
+ };
1610
+ const ROLE_WORDS = {
1611
+ primary: "primary metric",
1612
+ secondary: "secondary metric",
1613
+ guardrail: "guardrail"
1614
+ };
1615
+ const kindWords = (kind) => isMetricKind(kind) ? KIND_WORDS[kind] : kind;
1616
+ const directionWords = (direction) => isOneOf(METRIC_DIRECTIONS, direction) ? DIRECTION_WORDS[direction] : direction;
1617
+ const roleWords = (role) => isOneOf(METRIC_ROLES, role) ? ROLE_WORDS[role] : role;
1618
+ //#endregion
1619
+ //#region src/configs/run-results-text.ts
1620
+ const VERDICT_WORDS = {
1621
+ "likely-better": "likely better",
1622
+ "likely-worse": "likely worse",
1623
+ "no-clear-difference": "no clear difference"
1624
+ };
1625
+ const formatAverage = (value) => value.toLocaleString("en-US", {
1626
+ minimumFractionDigits: 2,
1627
+ maximumFractionDigits: 2
1628
+ });
1629
+ const VALUE_FORMATS = {
1630
+ conversion: (value) => `${(value * 100).toFixed(1)}%`,
1631
+ "count-per-context": formatAverage,
1632
+ "value-per-context": formatAverage
1633
+ };
1634
+ const formatResultValue = (kind, value) => isMetricKind(kind) ? VALUE_FORMATS[kind](value) : formatAverage(value);
1635
+ const formatLift = (lift) => {
1636
+ const wholePercent = Math.round(lift * 100);
1637
+ if (wholePercent > 0) return `+${wholePercent.toLocaleString("en-US")}%`;
1638
+ if (wholePercent < 0) return `${wholePercent.toLocaleString("en-US")}%`;
1639
+ return "0%";
1640
+ };
1641
+ const formatChance = (chance) => {
1642
+ const wholePercent = Math.round(chance * 100);
1643
+ if (wholePercent < 1) return "<1%";
1644
+ if (wholePercent > 99) return ">99%";
1645
+ return `${wholePercent}%`;
1646
+ };
1647
+ const liftCell = (variation) => {
1648
+ if (variation.lift === void 0) return "-";
1649
+ if (variation.interval === void 0) return formatLift(variation.lift);
1650
+ return `${formatLift(variation.lift)} (${formatLift(variation.interval.low)} to ${formatLift(variation.interval.high)})`;
1651
+ };
1652
+ const chanceCell = (chance, description) => chance === void 0 ? "" : `${formatChance(chance)} ${description}`;
1653
+ const comparisonOf = (rollout) => rollout.state === "needs-more-exposures" ? "needs-more-exposures" : "variations-compared";
1654
+ const resultRowFormOf = (variation, table) => {
1655
+ if (variation.variationId === table.controlVariationId) return "control";
1656
+ return table.comparison === "needs-more-exposures" ? "value-alone" : "compared";
1657
+ };
1658
+ const resultRow = (variation, table) => {
1659
+ const nameAndValue = [describeVariationOrNoLongerListed(variation.variationId, table.variations), formatResultValue(table.kind, variation.value)];
1660
+ const chanceToBeBestCells = table.showsChanceToBeBest ? [chanceCell(variation.chanceToBeBest, "chance to be best")] : [];
1661
+ switch (resultRowFormOf(variation, table)) {
1662
+ case "control": return [
1663
+ ...nameAndValue,
1664
+ "control",
1665
+ "",
1666
+ ...chanceToBeBestCells
1667
+ ];
1668
+ case "value-alone": return nameAndValue;
1669
+ case "compared": return [
1670
+ ...nameAndValue,
1671
+ liftCell(variation),
1672
+ chanceCell(variation.chanceToBeatControl, "chance to beat control"),
1673
+ ...chanceToBeBestCells,
1674
+ variation.verdict === void 0 ? "" : VERDICT_WORDS[variation.verdict] ?? variation.verdict
1675
+ ];
1676
+ }
1677
+ };
1678
+ const capitalized = (text) => `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
1679
+ const kindResultLines = (role, metric, kindResult, rollout, variations) => {
1680
+ const variationResults = kindResult.rollouts.find((candidate) => candidate.ruleId === rollout.ruleId)?.variations;
1681
+ if (variationResults === void 0) return [];
1682
+ const table = {
1683
+ kind: kindResult.kind,
1684
+ controlVariationId: rollout.controlVariationId,
1685
+ comparison: comparisonOf(rollout),
1686
+ variations,
1687
+ showsChanceToBeBest: variationResults.some((variation) => variation.chanceToBeBest !== void 0)
1688
+ };
1689
+ return [`${capitalized(roleWords(role))}: ${metric.eventName}, ${kindWords(kindResult.kind)}, ${directionWords(metric.direction)}`, ...alignedColumns(variationResults.map((variation) => resultRow(variation, table))).map((line) => ` ${line}`.trimEnd())];
1690
+ };
1691
+ const crossoverLines = (crossovers) => {
1692
+ if (crossovers === 0) return [];
1693
+ return [crossovers === 1 ? "1 context was exposed to more than one variation and is left out of every metric." : `${crossovers} contexts were exposed to more than one variation and are left out of every metric.`];
1694
+ };
1695
+ const comparisonLines = (comparison) => {
1696
+ switch (comparison) {
1697
+ case "variations-compared": return [];
1698
+ case "needs-more-exposures": return ["The run needs more exposures before the variations can be compared."];
1699
+ }
1700
+ };
1701
+ const runResultsOf = (run) => METRIC_ROLES.some((role) => run.results[role].length > 0) ? {
1702
+ form: "measured",
1703
+ results: run.results
1704
+ } : { form: "no-metrics" };
1705
+ const rolloutResultLines = (runResults, ruleId, variations) => {
1706
+ switch (runResults.form) {
1707
+ case "no-metrics": return [];
1708
+ case "measured": {
1709
+ const { results } = runResults;
1710
+ const rollout = results.rollouts.find((candidate) => candidate.ruleId === ruleId);
1711
+ if (rollout === void 0) return [];
1712
+ return [
1713
+ ...comparisonLines(comparisonOf(rollout)),
1714
+ ...crossoverLines(rollout.crossovers),
1715
+ ...METRIC_ROLES.flatMap((role) => results[role].flatMap((metric) => metric.kinds.flatMap((kindResult) => kindResultLines(role, metric, kindResult, rollout, variations))))
1716
+ ];
1717
+ }
1718
+ }
1719
+ };
1720
+ const measuredEventNamesOf = (runResults) => {
1721
+ switch (runResults.form) {
1722
+ case "no-metrics": return /* @__PURE__ */ new Set();
1723
+ case "measured": return new Set(METRIC_ROLES.flatMap((role) => runResults.results[role].map((metric) => metric.eventName)));
1724
+ }
1725
+ };
1726
+ const eventsWithoutAMetricLines = (run, runResults) => {
1727
+ const measuredEventNames = measuredEventNamesOf(runResults);
1728
+ const events = run.trackedEvents.events.filter((event) => !measuredEventNames.has(event.name));
1729
+ if (events.length === 0) return [];
1730
+ return [`Events with no metric: ${events.map((event) => `${event.name} (${event.contexts} ${plural(event.contexts, "context", "contexts")})`).join(", ")}`];
1731
+ };
1732
+ const decideResultsFreshness = (run, runResults) => {
1733
+ if (runResults.form === "no-metrics") return { form: "no-results" };
1734
+ if (run.endedAt === void 0) return { form: "running" };
1735
+ if (runResults.results.asOf === void 0) return { form: "ended-before-final-results" };
1736
+ return {
1737
+ form: "final",
1738
+ asOf: runResults.results.asOf
1739
+ };
1740
+ };
1741
+ const resultsFreshnessLines = (run, runResults) => {
1742
+ const freshness = decideResultsFreshness(run, runResults);
1743
+ switch (freshness.form) {
1744
+ case "no-results": return [];
1745
+ case "running": return ["Results can be up to 25 minutes behind."];
1746
+ case "final": return [`Final results, as of ${utcMinute(freshness.asOf)}, when the run ended.`];
1747
+ case "ended-before-final-results": return [];
1748
+ }
1749
+ };
1750
+ //#endregion
1455
1751
  //#region src/configs/run-text.ts
1456
1752
  const OUTCOME_LABELS = {
1457
1753
  shipped: "Shipped",
@@ -1463,36 +1759,40 @@ const VERDICT_SENTENCES = {
1463
1759
  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
1760
  "needs-more-exposures": "The run needs more exposures before the split can be checked against the configured shares."
1465
1761
  };
1466
- const variationDescription = (variationId, variations) => {
1467
- const variation = variations.find((candidate) => candidate.id === variationId);
1468
- return variation && describeVariation(variation);
1469
- };
1470
1762
  const runHeadline = (run, variations) => {
1471
1763
  if (run.outcome === void 0 || run.endedAt === void 0) return `Running since ${utcMinute(run.startedAt)}`;
1472
1764
  const outcomeLabel = OUTCOME_LABELS[run.outcome] ?? run.outcome;
1473
- const shippedVariation = run.shippedVariationId && variationDescription(run.shippedVariationId, variations);
1765
+ const shippedVariation = run.shippedVariationId && describeVariationById(run.shippedVariationId, variations);
1474
1766
  return `${shippedVariation ? `${outcomeLabel} ${shippedVariation}` : outcomeLabel}, ${utcMinute(run.startedAt)} to ${utcMinute(run.endedAt)}`;
1475
1767
  };
1476
1768
  const rolloutAudience = (run, rollout) => run.rollouts.find((candidate) => candidate.ruleId === rollout.ruleId)?.conditions ?? "everyone not caught by a rule above";
1477
1769
  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) => [
1770
+ const rolloutLines = (run, runResults, rollout, variations) => [
1479
1771
  `Rollout: ${rolloutAudience(run, rollout)}`,
1480
1772
  ...alignedColumns(rollout.variations.map((variation) => [
1481
- variationDescription(variation.variationId, variations) ?? "a variation no longer listed",
1773
+ describeVariationOrNoLongerListed(variation.variationId, variations),
1482
1774
  `${variation.exposures} ${plural(variation.exposures, "exposure", "exposures")}`,
1483
1775
  `configured ${formatPercentage(variation.percentage)}%`
1484
1776
  ])).map((line) => ` ${line}`),
1485
- ` ${rolloutSummary(rollout)}`
1777
+ ` ${rolloutSummary(rollout)}`,
1778
+ ...rolloutResultLines(runResults, rollout.ruleId, variations).map((line) => ` ${line}`)
1486
1779
  ];
1487
1780
  const uncountedEvaluationsLines = (evaluations, one, several) => {
1488
1781
  if (evaluations === 0) return [];
1489
1782
  return [evaluations === 1 ? `1 evaluation ${one}` : `${evaluations} evaluations ${several}`];
1490
1783
  };
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}`)];
1784
+ const runLines = (run, variations) => {
1785
+ const runResults = runResultsOf(run);
1786
+ return [runHeadline(run, variations), ...[
1787
+ ...run.hypothesis === void 0 ? [] : [`Hypothesis: ${run.hypothesis}`],
1788
+ ...run.note === void 0 ? [] : [`Ended with note: ${run.note}`],
1789
+ ...run.exposures.rollouts.flatMap((rollout) => rolloutLines(run, runResults, rollout, variations)),
1790
+ ...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."),
1791
+ ...uncountedEvaluationsLines(run.exposures.evaluationsWithoutIdentifier, "came from an anonymous context, so it is not an exposure. In a server SDK, a context without an identifier skips the split and is served the default targeting rule value; any other anonymous context sees its share of the split without being stored.", "came from anonymous contexts, so they are not exposures. In a server SDK, a context without an identifier skips the split and is served the default targeting rule value; any other anonymous context sees its share of the split without being stored."),
1792
+ ...eventsWithoutAMetricLines(run, runResults),
1793
+ ...resultsFreshnessLines(run, runResults)
1794
+ ].map((line) => ` ${line}`)];
1795
+ };
1496
1796
  const runsText = (config, requestedKey, environment, runs) => {
1497
1797
  const environmentName = environmentLabel(environment);
1498
1798
  return [
@@ -1530,6 +1830,10 @@ const findEnvironmentBySlug = async (client, project, slug) => {
1530
1830
  if (environment === void 0) throw new CliError(`No environment in ${project.slug} has the slug "${slug}". ${environmentSlugsSentence(environments)}`);
1531
1831
  return environment;
1532
1832
  };
1833
+ const environmentSlugsById = async (client, project) => {
1834
+ const environments = await ifPermitted(client.listEnvironments(project.id));
1835
+ return new Map((environments?.parsedBody ?? []).map((environment) => [environment.id, environment.slug]));
1836
+ };
1533
1837
  //#endregion
1534
1838
  //#region src/project-selection.ts
1535
1839
  const requireProjectSlug = (context) => {
@@ -1804,13 +2108,17 @@ const showConfigRuns = async (context, [configKey], options) => {
1804
2108
  exitCode: ExitCode.success
1805
2109
  };
1806
2110
  };
2111
+ const runEndRequestOf = (options) => options.note === void 0 ? {} : { note: options.note };
1807
2112
  const shipVariation = async (context, [configKey], options) => {
1808
2113
  const { client, project } = await connectToLinkedProject(context);
1809
2114
  const environment = await findEnvironmentBySlug(client, project, options.environment);
1810
2115
  const receivedConfig = await client.getConfig(project.id, configKey);
1811
2116
  if (receivedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1812
2117
  const variation = findVariationToShip(receivedConfig.parsedBody, options.variation);
1813
- const savedConfig = await client.shipVariation(project.id, configKey, environment.id, variation.id);
2118
+ const savedConfig = await client.shipVariation(project.id, configKey, environment.id, {
2119
+ variationId: variation.id,
2120
+ ...runEndRequestOf(options)
2121
+ });
1814
2122
  if (savedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1815
2123
  const segments = await segmentNamesFor(client, project, savedConfig.parsedBody);
1816
2124
  return {
@@ -1827,7 +2135,7 @@ const shipVariation = async (context, [configKey], options) => {
1827
2135
  const stopRun = async (context, [configKey], options) => {
1828
2136
  const { client, project } = await connectToLinkedProject(context);
1829
2137
  const environment = await findEnvironmentBySlug(client, project, options.environment);
1830
- const savedConfig = await client.stopRun(project.id, configKey, environment.id);
2138
+ const savedConfig = await client.stopRun(project.id, configKey, environment.id, runEndRequestOf(options));
1831
2139
  if (savedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1832
2140
  const segments = await segmentNamesFor(client, project, savedConfig.parsedBody);
1833
2141
  return {
@@ -2009,8 +2317,7 @@ const bucketPlacement = (bucket) => {
2009
2317
  if (selectedShare !== void 0) return `${assigned} is in ${describeShareRange(selectedShare.from, selectedShare.to)}`;
2010
2318
  return `${assigned} is outside ${describeShareRange(0, bucket.shares.at(-1)?.to ?? 0)}`;
2011
2319
  };
2012
- const describeBucket = (bucket) => bucket.defaultedToPredefinedBucket ? `no identifier, so ${bucketPlacement(bucket)}` : bucketPlacement(bucket);
2013
- const withBucket = (outcome, bucket) => bucket === void 0 ? outcome : `${outcome}: ${describeBucket(bucket)}`;
2320
+ const withBucket = (outcome, bucket) => bucket === void 0 ? outcome : `${outcome}: ${bucketPlacement(bucket)}`;
2014
2321
  const conditionsOf = (rule) => rule?.type === "conditional" ? rule.conditions : [];
2015
2322
  const segmentMismatchEvidence = (explanation) => {
2016
2323
  if (!explanation.segmentFound) return "the segment was not found";
@@ -2032,17 +2339,19 @@ const describeNotMatched = (explanation, conditions, segments) => {
2032
2339
  const failingCondition = failingConditionDescription(explanation, conditions, segments);
2033
2340
  return failingCondition === void 0 ? withBucket("not matched", explanation.bucket) : `not matched: ${failingCondition}`;
2034
2341
  };
2342
+ const evaluatorRuleOutcomeOf = (explanation) => explanation.rolloutSkippedNoIdentifier ? "rollout-skipped-no-identifier" : explanation.outcome;
2035
2343
  const RULE_OUTCOME_DESCRIPTIONS = {
2036
2344
  matched: (explanation) => withBucket("matched", explanation.bucket),
2037
2345
  "not-matched": describeNotMatched,
2038
2346
  "not-evaluated": () => "not evaluated",
2039
- errored: () => "errored"
2347
+ errored: () => "errored",
2348
+ "rollout-skipped-no-identifier": () => "rollout skipped: the context has no identifier"
2040
2349
  };
2041
2350
  const ruleLines = (evaluation) => {
2042
2351
  const rulesById = new Map(evaluation.targetingRules.rules.map((rule) => [rule.id, rule]));
2043
2352
  return alignedColumns(evaluation.rules.map((explanation, index) => {
2044
2353
  const rule = rulesById.get(explanation.ruleId);
2045
- return [`${index + 1}. ${rule === void 0 ? explanation.ruleId : describeRule(rule, evaluation.segments, evaluation.variations)}`, RULE_OUTCOME_DESCRIPTIONS[explanation.outcome](explanation, conditionsOf(rule), evaluation.segments)];
2354
+ return [`${index + 1}. ${rule === void 0 ? explanation.ruleId : describeRule(rule, evaluation.segments, evaluation.variations)}`, RULE_OUTCOME_DESCRIPTIONS[evaluatorRuleOutcomeOf(explanation)](explanation, conditionsOf(rule), evaluation.segments)];
2046
2355
  }));
2047
2356
  };
2048
2357
  const evaluationText = (evaluation, requested) => [
@@ -2279,6 +2588,134 @@ const logout = async (context) => {
2279
2588
  };
2280
2589
  };
2281
2590
  //#endregion
2591
+ //#region src/metrics/metric-changes.ts
2592
+ const metricChangesOf = (options) => {
2593
+ const changes = {
2594
+ ...options.kind === void 0 ? {} : { kinds: options.kind },
2595
+ ...options.direction === void 0 ? {} : { direction: options.direction },
2596
+ ...options.description === void 0 ? {} : { description: options.description }
2597
+ };
2598
+ if (Object.keys(changes).length === 0) throw new CliError("Say what to change with --kind, --direction or --description.");
2599
+ return changes;
2600
+ };
2601
+ //#endregion
2602
+ //#region src/metrics/metric-text.ts
2603
+ const openRunEnvironmentNames = (openRuns, environmentSlugs) => openRuns.map((run) => environmentSlugs.get(run.environmentId) ?? run.environmentId).join(", ");
2604
+ const usageLine$1 = (usage, environmentSlugs) => {
2605
+ const primaryKind = usage.primaryKind === null ? "" : ` on ${kindWords(usage.primaryKind)}`;
2606
+ const runningIn = usage.openRuns.length === 0 ? "" : `, running in ${openRunEnvironmentNames(usage.openRuns, environmentSlugs)}`;
2607
+ return ` ${usage.configKey}: ${roleWords(usage.role)}${primaryKind}${runningIn}`;
2608
+ };
2609
+ const usageLines = (metric, environmentSlugs) => metric.usages.length === 0 ? ["No experiment uses it."] : ["Used by", ...metric.usages.map((usage) => usageLine$1(usage, environmentSlugs))];
2610
+ const metricText = (metric, environmentSlugs) => [
2611
+ metric.eventName,
2612
+ ...metric.description === null ? [] : [` Description: ${metric.description}`],
2613
+ ` Kinds: ${metric.kinds.map(kindWords).join(", ")}`,
2614
+ ` Direction: ${directionWords(metric.direction)}`,
2615
+ "",
2616
+ ...usageLines(metric, environmentSlugs),
2617
+ ""
2618
+ ].join("\n");
2619
+ //#endregion
2620
+ //#region src/metrics/new-metric.ts
2621
+ const newMetricOf = (options) => ({
2622
+ eventName: options.eventName,
2623
+ kinds: options.kind,
2624
+ direction: options.direction,
2625
+ ...options.description === void 0 ? {} : { description: options.description }
2626
+ });
2627
+ //#endregion
2628
+ //#region src/commands/metrics.ts
2629
+ const usageDescription$1 = (metric) => `used by ${metric.usages.length} ${plural(metric.usages.length, "experiment", "experiments")}`;
2630
+ const METRIC_HEADER_ROW = [
2631
+ "EVENT NAME",
2632
+ "KINDS",
2633
+ "DIRECTION",
2634
+ "USED BY"
2635
+ ];
2636
+ const metricLines = (projectSlug, metrics, options) => metrics.length === 0 ? [`No metrics found in ${projectSlug}.`] : alignedListing(METRIC_HEADER_ROW, metrics.map((metric) => [
2637
+ metric.eventName,
2638
+ metric.kinds.map(kindWords).join(", "),
2639
+ directionWords(metric.direction),
2640
+ usageDescription$1(metric)
2641
+ ]), options);
2642
+ const listMetrics = async (context, _commandArguments, options) => {
2643
+ const { client, project } = await connectToLinkedProject(context);
2644
+ const { parsedBody: metrics, responseBody } = await client.listMetrics(project.id);
2645
+ return {
2646
+ json: responseBody,
2647
+ text: [...metricLines(project.slug, metrics, options), ""].join("\n"),
2648
+ exitCode: ExitCode.success
2649
+ };
2650
+ };
2651
+ const openRunEnvironmentSlugsOf = async (client, project, metric) => metric.usages.some((usage) => usage.openRuns.length > 0) ? environmentSlugsById(client, project) : /* @__PURE__ */ new Map();
2652
+ const noMetricWithEventName = (project, eventName) => new CliError(`No metric in ${project.slug} has the event name "${eventName}".`);
2653
+ const getMetric = async (context, [eventName]) => {
2654
+ const { client, project } = await connectToLinkedProject(context);
2655
+ const receivedMetric = await client.findMetricByEventName(project.id, eventName);
2656
+ if (receivedMetric === void 0) throw noMetricWithEventName(project, eventName);
2657
+ const { parsedBody: metric, responseBody } = receivedMetric;
2658
+ return {
2659
+ json: responseBody,
2660
+ text: metricText(metric, await openRunEnvironmentSlugsOf(client, project, metric)),
2661
+ exitCode: ExitCode.success
2662
+ };
2663
+ };
2664
+ const createMetric = async (context, _commandArguments, options) => {
2665
+ const { client, project } = await connectToLinkedProject(context);
2666
+ const { parsedBody: metric, responseBody } = await client.createMetric(project.id, newMetricOf(options));
2667
+ return {
2668
+ json: responseBody,
2669
+ text: [`Created metric ${metric.eventName} in ${project.slug}.`, metricText(metric, /* @__PURE__ */ new Map())].join("\n"),
2670
+ exitCode: ExitCode.success
2671
+ };
2672
+ };
2673
+ const kindInUseError = (eventName, experiments) => new CliError([
2674
+ `${experiments.length} ${plural(experiments.length, "experiment decides", "experiments decide")} on a kind this change removes, so ${eventName} was not updated:`,
2675
+ ...experiments.map((experiment) => ` ${experiment.configKey} decides on ${kindWords(experiment.primaryKind)}`),
2676
+ `Give ${plural(experiments.length, "it", "each of them")} another primary kind first, on the experiment's Settings tab in the dashboard.`
2677
+ ].join("\n"));
2678
+ const updateMetric = async (context, [eventName], options) => {
2679
+ const changes = metricChangesOf(options);
2680
+ const { client, project } = await connectToLinkedProject(context);
2681
+ const currentMetric = await client.findMetricByEventName(project.id, eventName);
2682
+ if (currentMetric === void 0) throw noMetricWithEventName(project, eventName);
2683
+ const update = await client.updateMetric(project.id, currentMetric.parsedBody.id, changes);
2684
+ switch (update.outcome) {
2685
+ case "not-found": throw noMetricWithEventName(project, eventName);
2686
+ case "kind-in-use": throw kindInUseError(eventName, update.decidingExperiments);
2687
+ case "updated": {
2688
+ const { parsedBody: metric, responseBody } = update.metric;
2689
+ return {
2690
+ json: responseBody,
2691
+ text: [`Updated metric ${eventName} in ${project.slug}.`, metricText(metric, await openRunEnvironmentSlugsOf(client, project, metric))].join("\n"),
2692
+ exitCode: ExitCode.success
2693
+ };
2694
+ }
2695
+ }
2696
+ };
2697
+ const experimentWithOpenRunsLine = (experiment, environmentSlugs) => ` ${experiment.configKey}, running in ${openRunEnvironmentNames(experiment.openRuns, environmentSlugs)}`;
2698
+ const metricInUseError = (eventName, experiments, environmentSlugs) => new CliError([
2699
+ `${experiments.length} ${plural(experiments.length, "experiment", "experiments")} with a run in progress ${plural(experiments.length, "uses", "use")} ${eventName}, so it was not deleted:`,
2700
+ ...experiments.map((experiment) => experimentWithOpenRunsLine(experiment, environmentSlugs)),
2701
+ "Ship or stop each run first, with configdirector configs ship or configs stop, or detach the metric on the experiment's Settings tab in the dashboard."
2702
+ ].join("\n"));
2703
+ const deleteMetric = async (context, [eventName]) => {
2704
+ const { client, project } = await connectToLinkedProject(context);
2705
+ const currentMetric = await client.findMetricByEventName(project.id, eventName);
2706
+ if (currentMetric === void 0) throw noMetricWithEventName(project, eventName);
2707
+ const deletion = await client.deleteMetric(project.id, currentMetric.parsedBody.id);
2708
+ switch (deletion.outcome) {
2709
+ case "deleted": return {
2710
+ json: { deletedEventName: eventName },
2711
+ text: [`Deleted metric ${eventName} from ${project.slug}.`, ""].join("\n"),
2712
+ exitCode: ExitCode.success
2713
+ };
2714
+ case "not-found": throw noMetricWithEventName(project, eventName);
2715
+ case "in-use": throw metricInUseError(eventName, deletion.experimentsWithOpenRuns, await environmentSlugsById(client, project));
2716
+ }
2717
+ };
2718
+ //#endregion
2282
2719
  //#region src/commands/projects.ts
2283
2720
  const PROJECT_HEADER_ROW = ["SLUG", "NAME"];
2284
2721
  const projectLines = (projects, options) => projects.length === 0 ? ["No projects found."] : alignedListing(PROJECT_HEADER_ROW, projects.map((project) => [project.slug, project.name]), options);
@@ -5033,10 +5470,6 @@ const listSegments = async (context, _commandArguments, options) => {
5033
5470
  exitCode: ExitCode.success
5034
5471
  };
5035
5472
  };
5036
- const environmentSlugsById = async (client, project) => {
5037
- const environments = await ifPermitted(client.listEnvironments(project.id));
5038
- return new Map((environments?.parsedBody ?? []).map((environment) => [environment.id, environment.slug]));
5039
- };
5040
5473
  const overrideEnvironmentSlugsOf = async (client, project, segment) => Object.keys(segment.overrides).length === 0 ? /* @__PURE__ */ new Map() : environmentSlugsById(client, project);
5041
5474
  const noSegmentWithKey = (project, segmentKey) => new CliError(`No segment in ${project.slug} has the key "${segmentKey}".`);
5042
5475
  const getSegment = async (context, [segmentKey]) => {
@@ -5384,6 +5817,10 @@ const withoutTrailingSlashes = (url) => {
5384
5817
  return trimmedUrl;
5385
5818
  };
5386
5819
  const repeatedOption = (value, values) => [...values ?? [], value];
5820
+ const repeatableChoiceOption = (flags, description, choices) => new Option(flags, description).choices(choices).argParser((value, values) => {
5821
+ if (!choices.includes(value)) throw new InvalidArgumentError(`Allowed choices are ${choices.join(", ")}.`);
5822
+ return repeatedOption(value, values);
5823
+ });
5387
5824
  const runCommand = async (invocation, command, actionArguments) => {
5388
5825
  const parsedCommand = actionArguments.at(-1);
5389
5826
  const options = parsedCommand.optsWithGlobals();
@@ -5428,11 +5865,11 @@ const run = async (invocation) => {
5428
5865
  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));
5429
5866
  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));
5430
5867
  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));
5431
- 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));
5432
- 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));
5433
- 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));
5434
- 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));
5435
- 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));
5868
+ 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).option("--hypothesis <text>", "what an experiment is expected to do; each run keeps the hypothesis it started with").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));
5869
+ 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, hypothesis 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));
5870
+ 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 and results, newest first").requiredOption("--environment <slug>", "slug of the environment to list the runs of (required)").action(commandAction(showConfigRuns));
5871
+ 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)").option("--note <text>", "why the variation is shipped, kept on the ended run").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));
5872
+ 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)").option("--note <text>", "why the run is stopped, kept on the ended run").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));
5436
5873
  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));
5437
5874
  const segmentsCommand = program.command("segments").description("list, show, create, update and delete a project's segments");
5438
5875
  segmentsCommand.command("list").description("list the project's segments with how many configs use each").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listSegments));
@@ -5440,6 +5877,12 @@ const run = async (invocation) => {
5440
5877
  segmentsCommand.command("create").description("create a segment from a JSON file").requiredOption("--segment <file>", SEGMENT_FILE_DESCRIPTION).addHelpText("after", "\nThe file holds {\"key\": ..., \"name\": ..., \"groups\": [...], \"overrides\": {...}}, like the output of segments get --json, so you can edit that output and send it. groups lists the condition groups, and a context is in the segment when every condition of any one group matches. overrides is optional: it maps an environment's id, which environments list --json shows, to the condition groups used there instead. A condition without an id gets a new one.").action(commandAction(createSegment));
5441
5878
  segmentsCommand.command("update").argument("<key>", "the segment's key").description("replace a segment's key, name and condition groups with a JSON file, and its environment overrides when the file has them").requiredOption("--segment <file>", SEGMENT_FILE_DESCRIPTION).addHelpText("after", "\nThe file takes the same form as for segments create, and the segment keeps its id whatever id the file holds. Its key, name and condition groups are replaced, so a different key renames it. Without overrides in the file, the environment overrides stay as they are; with \"overrides\": {} they are all removed, and a map leaves exactly those environments overridden. A condition without an id gets a new one.").action(commandAction(updateSegment));
5442
5879
  segmentsCommand.command("delete").argument("<key>", "the segment's key").description("delete a segment and its environment overrides").addHelpText("after", "\nThe segment is deleted with no confirmation. While the targeting rules of a config use it, the deletion is refused and the configs are listed with their environments.").action(commandAction(deleteSegment));
5880
+ const metricsCommand = program.command("metrics").description("list, show, create, update and delete the metrics a project's experiments measure");
5881
+ metricsCommand.command("list").description("list the project's metrics with their kinds, direction and how many experiments use each").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listMetrics));
5882
+ metricsCommand.command("get").argument("<event name>", "the metric's event name, as the application passes it to track").description("show a metric's kinds and direction, and the experiments that use it").action(commandAction(getMetric));
5883
+ metricsCommand.command("create").description("create the project's metric for an event name the application passes to track").requiredOption("--event-name <event name>", "the event name, exactly as the application passes it to track (required)").addOption(repeatableChoiceOption("--kind <kind>", "a number the metric reports: conversion, the share of exposed contexts that sent the event; count-per-context, how many times each sent it on average; value-per-context, the value sent with it per context on average; repeatable (required)", METRIC_KINDS).makeOptionMandatory()).addOption(new Option("--direction <direction>", "whether a higher or a lower number is better (required)").choices(METRIC_DIRECTIONS).makeOptionMandatory()).option("--description <text>", "what the event means").action(commandAction(createMetric));
5884
+ metricsCommand.command("update").argument("<event name>", "the metric's event name").description("change a metric's kinds, direction or description, leaving the others as they are").addOption(repeatableChoiceOption("--kind <kind>", "a number the metric reports; repeatable, and the kinds given replace the metric's", METRIC_KINDS)).addOption(new Option("--direction <direction>", "whether a higher or a lower number is better").choices(METRIC_DIRECTIONS)).option("--description <text>", "what the event means; --description \"\" removes it").addHelpText("after", "\nA metric keeps its event name: for another event name, create another metric. Removing a kind that an experiment's primary metric decides on is refused, and the experiments are listed.").action(commandAction(updateMetric));
5885
+ metricsCommand.command("delete").argument("<event name>", "the metric's event name").description("delete a metric and detach it from the experiments that use it").addHelpText("after", "\nThe metric is deleted with no confirmation. While an experiment that uses it has a run in progress, the deletion is refused and the experiments are listed with the environments of their runs. Events with its name keep being counted, so a metric created again for the name and attached again shows their history.").action(commandAction(deleteMetric));
5443
5886
  const targetingRulesCommand = program.command("targeting-rules").description("replace a config's targeting rules in one environment and show the format they take");
5444
5887
  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));
5445
5888
  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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@configdirector/cli",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Command line tool for ConfigDirector, the managed configuration and feature flag service.",
5
5
  "license": "MIT",
6
6
  "author": "ConfigDirector",