@configdirector/cli 0.17.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +2 -0
- package/dist/configdirector.mjs +97 -33
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to `@configdirector/cli` are documented here. The format fol
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.18.1] - 2026-11-03
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Commands no longer fail with `Could not reach the Admin API` when opening a connection takes longer than a quarter of a second, as it sometimes does on GitHub-hosted runners, which find the Admin API's IPv6 addresses but cannot reach them. Each of the Admin API's addresses now gets two seconds to connect before the CLI tries the next.
|
|
12
|
+
- When none of the Admin API's addresses can be reached, `Could not reach the Admin API` says why each one failed, where it showed empty brackets.
|
|
13
|
+
- Commands no longer fail the first time the network drops a connection to the Admin API. When a connection cannot be opened, the CLI tries again up to twice, half a second and then a second later. When the network breaks a connection after a request went out, it tries again only for requests that read, replace or delete; the others, such as `configs create`, still exit with 2, because ConfigDirector may already have received them.
|
|
14
|
+
|
|
15
|
+
## [0.18.0] - 2026-11-03
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `configdirector configs get`, `evaluate` and `configs create` name an experiment's variations wherever they show one of its values, with the value after the name, as in `40% get control (Start free trial), 60% get treatment-a (Try it free for 14 days)` and `Default targeting rule: control (Start free trial)`. `evaluate` names the variation it served too, as in `treatment-a (Try it free for 14 days), served by rule 2`. A variation without a name, and a config without variations, show the value alone, as before. `--json` prints the Admin API's response as received, which now carries the id of each value's variation.
|
|
20
|
+
|
|
7
21
|
## [0.17.0] - 2026-11-01
|
|
8
22
|
|
|
9
23
|
### Added
|
package/README.md
CHANGED
|
@@ -211,6 +211,8 @@ production (live)
|
|
|
211
211
|
Default targeting rule: false
|
|
212
212
|
```
|
|
213
213
|
|
|
214
|
+
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
|
+
|
|
214
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.`
|
|
215
217
|
|
|
216
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.
|
package/dist/configdirector.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { setDefaultAutoSelectFamilyAttemptTimeout } from "node:net";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { Command, CommanderError, Option } from "commander";
|
|
5
|
+
import { setTimeout } from "node:timers/promises";
|
|
4
6
|
import * as v from "valibot";
|
|
5
7
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
8
|
import { mkdir, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
@@ -12,7 +14,7 @@ import { promisify } from "node:util";
|
|
|
12
14
|
import { randomUUID } from "node:crypto";
|
|
13
15
|
import { text } from "node:stream/consumers";
|
|
14
16
|
//#region package.json
|
|
15
|
-
var version = "0.
|
|
17
|
+
var version = "0.18.1";
|
|
16
18
|
//#endregion
|
|
17
19
|
//#region src/CliError.ts
|
|
18
20
|
var CliError = class extends Error {
|
|
@@ -84,9 +86,11 @@ var ServiceSuspendedError = class extends CliError {
|
|
|
84
86
|
super(`Your organization's service is suspended, so ConfigDirector refuses Admin API requests. The dashboard's billing page shows why and how to reinstate it. Retry after ${retryAfterSeconds} seconds.`);
|
|
85
87
|
}
|
|
86
88
|
};
|
|
89
|
+
const messageOf = (failure) => failure instanceof Error ? failure.message : String(failure);
|
|
90
|
+
const connectionFailureReasonOf = (failure) => failure instanceof AggregateError ? failure.errors.map(messageOf).join("; ") : failure.message;
|
|
87
91
|
var ApiUnreachableError = class extends CliError {
|
|
88
92
|
constructor(baseUrl, cause) {
|
|
89
|
-
const reason = cause instanceof Error && cause.cause instanceof Error ? cause.cause
|
|
93
|
+
const reason = cause instanceof Error && cause.cause instanceof Error ? connectionFailureReasonOf(cause.cause) : String(cause);
|
|
90
94
|
super(`Could not reach the Admin API at ${baseUrl} (${reason}). Check the network connection, --base-url or CONFIGDIRECTOR_BASE_URL.`);
|
|
91
95
|
}
|
|
92
96
|
};
|
|
@@ -127,6 +131,40 @@ const CreatedConfigSchema = v.object({
|
|
|
127
131
|
server: v.boolean()
|
|
128
132
|
});
|
|
129
133
|
//#endregion
|
|
134
|
+
//#region src/admin-api/connection-failure-retry.ts
|
|
135
|
+
const millisecondsBeforeEachRetry = [500, 1e3];
|
|
136
|
+
const repeatableMethods = new Set([
|
|
137
|
+
"GET",
|
|
138
|
+
"PUT",
|
|
139
|
+
"DELETE"
|
|
140
|
+
]);
|
|
141
|
+
const retriesAfter = {
|
|
142
|
+
"never-opened": () => true,
|
|
143
|
+
dropped: (method) => repeatableMethods.has(method),
|
|
144
|
+
"not-transient": () => false
|
|
145
|
+
};
|
|
146
|
+
const neverOpenedCodes = new Set(["UND_ERR_CONNECT_TIMEOUT", "EAI_AGAIN"]);
|
|
147
|
+
const droppedCodes = new Set([
|
|
148
|
+
"ECONNRESET",
|
|
149
|
+
"EPIPE",
|
|
150
|
+
"UND_ERR_SOCKET"
|
|
151
|
+
]);
|
|
152
|
+
const connectionFailureOf = (failure) => {
|
|
153
|
+
const cause = failure instanceof Error ? failure.cause : void 0;
|
|
154
|
+
if (cause instanceof AggregateError) return "never-opened";
|
|
155
|
+
if (!(cause instanceof Error)) return "not-transient";
|
|
156
|
+
const { code = "", syscall } = cause;
|
|
157
|
+
if (syscall === "connect" || neverOpenedCodes.has(code)) return "never-opened";
|
|
158
|
+
return droppedCodes.has(code) ? "dropped" : "not-transient";
|
|
159
|
+
};
|
|
160
|
+
const retryDecisionAfter = (method, failure, attemptsMade) => {
|
|
161
|
+
const waitMilliseconds = millisecondsBeforeEachRetry[attemptsMade - 1];
|
|
162
|
+
return waitMilliseconds !== void 0 && retriesAfter[connectionFailureOf(failure)](method) ? {
|
|
163
|
+
decision: "retry",
|
|
164
|
+
waitMilliseconds
|
|
165
|
+
} : { decision: "give-up" };
|
|
166
|
+
};
|
|
167
|
+
//#endregion
|
|
130
168
|
//#region src/admin-api/targeting-rules.ts
|
|
131
169
|
const RuleValueSchema = v.union([
|
|
132
170
|
v.string(),
|
|
@@ -154,14 +192,16 @@ const ConditionSchema = v.variant("kind", [v.object({
|
|
|
154
192
|
}), SegmentConditionSchema]);
|
|
155
193
|
const PercentageSchema = v.object({
|
|
156
194
|
percentage: v.number(),
|
|
157
|
-
value: RuleValueSchema
|
|
195
|
+
value: RuleValueSchema,
|
|
196
|
+
variationId: v.optional(v.string())
|
|
158
197
|
});
|
|
159
198
|
const RuleSchema = v.variant("type", [v.variant("target", [v.object({
|
|
160
199
|
id: v.string(),
|
|
161
200
|
type: v.literal("conditional"),
|
|
162
201
|
conditions: v.array(ConditionSchema),
|
|
163
202
|
target: v.literal("value"),
|
|
164
|
-
value: RuleValueSchema
|
|
203
|
+
value: RuleValueSchema,
|
|
204
|
+
variationId: v.optional(v.string())
|
|
165
205
|
}), v.object({
|
|
166
206
|
id: v.string(),
|
|
167
207
|
type: v.literal("conditional"),
|
|
@@ -174,6 +214,12 @@ const RuleSchema = v.variant("type", [v.variant("target", [v.object({
|
|
|
174
214
|
percentages: v.array(PercentageSchema)
|
|
175
215
|
})]);
|
|
176
216
|
//#endregion
|
|
217
|
+
//#region src/admin-api/variations.ts
|
|
218
|
+
const VariationSchema = v.object({
|
|
219
|
+
id: v.string(),
|
|
220
|
+
name: v.nullish(v.string())
|
|
221
|
+
});
|
|
222
|
+
//#endregion
|
|
177
223
|
//#region src/admin-api/config-with-targeting-rules.ts
|
|
178
224
|
const TargetingRulesInEnvironmentSchema = v.object({
|
|
179
225
|
environment: v.object({
|
|
@@ -181,7 +227,8 @@ const TargetingRulesInEnvironmentSchema = v.object({
|
|
|
181
227
|
live: v.boolean()
|
|
182
228
|
}),
|
|
183
229
|
rules: v.array(RuleSchema),
|
|
184
|
-
defaultValue: v.string()
|
|
230
|
+
defaultValue: v.string(),
|
|
231
|
+
defaultVariationId: v.optional(v.string())
|
|
185
232
|
});
|
|
186
233
|
const NumericBoundSchema = v.object({
|
|
187
234
|
relation: v.string(),
|
|
@@ -239,6 +286,7 @@ const ConfigWithTargetingRulesSchema = v.intersect([v.object({
|
|
|
239
286
|
client: v.boolean(),
|
|
240
287
|
server: v.boolean(),
|
|
241
288
|
tags: v.array(v.object({ name: v.string() })),
|
|
289
|
+
variations: v.array(VariationSchema),
|
|
242
290
|
targets: v.array(TargetingRulesInEnvironmentSchema)
|
|
243
291
|
}), ConfigTypeSchema]);
|
|
244
292
|
//#endregion
|
|
@@ -348,6 +396,7 @@ const ConfigEvaluationSchema = v.object({
|
|
|
348
396
|
identifier: v.nullable(v.string())
|
|
349
397
|
})),
|
|
350
398
|
value: v.optional(v.string()),
|
|
399
|
+
variationId: v.optional(v.string()),
|
|
351
400
|
servedBy: v.variant("kind", [v.object({
|
|
352
401
|
kind: v.literal("rule"),
|
|
353
402
|
ruleId: v.string()
|
|
@@ -355,9 +404,11 @@ const ConfigEvaluationSchema = v.object({
|
|
|
355
404
|
rules: v.array(RuleExplanationSchema),
|
|
356
405
|
targetingRules: v.object({
|
|
357
406
|
rules: v.array(RuleSchema),
|
|
358
|
-
defaultValue: v.string()
|
|
407
|
+
defaultValue: v.string(),
|
|
408
|
+
defaultVariationId: v.optional(v.string())
|
|
359
409
|
}),
|
|
360
|
-
segments: v.record(v.string(), v.object({ name: v.string() }))
|
|
410
|
+
segments: v.record(v.string(), v.object({ name: v.string() })),
|
|
411
|
+
variations: v.optional(v.array(VariationSchema), [])
|
|
361
412
|
});
|
|
362
413
|
//#endregion
|
|
363
414
|
//#region src/admin-api/AdminApiClient.ts
|
|
@@ -606,20 +657,25 @@ var AdminApiClient = class {
|
|
|
606
657
|
"User-Agent": `configdirector-cli/${version}`,
|
|
607
658
|
"X-SourceClient": "cli"
|
|
608
659
|
};
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
headers
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
660
|
+
const requestInit = "body" in request ? {
|
|
661
|
+
method: request.method,
|
|
662
|
+
headers: {
|
|
663
|
+
...headers,
|
|
664
|
+
"Content-Type": "application/json"
|
|
665
|
+
},
|
|
666
|
+
body: JSON.stringify(request.body)
|
|
667
|
+
} : {
|
|
668
|
+
method: request.method,
|
|
669
|
+
headers
|
|
670
|
+
};
|
|
671
|
+
for (let attemptsMade = 1;; attemptsMade += 1) try {
|
|
672
|
+
return await fetch(url, requestInit);
|
|
673
|
+
} catch (failure) {
|
|
674
|
+
const retry = retryDecisionAfter(request.method, failure, attemptsMade);
|
|
675
|
+
switch (retry.decision) {
|
|
676
|
+
case "give-up": throw new ApiUnreachableError(this.connection.baseUrl, failure);
|
|
677
|
+
case "retry": await setTimeout(retry.waitMilliseconds);
|
|
678
|
+
}
|
|
623
679
|
}
|
|
624
680
|
}
|
|
625
681
|
};
|
|
@@ -961,10 +1017,12 @@ const describeCondition = (condition, segments) => {
|
|
|
961
1017
|
case "segment": return `${condition.operator} segment ${segmentNameOf(condition.segmentId, segments)}`;
|
|
962
1018
|
}
|
|
963
1019
|
};
|
|
964
|
-
const
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
|
|
1020
|
+
const valueWithVariationName = (value, variationName) => variationName ? `${variationName} (${String(value)})` : String(value);
|
|
1021
|
+
const describeValue = (value, variationId, variations) => valueWithVariationName(value, variations.find((variation) => variation.id === variationId)?.name);
|
|
1022
|
+
const describePercentages = (percentages, variations) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${describeValue(percentage.value, percentage.variationId, variations)}`).join(", ");
|
|
1023
|
+
const describeRule = (rule, segments, variations) => {
|
|
1024
|
+
if (rule.type === "percentage") return describePercentages(rule.percentages, variations);
|
|
1025
|
+
return `${rule.conditions.map((condition) => describeCondition(condition, segments)).join(" and ")}: ${rule.target === "value" ? describeValue(rule.value, rule.variationId, variations) : describePercentages(rule.percentages, variations)}`;
|
|
968
1026
|
};
|
|
969
1027
|
//#endregion
|
|
970
1028
|
//#region src/configs/config-text.ts
|
|
@@ -1001,16 +1059,16 @@ const settingLines = (config) => [
|
|
|
1001
1059
|
...config.tags.length === 0 ? [] : [`Tags: ${config.tags.map((tag) => tag.name).join(", ")}`]
|
|
1002
1060
|
];
|
|
1003
1061
|
const environmentLabel = (environment) => environment.live ? `${environment.slug} (live)` : environment.slug;
|
|
1004
|
-
const targetingRuleLines = (targetingRules, segments) => [
|
|
1062
|
+
const targetingRuleLines = (targetingRules, segments, variations) => [
|
|
1005
1063
|
environmentLabel(targetingRules.environment),
|
|
1006
|
-
...targetingRules.rules.map((rule, index) => ` ${index + 1}. ${describeRule(rule, segments)}`),
|
|
1007
|
-
` Default targeting rule: ${targetingRules.defaultValue}`
|
|
1064
|
+
...targetingRules.rules.map((rule, index) => ` ${index + 1}. ${describeRule(rule, segments, variations)}`),
|
|
1065
|
+
` Default targeting rule: ${describeValue(targetingRules.defaultValue, targetingRules.defaultVariationId, variations)}`
|
|
1008
1066
|
];
|
|
1009
1067
|
const configText = (config, requestedKey, segments) => [
|
|
1010
1068
|
...oldKeyLines(requestedKey, config.key),
|
|
1011
1069
|
config.key,
|
|
1012
1070
|
...settingLines(config).map((line) => ` ${line}`),
|
|
1013
|
-
...config.targets.flatMap((targetingRules) => ["", ...targetingRuleLines(targetingRules, segments)]),
|
|
1071
|
+
...config.targets.flatMap((targetingRules) => ["", ...targetingRuleLines(targetingRules, segments, config.variations)]),
|
|
1014
1072
|
""
|
|
1015
1073
|
].join("\n");
|
|
1016
1074
|
//#endregion
|
|
@@ -1464,6 +1522,10 @@ const getConfig = async (context, [configKey]) => {
|
|
|
1464
1522
|
exitCode: ExitCode.success
|
|
1465
1523
|
};
|
|
1466
1524
|
};
|
|
1525
|
+
const defaultValueDescription = (newConfig) => {
|
|
1526
|
+
const defaultVariation = newConfig.variations.find((variation) => variation.value === newConfig.defaultValue);
|
|
1527
|
+
return valueWithVariationName(newConfig.defaultValue, defaultVariation?.name);
|
|
1528
|
+
};
|
|
1467
1529
|
const createdConfigLines = (projectSlug, createdConfig, newConfig) => {
|
|
1468
1530
|
const settings = [
|
|
1469
1531
|
createdConfig.type,
|
|
@@ -1471,7 +1533,7 @@ const createdConfigLines = (projectSlug, createdConfig, newConfig) => {
|
|
|
1471
1533
|
createdConfig.lifetime,
|
|
1472
1534
|
availabilityDescription(createdConfig)
|
|
1473
1535
|
];
|
|
1474
|
-
return [`Created ${createdConfig.key} in ${projectSlug}: ${settings.join(", ")}.`, `Default targeting rule in every environment: ${
|
|
1536
|
+
return [`Created ${createdConfig.key} in ${projectSlug}: ${settings.join(", ")}.`, `Default targeting rule in every environment: ${defaultValueDescription(newConfig)}`];
|
|
1475
1537
|
};
|
|
1476
1538
|
const keyTakenLines = (projectSlug, configKey, existingConfig) => [`${projectSlug} already has a config with the key ${configKey}, so no config was created:`, ...alignedColumns([summaryColumnsOf(existingConfig)])];
|
|
1477
1539
|
const rejectsKey = (error) => error instanceof ApiValidationError && error.fieldErrors.some((fieldError) => fieldError.name === "key");
|
|
@@ -1766,6 +1828,7 @@ const contextDescription = (evaluation, contextForm) => {
|
|
|
1766
1828
|
const identity = `${evaluatedContext.kind} ${evaluatedContext.identifier}`;
|
|
1767
1829
|
return contextForm.form === "saved" ? `saved context ${identity}` : identity;
|
|
1768
1830
|
};
|
|
1831
|
+
const servedValueDescription = (evaluation) => evaluation.value === void 0 ? "no value" : describeValue(evaluation.value, evaluation.variationId, evaluation.variations);
|
|
1769
1832
|
const servedByDescription = (evaluation) => {
|
|
1770
1833
|
const { servedBy } = evaluation;
|
|
1771
1834
|
switch (servedBy.kind) {
|
|
@@ -1813,17 +1876,17 @@ const ruleLines = (evaluation) => {
|
|
|
1813
1876
|
const rulesById = new Map(evaluation.targetingRules.rules.map((rule) => [rule.id, rule]));
|
|
1814
1877
|
return alignedColumns(evaluation.rules.map((explanation, index) => {
|
|
1815
1878
|
const rule = rulesById.get(explanation.ruleId);
|
|
1816
|
-
return [`${index + 1}. ${rule === void 0 ? explanation.ruleId : describeRule(rule, evaluation.segments)}`, RULE_OUTCOME_DESCRIPTIONS[explanation.outcome](explanation, conditionsOf(rule), evaluation.segments)];
|
|
1879
|
+
return [`${index + 1}. ${rule === void 0 ? explanation.ruleId : describeRule(rule, evaluation.segments, evaluation.variations)}`, RULE_OUTCOME_DESCRIPTIONS[explanation.outcome](explanation, conditionsOf(rule), evaluation.segments)];
|
|
1817
1880
|
}));
|
|
1818
1881
|
};
|
|
1819
1882
|
const evaluationText = (evaluation, requested) => [
|
|
1820
1883
|
...oldKeyLines(requested.configKey, evaluation.key),
|
|
1821
1884
|
`${evaluation.key} in ${evaluation.environment.name} for ${contextDescription(evaluation, requested.contextForm)}`,
|
|
1822
1885
|
"",
|
|
1823
|
-
`${evaluation
|
|
1886
|
+
`${servedValueDescription(evaluation)}, ${servedByDescription(evaluation)}`,
|
|
1824
1887
|
"",
|
|
1825
1888
|
...ruleLines(evaluation),
|
|
1826
|
-
`Default targeting rule: ${evaluation.targetingRules.defaultValue}`,
|
|
1889
|
+
`Default targeting rule: ${describeValue(evaluation.targetingRules.defaultValue, evaluation.targetingRules.defaultVariationId, evaluation.variations)}`,
|
|
1827
1890
|
""
|
|
1828
1891
|
].join("\n");
|
|
1829
1892
|
//#endregion
|
|
@@ -5223,6 +5286,7 @@ const run = async (invocation) => {
|
|
|
5223
5286
|
};
|
|
5224
5287
|
//#endregion
|
|
5225
5288
|
//#region src/main.ts
|
|
5289
|
+
setDefaultAutoSelectFamilyAttemptTimeout(2e3);
|
|
5226
5290
|
process.exitCode = await run({
|
|
5227
5291
|
commandLineArguments: process.argv.slice(2),
|
|
5228
5292
|
environmentVariables: process.env,
|
package/package.json
CHANGED