@configdirector/cli 0.18.1 → 0.19.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 +7 -0
- package/README.md +30 -1
- package/dist/configdirector.mjs +181 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@ All notable changes to `@configdirector/cli` are documented here. The format fol
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.19.0] - 2026-11-04
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `configdirector configs runs <key> --environment <slug>` lists an experiment's runs in an environment, newest first: when each started and ended, how it ended, and, per rollout, how many distinct contexts each variation reached against the share configured when the run started, and whether the split matches those shares. `--json` prints the Admin API's response as received.
|
|
12
|
+
- `configdirector configs ship <key> --environment <slug> --variation <name or id>` serves one variation to everyone in the environment and ends its run: in one save of the targeting rules, the default targeting rule value becomes the variation's value, every percentage rollout is removed, including one a conditional rule serves, and a conditional rule serving a single value stays. `configdirector configs stop <key> --environment <slug>` ends the run without choosing a variation, keeping the default targeting rule value. Both print the rules they saved, ask for no confirmation and treat a live environment like any other. They exit with 2 when ConfigDirector refuses the change, such as a stop on a config that is not an experiment or with no percentage rollout saved in the environment, and `configs ship` exits with 2 listing the variations when `--variation` matches none, or several with the same name, and saying so when the config has no variations.
|
|
13
|
+
|
|
7
14
|
## [0.18.1] - 2026-11-03
|
|
8
15
|
|
|
9
16
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ConfigDirector CLI
|
|
2
2
|
|
|
3
|
-
`configdirector` is the command line tool for [ConfigDirector](https://www.configdirector.com), the managed configuration and feature flag service. Commands and output may change before 1.0. This release signs in to one or more organizations, links a repository to a project, lists where the repository's code reads config keys, checks those keys against the project's configs, shows the value a context gets for a config and why, reads a project's environments, configs, targeting rules and segments, creates and copies configs, replaces a config's targeting rules in an environment, archives configs, creates, updates and deletes segments, uploads the repository's code references to the project, and lists and removes the project's scanned repositories.
|
|
3
|
+
`configdirector` is the command line tool for [ConfigDirector](https://www.configdirector.com), the managed configuration and feature flag service. Commands and output may change before 1.0. This release signs in to one or more organizations, links a repository to a project, lists where the repository's code reads config keys, checks those keys against the project's configs, shows the value a context gets for a config and why, reads a project's environments, configs, targeting rules and segments, creates and copies configs, replaces a config's targeting rules in an environment, lists an experiment's runs and ships or stops them, archives configs, creates, updates and deletes segments, uploads the repository's code references to the project, and lists and removes the project's scanned repositories.
|
|
4
4
|
|
|
5
5
|
The [CLI page of the ConfigDirector docs](https://docs.configdirector.com/integrations/cli) covers the API token, CI setup for GitHub Actions and GitLab, and what each problem `validate` reports means for your build.
|
|
6
6
|
|
|
@@ -318,6 +318,35 @@ configdirector targeting-rules set new-checkout --environment production --rules
|
|
|
318
318
|
|
|
319
319
|
`targeting-rules set` needs `projects:read`, `environments:read` and `config-targeting-rules:update`. `targeting-rules reference` needs only a valid token and no project.
|
|
320
320
|
|
|
321
|
+
## Experiment runs
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
configdirector configs runs checkout-cta-copy --environment production
|
|
325
|
+
configdirector configs ship checkout-cta-copy --environment production --variation treatment-a
|
|
326
|
+
configdirector configs stop checkout-cta-copy --environment production
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
A run is a period during which an experiment's targeting rules in one environment split traffic between its variations. `configs runs` lists the experiment's runs in an environment, newest first, with how many distinct contexts each variation reached, called exposures, against the share configured when the run started:
|
|
330
|
+
|
|
331
|
+
```txt
|
|
332
|
+
Runs of checkout-cta-copy in production (live), newest first:
|
|
333
|
+
|
|
334
|
+
Running since 2026-11-03 12:00 UTC
|
|
335
|
+
Rollout: everyone not caught by a rule above
|
|
336
|
+
control (Start free trial) 412 exposures configured 50%
|
|
337
|
+
treatment-a (Try it free for 14 days) 398 exposures configured 50%
|
|
338
|
+
810 contexts exposed, 3201 evaluations. The split matches the configured shares.
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
- `configs ship` serves one variation to everyone in the environment and ends the run. `--variation` takes its name or its ID. The default targeting rule value becomes the variation's value, every percentage rollout is removed, including one a conditional rule serves, and a conditional rule serving a single value stays.
|
|
342
|
+
- `configs stop` ends the run without choosing a variation: every percentage rollout is removed, including one a conditional rule serves, and the default targeting rule value and any conditional rule serving a single value stay.
|
|
343
|
+
|
|
344
|
+
Both print the rules they saved, ask for no confirmation and treat a live environment like any other, so check the environment before you run them.
|
|
345
|
+
|
|
346
|
+
`configs ship` exits with 2 when `--variation` matches none of the experiment's variations, or several with the same name, and lists them with their IDs, and when the config has no variations, since only an experiment has them. `configs stop` exits with 2 when the config is not an experiment or the environment holds no percentage rollout. All three exit with 2 when no config has the key or no environment has the slug. With `--json`, they print the Admin API's response as received.
|
|
347
|
+
|
|
348
|
+
`configs runs` needs `projects:read`, `environments:read` and `configs:read`. `configs ship` also needs `config-targeting-rules:update`, and `configs stop` needs `projects:read`, `environments:read` and `config-targeting-rules:update`. Shipped and stopped runs show in the audit log with the source CLI.
|
|
349
|
+
|
|
321
350
|
## Change segments
|
|
322
351
|
|
|
323
352
|
```bash
|
package/dist/configdirector.mjs
CHANGED
|
@@ -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.
|
|
17
|
+
var version = "0.19.0";
|
|
18
18
|
//#endregion
|
|
19
19
|
//#region src/CliError.ts
|
|
20
20
|
var CliError = class extends Error {
|
|
@@ -121,6 +121,36 @@ const CopyPlanSchema = v.object({
|
|
|
121
121
|
});
|
|
122
122
|
const CopyPlanResponseSchema = v.object({ plan: CopyPlanSchema });
|
|
123
123
|
//#endregion
|
|
124
|
+
//#region src/admin-api/experiment-runs.ts
|
|
125
|
+
const TimestampSchema = v.pipe(v.string(), v.isoTimestamp());
|
|
126
|
+
const RolloutExposuresSchema = v.object({
|
|
127
|
+
ruleId: v.string(),
|
|
128
|
+
exposures: v.number(),
|
|
129
|
+
evaluations: v.number(),
|
|
130
|
+
verdict: v.string(),
|
|
131
|
+
variations: v.array(v.object({
|
|
132
|
+
variationId: v.string(),
|
|
133
|
+
percentage: v.number(),
|
|
134
|
+
exposures: v.number()
|
|
135
|
+
}))
|
|
136
|
+
});
|
|
137
|
+
const ExperimentRunSchema = v.object({
|
|
138
|
+
startedAt: TimestampSchema,
|
|
139
|
+
endedAt: v.optional(TimestampSchema),
|
|
140
|
+
outcome: v.optional(v.string()),
|
|
141
|
+
shippedVariationId: v.optional(v.string()),
|
|
142
|
+
rollouts: v.array(v.object({
|
|
143
|
+
ruleId: v.string(),
|
|
144
|
+
conditions: v.optional(v.string())
|
|
145
|
+
})),
|
|
146
|
+
exposures: v.object({
|
|
147
|
+
rollouts: v.array(RolloutExposuresSchema),
|
|
148
|
+
evaluationsWithoutIdentifier: v.number(),
|
|
149
|
+
evaluationsFromSdksNotReportingExposures: v.number()
|
|
150
|
+
})
|
|
151
|
+
});
|
|
152
|
+
const ExperimentRunsSchema = v.array(ExperimentRunSchema);
|
|
153
|
+
//#endregion
|
|
124
154
|
//#region src/admin-api/config-creation.ts
|
|
125
155
|
const CreatedConfigSchema = v.object({
|
|
126
156
|
key: v.string(),
|
|
@@ -217,7 +247,8 @@ const RuleSchema = v.variant("type", [v.variant("target", [v.object({
|
|
|
217
247
|
//#region src/admin-api/variations.ts
|
|
218
248
|
const VariationSchema = v.object({
|
|
219
249
|
id: v.string(),
|
|
220
|
-
name: v.nullish(v.string())
|
|
250
|
+
name: v.nullish(v.string()),
|
|
251
|
+
value: RuleValueSchema
|
|
221
252
|
});
|
|
222
253
|
//#endregion
|
|
223
254
|
//#region src/admin-api/config-with-targeting-rules.ts
|
|
@@ -594,6 +625,21 @@ var AdminApiClient = class {
|
|
|
594
625
|
body: request
|
|
595
626
|
});
|
|
596
627
|
}
|
|
628
|
+
listRuns(projectId, configKey, environmentId) {
|
|
629
|
+
return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/runs`, ExperimentRunsSchema);
|
|
630
|
+
}
|
|
631
|
+
shipVariation(projectId, configKey, environmentId, variationId) {
|
|
632
|
+
return this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/ship`, ConfigWithTargetingRulesSchema, {
|
|
633
|
+
method: "POST",
|
|
634
|
+
body: { variationId }
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
stopRun(projectId, configKey, environmentId) {
|
|
638
|
+
return this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/stop`, ConfigWithTargetingRulesSchema, {
|
|
639
|
+
method: "POST",
|
|
640
|
+
body: {}
|
|
641
|
+
});
|
|
642
|
+
}
|
|
597
643
|
async evaluateConfig(projectId, configKey, request) {
|
|
598
644
|
const { responseBody, parsedBody } = await this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/evaluate`, ConfigEvaluationSchema, {
|
|
599
645
|
method: "POST",
|
|
@@ -1018,6 +1064,7 @@ const describeCondition = (condition, segments) => {
|
|
|
1018
1064
|
}
|
|
1019
1065
|
};
|
|
1020
1066
|
const valueWithVariationName = (value, variationName) => variationName ? `${variationName} (${String(value)})` : String(value);
|
|
1067
|
+
const describeVariation = (variation) => valueWithVariationName(variation.value, variation.name);
|
|
1021
1068
|
const describeValue = (value, variationId, variations) => valueWithVariationName(value, variations.find((variation) => variation.id === variationId)?.name);
|
|
1022
1069
|
const describePercentages = (percentages, variations) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${describeValue(percentage.value, percentage.variationId, variations)}`).join(", ");
|
|
1023
1070
|
const describeRule = (rule, segments, variations) => {
|
|
@@ -1405,6 +1452,85 @@ const newConfigOf = async (options, workingDirectory) => {
|
|
|
1405
1452
|
};
|
|
1406
1453
|
};
|
|
1407
1454
|
//#endregion
|
|
1455
|
+
//#region src/configs/run-text.ts
|
|
1456
|
+
const OUTCOME_LABELS = {
|
|
1457
|
+
shipped: "Shipped",
|
|
1458
|
+
stopped: "Stopped",
|
|
1459
|
+
replaced: "Replaced"
|
|
1460
|
+
};
|
|
1461
|
+
const VERDICT_SENTENCES = {
|
|
1462
|
+
matches: "The split matches the configured shares.",
|
|
1463
|
+
mismatch: "The split does not match the configured shares. This usually means some contexts evaluate without a stable identifier, or a rule above the split catches part of the traffic. Results from this run are not reliable.",
|
|
1464
|
+
"needs-more-exposures": "The run needs more exposures before the split can be checked against the configured shares."
|
|
1465
|
+
};
|
|
1466
|
+
const variationDescription = (variationId, variations) => {
|
|
1467
|
+
const variation = variations.find((candidate) => candidate.id === variationId);
|
|
1468
|
+
return variation && describeVariation(variation);
|
|
1469
|
+
};
|
|
1470
|
+
const runHeadline = (run, variations) => {
|
|
1471
|
+
if (run.outcome === void 0 || run.endedAt === void 0) return `Running since ${utcMinute(run.startedAt)}`;
|
|
1472
|
+
const outcomeLabel = OUTCOME_LABELS[run.outcome] ?? run.outcome;
|
|
1473
|
+
const shippedVariation = run.shippedVariationId && variationDescription(run.shippedVariationId, variations);
|
|
1474
|
+
return `${shippedVariation ? `${outcomeLabel} ${shippedVariation}` : outcomeLabel}, ${utcMinute(run.startedAt)} to ${utcMinute(run.endedAt)}`;
|
|
1475
|
+
};
|
|
1476
|
+
const rolloutAudience = (run, rollout) => run.rollouts.find((candidate) => candidate.ruleId === rollout.ruleId)?.conditions ?? "everyone not caught by a rule above";
|
|
1477
|
+
const rolloutSummary = (rollout) => `${rollout.exposures} ${plural(rollout.exposures, "context exposed", "contexts exposed")}, ${rollout.evaluations} ${plural(rollout.evaluations, "evaluation", "evaluations")}. ` + (VERDICT_SENTENCES[rollout.verdict] ?? rollout.verdict);
|
|
1478
|
+
const rolloutLines = (run, rollout, variations) => [
|
|
1479
|
+
`Rollout: ${rolloutAudience(run, rollout)}`,
|
|
1480
|
+
...alignedColumns(rollout.variations.map((variation) => [
|
|
1481
|
+
variationDescription(variation.variationId, variations) ?? "a variation no longer listed",
|
|
1482
|
+
`${variation.exposures} ${plural(variation.exposures, "exposure", "exposures")}`,
|
|
1483
|
+
`configured ${formatPercentage(variation.percentage)}%`
|
|
1484
|
+
])).map((line) => ` ${line}`),
|
|
1485
|
+
` ${rolloutSummary(rollout)}`
|
|
1486
|
+
];
|
|
1487
|
+
const uncountedEvaluationsLines = (evaluations, one, several) => {
|
|
1488
|
+
if (evaluations === 0) return [];
|
|
1489
|
+
return [evaluations === 1 ? `1 evaluation ${one}` : `${evaluations} evaluations ${several}`];
|
|
1490
|
+
};
|
|
1491
|
+
const runLines = (run, variations) => [runHeadline(run, variations), ...[
|
|
1492
|
+
...run.exposures.rollouts.flatMap((rollout) => rolloutLines(run, rollout, variations)),
|
|
1493
|
+
...uncountedEvaluationsLines(run.exposures.evaluationsFromSdksNotReportingExposures, "came from an SDK version that does not report exposures yet. It is not counted above; update that SDK to count it.", "came from SDK versions that do not report exposures yet. They are not counted above; update those SDKs to count them."),
|
|
1494
|
+
...uncountedEvaluationsLines(run.exposures.evaluationsWithoutIdentifier, "came from a context without an identifier and was served the default targeting rule value. It skips the split and is not an exposure.", "came from contexts without an identifier and were served the default targeting rule value. They skip the split and are not exposures.")
|
|
1495
|
+
].map((line) => ` ${line}`)];
|
|
1496
|
+
const runsText = (config, requestedKey, environment, runs) => {
|
|
1497
|
+
const environmentName = environmentLabel(environment);
|
|
1498
|
+
return [
|
|
1499
|
+
...oldKeyLines(requestedKey, config.key),
|
|
1500
|
+
...runs.length === 0 ? [`No runs of ${config.key} in ${environmentName}.`] : [`Runs of ${config.key} in ${environmentName}, newest first:`, ...runs.flatMap((run) => ["", ...runLines(run, config.variations)])],
|
|
1501
|
+
""
|
|
1502
|
+
].join("\n");
|
|
1503
|
+
};
|
|
1504
|
+
const savedRulesText = ({ config, requestedKey, environment, segments }, headline) => [
|
|
1505
|
+
...oldKeyLines(requestedKey, config.key),
|
|
1506
|
+
headline,
|
|
1507
|
+
"",
|
|
1508
|
+
...config.targets.filter((targetingRules) => targetingRules.environment.slug === environment.slug).flatMap((targetingRules) => targetingRuleLines(targetingRules, segments, config.variations)),
|
|
1509
|
+
""
|
|
1510
|
+
].join("\n");
|
|
1511
|
+
const shippedVariationText = (savedRules, variation) => savedRulesText(savedRules, `Shipped ${describeVariation(variation)} in ${environmentLabel(savedRules.environment)}.`);
|
|
1512
|
+
const stoppedRunText = (savedRules) => savedRulesText(savedRules, `Stopped the run of ${savedRules.config.key} in ${environmentLabel(savedRules.environment)}.`);
|
|
1513
|
+
//#endregion
|
|
1514
|
+
//#region src/configs/variation-selection.ts
|
|
1515
|
+
const variationListing = (variations) => alignedColumns(variations.map((variation) => [describeVariation(variation), variation.id])).map((line) => ` ${line}`);
|
|
1516
|
+
const findVariationToShip = (config, nameOrId) => {
|
|
1517
|
+
if (config.variations.length === 0) throw new CliError(`${config.key} has no variations to ship. Only an experiment has variations.`);
|
|
1518
|
+
const matches = config.variations.filter((variation) => variation.id === nameOrId || variation.name === nameOrId);
|
|
1519
|
+
const [onlyMatch, ...otherMatches] = matches;
|
|
1520
|
+
if (onlyMatch === void 0) throw new CliError([`${config.key} has no variation named or with the id "${nameOrId}". Its variations are:`, ...variationListing(config.variations)].join("\n"));
|
|
1521
|
+
if (otherMatches.length > 0) throw new CliError([`Several variations of ${config.key} are named "${nameOrId}". Give the id of the one to ship:`, ...variationListing(matches)].join("\n"));
|
|
1522
|
+
return onlyMatch;
|
|
1523
|
+
};
|
|
1524
|
+
//#endregion
|
|
1525
|
+
//#region src/environment-selection.ts
|
|
1526
|
+
const environmentSlugsSentence = (environments) => environments.length === 0 ? "The project has no environments." : `Its environments are ${environments.map((environment) => environment.slug).join(", ")}.`;
|
|
1527
|
+
const findEnvironmentBySlug = async (client, project, slug) => {
|
|
1528
|
+
const { parsedBody: environments } = await client.listEnvironments(project.id);
|
|
1529
|
+
const environment = environments.find((listedEnvironment) => listedEnvironment.slug === slug);
|
|
1530
|
+
if (environment === void 0) throw new CliError(`No environment in ${project.slug} has the slug "${slug}". ${environmentSlugsSentence(environments)}`);
|
|
1531
|
+
return environment;
|
|
1532
|
+
};
|
|
1533
|
+
//#endregion
|
|
1408
1534
|
//#region src/project-selection.ts
|
|
1409
1535
|
const requireProjectSlug = (context) => {
|
|
1410
1536
|
if (context.projectSlug === void 0) throw new CliError("Name the project with --project <slug> or CONFIGDIRECTOR_PROJECT.");
|
|
@@ -1666,6 +1792,55 @@ const copyConfig = async (context, [configKey], options) => {
|
|
|
1666
1792
|
exitCode: ExitCode.success
|
|
1667
1793
|
};
|
|
1668
1794
|
};
|
|
1795
|
+
const showConfigRuns = async (context, [configKey], options) => {
|
|
1796
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1797
|
+
const environment = await findEnvironmentBySlug(client, project, options.environment);
|
|
1798
|
+
const receivedConfig = await client.getConfig(project.id, configKey);
|
|
1799
|
+
const receivedRuns = receivedConfig && await client.listRuns(project.id, configKey, environment.id);
|
|
1800
|
+
if (receivedConfig === void 0 || receivedRuns === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
1801
|
+
return {
|
|
1802
|
+
json: receivedRuns.responseBody,
|
|
1803
|
+
text: runsText(receivedConfig.parsedBody, configKey, environment, receivedRuns.parsedBody),
|
|
1804
|
+
exitCode: ExitCode.success
|
|
1805
|
+
};
|
|
1806
|
+
};
|
|
1807
|
+
const shipVariation = async (context, [configKey], options) => {
|
|
1808
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1809
|
+
const environment = await findEnvironmentBySlug(client, project, options.environment);
|
|
1810
|
+
const receivedConfig = await client.getConfig(project.id, configKey);
|
|
1811
|
+
if (receivedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
1812
|
+
const variation = findVariationToShip(receivedConfig.parsedBody, options.variation);
|
|
1813
|
+
const savedConfig = await client.shipVariation(project.id, configKey, environment.id, variation.id);
|
|
1814
|
+
if (savedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
1815
|
+
const segments = await segmentNamesFor(client, project, savedConfig.parsedBody);
|
|
1816
|
+
return {
|
|
1817
|
+
json: savedConfig.responseBody,
|
|
1818
|
+
text: shippedVariationText({
|
|
1819
|
+
config: savedConfig.parsedBody,
|
|
1820
|
+
requestedKey: configKey,
|
|
1821
|
+
environment,
|
|
1822
|
+
segments
|
|
1823
|
+
}, variation),
|
|
1824
|
+
exitCode: ExitCode.success
|
|
1825
|
+
};
|
|
1826
|
+
};
|
|
1827
|
+
const stopRun = async (context, [configKey], options) => {
|
|
1828
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1829
|
+
const environment = await findEnvironmentBySlug(client, project, options.environment);
|
|
1830
|
+
const savedConfig = await client.stopRun(project.id, configKey, environment.id);
|
|
1831
|
+
if (savedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
1832
|
+
const segments = await segmentNamesFor(client, project, savedConfig.parsedBody);
|
|
1833
|
+
return {
|
|
1834
|
+
json: savedConfig.responseBody,
|
|
1835
|
+
text: stoppedRunText({
|
|
1836
|
+
config: savedConfig.parsedBody,
|
|
1837
|
+
requestedKey: configKey,
|
|
1838
|
+
environment,
|
|
1839
|
+
segments
|
|
1840
|
+
}),
|
|
1841
|
+
exitCode: ExitCode.success
|
|
1842
|
+
};
|
|
1843
|
+
};
|
|
1669
1844
|
const archiveConfig = async (context, [configKey]) => {
|
|
1670
1845
|
const { client, project } = await connectToLinkedProject(context);
|
|
1671
1846
|
if (await client.archiveConfig(project.id, configKey) === "not-found") throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
@@ -1721,15 +1896,6 @@ const listEnvironments = async (context, _commandArguments, options) => {
|
|
|
1721
1896
|
};
|
|
1722
1897
|
};
|
|
1723
1898
|
//#endregion
|
|
1724
|
-
//#region src/environment-selection.ts
|
|
1725
|
-
const environmentSlugsSentence = (environments) => environments.length === 0 ? "The project has no environments." : `Its environments are ${environments.map((environment) => environment.slug).join(", ")}.`;
|
|
1726
|
-
const findEnvironmentBySlug = async (client, project, slug) => {
|
|
1727
|
-
const { parsedBody: environments } = await client.listEnvironments(project.id);
|
|
1728
|
-
const environment = environments.find((listedEnvironment) => listedEnvironment.slug === slug);
|
|
1729
|
-
if (environment === void 0) throw new CliError(`No environment in ${project.slug} has the slug "${slug}". ${environmentSlugsSentence(environments)}`);
|
|
1730
|
-
return environment;
|
|
1731
|
-
};
|
|
1732
|
-
//#endregion
|
|
1733
1899
|
//#region src/evaluate/context-form.ts
|
|
1734
1900
|
const DEFAULT_CONTEXT_KIND = "user";
|
|
1735
1901
|
const TYPED_TRAIT_MARKER = ":";
|
|
@@ -5258,12 +5424,15 @@ const run = async (invocation) => {
|
|
|
5258
5424
|
program.command("api-token").description("describe the API token a command here would use").command("get").description("show the token's organization, name, permissions and expiration, and where it came from; never the token itself").action(commandAction(getApiToken));
|
|
5259
5425
|
program.command("projects").description("list the projects of the API token's organization").command("list").description("list the organization's projects, whatever project is selected").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listProjects));
|
|
5260
5426
|
program.command("environments").description("list the project's environments").command("list").description("list the project's environments with their slugs, marking the live ones").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listEnvironments));
|
|
5261
|
-
const configsCommand = program.command("configs").description("list, show, create, copy and archive a project's configs");
|
|
5427
|
+
const configsCommand = program.command("configs").description("list, show, create, copy and archive a project's configs, and list, ship and stop an experiment's runs");
|
|
5262
5428
|
configsCommand.command("list").description("list the project's configs with their type, role, lifetime and lifecycle state").option("--tag <name>", "list only the configs that have this tag; repeatable, for configs with any of them", repeatedOption).addOption(new Option("--untagged", "list only the configs without tags").conflicts("tag")).addOption(new Option("--state <state>", "list only the configs in this lifecycle state").choices(LIFECYCLE_STATES)).option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listConfigs));
|
|
5263
5429
|
configsCommand.command("get").argument("<key>", "the config's key, or an old key of it").description("show a config's settings and its targeting rules in every environment").action(commandAction(getConfig));
|
|
5264
5430
|
configsCommand.command("activity").argument("<key>", "the config's key, or an old key of it").description("show when SDKs last evaluated a config, per environment, SDK key kind, SDK name and SDK version").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(showConfigActivity));
|
|
5265
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));
|
|
5266
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));
|
|
5267
5436
|
configsCommand.command("archive").argument("<key>", "the config's key, or an old key of it").description("archive a config, so SDKs stop receiving it; the dashboard can restore it for 30 days").addHelpText("after", "\nThe config is archived with no confirmation and no check of whether the code still reads its key, so run configdirector scan first. Applications that still read it get their in-code default value.").action(commandAction(archiveConfig));
|
|
5268
5437
|
const segmentsCommand = program.command("segments").description("list, show, create, update and delete a project's segments");
|
|
5269
5438
|
segmentsCommand.command("list").description("list the project's segments with how many configs use each").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listSegments));
|
package/package.json
CHANGED