@configdirector/cli 0.15.0 → 0.17.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 +16 -0
- package/README.md +33 -3
- package/dist/configdirector.mjs +182 -24
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ All notable changes to `@configdirector/cli` are documented here. The format fol
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.17.0] - 2026-11-01
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `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.
|
|
12
|
+
|
|
13
|
+
## [0.16.0] - 2026-10-30
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- `configdirector configs create` no longer needs `--lifetime` for a feature flag, kill switch or experiment, since the role decides their lifetime: a feature flag and an experiment are always temporary, and a kill switch is always permanent. Without `--lifetime`, ConfigDirector stores the role's lifetime and the command prints it, as in `Created new-checkout in web-app: boolean, flag, temporary, client and server SDKs.` `--role config` still needs `--lifetime`, and exits with 2 before anything is sent without it. A `--lifetime` the role does not have, such as `--role kill-switch --lifetime temporary`, exits with 2 before anything is sent and says `A kill switch is always permanent.` Commands that give the role's own lifetime keep working.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- When no config, segment, tag or scanned repository has the key or name you gave, `configdirector configs get`, `configs activity`, `configs archive`, `targeting-rules set`, `segments get`, `segments update`, `segments delete`, `repositories remove` and `--tag` say so in their own words again, such as `No config in web-app has the key "new-chekout".` Now that ConfigDirector explains in its answer what is missing, earlier versions printed `The Admin API could not evaluate the config:` followed by that explanation, or `HTTP 404`. Every other command, `evaluate` included, prints ConfigDirector's explanation when something it needs is missing, where it printed `HTTP 404`.
|
|
22
|
+
|
|
7
23
|
## [0.15.0] - 2026-10-28
|
|
8
24
|
|
|
9
25
|
### Added
|
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
|
|
|
@@ -220,7 +220,7 @@ With `--json`, each prints the Admin API's response as received, except `configs
|
|
|
220
220
|
## Create a config
|
|
221
221
|
|
|
222
222
|
```bash
|
|
223
|
-
configdirector configs create --key new-checkout --type boolean --role flag --
|
|
223
|
+
configdirector configs create --key new-checkout --type boolean --role flag --default-value false
|
|
224
224
|
```
|
|
225
225
|
|
|
226
226
|
`configs create` creates a config from options that match the Admin API's fields one to one, with no guessing and no prompts:
|
|
@@ -230,7 +230,8 @@ Created new-checkout in web-app: boolean, flag, temporary, client and server SDK
|
|
|
230
230
|
Default targeting rule in every environment: false
|
|
231
231
|
```
|
|
232
232
|
|
|
233
|
-
- `--key`, `--type` (`boolean`, `string`, `integer`, `float`, `timespan`, `enum`, `url` or `json`), `--role` (`config`, `flag`, `kill-switch` or `experiment`)
|
|
233
|
+
- `--key`, `--type` (`boolean`, `string`, `integer`, `float`, `timespan`, `enum`, `url` or `json`), `--role` (`config`, `flag`, `kill-switch` or `experiment`) and `--default-value` are required. `--default-value` is the default targeting rule value every environment starts with, not the in-code default value an application passes to the SDK.
|
|
234
|
+
- `--lifetime` (`temporary` or `permanent`) is required for the `config` role. The role decides the lifetime of the others, so you can leave it out: a feature flag and an experiment are always temporary, and a kill switch is always permanent. A lifetime the role does not have is a usage error.
|
|
234
235
|
- `--description <text>`; `--client-only` or `--server-only`, without which client and server SDKs both receive the config; and `--variation <name=value>`, repeatable, for an experiment's variations.
|
|
235
236
|
- `--tag <name>`, repeatable, names a tag the project already has, and `--new-tag <name>`, repeatable, creates a tag with that name in the project's tag catalog. The config gets the tags of both, at most 20 in all. Every `--tag` name is looked up before any tag is created, so a misspelled `--tag` creates nothing; a name no tag has, a `--new-tag` name a tag already has, a `--new-tag` name given twice and a name given with both options exit with 2. When a later step fails, such as a key that is taken, the tags already created stay in the catalog, and the command names them so you can pass them with `--tag` next time.
|
|
236
237
|
- The values of `--default-value` and `--variation` are read in the config's type: `true` or `false`, a number for an integer, float or timespan, the text as written for a string, enum or URL, and JSON for a JSON config, with `@file` reading it from a file. A value that does not fit the type is a usage error.
|
|
@@ -245,6 +246,35 @@ When the project already has a config with the key, as its current key or an old
|
|
|
245
246
|
|
|
246
247
|
`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.
|
|
247
248
|
|
|
249
|
+
## Copy a config
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
configdirector configs copy search-ranking --key search-ranking --to mobile-app --environment qa=staging
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`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:
|
|
256
|
+
|
|
257
|
+
```txt
|
|
258
|
+
Copied search-ranking in web-app to search-ranking in mobile-app.
|
|
259
|
+
Environments:
|
|
260
|
+
production (live) from production 2 rules copied rule 3 dropped: mobile-app has no segment beta-testers
|
|
261
|
+
qa from staging 1 rule copied
|
|
262
|
+
Skipped source environments:
|
|
263
|
+
test mobile-app has no environment with this slug; create it there to copy its rules
|
|
264
|
+
Tags:
|
|
265
|
+
search matched
|
|
266
|
+
growth created
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
- 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.
|
|
270
|
+
- 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.
|
|
271
|
+
- 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.
|
|
272
|
+
- `--dry-run` prints the plan without creating anything, saying what would be copied, dropped, skipped and created.
|
|
273
|
+
|
|
274
|
+
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.
|
|
275
|
+
|
|
276
|
+
`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.
|
|
277
|
+
|
|
248
278
|
## Change targeting rules
|
|
249
279
|
|
|
250
280
|
```bash
|
package/dist/configdirector.mjs
CHANGED
|
@@ -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.
|
|
15
|
+
var version = "0.17.0";
|
|
16
16
|
//#endregion
|
|
17
17
|
//#region src/CliError.ts
|
|
18
18
|
var CliError = class extends Error {
|
|
@@ -43,13 +43,9 @@ const ApiValidationErrorBodySchema = v.object({ errors: v.array(v.object({
|
|
|
43
43
|
})) });
|
|
44
44
|
const ServiceSuspendedBodySchema = v.object({ code: v.literal("SERVICE_SUSPENDED") });
|
|
45
45
|
const RepositoryLimitReachedBodySchema = v.object({ code: v.literal("REPOSITORY_LIMIT_REACHED") });
|
|
46
|
-
const
|
|
46
|
+
const ResourceNotFoundBodySchema = v.object({
|
|
47
47
|
error: v.string(),
|
|
48
|
-
code: v.
|
|
49
|
-
"CONFIG_NOT_FOUND",
|
|
50
|
-
"ENVIRONMENT_NOT_FOUND",
|
|
51
|
-
"CONTEXT_NOT_FOUND"
|
|
52
|
-
])
|
|
48
|
+
code: v.pipe(v.string(), v.endsWith("_NOT_FOUND"))
|
|
53
49
|
});
|
|
54
50
|
const describeFieldError = (fieldError) => fieldError.name === void 0 ? fieldError.message : `${fieldError.name}: ${fieldError.message}`;
|
|
55
51
|
var ApiAuthorizationError = class extends CliError {
|
|
@@ -72,9 +68,9 @@ var ApiValidationError = class extends CliError {
|
|
|
72
68
|
this.fieldErrors = fieldErrors;
|
|
73
69
|
}
|
|
74
70
|
};
|
|
75
|
-
var
|
|
71
|
+
var ResourceNotFoundError = class extends CliError {
|
|
76
72
|
constructor(code, apiMessage) {
|
|
77
|
-
super(
|
|
73
|
+
super(`${apiMessage} (${code})`);
|
|
78
74
|
this.code = code;
|
|
79
75
|
}
|
|
80
76
|
};
|
|
@@ -100,6 +96,27 @@ var UnexpectedApiResponseError = class extends CliError {
|
|
|
100
96
|
}
|
|
101
97
|
};
|
|
102
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
|
|
103
120
|
//#region src/admin-api/config-creation.ts
|
|
104
121
|
const CreatedConfigSchema = v.object({
|
|
105
122
|
key: v.string(),
|
|
@@ -406,7 +423,7 @@ const tagFilterQuery = (tagFilter) => {
|
|
|
406
423
|
case "untagged": return "?untagged=true";
|
|
407
424
|
}
|
|
408
425
|
};
|
|
409
|
-
const MissingResourceBodySchema = v.strictObject({});
|
|
426
|
+
const MissingResourceBodySchema = v.union([v.strictObject({}), ResourceNotFoundBodySchema]);
|
|
410
427
|
const readJsonBody = async (response) => {
|
|
411
428
|
try {
|
|
412
429
|
return await response.json();
|
|
@@ -427,8 +444,8 @@ const failureFor = (url, response, body) => {
|
|
|
427
444
|
return authorizationError.success ? new ApiAuthorizationError(authorizationError.output.code, authorizationError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
428
445
|
}
|
|
429
446
|
case 404: {
|
|
430
|
-
const notFoundError = v.safeParse(
|
|
431
|
-
return notFoundError.success ? new
|
|
447
|
+
const notFoundError = v.safeParse(ResourceNotFoundBodySchema, body);
|
|
448
|
+
return notFoundError.success ? new ResourceNotFoundError(notFoundError.output.code, notFoundError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
432
449
|
}
|
|
433
450
|
case 429: {
|
|
434
451
|
const retryAfterSeconds = response.headers.get("retry-after");
|
|
@@ -520,6 +537,12 @@ var AdminApiClient = class {
|
|
|
520
537
|
body: request
|
|
521
538
|
});
|
|
522
539
|
}
|
|
540
|
+
copyConfig(projectId, configKey, request) {
|
|
541
|
+
return this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/copy`, CopyPlanResponseSchema, {
|
|
542
|
+
method: "POST",
|
|
543
|
+
body: request
|
|
544
|
+
});
|
|
545
|
+
}
|
|
523
546
|
async evaluateConfig(projectId, configKey, request) {
|
|
524
547
|
const { responseBody, parsedBody } = await this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/evaluate`, ConfigEvaluationSchema, {
|
|
525
548
|
method: "POST",
|
|
@@ -977,9 +1000,9 @@ const settingLines = (config) => [
|
|
|
977
1000
|
`Lifecycle state: ${config.state}`,
|
|
978
1001
|
...config.tags.length === 0 ? [] : [`Tags: ${config.tags.map((tag) => tag.name).join(", ")}`]
|
|
979
1002
|
];
|
|
980
|
-
const
|
|
1003
|
+
const environmentLabel = (environment) => environment.live ? `${environment.slug} (live)` : environment.slug;
|
|
981
1004
|
const targetingRuleLines = (targetingRules, segments) => [
|
|
982
|
-
|
|
1005
|
+
environmentLabel(targetingRules.environment),
|
|
983
1006
|
...targetingRules.rules.map((rule, index) => ` ${index + 1}. ${describeRule(rule, segments)}`),
|
|
984
1007
|
` Default targeting rule: ${targetingRules.defaultValue}`
|
|
985
1008
|
];
|
|
@@ -991,6 +1014,89 @@ const configText = (config, requestedKey, segments) => [
|
|
|
991
1014
|
""
|
|
992
1015
|
].join("\n");
|
|
993
1016
|
//#endregion
|
|
1017
|
+
//#region src/plural.ts
|
|
1018
|
+
const plural = (count, singular, pluralForm) => count === 1 ? singular : pluralForm;
|
|
1019
|
+
//#endregion
|
|
1020
|
+
//#region src/same-slug.ts
|
|
1021
|
+
const sameSlug = (slug, otherSlug) => slug.toLowerCase() === otherSlug.toLowerCase();
|
|
1022
|
+
//#endregion
|
|
1023
|
+
//#region src/configs/copy-plan-text.ts
|
|
1024
|
+
const PLAN_WORDING = {
|
|
1025
|
+
copy: {
|
|
1026
|
+
headline: (copying) => `Copied ${copying.sourceKey} in ${copying.sourceProject.slug} to ${copying.copyKey} in ${copying.destinationProject.slug}.`,
|
|
1027
|
+
rulesCopied: "copied",
|
|
1028
|
+
ruleDropped: "dropped",
|
|
1029
|
+
skippedSourceEnvironmentsHeading: "Skipped source environments:",
|
|
1030
|
+
tagCreated: "created",
|
|
1031
|
+
tagSkipped: "skipped"
|
|
1032
|
+
},
|
|
1033
|
+
"dry-run": {
|
|
1034
|
+
headline: (copying) => `Nothing was created: this is the plan for copying ${copying.sourceKey} in ${copying.sourceProject.slug} to ${copying.copyKey} in ${copying.destinationProject.slug}.`,
|
|
1035
|
+
rulesCopied: "would be copied",
|
|
1036
|
+
ruleDropped: "would be dropped",
|
|
1037
|
+
skippedSourceEnvironmentsHeading: "Source environments that would be skipped:",
|
|
1038
|
+
tagCreated: "would be created",
|
|
1039
|
+
tagSkipped: "would be skipped"
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
const droppedRuleDescription = (wordedPlan, droppedRule) => `rule ${droppedRule.position} ${wordedPlan.wording.ruleDropped}: ${wordedPlan.destinationProjectSlug} has no segment ${droppedRule.segmentKey}`;
|
|
1043
|
+
const environmentColumnsOf = (wordedPlan, environment) => [
|
|
1044
|
+
environmentLabel(environment),
|
|
1045
|
+
`from ${environment.sourceSlug}`,
|
|
1046
|
+
`${environment.rulesCopied} ${plural(environment.rulesCopied, "rule", "rules")} ${wordedPlan.wording.rulesCopied}`,
|
|
1047
|
+
...environment.droppedRules.length === 0 ? [] : [environment.droppedRules.map((droppedRule) => droppedRuleDescription(wordedPlan, droppedRule)).join("; ")]
|
|
1048
|
+
];
|
|
1049
|
+
const skippedSourceEnvironmentReasonOf = (plan, slug) => {
|
|
1050
|
+
const destinationEnvironment = plan.environments.find((environment) => sameSlug(environment.slug, slug));
|
|
1051
|
+
return destinationEnvironment === void 0 ? { reason: "no-destination-environment" } : {
|
|
1052
|
+
reason: "destination-copies-another-source",
|
|
1053
|
+
destinationEnvironment
|
|
1054
|
+
};
|
|
1055
|
+
};
|
|
1056
|
+
const skippedSourceEnvironmentDescription = (destinationProjectSlug, slug, skipped) => {
|
|
1057
|
+
switch (skipped.reason) {
|
|
1058
|
+
case "no-destination-environment": return `${destinationProjectSlug} has no environment with this slug; create it there to copy its rules`;
|
|
1059
|
+
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`;
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
const skippedSourceEnvironmentRowsOf = ({ plan, destinationProjectSlug }) => plan.skippedSourceEnvironments.map((slug) => [slug, skippedSourceEnvironmentDescription(destinationProjectSlug, slug, skippedSourceEnvironmentReasonOf(plan, slug))]);
|
|
1063
|
+
const tagRowsOf = ({ plan, wording }) => [
|
|
1064
|
+
...plan.tags.matched.map((name) => [name, "matched"]),
|
|
1065
|
+
...plan.tags.created.map((name) => [name, wording.tagCreated]),
|
|
1066
|
+
...plan.tags.skipped.map((name) => [name, `${wording.tagSkipped}: the API token cannot create tags`])
|
|
1067
|
+
];
|
|
1068
|
+
const sectionLines = (heading, rows) => rows.length === 0 ? [] : [heading, ...alignedColumns(rows).map((line) => ` ${line}`)];
|
|
1069
|
+
const copyPlanText = (copying, plan, mode) => {
|
|
1070
|
+
const wordedPlan = {
|
|
1071
|
+
plan,
|
|
1072
|
+
destinationProjectSlug: copying.destinationProject.slug,
|
|
1073
|
+
wording: PLAN_WORDING[mode]
|
|
1074
|
+
};
|
|
1075
|
+
return [
|
|
1076
|
+
wordedPlan.wording.headline(copying),
|
|
1077
|
+
...sectionLines("Environments:", plan.environments.map((environment) => environmentColumnsOf(wordedPlan, environment))),
|
|
1078
|
+
...sectionLines(wordedPlan.wording.skippedSourceEnvironmentsHeading, skippedSourceEnvironmentRowsOf(wordedPlan)),
|
|
1079
|
+
...sectionLines("Tags:", tagRowsOf(wordedPlan)),
|
|
1080
|
+
""
|
|
1081
|
+
].join("\n");
|
|
1082
|
+
};
|
|
1083
|
+
//#endregion
|
|
1084
|
+
//#region src/configs/environment-mapping.ts
|
|
1085
|
+
const MAPPING_SEPARATOR = "=";
|
|
1086
|
+
const mappedSlugsOf = (mapping) => {
|
|
1087
|
+
const separatorIndex = mapping.indexOf(MAPPING_SEPARATOR);
|
|
1088
|
+
const destinationSlug = mapping.slice(0, separatorIndex);
|
|
1089
|
+
const sourceSlug = mapping.slice(separatorIndex + 1);
|
|
1090
|
+
if (separatorIndex === -1 || destinationSlug === "" || sourceSlug === "") throw new CliError(`--environment takes <destination slug>=<source slug>, not ${mapping}.`);
|
|
1091
|
+
return [destinationSlug, sourceSlug];
|
|
1092
|
+
};
|
|
1093
|
+
const environmentMappingOf = (mappings) => {
|
|
1094
|
+
const mappedSlugs = (mappings ?? []).map(mappedSlugsOf);
|
|
1095
|
+
const repeatedDestinationSlug = mappedSlugs.find(([destinationSlug], index) => mappedSlugs.slice(0, index).some(([earlierDestinationSlug]) => sameSlug(earlierDestinationSlug, destinationSlug)))?.[0];
|
|
1096
|
+
if (repeatedDestinationSlug !== void 0) throw new CliError(`--environment maps the destination environment "${repeatedDestinationSlug}" more than once. Case doesn't matter in slugs.`);
|
|
1097
|
+
return Object.fromEntries(mappedSlugs);
|
|
1098
|
+
};
|
|
1099
|
+
//#endregion
|
|
994
1100
|
//#region src/configs/new-config.ts
|
|
995
1101
|
const CONFIG_TYPES = [
|
|
996
1102
|
"boolean",
|
|
@@ -1009,6 +1115,24 @@ const CONFIG_ROLES = [
|
|
|
1009
1115
|
"experiment"
|
|
1010
1116
|
];
|
|
1011
1117
|
const CONFIG_LIFETIMES = ["permanent", "temporary"];
|
|
1118
|
+
const ROLE_LIFETIME_RULES = {
|
|
1119
|
+
config: { chosenBy: "member" },
|
|
1120
|
+
flag: {
|
|
1121
|
+
chosenBy: "role",
|
|
1122
|
+
lifetime: "temporary",
|
|
1123
|
+
roleNameWithArticle: "A feature flag"
|
|
1124
|
+
},
|
|
1125
|
+
"kill-switch": {
|
|
1126
|
+
chosenBy: "role",
|
|
1127
|
+
lifetime: "permanent",
|
|
1128
|
+
roleNameWithArticle: "A kill switch"
|
|
1129
|
+
},
|
|
1130
|
+
experiment: {
|
|
1131
|
+
chosenBy: "role",
|
|
1132
|
+
lifetime: "temporary",
|
|
1133
|
+
roleNameWithArticle: "An experiment"
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1012
1136
|
const TIMESPAN_UNITS = [
|
|
1013
1137
|
"milliseconds",
|
|
1014
1138
|
"seconds",
|
|
@@ -1173,6 +1297,17 @@ const typedValueOf = async (type, argument) => {
|
|
|
1173
1297
|
if (value === void 0) throw new CliError(`${argument.argumentLabel} takes ${rules.valueDescription} with --type ${type}, not ${argument.text}.`);
|
|
1174
1298
|
return value;
|
|
1175
1299
|
};
|
|
1300
|
+
const requestedLifetimeOf = ({ role, lifetime }) => {
|
|
1301
|
+
const rule = ROLE_LIFETIME_RULES[role];
|
|
1302
|
+
switch (rule.chosenBy) {
|
|
1303
|
+
case "member":
|
|
1304
|
+
if (lifetime === void 0) throw new CliError(`--role ${role} needs --lifetime.`);
|
|
1305
|
+
return lifetime;
|
|
1306
|
+
case "role":
|
|
1307
|
+
if (lifetime !== void 0 && lifetime !== rule.lifetime) throw new CliError(`${rule.roleNameWithArticle} is always ${rule.lifetime}.`);
|
|
1308
|
+
return lifetime;
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1176
1311
|
const VARIATION_SEPARATOR = "=";
|
|
1177
1312
|
const variationOf = async (type, variation, workingDirectory) => {
|
|
1178
1313
|
const separatorIndex = variation.indexOf(VARIATION_SEPARATOR);
|
|
@@ -1199,7 +1334,7 @@ const newConfigOf = async (options, workingDirectory) => {
|
|
|
1199
1334
|
key: options.key,
|
|
1200
1335
|
description: options.description,
|
|
1201
1336
|
role: options.role,
|
|
1202
|
-
lifetime: options
|
|
1337
|
+
lifetime: requestedLifetimeOf(options),
|
|
1203
1338
|
type: options.type,
|
|
1204
1339
|
typeOptions: await CONFIG_TYPE_RULES[options.type].typeOptionsOf(options, workingDirectory),
|
|
1205
1340
|
...availabilityOf(options),
|
|
@@ -1212,9 +1347,6 @@ const newConfigOf = async (options, workingDirectory) => {
|
|
|
1212
1347
|
};
|
|
1213
1348
|
};
|
|
1214
1349
|
//#endregion
|
|
1215
|
-
//#region src/plural.ts
|
|
1216
|
-
const plural = (count, singular, pluralForm) => count === 1 ? singular : pluralForm;
|
|
1217
|
-
//#endregion
|
|
1218
1350
|
//#region src/project-selection.ts
|
|
1219
1351
|
const requireProjectSlug = (context) => {
|
|
1220
1352
|
if (context.projectSlug === void 0) throw new CliError("Name the project with --project <slug> or CONFIGDIRECTOR_PROJECT.");
|
|
@@ -1225,8 +1357,7 @@ const requireLinkedProjectSlug = (context, settings) => {
|
|
|
1225
1357
|
if (slug === void 0) throw new CliError("Name the project with --project <slug>, CONFIGDIRECTOR_PROJECT, or `project` in configdirector.json, which `configdirector init` writes.");
|
|
1226
1358
|
return slug;
|
|
1227
1359
|
};
|
|
1228
|
-
const
|
|
1229
|
-
const { parsedBody: projects } = await client.listProjects();
|
|
1360
|
+
const projectWithSlug = (projects, slug) => {
|
|
1230
1361
|
const project = projects.find((listedProject) => listedProject.slug === slug);
|
|
1231
1362
|
if (project === void 0) throw new CliError(`No project in the API token's organization has the slug "${slug}".`);
|
|
1232
1363
|
return project;
|
|
@@ -1254,11 +1385,13 @@ const connectToLinkedProject = async (context) => {
|
|
|
1254
1385
|
baseUrl: context.baseUrl,
|
|
1255
1386
|
token: apiToken.token
|
|
1256
1387
|
});
|
|
1257
|
-
const
|
|
1388
|
+
const { parsedBody: organizationProjects } = await client.listProjects();
|
|
1389
|
+
const project = projectWithSlug(organizationProjects, projectSlug);
|
|
1258
1390
|
refuseProjectOfAnotherOrganization(project, settings, apiToken);
|
|
1259
1391
|
return {
|
|
1260
1392
|
client,
|
|
1261
1393
|
project,
|
|
1394
|
+
organizationProjects,
|
|
1262
1395
|
settings
|
|
1263
1396
|
};
|
|
1264
1397
|
};
|
|
@@ -1448,6 +1581,29 @@ const createConfig = async (context, _commandArguments, options) => {
|
|
|
1448
1581
|
throw error;
|
|
1449
1582
|
}
|
|
1450
1583
|
};
|
|
1584
|
+
const copyConfig = async (context, [configKey], options) => {
|
|
1585
|
+
const environmentMapping = environmentMappingOf(options.environment);
|
|
1586
|
+
const mode = options.dryRun ? "dry-run" : "copy";
|
|
1587
|
+
const { client, project, organizationProjects } = await connectToLinkedProject(context);
|
|
1588
|
+
const copying = {
|
|
1589
|
+
sourceKey: configKey,
|
|
1590
|
+
sourceProject: project,
|
|
1591
|
+
copyKey: options.key,
|
|
1592
|
+
destinationProject: options.to === void 0 ? project : projectWithSlug(organizationProjects, options.to)
|
|
1593
|
+
};
|
|
1594
|
+
const receivedPlan = await client.copyConfig(project.id, configKey, {
|
|
1595
|
+
projectId: copying.destinationProject.id,
|
|
1596
|
+
key: copying.copyKey,
|
|
1597
|
+
environments: environmentMapping,
|
|
1598
|
+
dryRun: mode === "dry-run"
|
|
1599
|
+
});
|
|
1600
|
+
if (receivedPlan === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
1601
|
+
return {
|
|
1602
|
+
json: receivedPlan.responseBody,
|
|
1603
|
+
text: copyPlanText(copying, receivedPlan.parsedBody.plan, mode),
|
|
1604
|
+
exitCode: ExitCode.success
|
|
1605
|
+
};
|
|
1606
|
+
};
|
|
1451
1607
|
const archiveConfig = async (context, [configKey]) => {
|
|
1452
1608
|
const { client, project } = await connectToLinkedProject(context);
|
|
1453
1609
|
if (await client.archiveConfig(project.id, configKey) === "not-found") throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
@@ -1684,6 +1840,7 @@ const NOT_FOUND_MESSAGES = {
|
|
|
1684
1840
|
ENVIRONMENT_NOT_FOUND: ({ project, environment }) => `No environment in ${project.slug} has the slug "${environment.slug}" any more.`,
|
|
1685
1841
|
CONTEXT_NOT_FOUND: ({ environment, contextForm }, error) => contextForm.form === "saved" ? `No context saved in ${environment.slug} has the context kind ${contextForm.reference.kind} and the identifier "${contextForm.reference.identifier}".` : error.message
|
|
1686
1842
|
};
|
|
1843
|
+
const isEvaluationNotFoundCode = (code) => Object.hasOwn(NOT_FOUND_MESSAGES, code);
|
|
1687
1844
|
const requestEvaluation = async (client, requested) => {
|
|
1688
1845
|
try {
|
|
1689
1846
|
return await client.evaluateConfig(requested.project.id, requested.configKey, {
|
|
@@ -1691,7 +1848,7 @@ const requestEvaluation = async (client, requested) => {
|
|
|
1691
1848
|
...contextFieldsOf(requested.contextForm)
|
|
1692
1849
|
});
|
|
1693
1850
|
} catch (error) {
|
|
1694
|
-
if (error instanceof
|
|
1851
|
+
if (error instanceof ResourceNotFoundError && isEvaluationNotFoundCode(error.code)) throw new CliError(NOT_FOUND_MESSAGES[error.code](requested, error));
|
|
1695
1852
|
throw error;
|
|
1696
1853
|
}
|
|
1697
1854
|
};
|
|
@@ -5038,11 +5195,12 @@ const run = async (invocation) => {
|
|
|
5038
5195
|
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));
|
|
5039
5196
|
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));
|
|
5040
5197
|
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));
|
|
5041
|
-
const configsCommand = program.command("configs").description("list, show, create and archive a project's configs");
|
|
5198
|
+
const configsCommand = program.command("configs").description("list, show, create, copy and archive a project's configs");
|
|
5042
5199
|
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));
|
|
5043
5200
|
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));
|
|
5044
5201
|
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));
|
|
5045
|
-
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
|
|
5202
|
+
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));
|
|
5203
|
+
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));
|
|
5046
5204
|
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));
|
|
5047
5205
|
const segmentsCommand = program.command("segments").description("list, show, create, update and delete a project's segments");
|
|
5048
5206
|
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.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Command line tool for ConfigDirector, the managed configuration and feature flag service.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "ConfigDirector",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"build": "tsdown",
|
|
38
38
|
"pack:tarball": "yarn pack --out package.tgz",
|
|
39
39
|
"ci:test:packed-tarball": "vitest run --config vitest.packed-tarball.config.ts",
|
|
40
|
+
"ci:test:integration": "vitest run --config vitest.integration.config.ts",
|
|
40
41
|
"release": "yarn typecheck && yarn lint && yarn ci:test && yarn build && yarn pack:tarball && yarn ci:test:packed-tarball && npm publish package.tgz --access public"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|