@configdirector/cli 0.15.0 → 0.16.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 +10 -0
- package/README.md +3 -2
- package/dist/configdirector.mjs +41 -15
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ All notable changes to `@configdirector/cli` are documented here. The format fol
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.16.0] - 2026-10-30
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- `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.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- 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`.
|
|
16
|
+
|
|
7
17
|
## [0.15.0] - 2026-10-28
|
|
8
18
|
|
|
9
19
|
### Added
|
package/README.md
CHANGED
|
@@ -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.
|
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.16.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
|
};
|
|
@@ -406,7 +402,7 @@ const tagFilterQuery = (tagFilter) => {
|
|
|
406
402
|
case "untagged": return "?untagged=true";
|
|
407
403
|
}
|
|
408
404
|
};
|
|
409
|
-
const MissingResourceBodySchema = v.strictObject({});
|
|
405
|
+
const MissingResourceBodySchema = v.union([v.strictObject({}), ResourceNotFoundBodySchema]);
|
|
410
406
|
const readJsonBody = async (response) => {
|
|
411
407
|
try {
|
|
412
408
|
return await response.json();
|
|
@@ -427,8 +423,8 @@ const failureFor = (url, response, body) => {
|
|
|
427
423
|
return authorizationError.success ? new ApiAuthorizationError(authorizationError.output.code, authorizationError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
428
424
|
}
|
|
429
425
|
case 404: {
|
|
430
|
-
const notFoundError = v.safeParse(
|
|
431
|
-
return notFoundError.success ? new
|
|
426
|
+
const notFoundError = v.safeParse(ResourceNotFoundBodySchema, body);
|
|
427
|
+
return notFoundError.success ? new ResourceNotFoundError(notFoundError.output.code, notFoundError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
432
428
|
}
|
|
433
429
|
case 429: {
|
|
434
430
|
const retryAfterSeconds = response.headers.get("retry-after");
|
|
@@ -1009,6 +1005,24 @@ const CONFIG_ROLES = [
|
|
|
1009
1005
|
"experiment"
|
|
1010
1006
|
];
|
|
1011
1007
|
const CONFIG_LIFETIMES = ["permanent", "temporary"];
|
|
1008
|
+
const ROLE_LIFETIME_RULES = {
|
|
1009
|
+
config: { chosenBy: "member" },
|
|
1010
|
+
flag: {
|
|
1011
|
+
chosenBy: "role",
|
|
1012
|
+
lifetime: "temporary",
|
|
1013
|
+
roleNameWithArticle: "A feature flag"
|
|
1014
|
+
},
|
|
1015
|
+
"kill-switch": {
|
|
1016
|
+
chosenBy: "role",
|
|
1017
|
+
lifetime: "permanent",
|
|
1018
|
+
roleNameWithArticle: "A kill switch"
|
|
1019
|
+
},
|
|
1020
|
+
experiment: {
|
|
1021
|
+
chosenBy: "role",
|
|
1022
|
+
lifetime: "temporary",
|
|
1023
|
+
roleNameWithArticle: "An experiment"
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1012
1026
|
const TIMESPAN_UNITS = [
|
|
1013
1027
|
"milliseconds",
|
|
1014
1028
|
"seconds",
|
|
@@ -1173,6 +1187,17 @@ const typedValueOf = async (type, argument) => {
|
|
|
1173
1187
|
if (value === void 0) throw new CliError(`${argument.argumentLabel} takes ${rules.valueDescription} with --type ${type}, not ${argument.text}.`);
|
|
1174
1188
|
return value;
|
|
1175
1189
|
};
|
|
1190
|
+
const requestedLifetimeOf = ({ role, lifetime }) => {
|
|
1191
|
+
const rule = ROLE_LIFETIME_RULES[role];
|
|
1192
|
+
switch (rule.chosenBy) {
|
|
1193
|
+
case "member":
|
|
1194
|
+
if (lifetime === void 0) throw new CliError(`--role ${role} needs --lifetime.`);
|
|
1195
|
+
return lifetime;
|
|
1196
|
+
case "role":
|
|
1197
|
+
if (lifetime !== void 0 && lifetime !== rule.lifetime) throw new CliError(`${rule.roleNameWithArticle} is always ${rule.lifetime}.`);
|
|
1198
|
+
return lifetime;
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1176
1201
|
const VARIATION_SEPARATOR = "=";
|
|
1177
1202
|
const variationOf = async (type, variation, workingDirectory) => {
|
|
1178
1203
|
const separatorIndex = variation.indexOf(VARIATION_SEPARATOR);
|
|
@@ -1199,7 +1224,7 @@ const newConfigOf = async (options, workingDirectory) => {
|
|
|
1199
1224
|
key: options.key,
|
|
1200
1225
|
description: options.description,
|
|
1201
1226
|
role: options.role,
|
|
1202
|
-
lifetime: options
|
|
1227
|
+
lifetime: requestedLifetimeOf(options),
|
|
1203
1228
|
type: options.type,
|
|
1204
1229
|
typeOptions: await CONFIG_TYPE_RULES[options.type].typeOptionsOf(options, workingDirectory),
|
|
1205
1230
|
...availabilityOf(options),
|
|
@@ -1684,6 +1709,7 @@ const NOT_FOUND_MESSAGES = {
|
|
|
1684
1709
|
ENVIRONMENT_NOT_FOUND: ({ project, environment }) => `No environment in ${project.slug} has the slug "${environment.slug}" any more.`,
|
|
1685
1710
|
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
1711
|
};
|
|
1712
|
+
const isEvaluationNotFoundCode = (code) => Object.hasOwn(NOT_FOUND_MESSAGES, code);
|
|
1687
1713
|
const requestEvaluation = async (client, requested) => {
|
|
1688
1714
|
try {
|
|
1689
1715
|
return await client.evaluateConfig(requested.project.id, requested.configKey, {
|
|
@@ -1691,7 +1717,7 @@ const requestEvaluation = async (client, requested) => {
|
|
|
1691
1717
|
...contextFieldsOf(requested.contextForm)
|
|
1692
1718
|
});
|
|
1693
1719
|
} catch (error) {
|
|
1694
|
-
if (error instanceof
|
|
1720
|
+
if (error instanceof ResourceNotFoundError && isEvaluationNotFoundCode(error.code)) throw new CliError(NOT_FOUND_MESSAGES[error.code](requested, error));
|
|
1695
1721
|
throw error;
|
|
1696
1722
|
}
|
|
1697
1723
|
};
|
|
@@ -5042,7 +5068,7 @@ const run = async (invocation) => {
|
|
|
5042
5068
|
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
5069
|
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
5070
|
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
|
|
5071
|
+
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));
|
|
5046
5072
|
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
5073
|
const segmentsCommand = program.command("segments").description("list, show, create, update and delete a project's segments");
|
|
5048
5074
|
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.16.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": {
|