@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 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.
@@ -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.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.message : String(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
- try {
610
- return await fetch(url, "body" in request ? {
611
- method: request.method,
612
- headers: {
613
- ...headers,
614
- "Content-Type": "application/json"
615
- },
616
- body: JSON.stringify(request.body)
617
- } : {
618
- method: request.method,
619
- headers
620
- });
621
- } catch (error) {
622
- throw new ApiUnreachableError(this.connection.baseUrl, error);
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 describePercentages = (percentages) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${String(percentage.value)}`).join(", ");
965
- const describeRule = (rule, segments) => {
966
- if (rule.type === "percentage") return describePercentages(rule.percentages);
967
- return `${rule.conditions.map((condition) => describeCondition(condition, segments)).join(" and ")}: ${rule.target === "value" ? String(rule.value) : describePercentages(rule.percentages)}`;
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: ${String(newConfig.defaultValue)}`];
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.value ?? "no value"}, ${servedByDescription(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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@configdirector/cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "Command line tool for ConfigDirector, the managed configuration and feature flag service.",
5
5
  "license": "MIT",
6
6
  "author": "ConfigDirector",