@configdirector/cli 0.20.0 → 0.22.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.22.0] - 2026-11-11
8
+
9
+ ### Added
10
+
11
+ - `configdirector configs runs` shows how long a run in progress needs, under each rollout's primary metric results once its variations can be compared. For a real lift of 2%, 5%, 10%, 20% and 50% on the primary metric, in its better direction, it gives the exposures so far against those needed before a variation with that lift has a good chance of reading likely better, and about how many days remain at the pace of new exposures since the time it names. A lift the rollout has enough exposures for says so, more than 365 days reads as more than a year, and when no new contexts were exposed since that time, it says the days cannot be estimated and gives the exposures alone. It also says that a row's lift is one a variation might really have, not the lift measured so far, and that whatever the estimate says, a run should cover at least one full week. The estimate doesn't show while the control's numbers give nothing to estimate from, as when none of its contexts has sent the event yet. `--json` prints the Admin API's response as received, as before.
12
+
13
+ ## [0.21.0] - 2026-11-10
14
+
15
+ ### Added
16
+
17
+ - `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": ... }`.
18
+ - `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.
19
+ - `configdirector configs ship` and `configs stop` take `--note <text>`, up to 1,000 characters on why, which the ended run keeps.
20
+ - `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.
21
+
22
+ ### Changed
23
+
24
+ - `configdirector configs copy --help` lists the hypothesis among what a copy gets.
25
+
26
+ ### Fixed
27
+
28
+ - `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.
29
+
7
30
  ## [0.20.0] - 2026-11-06
8
31
 
9
32
  ### Changed
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
 
@@ -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,41 @@ 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) 3.5% +19% (-43% to +154%) 69% chance to beat control no clear difference
346
+ How long to run, at the pace since 2026-11-04 00:00 UTC: about 98 new exposures a day
347
+ For a real lift of +2% 810 of about 2,082,762 exposures more than a year remaining
348
+ For a real lift of +5% 810 of about 338,034 exposures more than a year remaining
349
+ For a real lift of +10% 810 of about 86,501 exposures more than a year remaining
350
+ For a real lift of +20% 810 of about 22,617 exposures about 222 days remaining
351
+ For a real lift of +50% 810 of about 4,089 exposures about 34 days remaining
352
+ A row's lift is one a variation might really have over the control, not the lift measured so far: the smaller it is, the longer the run.
353
+ Whatever the estimate says, a run should cover at least one full week, so every day of the week is in the result.
354
+ Guardrail: support_ticket_opened, count per context, lower is better
355
+ control (Start free trial) 0.12 control
356
+ treatment-a (Try it free for 14 days) 0.13 +8% (-10% to +29%) 22% chance to beat control no clear difference
357
+ Events with no metric: add_to_cart (340 contexts)
358
+ Results can be up to 25 minutes behind.
339
359
  ```
340
360
 
361
+ 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. While a run is going, `How long to run` follows the primary metric's results: for a real lift of 2%, 5%, 10%, 20% and 50%, in the metric's better direction, the exposures so far against those needed before a variation with that lift has a good chance of reading likely better, and about how many days remain at the pace of new exposures since the time it names. 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.
362
+
341
363
  - `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
364
  - `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
365
 
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.
366
+ 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
367
 
346
368
  `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
369
 
@@ -400,6 +422,24 @@ configdirector segments delete beta-testers
400
422
 
401
423
  `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
424
 
425
+ ## Change metrics
426
+
427
+ ```bash
428
+ configdirector metrics create --event-name checkout_completed --kind conversion --kind value-per-context --direction higher-is-better
429
+ configdirector metrics update checkout_completed --direction lower-is-better
430
+ configdirector metrics delete checkout_completed
431
+ ```
432
+
433
+ 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.
434
+
435
+ - `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.
436
+ - `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.
437
+ - `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.
438
+
439
+ 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.
440
+
441
+ `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.
442
+
403
443
  ## Archive a config
404
444
 
405
445
  ```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.20.0";
17
+ var version = "0.22.0";
18
18
  //#endregion
19
19
  //#region src/CliError.ts
20
20
  var CliError = class extends Error {
@@ -134,11 +134,61 @@ 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 RunLengthEstimateSchema = v.object({
150
+ analyzedExposures: v.number(),
151
+ exposuresPerDay: v.number(),
152
+ paceMeasuredSince: TimestampSchema,
153
+ lifts: v.array(v.object({
154
+ lift: v.number(),
155
+ exposuresNeeded: v.number(),
156
+ daysRemaining: v.optional(v.number())
157
+ }))
158
+ });
159
+ const MetricKindResultSchema = v.object({
160
+ kind: v.string(),
161
+ rollouts: v.array(v.object({
162
+ ruleId: v.string(),
163
+ variations: v.array(VariationResultSchema),
164
+ runLengthEstimate: v.optional(RunLengthEstimateSchema)
165
+ }))
166
+ });
167
+ const MetricResultSchema = v.object({
168
+ eventName: v.string(),
169
+ direction: v.string(),
170
+ kinds: v.array(MetricKindResultSchema)
171
+ });
172
+ const RolloutResultsSchema = v.object({
173
+ ruleId: v.string(),
174
+ controlVariationId: v.string(),
175
+ state: v.string(),
176
+ crossovers: v.number()
177
+ });
178
+ const ResultsSchema = v.object({
179
+ asOf: v.optional(TimestampSchema),
180
+ rollouts: v.array(RolloutResultsSchema),
181
+ primary: v.array(MetricResultSchema),
182
+ secondary: v.array(MetricResultSchema),
183
+ guardrail: v.array(MetricResultSchema)
184
+ });
137
185
  const ExperimentRunSchema = v.object({
138
186
  startedAt: TimestampSchema,
139
187
  endedAt: v.optional(TimestampSchema),
140
188
  outcome: v.optional(v.string()),
141
189
  shippedVariationId: v.optional(v.string()),
190
+ hypothesis: v.optional(v.string()),
191
+ note: v.optional(v.string()),
142
192
  rollouts: v.array(v.object({
143
193
  ruleId: v.string(),
144
194
  conditions: v.optional(v.string())
@@ -147,10 +197,48 @@ const ExperimentRunSchema = v.object({
147
197
  rollouts: v.array(RolloutExposuresSchema),
148
198
  evaluationsWithoutIdentifier: v.number(),
149
199
  evaluationsFromSdksNotReportingExposures: v.number()
150
- })
200
+ }),
201
+ trackedEvents: v.object({ events: v.array(v.object({
202
+ name: v.string(),
203
+ contexts: v.number()
204
+ })) }),
205
+ results: ResultsSchema
151
206
  });
152
207
  const ExperimentRunsSchema = v.array(ExperimentRunSchema);
153
208
  //#endregion
209
+ //#region src/admin-api/metrics.ts
210
+ const OpenRunsSchema = v.array(v.object({ environmentId: v.string() }));
211
+ const MetricUsageSchema = v.object({
212
+ configKey: v.string(),
213
+ role: v.string(),
214
+ primaryKind: v.nullable(v.string()),
215
+ openRuns: OpenRunsSchema
216
+ });
217
+ const MetricSchema = v.object({
218
+ id: v.string(),
219
+ eventName: v.string(),
220
+ description: v.nullable(v.string()),
221
+ direction: v.string(),
222
+ kinds: v.array(v.string()),
223
+ usages: v.array(MetricUsageSchema)
224
+ });
225
+ const DecidingExperimentSchema = v.object({
226
+ configKey: v.string(),
227
+ primaryKind: v.string()
228
+ });
229
+ const MetricKindInUseBodySchema = v.object({
230
+ code: v.literal("METRIC_KIND_IN_USE"),
231
+ usages: v.array(DecidingExperimentSchema)
232
+ });
233
+ const ExperimentWithOpenRunsSchema = v.object({
234
+ configKey: v.string(),
235
+ openRuns: OpenRunsSchema
236
+ });
237
+ const MetricInUseBodySchema = v.object({
238
+ code: v.literal("METRIC_IN_USE"),
239
+ usages: v.array(ExperimentWithOpenRunsSchema)
240
+ });
241
+ //#endregion
154
242
  //#region src/admin-api/config-creation.ts
155
243
  const CreatedConfigSchema = v.object({
156
244
  key: v.string(),
@@ -547,6 +635,28 @@ const segmentDeletionOutcomeOf = (received) => {
547
635
  if (!received.response.ok) throw failureFor(received.url, received.response, received.body);
548
636
  return { outcome: "deleted" };
549
637
  };
638
+ const isMetricKindInUse = (received) => received.response.status === 409 && v.is(MetricKindInUseBodySchema, received.body);
639
+ const metricUpdateOutcomeOf = (received) => {
640
+ if (isMissingResource(received)) return { outcome: "not-found" };
641
+ if (isMetricKindInUse(received)) return {
642
+ outcome: "kind-in-use",
643
+ decidingExperiments: received.body.usages
644
+ };
645
+ return {
646
+ outcome: "updated",
647
+ metric: parsedResponseOf(received, MetricSchema)
648
+ };
649
+ };
650
+ const isMetricInUse = (received) => received.response.status === 409 && v.is(MetricInUseBodySchema, received.body);
651
+ const metricDeletionOutcomeOf = (received) => {
652
+ if (isMissingResource(received)) return { outcome: "not-found" };
653
+ if (isMetricInUse(received)) return {
654
+ outcome: "in-use",
655
+ experimentsWithOpenRuns: received.body.usages
656
+ };
657
+ if (!received.response.ok) throw failureFor(received.url, received.response, received.body);
658
+ return { outcome: "deleted" };
659
+ };
550
660
  const parsedResponseOf = ({ url, response, body }, schema) => {
551
661
  if (!response.ok) throw failureFor(url, response, body);
552
662
  const parsedBody = v.safeParse(schema, body);
@@ -604,6 +714,33 @@ var AdminApiClient = class {
604
714
  async deleteSegment(projectId, segmentKey) {
605
715
  return segmentDeletionOutcomeOf(await this.send(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, { method: "DELETE" }));
606
716
  }
717
+ listMetrics(projectId) {
718
+ return this.get(`/v1/projects/${encodeURIComponent(projectId)}/metrics`, v.array(MetricSchema));
719
+ }
720
+ async findMetricByEventName(projectId, eventName) {
721
+ const { parsedBody: metrics, responseBody } = await this.get(`/v1/projects/${encodeURIComponent(projectId)}/metrics?${new URLSearchParams({ eventName })}`, v.array(MetricSchema));
722
+ const index = metrics.findIndex((metric) => metric.eventName === eventName);
723
+ const metric = metrics[index];
724
+ return metric === void 0 ? void 0 : {
725
+ parsedBody: metric,
726
+ responseBody: v.parse(v.array(v.unknown()), responseBody)[index]
727
+ };
728
+ }
729
+ createMetric(projectId, request) {
730
+ return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/metrics`, MetricSchema, {
731
+ method: "POST",
732
+ body: request
733
+ });
734
+ }
735
+ async updateMetric(projectId, metricId, request) {
736
+ return metricUpdateOutcomeOf(await this.send(`/v1/projects/${encodeURIComponent(projectId)}/metrics/${encodeURIComponent(metricId)}`, {
737
+ method: "PATCH",
738
+ body: request
739
+ }));
740
+ }
741
+ async deleteMetric(projectId, metricId) {
742
+ return metricDeletionOutcomeOf(await this.send(`/v1/projects/${encodeURIComponent(projectId)}/metrics/${encodeURIComponent(metricId)}`, { method: "DELETE" }));
743
+ }
607
744
  async findTagByName(projectId, name) {
608
745
  return (await this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/tags/by-name?${new URLSearchParams({ name })}`, TagSchema))?.parsedBody;
609
746
  }
@@ -628,16 +765,16 @@ var AdminApiClient = class {
628
765
  listRuns(projectId, configKey, environmentId) {
629
766
  return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/runs`, ExperimentRunsSchema);
630
767
  }
631
- shipVariation(projectId, configKey, environmentId, variationId) {
768
+ shipVariation(projectId, configKey, environmentId, request) {
632
769
  return this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/ship`, ConfigWithTargetingRulesSchema, {
633
770
  method: "POST",
634
- body: { variationId }
771
+ body: request
635
772
  });
636
773
  }
637
- stopRun(projectId, configKey, environmentId) {
774
+ stopRun(projectId, configKey, environmentId, request) {
638
775
  return this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/stop`, ConfigWithTargetingRulesSchema, {
639
776
  method: "POST",
640
- body: {}
777
+ body: request
641
778
  });
642
779
  }
643
780
  async evaluateConfig(projectId, configKey, request) {
@@ -1065,6 +1202,11 @@ const describeCondition = (condition, segments) => {
1065
1202
  };
1066
1203
  const valueWithVariationName = (value, variationName) => variationName ? `${variationName} (${String(value)})` : String(value);
1067
1204
  const describeVariation = (variation) => valueWithVariationName(variation.value, variation.name);
1205
+ const describeVariationById = (variationId, variations) => {
1206
+ const variation = variations.find((candidate) => candidate.id === variationId);
1207
+ return variation && describeVariation(variation);
1208
+ };
1209
+ const describeVariationOrNoLongerListed = (variationId, variations) => describeVariationById(variationId, variations) ?? "a variation no longer listed";
1068
1210
  const describeValue = (value, variationId, variations) => valueWithVariationName(value, variations.find((variation) => variation.id === variationId)?.name);
1069
1211
  const describePercentages = (percentages, variations) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${describeValue(percentage.value, percentage.variationId, variations)}`).join(", ");
1070
1212
  const describeRule = (rule, segments, variations) => {
@@ -1444,6 +1586,7 @@ const newConfigOf = async (options, workingDirectory) => {
1444
1586
  typeOptions: await CONFIG_TYPE_RULES[options.type].typeOptionsOf(options, workingDirectory),
1445
1587
  ...availabilityOf(options),
1446
1588
  variations: await variationsOf(options, workingDirectory),
1589
+ hypothesis: options.hypothesis,
1447
1590
  defaultValue: await typedValueOf(options.type, {
1448
1591
  argumentLabel: "--default-value",
1449
1592
  text: options.defaultValue,
@@ -1452,6 +1595,218 @@ const newConfigOf = async (options, workingDirectory) => {
1452
1595
  };
1453
1596
  };
1454
1597
  //#endregion
1598
+ //#region src/metrics/metric-words.ts
1599
+ const METRIC_KINDS = [
1600
+ "conversion",
1601
+ "count-per-context",
1602
+ "value-per-context"
1603
+ ];
1604
+ const METRIC_DIRECTIONS = ["higher-is-better", "lower-is-better"];
1605
+ const METRIC_ROLES = [
1606
+ "primary",
1607
+ "secondary",
1608
+ "guardrail"
1609
+ ];
1610
+ const isOneOf = (values, candidate) => values.includes(candidate);
1611
+ const isMetricKind = (kind) => isOneOf(METRIC_KINDS, kind);
1612
+ const KIND_WORDS = {
1613
+ conversion: "conversion",
1614
+ "count-per-context": "count per context",
1615
+ "value-per-context": "value per context"
1616
+ };
1617
+ const DIRECTION_WORDS = {
1618
+ "higher-is-better": "higher is better",
1619
+ "lower-is-better": "lower is better"
1620
+ };
1621
+ const ROLE_WORDS = {
1622
+ primary: "primary metric",
1623
+ secondary: "secondary metric",
1624
+ guardrail: "guardrail"
1625
+ };
1626
+ const kindWords = (kind) => isMetricKind(kind) ? KIND_WORDS[kind] : kind;
1627
+ const directionWords = (direction) => isOneOf(METRIC_DIRECTIONS, direction) ? DIRECTION_WORDS[direction] : direction;
1628
+ const roleWords = (role) => isOneOf(METRIC_ROLES, role) ? ROLE_WORDS[role] : role;
1629
+ //#endregion
1630
+ //#region src/configs/run-results-text.ts
1631
+ const VERDICT_WORDS = {
1632
+ "likely-better": "likely better",
1633
+ "likely-worse": "likely worse",
1634
+ "no-clear-difference": "no clear difference"
1635
+ };
1636
+ const formatAverage = (value) => value.toLocaleString("en-US", {
1637
+ minimumFractionDigits: 2,
1638
+ maximumFractionDigits: 2
1639
+ });
1640
+ const VALUE_FORMATS = {
1641
+ conversion: (value) => `${(value * 100).toFixed(1)}%`,
1642
+ "count-per-context": formatAverage,
1643
+ "value-per-context": formatAverage
1644
+ };
1645
+ const formatResultValue = (kind, value) => isMetricKind(kind) ? VALUE_FORMATS[kind](value) : formatAverage(value);
1646
+ const formatLift = (lift) => {
1647
+ const wholePercent = Math.round(lift * 100);
1648
+ if (wholePercent > 0) return `+${wholePercent.toLocaleString("en-US")}%`;
1649
+ if (wholePercent < 0) return `${wholePercent.toLocaleString("en-US")}%`;
1650
+ return "0%";
1651
+ };
1652
+ const formatChance = (chance) => {
1653
+ const wholePercent = Math.round(chance * 100);
1654
+ if (wholePercent < 1) return "<1%";
1655
+ if (wholePercent > 99) return ">99%";
1656
+ return `${wholePercent}%`;
1657
+ };
1658
+ const liftCell = (variation) => {
1659
+ if (variation.lift === void 0) return "-";
1660
+ if (variation.interval === void 0) return formatLift(variation.lift);
1661
+ return `${formatLift(variation.lift)} (${formatLift(variation.interval.low)} to ${formatLift(variation.interval.high)})`;
1662
+ };
1663
+ const chanceCell = (chance, description) => chance === void 0 ? "" : `${formatChance(chance)} ${description}`;
1664
+ const comparisonOf = (rollout) => rollout.state === "needs-more-exposures" ? "needs-more-exposures" : "variations-compared";
1665
+ const resultRowFormOf = (variation, table) => {
1666
+ if (variation.variationId === table.controlVariationId) return "control";
1667
+ return table.comparison === "needs-more-exposures" ? "value-alone" : "compared";
1668
+ };
1669
+ const resultRow = (variation, table) => {
1670
+ const nameAndValue = [describeVariationOrNoLongerListed(variation.variationId, table.variations), formatResultValue(table.kind, variation.value)];
1671
+ const chanceToBeBestCells = table.showsChanceToBeBest ? [chanceCell(variation.chanceToBeBest, "chance to be best")] : [];
1672
+ switch (resultRowFormOf(variation, table)) {
1673
+ case "control": return [
1674
+ ...nameAndValue,
1675
+ "control",
1676
+ "",
1677
+ ...chanceToBeBestCells
1678
+ ];
1679
+ case "value-alone": return nameAndValue;
1680
+ case "compared": return [
1681
+ ...nameAndValue,
1682
+ liftCell(variation),
1683
+ chanceCell(variation.chanceToBeatControl, "chance to beat control"),
1684
+ ...chanceToBeBestCells,
1685
+ variation.verdict === void 0 ? "" : VERDICT_WORDS[variation.verdict] ?? variation.verdict
1686
+ ];
1687
+ }
1688
+ };
1689
+ const capitalized = (text) => `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
1690
+ const DAYS_IN_A_YEAR = 365;
1691
+ const formatCount = (count) => count.toLocaleString("en-US");
1692
+ const liftRowFormOf = (daysRemaining) => {
1693
+ if (daysRemaining === void 0) return { form: "no-pace" };
1694
+ if (daysRemaining === 0) return { form: "enough-exposures" };
1695
+ return daysRemaining > DAYS_IN_A_YEAR ? { form: "more-than-a-year" } : {
1696
+ form: "days-remaining",
1697
+ days: daysRemaining
1698
+ };
1699
+ };
1700
+ const daysRemainingCells = (liftRow) => {
1701
+ switch (liftRow.form) {
1702
+ case "days-remaining": return [`about ${formatCount(liftRow.days)} ${plural(liftRow.days, "day", "days")} remaining`];
1703
+ case "more-than-a-year": return ["more than a year remaining"];
1704
+ case "enough-exposures": return ["enough exposures"];
1705
+ case "no-pace": return [];
1706
+ }
1707
+ };
1708
+ const exposurePaceOf = (estimate) => estimate.exposuresPerDay > 0 ? {
1709
+ form: "new-exposures",
1710
+ exposuresPerDay: Math.round(estimate.exposuresPerDay)
1711
+ } : { form: "no-new-exposures" };
1712
+ const paceLine = (estimate) => {
1713
+ const since = utcMinute(estimate.paceMeasuredSince);
1714
+ const pace = exposurePaceOf(estimate);
1715
+ switch (pace.form) {
1716
+ case "new-exposures": return `How long to run, at the pace since ${since}: about ${formatCount(pace.exposuresPerDay)} ${plural(pace.exposuresPerDay, "new exposure", "new exposures")} a day`;
1717
+ case "no-new-exposures": return `How long to run: no new contexts were exposed since ${since}, so the days cannot be estimated.`;
1718
+ }
1719
+ };
1720
+ const runLengthEstimateLines = (estimate) => {
1721
+ if (estimate === void 0) return [];
1722
+ return [
1723
+ paceLine(estimate),
1724
+ ...alignedColumns(estimate.lifts.map((liftEstimate) => [
1725
+ `For a real lift of ${formatLift(liftEstimate.lift)}`,
1726
+ `${formatCount(estimate.analyzedExposures)} of about ${formatCount(liftEstimate.exposuresNeeded)} exposures`,
1727
+ ...daysRemainingCells(liftRowFormOf(liftEstimate.daysRemaining))
1728
+ ])).map((line) => ` ${line}`),
1729
+ "A row's lift is one a variation might really have over the control, not the lift measured so far: the smaller it is, the longer the run.",
1730
+ "Whatever the estimate says, a run should cover at least one full week, so every day of the week is in the result."
1731
+ ];
1732
+ };
1733
+ const kindResultLines = (role, metric, kindResult, rollout, variations) => {
1734
+ const kindResultOfRollout = kindResult.rollouts.find((candidate) => candidate.ruleId === rollout.ruleId);
1735
+ if (kindResultOfRollout === void 0) return [];
1736
+ const variationResults = kindResultOfRollout.variations;
1737
+ const table = {
1738
+ kind: kindResult.kind,
1739
+ controlVariationId: rollout.controlVariationId,
1740
+ comparison: comparisonOf(rollout),
1741
+ variations,
1742
+ showsChanceToBeBest: variationResults.some((variation) => variation.chanceToBeBest !== void 0)
1743
+ };
1744
+ return [
1745
+ `${capitalized(roleWords(role))}: ${metric.eventName}, ${kindWords(kindResult.kind)}, ${directionWords(metric.direction)}`,
1746
+ ...alignedColumns(variationResults.map((variation) => resultRow(variation, table))).map((line) => ` ${line}`.trimEnd()),
1747
+ ...runLengthEstimateLines(kindResultOfRollout.runLengthEstimate)
1748
+ ];
1749
+ };
1750
+ const crossoverLines = (crossovers) => {
1751
+ if (crossovers === 0) return [];
1752
+ 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.`];
1753
+ };
1754
+ const comparisonLines = (comparison) => {
1755
+ switch (comparison) {
1756
+ case "variations-compared": return [];
1757
+ case "needs-more-exposures": return ["The run needs more exposures before the variations can be compared."];
1758
+ }
1759
+ };
1760
+ const runResultsOf = (run) => METRIC_ROLES.some((role) => run.results[role].length > 0) ? {
1761
+ form: "measured",
1762
+ results: run.results
1763
+ } : { form: "no-metrics" };
1764
+ const rolloutResultLines = (runResults, ruleId, variations) => {
1765
+ switch (runResults.form) {
1766
+ case "no-metrics": return [];
1767
+ case "measured": {
1768
+ const { results } = runResults;
1769
+ const rollout = results.rollouts.find((candidate) => candidate.ruleId === ruleId);
1770
+ if (rollout === void 0) return [];
1771
+ return [
1772
+ ...comparisonLines(comparisonOf(rollout)),
1773
+ ...crossoverLines(rollout.crossovers),
1774
+ ...METRIC_ROLES.flatMap((role) => results[role].flatMap((metric) => metric.kinds.flatMap((kindResult) => kindResultLines(role, metric, kindResult, rollout, variations))))
1775
+ ];
1776
+ }
1777
+ }
1778
+ };
1779
+ const measuredEventNamesOf = (runResults) => {
1780
+ switch (runResults.form) {
1781
+ case "no-metrics": return /* @__PURE__ */ new Set();
1782
+ case "measured": return new Set(METRIC_ROLES.flatMap((role) => runResults.results[role].map((metric) => metric.eventName)));
1783
+ }
1784
+ };
1785
+ const eventsWithoutAMetricLines = (run, runResults) => {
1786
+ const measuredEventNames = measuredEventNamesOf(runResults);
1787
+ const events = run.trackedEvents.events.filter((event) => !measuredEventNames.has(event.name));
1788
+ if (events.length === 0) return [];
1789
+ return [`Events with no metric: ${events.map((event) => `${event.name} (${event.contexts} ${plural(event.contexts, "context", "contexts")})`).join(", ")}`];
1790
+ };
1791
+ const decideResultsFreshness = (run, runResults) => {
1792
+ if (runResults.form === "no-metrics") return { form: "no-results" };
1793
+ if (run.endedAt === void 0) return { form: "running" };
1794
+ if (runResults.results.asOf === void 0) return { form: "ended-before-final-results" };
1795
+ return {
1796
+ form: "final",
1797
+ asOf: runResults.results.asOf
1798
+ };
1799
+ };
1800
+ const resultsFreshnessLines = (run, runResults) => {
1801
+ const freshness = decideResultsFreshness(run, runResults);
1802
+ switch (freshness.form) {
1803
+ case "no-results": return [];
1804
+ case "running": return ["Results can be up to 25 minutes behind."];
1805
+ case "final": return [`Final results, as of ${utcMinute(freshness.asOf)}, when the run ended.`];
1806
+ case "ended-before-final-results": return [];
1807
+ }
1808
+ };
1809
+ //#endregion
1455
1810
  //#region src/configs/run-text.ts
1456
1811
  const OUTCOME_LABELS = {
1457
1812
  shipped: "Shipped",
@@ -1463,36 +1818,40 @@ const VERDICT_SENTENCES = {
1463
1818
  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
1819
  "needs-more-exposures": "The run needs more exposures before the split can be checked against the configured shares."
1465
1820
  };
1466
- const variationDescription = (variationId, variations) => {
1467
- const variation = variations.find((candidate) => candidate.id === variationId);
1468
- return variation && describeVariation(variation);
1469
- };
1470
1821
  const runHeadline = (run, variations) => {
1471
1822
  if (run.outcome === void 0 || run.endedAt === void 0) return `Running since ${utcMinute(run.startedAt)}`;
1472
1823
  const outcomeLabel = OUTCOME_LABELS[run.outcome] ?? run.outcome;
1473
- const shippedVariation = run.shippedVariationId && variationDescription(run.shippedVariationId, variations);
1824
+ const shippedVariation = run.shippedVariationId && describeVariationById(run.shippedVariationId, variations);
1474
1825
  return `${shippedVariation ? `${outcomeLabel} ${shippedVariation}` : outcomeLabel}, ${utcMinute(run.startedAt)} to ${utcMinute(run.endedAt)}`;
1475
1826
  };
1476
1827
  const rolloutAudience = (run, rollout) => run.rollouts.find((candidate) => candidate.ruleId === rollout.ruleId)?.conditions ?? "everyone not caught by a rule above";
1477
1828
  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) => [
1829
+ const rolloutLines = (run, runResults, rollout, variations) => [
1479
1830
  `Rollout: ${rolloutAudience(run, rollout)}`,
1480
1831
  ...alignedColumns(rollout.variations.map((variation) => [
1481
- variationDescription(variation.variationId, variations) ?? "a variation no longer listed",
1832
+ describeVariationOrNoLongerListed(variation.variationId, variations),
1482
1833
  `${variation.exposures} ${plural(variation.exposures, "exposure", "exposures")}`,
1483
1834
  `configured ${formatPercentage(variation.percentage)}%`
1484
1835
  ])).map((line) => ` ${line}`),
1485
- ` ${rolloutSummary(rollout)}`
1836
+ ` ${rolloutSummary(rollout)}`,
1837
+ ...rolloutResultLines(runResults, rollout.ruleId, variations).map((line) => ` ${line}`)
1486
1838
  ];
1487
1839
  const uncountedEvaluationsLines = (evaluations, one, several) => {
1488
1840
  if (evaluations === 0) return [];
1489
1841
  return [evaluations === 1 ? `1 evaluation ${one}` : `${evaluations} evaluations ${several}`];
1490
1842
  };
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}`)];
1843
+ const runLines = (run, variations) => {
1844
+ const runResults = runResultsOf(run);
1845
+ return [runHeadline(run, variations), ...[
1846
+ ...run.hypothesis === void 0 ? [] : [`Hypothesis: ${run.hypothesis}`],
1847
+ ...run.note === void 0 ? [] : [`Ended with note: ${run.note}`],
1848
+ ...run.exposures.rollouts.flatMap((rollout) => rolloutLines(run, runResults, rollout, variations)),
1849
+ ...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."),
1850
+ ...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."),
1851
+ ...eventsWithoutAMetricLines(run, runResults),
1852
+ ...resultsFreshnessLines(run, runResults)
1853
+ ].map((line) => ` ${line}`)];
1854
+ };
1496
1855
  const runsText = (config, requestedKey, environment, runs) => {
1497
1856
  const environmentName = environmentLabel(environment);
1498
1857
  return [
@@ -1530,6 +1889,10 @@ const findEnvironmentBySlug = async (client, project, slug) => {
1530
1889
  if (environment === void 0) throw new CliError(`No environment in ${project.slug} has the slug "${slug}". ${environmentSlugsSentence(environments)}`);
1531
1890
  return environment;
1532
1891
  };
1892
+ const environmentSlugsById = async (client, project) => {
1893
+ const environments = await ifPermitted(client.listEnvironments(project.id));
1894
+ return new Map((environments?.parsedBody ?? []).map((environment) => [environment.id, environment.slug]));
1895
+ };
1533
1896
  //#endregion
1534
1897
  //#region src/project-selection.ts
1535
1898
  const requireProjectSlug = (context) => {
@@ -1804,13 +2167,17 @@ const showConfigRuns = async (context, [configKey], options) => {
1804
2167
  exitCode: ExitCode.success
1805
2168
  };
1806
2169
  };
2170
+ const runEndRequestOf = (options) => options.note === void 0 ? {} : { note: options.note };
1807
2171
  const shipVariation = async (context, [configKey], options) => {
1808
2172
  const { client, project } = await connectToLinkedProject(context);
1809
2173
  const environment = await findEnvironmentBySlug(client, project, options.environment);
1810
2174
  const receivedConfig = await client.getConfig(project.id, configKey);
1811
2175
  if (receivedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1812
2176
  const variation = findVariationToShip(receivedConfig.parsedBody, options.variation);
1813
- const savedConfig = await client.shipVariation(project.id, configKey, environment.id, variation.id);
2177
+ const savedConfig = await client.shipVariation(project.id, configKey, environment.id, {
2178
+ variationId: variation.id,
2179
+ ...runEndRequestOf(options)
2180
+ });
1814
2181
  if (savedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1815
2182
  const segments = await segmentNamesFor(client, project, savedConfig.parsedBody);
1816
2183
  return {
@@ -1827,7 +2194,7 @@ const shipVariation = async (context, [configKey], options) => {
1827
2194
  const stopRun = async (context, [configKey], options) => {
1828
2195
  const { client, project } = await connectToLinkedProject(context);
1829
2196
  const environment = await findEnvironmentBySlug(client, project, options.environment);
1830
- const savedConfig = await client.stopRun(project.id, configKey, environment.id);
2197
+ const savedConfig = await client.stopRun(project.id, configKey, environment.id, runEndRequestOf(options));
1831
2198
  if (savedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1832
2199
  const segments = await segmentNamesFor(client, project, savedConfig.parsedBody);
1833
2200
  return {
@@ -2280,6 +2647,134 @@ const logout = async (context) => {
2280
2647
  };
2281
2648
  };
2282
2649
  //#endregion
2650
+ //#region src/metrics/metric-changes.ts
2651
+ const metricChangesOf = (options) => {
2652
+ const changes = {
2653
+ ...options.kind === void 0 ? {} : { kinds: options.kind },
2654
+ ...options.direction === void 0 ? {} : { direction: options.direction },
2655
+ ...options.description === void 0 ? {} : { description: options.description }
2656
+ };
2657
+ if (Object.keys(changes).length === 0) throw new CliError("Say what to change with --kind, --direction or --description.");
2658
+ return changes;
2659
+ };
2660
+ //#endregion
2661
+ //#region src/metrics/metric-text.ts
2662
+ const openRunEnvironmentNames = (openRuns, environmentSlugs) => openRuns.map((run) => environmentSlugs.get(run.environmentId) ?? run.environmentId).join(", ");
2663
+ const usageLine$1 = (usage, environmentSlugs) => {
2664
+ const primaryKind = usage.primaryKind === null ? "" : ` on ${kindWords(usage.primaryKind)}`;
2665
+ const runningIn = usage.openRuns.length === 0 ? "" : `, running in ${openRunEnvironmentNames(usage.openRuns, environmentSlugs)}`;
2666
+ return ` ${usage.configKey}: ${roleWords(usage.role)}${primaryKind}${runningIn}`;
2667
+ };
2668
+ const usageLines = (metric, environmentSlugs) => metric.usages.length === 0 ? ["No experiment uses it."] : ["Used by", ...metric.usages.map((usage) => usageLine$1(usage, environmentSlugs))];
2669
+ const metricText = (metric, environmentSlugs) => [
2670
+ metric.eventName,
2671
+ ...metric.description === null ? [] : [` Description: ${metric.description}`],
2672
+ ` Kinds: ${metric.kinds.map(kindWords).join(", ")}`,
2673
+ ` Direction: ${directionWords(metric.direction)}`,
2674
+ "",
2675
+ ...usageLines(metric, environmentSlugs),
2676
+ ""
2677
+ ].join("\n");
2678
+ //#endregion
2679
+ //#region src/metrics/new-metric.ts
2680
+ const newMetricOf = (options) => ({
2681
+ eventName: options.eventName,
2682
+ kinds: options.kind,
2683
+ direction: options.direction,
2684
+ ...options.description === void 0 ? {} : { description: options.description }
2685
+ });
2686
+ //#endregion
2687
+ //#region src/commands/metrics.ts
2688
+ const usageDescription$1 = (metric) => `used by ${metric.usages.length} ${plural(metric.usages.length, "experiment", "experiments")}`;
2689
+ const METRIC_HEADER_ROW = [
2690
+ "EVENT NAME",
2691
+ "KINDS",
2692
+ "DIRECTION",
2693
+ "USED BY"
2694
+ ];
2695
+ const metricLines = (projectSlug, metrics, options) => metrics.length === 0 ? [`No metrics found in ${projectSlug}.`] : alignedListing(METRIC_HEADER_ROW, metrics.map((metric) => [
2696
+ metric.eventName,
2697
+ metric.kinds.map(kindWords).join(", "),
2698
+ directionWords(metric.direction),
2699
+ usageDescription$1(metric)
2700
+ ]), options);
2701
+ const listMetrics = async (context, _commandArguments, options) => {
2702
+ const { client, project } = await connectToLinkedProject(context);
2703
+ const { parsedBody: metrics, responseBody } = await client.listMetrics(project.id);
2704
+ return {
2705
+ json: responseBody,
2706
+ text: [...metricLines(project.slug, metrics, options), ""].join("\n"),
2707
+ exitCode: ExitCode.success
2708
+ };
2709
+ };
2710
+ const openRunEnvironmentSlugsOf = async (client, project, metric) => metric.usages.some((usage) => usage.openRuns.length > 0) ? environmentSlugsById(client, project) : /* @__PURE__ */ new Map();
2711
+ const noMetricWithEventName = (project, eventName) => new CliError(`No metric in ${project.slug} has the event name "${eventName}".`);
2712
+ const getMetric = async (context, [eventName]) => {
2713
+ const { client, project } = await connectToLinkedProject(context);
2714
+ const receivedMetric = await client.findMetricByEventName(project.id, eventName);
2715
+ if (receivedMetric === void 0) throw noMetricWithEventName(project, eventName);
2716
+ const { parsedBody: metric, responseBody } = receivedMetric;
2717
+ return {
2718
+ json: responseBody,
2719
+ text: metricText(metric, await openRunEnvironmentSlugsOf(client, project, metric)),
2720
+ exitCode: ExitCode.success
2721
+ };
2722
+ };
2723
+ const createMetric = async (context, _commandArguments, options) => {
2724
+ const { client, project } = await connectToLinkedProject(context);
2725
+ const { parsedBody: metric, responseBody } = await client.createMetric(project.id, newMetricOf(options));
2726
+ return {
2727
+ json: responseBody,
2728
+ text: [`Created metric ${metric.eventName} in ${project.slug}.`, metricText(metric, /* @__PURE__ */ new Map())].join("\n"),
2729
+ exitCode: ExitCode.success
2730
+ };
2731
+ };
2732
+ const kindInUseError = (eventName, experiments) => new CliError([
2733
+ `${experiments.length} ${plural(experiments.length, "experiment decides", "experiments decide")} on a kind this change removes, so ${eventName} was not updated:`,
2734
+ ...experiments.map((experiment) => ` ${experiment.configKey} decides on ${kindWords(experiment.primaryKind)}`),
2735
+ `Give ${plural(experiments.length, "it", "each of them")} another primary kind first, on the experiment's Settings tab in the dashboard.`
2736
+ ].join("\n"));
2737
+ const updateMetric = async (context, [eventName], options) => {
2738
+ const changes = metricChangesOf(options);
2739
+ const { client, project } = await connectToLinkedProject(context);
2740
+ const currentMetric = await client.findMetricByEventName(project.id, eventName);
2741
+ if (currentMetric === void 0) throw noMetricWithEventName(project, eventName);
2742
+ const update = await client.updateMetric(project.id, currentMetric.parsedBody.id, changes);
2743
+ switch (update.outcome) {
2744
+ case "not-found": throw noMetricWithEventName(project, eventName);
2745
+ case "kind-in-use": throw kindInUseError(eventName, update.decidingExperiments);
2746
+ case "updated": {
2747
+ const { parsedBody: metric, responseBody } = update.metric;
2748
+ return {
2749
+ json: responseBody,
2750
+ text: [`Updated metric ${eventName} in ${project.slug}.`, metricText(metric, await openRunEnvironmentSlugsOf(client, project, metric))].join("\n"),
2751
+ exitCode: ExitCode.success
2752
+ };
2753
+ }
2754
+ }
2755
+ };
2756
+ const experimentWithOpenRunsLine = (experiment, environmentSlugs) => ` ${experiment.configKey}, running in ${openRunEnvironmentNames(experiment.openRuns, environmentSlugs)}`;
2757
+ const metricInUseError = (eventName, experiments, environmentSlugs) => new CliError([
2758
+ `${experiments.length} ${plural(experiments.length, "experiment", "experiments")} with a run in progress ${plural(experiments.length, "uses", "use")} ${eventName}, so it was not deleted:`,
2759
+ ...experiments.map((experiment) => experimentWithOpenRunsLine(experiment, environmentSlugs)),
2760
+ "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."
2761
+ ].join("\n"));
2762
+ const deleteMetric = async (context, [eventName]) => {
2763
+ const { client, project } = await connectToLinkedProject(context);
2764
+ const currentMetric = await client.findMetricByEventName(project.id, eventName);
2765
+ if (currentMetric === void 0) throw noMetricWithEventName(project, eventName);
2766
+ const deletion = await client.deleteMetric(project.id, currentMetric.parsedBody.id);
2767
+ switch (deletion.outcome) {
2768
+ case "deleted": return {
2769
+ json: { deletedEventName: eventName },
2770
+ text: [`Deleted metric ${eventName} from ${project.slug}.`, ""].join("\n"),
2771
+ exitCode: ExitCode.success
2772
+ };
2773
+ case "not-found": throw noMetricWithEventName(project, eventName);
2774
+ case "in-use": throw metricInUseError(eventName, deletion.experimentsWithOpenRuns, await environmentSlugsById(client, project));
2775
+ }
2776
+ };
2777
+ //#endregion
2283
2778
  //#region src/commands/projects.ts
2284
2779
  const PROJECT_HEADER_ROW = ["SLUG", "NAME"];
2285
2780
  const projectLines = (projects, options) => projects.length === 0 ? ["No projects found."] : alignedListing(PROJECT_HEADER_ROW, projects.map((project) => [project.slug, project.name]), options);
@@ -5034,10 +5529,6 @@ const listSegments = async (context, _commandArguments, options) => {
5034
5529
  exitCode: ExitCode.success
5035
5530
  };
5036
5531
  };
5037
- const environmentSlugsById = async (client, project) => {
5038
- const environments = await ifPermitted(client.listEnvironments(project.id));
5039
- return new Map((environments?.parsedBody ?? []).map((environment) => [environment.id, environment.slug]));
5040
- };
5041
5532
  const overrideEnvironmentSlugsOf = async (client, project, segment) => Object.keys(segment.overrides).length === 0 ? /* @__PURE__ */ new Map() : environmentSlugsById(client, project);
5042
5533
  const noSegmentWithKey = (project, segmentKey) => new CliError(`No segment in ${project.slug} has the key "${segmentKey}".`);
5043
5534
  const getSegment = async (context, [segmentKey]) => {
@@ -5385,6 +5876,10 @@ const withoutTrailingSlashes = (url) => {
5385
5876
  return trimmedUrl;
5386
5877
  };
5387
5878
  const repeatedOption = (value, values) => [...values ?? [], value];
5879
+ const repeatableChoiceOption = (flags, description, choices) => new Option(flags, description).choices(choices).argParser((value, values) => {
5880
+ if (!choices.includes(value)) throw new InvalidArgumentError(`Allowed choices are ${choices.join(", ")}.`);
5881
+ return repeatedOption(value, values);
5882
+ });
5388
5883
  const runCommand = async (invocation, command, actionArguments) => {
5389
5884
  const parsedCommand = actionArguments.at(-1);
5390
5885
  const options = parsedCommand.optsWithGlobals();
@@ -5429,11 +5924,11 @@ const run = async (invocation) => {
5429
5924
  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));
5430
5925
  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));
5431
5926
  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));
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));
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));
5927
+ 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));
5928
+ 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));
5929
+ 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));
5930
+ 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));
5931
+ 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));
5437
5932
  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));
5438
5933
  const segmentsCommand = program.command("segments").description("list, show, create, update and delete a project's segments");
5439
5934
  segmentsCommand.command("list").description("list the project's segments with how many configs use each").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listSegments));
@@ -5441,6 +5936,12 @@ const run = async (invocation) => {
5441
5936
  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));
5442
5937
  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));
5443
5938
  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));
5939
+ const metricsCommand = program.command("metrics").description("list, show, create, update and delete the metrics a project's experiments measure");
5940
+ 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));
5941
+ 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));
5942
+ 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));
5943
+ 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));
5944
+ 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));
5444
5945
  const targetingRulesCommand = program.command("targeting-rules").description("replace a config's targeting rules in one environment and show the format they take");
5445
5946
  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));
5446
5947
  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.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Command line tool for ConfigDirector, the managed configuration and feature flag service.",
5
5
  "license": "MIT",
6
6
  "author": "ConfigDirector",