@configdirector/cli 0.16.0 → 0.18.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,18 @@ All notable changes to `@configdirector/cli` are documented here. The format fol
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.18.0] - 2026-11-03
8
+
9
+ ### Changed
10
+
11
+ - `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.
12
+
13
+ ## [0.17.0] - 2026-11-01
14
+
15
+ ### Added
16
+
17
+ - `configdirector configs copy <key> --key <new key>` copies a config under a new key, in its project or, with `--to <project slug>`, in another project of the organization. The copy gets the config's settings and tags, and every environment's targeting rules and default targeting rule value, and starts as a new config with no old keys. In another project, environments are matched by slug, tags by name and segments by key, and `--environment <destination slug>=<source slug>`, repeatable, names the environment to copy from for an environment there with no environment of its slug in the config's project. The command prints the plan: each environment with the environment its rules come from, how many rules were copied and each rule dropped because the other project has no segment with its key, the environments it skipped, and the tags matched, created and skipped. `--dry-run` prints the plan without creating anything, and `--json` prints the Admin API's response as received. A dropped rule is not a failure: the command exits with 0, and with 2 when ConfigDirector refuses the copy, for example because the key is already in use or an environment has nothing to copy from.
18
+
7
19
  ## [0.16.0] - 2026-10-30
8
20
 
9
21
  ### 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 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, 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
 
@@ -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.
@@ -246,6 +248,35 @@ When the project already has a config with the key, as its current key or an old
246
248
 
247
249
  `configs create` needs `projects:read`, `configs:create`, and `configs:read` to show the config that already has the key; `--tag` also needs `tags:read`, and `--new-tag` needs `tags:create`. The config shows in the audit log with the source CLI.
248
250
 
251
+ ## Copy a config
252
+
253
+ ```bash
254
+ configdirector configs copy search-ranking --key search-ranking --to mobile-app --environment qa=staging
255
+ ```
256
+
257
+ `configs copy` copies a config under the key `--key` gives, in its project or, with `--to <project slug>`, in another project of the organization, and prints the plan:
258
+
259
+ ```txt
260
+ Copied search-ranking in web-app to search-ranking in mobile-app.
261
+ Environments:
262
+ production (live) from production 2 rules copied rule 3 dropped: mobile-app has no segment beta-testers
263
+ qa from staging 1 rule copied
264
+ Skipped source environments:
265
+ test mobile-app has no environment with this slug; create it there to copy its rules
266
+ Tags:
267
+ search matched
268
+ growth created
269
+ ```
270
+
271
+ - The 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.
272
+ - In another project, environments are matched by slug, tags by name and segments by key. An environment there with no environment of the same slug in the config's project needs `--environment <destination slug>=<source slug>`, repeatable, to name the environment it copies from; `--environment` can also replace a slug match. A source environment with no environment of its slug there is skipped.
273
+ - A targeting rule that uses a segment the other project doesn't have is dropped, and the plan names it by its position and the segment's key. A tag the other project doesn't have is created when the token has `tags:create`, and skipped otherwise.
274
+ - `--dry-run` prints the plan without creating anything, saying what would be copied, dropped, skipped and created.
275
+
276
+ The command exits with 0 when it copied the config or printed the plan, dropped rules included, and with 2 when ConfigDirector refuses the copy, for example because the key is in use or an environment has nothing to copy from, listing the reasons it gave. With `--json`, it prints the Admin API's response as received: the plan, and the created config with its targeting rules unless `--dry-run` was given.
277
+
278
+ `configs copy` needs `projects:read` and `configs:create`, and `tags:create` to create the tags another project doesn't have. The copy shows in the audit log with the source CLI.
279
+
249
280
  ## Change targeting rules
250
281
 
251
282
  ```bash
@@ -12,7 +12,7 @@ import { promisify } from "node:util";
12
12
  import { randomUUID } from "node:crypto";
13
13
  import { text } from "node:stream/consumers";
14
14
  //#region package.json
15
- var version = "0.16.0";
15
+ var version = "0.18.0";
16
16
  //#endregion
17
17
  //#region src/CliError.ts
18
18
  var CliError = class extends Error {
@@ -96,6 +96,27 @@ var UnexpectedApiResponseError = class extends CliError {
96
96
  }
97
97
  };
98
98
  //#endregion
99
+ //#region src/admin-api/config-copy.ts
100
+ const CopyPlanSchema = v.object({
101
+ environments: v.array(v.object({
102
+ slug: v.string(),
103
+ live: v.boolean(),
104
+ sourceSlug: v.string(),
105
+ rulesCopied: v.number(),
106
+ droppedRules: v.array(v.object({
107
+ position: v.number(),
108
+ segmentKey: v.string()
109
+ }))
110
+ })),
111
+ skippedSourceEnvironments: v.array(v.string()),
112
+ tags: v.object({
113
+ matched: v.array(v.string()),
114
+ created: v.array(v.string()),
115
+ skipped: v.array(v.string())
116
+ })
117
+ });
118
+ const CopyPlanResponseSchema = v.object({ plan: CopyPlanSchema });
119
+ //#endregion
99
120
  //#region src/admin-api/config-creation.ts
100
121
  const CreatedConfigSchema = v.object({
101
122
  key: v.string(),
@@ -133,14 +154,16 @@ const ConditionSchema = v.variant("kind", [v.object({
133
154
  }), SegmentConditionSchema]);
134
155
  const PercentageSchema = v.object({
135
156
  percentage: v.number(),
136
- value: RuleValueSchema
157
+ value: RuleValueSchema,
158
+ variationId: v.optional(v.string())
137
159
  });
138
160
  const RuleSchema = v.variant("type", [v.variant("target", [v.object({
139
161
  id: v.string(),
140
162
  type: v.literal("conditional"),
141
163
  conditions: v.array(ConditionSchema),
142
164
  target: v.literal("value"),
143
- value: RuleValueSchema
165
+ value: RuleValueSchema,
166
+ variationId: v.optional(v.string())
144
167
  }), v.object({
145
168
  id: v.string(),
146
169
  type: v.literal("conditional"),
@@ -153,6 +176,12 @@ const RuleSchema = v.variant("type", [v.variant("target", [v.object({
153
176
  percentages: v.array(PercentageSchema)
154
177
  })]);
155
178
  //#endregion
179
+ //#region src/admin-api/variations.ts
180
+ const VariationSchema = v.object({
181
+ id: v.string(),
182
+ name: v.nullish(v.string())
183
+ });
184
+ //#endregion
156
185
  //#region src/admin-api/config-with-targeting-rules.ts
157
186
  const TargetingRulesInEnvironmentSchema = v.object({
158
187
  environment: v.object({
@@ -160,7 +189,8 @@ const TargetingRulesInEnvironmentSchema = v.object({
160
189
  live: v.boolean()
161
190
  }),
162
191
  rules: v.array(RuleSchema),
163
- defaultValue: v.string()
192
+ defaultValue: v.string(),
193
+ defaultVariationId: v.optional(v.string())
164
194
  });
165
195
  const NumericBoundSchema = v.object({
166
196
  relation: v.string(),
@@ -218,6 +248,7 @@ const ConfigWithTargetingRulesSchema = v.intersect([v.object({
218
248
  client: v.boolean(),
219
249
  server: v.boolean(),
220
250
  tags: v.array(v.object({ name: v.string() })),
251
+ variations: v.array(VariationSchema),
221
252
  targets: v.array(TargetingRulesInEnvironmentSchema)
222
253
  }), ConfigTypeSchema]);
223
254
  //#endregion
@@ -327,6 +358,7 @@ const ConfigEvaluationSchema = v.object({
327
358
  identifier: v.nullable(v.string())
328
359
  })),
329
360
  value: v.optional(v.string()),
361
+ variationId: v.optional(v.string()),
330
362
  servedBy: v.variant("kind", [v.object({
331
363
  kind: v.literal("rule"),
332
364
  ruleId: v.string()
@@ -334,9 +366,11 @@ const ConfigEvaluationSchema = v.object({
334
366
  rules: v.array(RuleExplanationSchema),
335
367
  targetingRules: v.object({
336
368
  rules: v.array(RuleSchema),
337
- defaultValue: v.string()
369
+ defaultValue: v.string(),
370
+ defaultVariationId: v.optional(v.string())
338
371
  }),
339
- segments: v.record(v.string(), v.object({ name: v.string() }))
372
+ segments: v.record(v.string(), v.object({ name: v.string() })),
373
+ variations: v.optional(v.array(VariationSchema), [])
340
374
  });
341
375
  //#endregion
342
376
  //#region src/admin-api/AdminApiClient.ts
@@ -516,6 +550,12 @@ var AdminApiClient = class {
516
550
  body: request
517
551
  });
518
552
  }
553
+ copyConfig(projectId, configKey, request) {
554
+ return this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/copy`, CopyPlanResponseSchema, {
555
+ method: "POST",
556
+ body: request
557
+ });
558
+ }
519
559
  async evaluateConfig(projectId, configKey, request) {
520
560
  const { responseBody, parsedBody } = await this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/evaluate`, ConfigEvaluationSchema, {
521
561
  method: "POST",
@@ -934,10 +974,12 @@ const describeCondition = (condition, segments) => {
934
974
  case "segment": return `${condition.operator} segment ${segmentNameOf(condition.segmentId, segments)}`;
935
975
  }
936
976
  };
937
- const describePercentages = (percentages) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${String(percentage.value)}`).join(", ");
938
- const describeRule = (rule, segments) => {
939
- if (rule.type === "percentage") return describePercentages(rule.percentages);
940
- return `${rule.conditions.map((condition) => describeCondition(condition, segments)).join(" and ")}: ${rule.target === "value" ? String(rule.value) : describePercentages(rule.percentages)}`;
977
+ const valueWithVariationName = (value, variationName) => variationName ? `${variationName} (${String(value)})` : String(value);
978
+ const describeValue = (value, variationId, variations) => valueWithVariationName(value, variations.find((variation) => variation.id === variationId)?.name);
979
+ const describePercentages = (percentages, variations) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${describeValue(percentage.value, percentage.variationId, variations)}`).join(", ");
980
+ const describeRule = (rule, segments, variations) => {
981
+ if (rule.type === "percentage") return describePercentages(rule.percentages, variations);
982
+ return `${rule.conditions.map((condition) => describeCondition(condition, segments)).join(" and ")}: ${rule.target === "value" ? describeValue(rule.value, rule.variationId, variations) : describePercentages(rule.percentages, variations)}`;
941
983
  };
942
984
  //#endregion
943
985
  //#region src/configs/config-text.ts
@@ -973,20 +1015,103 @@ const settingLines = (config) => [
973
1015
  `Lifecycle state: ${config.state}`,
974
1016
  ...config.tags.length === 0 ? [] : [`Tags: ${config.tags.map((tag) => tag.name).join(", ")}`]
975
1017
  ];
976
- const environmentHeading = (targetingRules) => targetingRules.environment.live ? `${targetingRules.environment.slug} (live)` : targetingRules.environment.slug;
977
- const targetingRuleLines = (targetingRules, segments) => [
978
- environmentHeading(targetingRules),
979
- ...targetingRules.rules.map((rule, index) => ` ${index + 1}. ${describeRule(rule, segments)}`),
980
- ` Default targeting rule: ${targetingRules.defaultValue}`
1018
+ const environmentLabel = (environment) => environment.live ? `${environment.slug} (live)` : environment.slug;
1019
+ const targetingRuleLines = (targetingRules, segments, variations) => [
1020
+ environmentLabel(targetingRules.environment),
1021
+ ...targetingRules.rules.map((rule, index) => ` ${index + 1}. ${describeRule(rule, segments, variations)}`),
1022
+ ` Default targeting rule: ${describeValue(targetingRules.defaultValue, targetingRules.defaultVariationId, variations)}`
981
1023
  ];
982
1024
  const configText = (config, requestedKey, segments) => [
983
1025
  ...oldKeyLines(requestedKey, config.key),
984
1026
  config.key,
985
1027
  ...settingLines(config).map((line) => ` ${line}`),
986
- ...config.targets.flatMap((targetingRules) => ["", ...targetingRuleLines(targetingRules, segments)]),
1028
+ ...config.targets.flatMap((targetingRules) => ["", ...targetingRuleLines(targetingRules, segments, config.variations)]),
987
1029
  ""
988
1030
  ].join("\n");
989
1031
  //#endregion
1032
+ //#region src/plural.ts
1033
+ const plural = (count, singular, pluralForm) => count === 1 ? singular : pluralForm;
1034
+ //#endregion
1035
+ //#region src/same-slug.ts
1036
+ const sameSlug = (slug, otherSlug) => slug.toLowerCase() === otherSlug.toLowerCase();
1037
+ //#endregion
1038
+ //#region src/configs/copy-plan-text.ts
1039
+ const PLAN_WORDING = {
1040
+ copy: {
1041
+ headline: (copying) => `Copied ${copying.sourceKey} in ${copying.sourceProject.slug} to ${copying.copyKey} in ${copying.destinationProject.slug}.`,
1042
+ rulesCopied: "copied",
1043
+ ruleDropped: "dropped",
1044
+ skippedSourceEnvironmentsHeading: "Skipped source environments:",
1045
+ tagCreated: "created",
1046
+ tagSkipped: "skipped"
1047
+ },
1048
+ "dry-run": {
1049
+ headline: (copying) => `Nothing was created: this is the plan for copying ${copying.sourceKey} in ${copying.sourceProject.slug} to ${copying.copyKey} in ${copying.destinationProject.slug}.`,
1050
+ rulesCopied: "would be copied",
1051
+ ruleDropped: "would be dropped",
1052
+ skippedSourceEnvironmentsHeading: "Source environments that would be skipped:",
1053
+ tagCreated: "would be created",
1054
+ tagSkipped: "would be skipped"
1055
+ }
1056
+ };
1057
+ const droppedRuleDescription = (wordedPlan, droppedRule) => `rule ${droppedRule.position} ${wordedPlan.wording.ruleDropped}: ${wordedPlan.destinationProjectSlug} has no segment ${droppedRule.segmentKey}`;
1058
+ const environmentColumnsOf = (wordedPlan, environment) => [
1059
+ environmentLabel(environment),
1060
+ `from ${environment.sourceSlug}`,
1061
+ `${environment.rulesCopied} ${plural(environment.rulesCopied, "rule", "rules")} ${wordedPlan.wording.rulesCopied}`,
1062
+ ...environment.droppedRules.length === 0 ? [] : [environment.droppedRules.map((droppedRule) => droppedRuleDescription(wordedPlan, droppedRule)).join("; ")]
1063
+ ];
1064
+ const skippedSourceEnvironmentReasonOf = (plan, slug) => {
1065
+ const destinationEnvironment = plan.environments.find((environment) => sameSlug(environment.slug, slug));
1066
+ return destinationEnvironment === void 0 ? { reason: "no-destination-environment" } : {
1067
+ reason: "destination-copies-another-source",
1068
+ destinationEnvironment
1069
+ };
1070
+ };
1071
+ const skippedSourceEnvironmentDescription = (destinationProjectSlug, slug, skipped) => {
1072
+ switch (skipped.reason) {
1073
+ case "no-destination-environment": return `${destinationProjectSlug} has no environment with this slug; create it there to copy its rules`;
1074
+ case "destination-copies-another-source": return `${skipped.destinationEnvironment.slug} in ${destinationProjectSlug} copies from ${skipped.destinationEnvironment.sourceSlug} instead; --environment ${skipped.destinationEnvironment.slug}=${slug} copies this one`;
1075
+ }
1076
+ };
1077
+ const skippedSourceEnvironmentRowsOf = ({ plan, destinationProjectSlug }) => plan.skippedSourceEnvironments.map((slug) => [slug, skippedSourceEnvironmentDescription(destinationProjectSlug, slug, skippedSourceEnvironmentReasonOf(plan, slug))]);
1078
+ const tagRowsOf = ({ plan, wording }) => [
1079
+ ...plan.tags.matched.map((name) => [name, "matched"]),
1080
+ ...plan.tags.created.map((name) => [name, wording.tagCreated]),
1081
+ ...plan.tags.skipped.map((name) => [name, `${wording.tagSkipped}: the API token cannot create tags`])
1082
+ ];
1083
+ const sectionLines = (heading, rows) => rows.length === 0 ? [] : [heading, ...alignedColumns(rows).map((line) => ` ${line}`)];
1084
+ const copyPlanText = (copying, plan, mode) => {
1085
+ const wordedPlan = {
1086
+ plan,
1087
+ destinationProjectSlug: copying.destinationProject.slug,
1088
+ wording: PLAN_WORDING[mode]
1089
+ };
1090
+ return [
1091
+ wordedPlan.wording.headline(copying),
1092
+ ...sectionLines("Environments:", plan.environments.map((environment) => environmentColumnsOf(wordedPlan, environment))),
1093
+ ...sectionLines(wordedPlan.wording.skippedSourceEnvironmentsHeading, skippedSourceEnvironmentRowsOf(wordedPlan)),
1094
+ ...sectionLines("Tags:", tagRowsOf(wordedPlan)),
1095
+ ""
1096
+ ].join("\n");
1097
+ };
1098
+ //#endregion
1099
+ //#region src/configs/environment-mapping.ts
1100
+ const MAPPING_SEPARATOR = "=";
1101
+ const mappedSlugsOf = (mapping) => {
1102
+ const separatorIndex = mapping.indexOf(MAPPING_SEPARATOR);
1103
+ const destinationSlug = mapping.slice(0, separatorIndex);
1104
+ const sourceSlug = mapping.slice(separatorIndex + 1);
1105
+ if (separatorIndex === -1 || destinationSlug === "" || sourceSlug === "") throw new CliError(`--environment takes <destination slug>=<source slug>, not ${mapping}.`);
1106
+ return [destinationSlug, sourceSlug];
1107
+ };
1108
+ const environmentMappingOf = (mappings) => {
1109
+ const mappedSlugs = (mappings ?? []).map(mappedSlugsOf);
1110
+ const repeatedDestinationSlug = mappedSlugs.find(([destinationSlug], index) => mappedSlugs.slice(0, index).some(([earlierDestinationSlug]) => sameSlug(earlierDestinationSlug, destinationSlug)))?.[0];
1111
+ if (repeatedDestinationSlug !== void 0) throw new CliError(`--environment maps the destination environment "${repeatedDestinationSlug}" more than once. Case doesn't matter in slugs.`);
1112
+ return Object.fromEntries(mappedSlugs);
1113
+ };
1114
+ //#endregion
990
1115
  //#region src/configs/new-config.ts
991
1116
  const CONFIG_TYPES = [
992
1117
  "boolean",
@@ -1237,9 +1362,6 @@ const newConfigOf = async (options, workingDirectory) => {
1237
1362
  };
1238
1363
  };
1239
1364
  //#endregion
1240
- //#region src/plural.ts
1241
- const plural = (count, singular, pluralForm) => count === 1 ? singular : pluralForm;
1242
- //#endregion
1243
1365
  //#region src/project-selection.ts
1244
1366
  const requireProjectSlug = (context) => {
1245
1367
  if (context.projectSlug === void 0) throw new CliError("Name the project with --project <slug> or CONFIGDIRECTOR_PROJECT.");
@@ -1250,8 +1372,7 @@ const requireLinkedProjectSlug = (context, settings) => {
1250
1372
  if (slug === void 0) throw new CliError("Name the project with --project <slug>, CONFIGDIRECTOR_PROJECT, or `project` in configdirector.json, which `configdirector init` writes.");
1251
1373
  return slug;
1252
1374
  };
1253
- const findProjectBySlug = async (client, slug) => {
1254
- const { parsedBody: projects } = await client.listProjects();
1375
+ const projectWithSlug = (projects, slug) => {
1255
1376
  const project = projects.find((listedProject) => listedProject.slug === slug);
1256
1377
  if (project === void 0) throw new CliError(`No project in the API token's organization has the slug "${slug}".`);
1257
1378
  return project;
@@ -1279,11 +1400,13 @@ const connectToLinkedProject = async (context) => {
1279
1400
  baseUrl: context.baseUrl,
1280
1401
  token: apiToken.token
1281
1402
  });
1282
- const project = await findProjectBySlug(client, projectSlug);
1403
+ const { parsedBody: organizationProjects } = await client.listProjects();
1404
+ const project = projectWithSlug(organizationProjects, projectSlug);
1283
1405
  refuseProjectOfAnotherOrganization(project, settings, apiToken);
1284
1406
  return {
1285
1407
  client,
1286
1408
  project,
1409
+ organizationProjects,
1287
1410
  settings
1288
1411
  };
1289
1412
  };
@@ -1356,6 +1479,10 @@ const getConfig = async (context, [configKey]) => {
1356
1479
  exitCode: ExitCode.success
1357
1480
  };
1358
1481
  };
1482
+ const defaultValueDescription = (newConfig) => {
1483
+ const defaultVariation = newConfig.variations.find((variation) => variation.value === newConfig.defaultValue);
1484
+ return valueWithVariationName(newConfig.defaultValue, defaultVariation?.name);
1485
+ };
1359
1486
  const createdConfigLines = (projectSlug, createdConfig, newConfig) => {
1360
1487
  const settings = [
1361
1488
  createdConfig.type,
@@ -1363,7 +1490,7 @@ const createdConfigLines = (projectSlug, createdConfig, newConfig) => {
1363
1490
  createdConfig.lifetime,
1364
1491
  availabilityDescription(createdConfig)
1365
1492
  ];
1366
- return [`Created ${createdConfig.key} in ${projectSlug}: ${settings.join(", ")}.`, `Default targeting rule in every environment: ${String(newConfig.defaultValue)}`];
1493
+ return [`Created ${createdConfig.key} in ${projectSlug}: ${settings.join(", ")}.`, `Default targeting rule in every environment: ${defaultValueDescription(newConfig)}`];
1367
1494
  };
1368
1495
  const keyTakenLines = (projectSlug, configKey, existingConfig) => [`${projectSlug} already has a config with the key ${configKey}, so no config was created:`, ...alignedColumns([summaryColumnsOf(existingConfig)])];
1369
1496
  const rejectsKey = (error) => error instanceof ApiValidationError && error.fieldErrors.some((fieldError) => fieldError.name === "key");
@@ -1473,6 +1600,29 @@ const createConfig = async (context, _commandArguments, options) => {
1473
1600
  throw error;
1474
1601
  }
1475
1602
  };
1603
+ const copyConfig = async (context, [configKey], options) => {
1604
+ const environmentMapping = environmentMappingOf(options.environment);
1605
+ const mode = options.dryRun ? "dry-run" : "copy";
1606
+ const { client, project, organizationProjects } = await connectToLinkedProject(context);
1607
+ const copying = {
1608
+ sourceKey: configKey,
1609
+ sourceProject: project,
1610
+ copyKey: options.key,
1611
+ destinationProject: options.to === void 0 ? project : projectWithSlug(organizationProjects, options.to)
1612
+ };
1613
+ const receivedPlan = await client.copyConfig(project.id, configKey, {
1614
+ projectId: copying.destinationProject.id,
1615
+ key: copying.copyKey,
1616
+ environments: environmentMapping,
1617
+ dryRun: mode === "dry-run"
1618
+ });
1619
+ if (receivedPlan === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
1620
+ return {
1621
+ json: receivedPlan.responseBody,
1622
+ text: copyPlanText(copying, receivedPlan.parsedBody.plan, mode),
1623
+ exitCode: ExitCode.success
1624
+ };
1625
+ };
1476
1626
  const archiveConfig = async (context, [configKey]) => {
1477
1627
  const { client, project } = await connectToLinkedProject(context);
1478
1628
  if (await client.archiveConfig(project.id, configKey) === "not-found") throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
@@ -1635,6 +1785,7 @@ const contextDescription = (evaluation, contextForm) => {
1635
1785
  const identity = `${evaluatedContext.kind} ${evaluatedContext.identifier}`;
1636
1786
  return contextForm.form === "saved" ? `saved context ${identity}` : identity;
1637
1787
  };
1788
+ const servedValueDescription = (evaluation) => evaluation.value === void 0 ? "no value" : describeValue(evaluation.value, evaluation.variationId, evaluation.variations);
1638
1789
  const servedByDescription = (evaluation) => {
1639
1790
  const { servedBy } = evaluation;
1640
1791
  switch (servedBy.kind) {
@@ -1682,17 +1833,17 @@ const ruleLines = (evaluation) => {
1682
1833
  const rulesById = new Map(evaluation.targetingRules.rules.map((rule) => [rule.id, rule]));
1683
1834
  return alignedColumns(evaluation.rules.map((explanation, index) => {
1684
1835
  const rule = rulesById.get(explanation.ruleId);
1685
- return [`${index + 1}. ${rule === void 0 ? explanation.ruleId : describeRule(rule, evaluation.segments)}`, RULE_OUTCOME_DESCRIPTIONS[explanation.outcome](explanation, conditionsOf(rule), evaluation.segments)];
1836
+ return [`${index + 1}. ${rule === void 0 ? explanation.ruleId : describeRule(rule, evaluation.segments, evaluation.variations)}`, RULE_OUTCOME_DESCRIPTIONS[explanation.outcome](explanation, conditionsOf(rule), evaluation.segments)];
1686
1837
  }));
1687
1838
  };
1688
1839
  const evaluationText = (evaluation, requested) => [
1689
1840
  ...oldKeyLines(requested.configKey, evaluation.key),
1690
1841
  `${evaluation.key} in ${evaluation.environment.name} for ${contextDescription(evaluation, requested.contextForm)}`,
1691
1842
  "",
1692
- `${evaluation.value ?? "no value"}, ${servedByDescription(evaluation)}`,
1843
+ `${servedValueDescription(evaluation)}, ${servedByDescription(evaluation)}`,
1693
1844
  "",
1694
1845
  ...ruleLines(evaluation),
1695
- `Default targeting rule: ${evaluation.targetingRules.defaultValue}`,
1846
+ `Default targeting rule: ${describeValue(evaluation.targetingRules.defaultValue, evaluation.targetingRules.defaultVariationId, evaluation.variations)}`,
1696
1847
  ""
1697
1848
  ].join("\n");
1698
1849
  //#endregion
@@ -5064,11 +5215,12 @@ const run = async (invocation) => {
5064
5215
  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));
5065
5216
  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));
5066
5217
  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));
5067
- const configsCommand = program.command("configs").description("list, show, create and archive a project's configs");
5218
+ const configsCommand = program.command("configs").description("list, show, create, copy and archive a project's configs");
5068
5219
  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));
5069
5220
  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));
5070
5221
  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));
5071
5222
  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));
5223
+ 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));
5072
5224
  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));
5073
5225
  const segmentsCommand = program.command("segments").description("list, show, create, update and delete a project's segments");
5074
5226
  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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@configdirector/cli",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Command line tool for ConfigDirector, the managed configuration and feature flag service.",
5
5
  "license": "MIT",
6
6
  "author": "ConfigDirector",