@configdirector/cli 0.14.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 +20 -0
- package/README.md +8 -4
- package/dist/configdirector.mjs +100 -41
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ 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
|
+
|
|
17
|
+
## [0.15.0] - 2026-10-28
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `--no-headers` on `configdirector projects list`, `environments list`, `configs list`, `configs activity`, `segments list` and `repositories list` leaves out the header row and prints the lines alone, as earlier versions did. A script that reads these lines can add `--no-headers`, or read `--json`, which has not changed.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- `configdirector projects list`, `environments list`, `configs list`, `configs activity`, `segments list` and `repositories list` print a header row that names their columns, such as `KEY TYPE ROLE LIFETIME STATE` above the configs. The columns line up under it, so a header longer than every value in its column widens that column. An empty list still prints only its sentence, such as `No configs found in web-app.`
|
|
26
|
+
|
|
7
27
|
## [0.14.0] - 2026-10-27
|
|
8
28
|
|
|
9
29
|
### Added
|
package/README.md
CHANGED
|
@@ -88,16 +88,17 @@ A project holds at most 50 scanned repositories, and one upload holds at most 10
|
|
|
88
88
|
configdirector repositories list
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
`repositories list` lists the repositories whose scan was uploaded to the project, one per line, with the commit, when it was scanned in UTC, and how many references and dynamic references it holds, and ends with how many scanned repositories the project has out of the 50 it can hold:
|
|
91
|
+
`repositories list` lists the repositories whose scan was uploaded to the project, one per line under a header row, with the commit, when it was scanned in UTC, and how many references and dynamic references it holds, and ends with how many scanned repositories the project has out of the 50 it can hold:
|
|
92
92
|
|
|
93
93
|
```txt
|
|
94
|
+
REPOSITORY COMMIT SCANNED REFERENCES DYNAMIC REFERENCES
|
|
94
95
|
ConfigDirector/cli 4d40f50 2026-10-17 10:00 UTC 12 references 1 dynamic reference
|
|
95
96
|
acme/storefront 9f3e2a1 2026-10-16 09:12 UTC 1 reference 0 dynamic references
|
|
96
97
|
|
|
97
98
|
web-app has 2 of the 50 scanned repositories a project can hold.
|
|
98
99
|
```
|
|
99
100
|
|
|
100
|
-
A repository uploaded without a commit shows `no commit
|
|
101
|
+
A repository uploaded without a commit shows `no commit`, and `--no-headers` leaves out the header row. With `--json`, it prints the Admin API's response as received, which also holds each repository's remote URL and how many files its scan read. `repositories list` needs `projects:read` and `code-references:read`.
|
|
101
102
|
|
|
102
103
|
```bash
|
|
103
104
|
configdirector repositories remove acme/storefront
|
|
@@ -210,6 +211,8 @@ production (live)
|
|
|
210
211
|
Default targeting rule: false
|
|
211
212
|
```
|
|
212
213
|
|
|
214
|
+
`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
|
+
|
|
213
216
|
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.
|
|
214
217
|
|
|
215
218
|
`projects list` needs `projects:read`. The others need `projects:read` to find the project, and `environments list` needs `environments:read`, `configs list`, `configs get` and `configs activity` need `configs:read`, plus `tags:read` for `--tag`, and `segments list` and `segments get` need `segments:read`. Without `segments:read`, `configs get` shows a segment condition's segment ID instead of its name; without `environments:read`, `segments get` names an override's environment by its ID instead of its slug.
|
|
@@ -217,7 +220,7 @@ With `--json`, each prints the Admin API's response as received, except `configs
|
|
|
217
220
|
## Create a config
|
|
218
221
|
|
|
219
222
|
```bash
|
|
220
|
-
configdirector configs create --key new-checkout --type boolean --role flag --
|
|
223
|
+
configdirector configs create --key new-checkout --type boolean --role flag --default-value false
|
|
221
224
|
```
|
|
222
225
|
|
|
223
226
|
`configs create` creates a config from options that match the Admin API's fields one to one, with no guessing and no prompts:
|
|
@@ -227,7 +230,8 @@ Created new-checkout in web-app: boolean, flag, temporary, client and server SDK
|
|
|
227
230
|
Default targeting rule in every environment: false
|
|
228
231
|
```
|
|
229
232
|
|
|
230
|
-
- `--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.
|
|
231
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.
|
|
232
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.
|
|
233
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");
|
|
@@ -910,6 +906,7 @@ const getApiToken = async (context) => {
|
|
|
910
906
|
const COLUMN_GAP = " ";
|
|
911
907
|
const widestCellOf = (rows, column) => Math.max(0, ...rows.map((row) => row[column]?.length ?? 0));
|
|
912
908
|
const alignedColumns = (rows) => rows.map((row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widestCellOf(rows, column))).join(COLUMN_GAP));
|
|
909
|
+
const alignedListing = (headerRow, rows, options) => alignedColumns(options.headers ? [headerRow, ...rows] : rows);
|
|
913
910
|
//#endregion
|
|
914
911
|
//#region src/old-key-lines.ts
|
|
915
912
|
const oldKeyLines = (requestedKey, currentKey) => requestedKey === currentKey ? [] : [`${requestedKey} is an old key of ${currentKey}.`];
|
|
@@ -1008,6 +1005,24 @@ const CONFIG_ROLES = [
|
|
|
1008
1005
|
"experiment"
|
|
1009
1006
|
];
|
|
1010
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
|
+
};
|
|
1011
1026
|
const TIMESPAN_UNITS = [
|
|
1012
1027
|
"milliseconds",
|
|
1013
1028
|
"seconds",
|
|
@@ -1172,6 +1187,17 @@ const typedValueOf = async (type, argument) => {
|
|
|
1172
1187
|
if (value === void 0) throw new CliError(`${argument.argumentLabel} takes ${rules.valueDescription} with --type ${type}, not ${argument.text}.`);
|
|
1173
1188
|
return value;
|
|
1174
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
|
+
};
|
|
1175
1201
|
const VARIATION_SEPARATOR = "=";
|
|
1176
1202
|
const variationOf = async (type, variation, workingDirectory) => {
|
|
1177
1203
|
const separatorIndex = variation.indexOf(VARIATION_SEPARATOR);
|
|
@@ -1198,7 +1224,7 @@ const newConfigOf = async (options, workingDirectory) => {
|
|
|
1198
1224
|
key: options.key,
|
|
1199
1225
|
description: options.description,
|
|
1200
1226
|
role: options.role,
|
|
1201
|
-
lifetime: options
|
|
1227
|
+
lifetime: requestedLifetimeOf(options),
|
|
1202
1228
|
type: options.type,
|
|
1203
1229
|
typeOptions: await CONFIG_TYPE_RULES[options.type].typeOptionsOf(options, workingDirectory),
|
|
1204
1230
|
...availabilityOf(options),
|
|
@@ -1289,6 +1315,13 @@ const tagFilterOf = async (client, project, options) => {
|
|
|
1289
1315
|
};
|
|
1290
1316
|
};
|
|
1291
1317
|
const configsInState = (listedConfigs, state) => state === void 0 ? listedConfigs : listedConfigs.filter((listedConfig) => listedConfig.config.state === state);
|
|
1318
|
+
const CONFIG_SUMMARY_HEADER_ROW = [
|
|
1319
|
+
"KEY",
|
|
1320
|
+
"TYPE",
|
|
1321
|
+
"ROLE",
|
|
1322
|
+
"LIFETIME",
|
|
1323
|
+
"STATE"
|
|
1324
|
+
];
|
|
1292
1325
|
const summaryColumnsOf = (config) => [
|
|
1293
1326
|
config.key,
|
|
1294
1327
|
config.type,
|
|
@@ -1296,13 +1329,13 @@ const summaryColumnsOf = (config) => [
|
|
|
1296
1329
|
config.lifetime,
|
|
1297
1330
|
config.state
|
|
1298
1331
|
];
|
|
1299
|
-
const configLines = (projectSlug, configs) => configs.length === 0 ? [`No configs found in ${projectSlug}.`] :
|
|
1332
|
+
const configLines = (projectSlug, configs, options) => configs.length === 0 ? [`No configs found in ${projectSlug}.`] : alignedListing(CONFIG_SUMMARY_HEADER_ROW, configs.map(summaryColumnsOf), options);
|
|
1300
1333
|
const listConfigs = async (context, _commandArguments, options) => {
|
|
1301
1334
|
const { client, project } = await connectToLinkedProject(context);
|
|
1302
1335
|
const listedConfigs = configsInState(await client.listConfigs(project.id, await tagFilterOf(client, project, options)), options.state);
|
|
1303
1336
|
return {
|
|
1304
1337
|
json: listedConfigs.map((listedConfig) => listedConfig.receivedConfig),
|
|
1305
|
-
text: [...configLines(project.slug, listedConfigs.map((listedConfig) => listedConfig.config)), ""].join("\n"),
|
|
1338
|
+
text: [...configLines(project.slug, listedConfigs.map((listedConfig) => listedConfig.config), options), ""].join("\n"),
|
|
1306
1339
|
exitCode: ExitCode.success
|
|
1307
1340
|
};
|
|
1308
1341
|
};
|
|
@@ -1450,36 +1483,47 @@ const archiveConfig = async (context, [configKey]) => {
|
|
|
1450
1483
|
};
|
|
1451
1484
|
};
|
|
1452
1485
|
const sdkDescription = (evaluation) => [evaluation.sdkName ?? "unknown SDK", ...evaluation.sdkVersion === null ? [] : [evaluation.sdkVersion]].join(" ");
|
|
1453
|
-
const
|
|
1486
|
+
const LATEST_EVALUATION_HEADER_ROW = [
|
|
1487
|
+
"ENVIRONMENT",
|
|
1488
|
+
"SDK KEY",
|
|
1489
|
+
"SDK",
|
|
1490
|
+
"LAST EVALUATED"
|
|
1491
|
+
];
|
|
1492
|
+
const activityLines = (configKey, activity, options) => activity.latestEvaluations.length === 0 ? [`No SDK evaluations of ${configKey} found.`] : alignedListing(LATEST_EVALUATION_HEADER_ROW, activity.latestEvaluations.map((evaluation) => [
|
|
1454
1493
|
evaluation.environment.slug,
|
|
1455
1494
|
evaluation.source,
|
|
1456
1495
|
sdkDescription(evaluation),
|
|
1457
1496
|
utcMinute(evaluation.time)
|
|
1458
|
-
]));
|
|
1459
|
-
const showConfigActivity = async (context, [configKey]) => {
|
|
1497
|
+
]), options);
|
|
1498
|
+
const showConfigActivity = async (context, [configKey], options) => {
|
|
1460
1499
|
const { client, project } = await connectToLinkedProject(context);
|
|
1461
1500
|
const receivedActivity = await client.getConfigActivity(project.id, configKey);
|
|
1462
1501
|
if (receivedActivity === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
1463
1502
|
return {
|
|
1464
1503
|
json: receivedActivity.responseBody,
|
|
1465
|
-
text: [...activityLines(configKey, receivedActivity.parsedBody), ""].join("\n"),
|
|
1504
|
+
text: [...activityLines(configKey, receivedActivity.parsedBody, options), ""].join("\n"),
|
|
1466
1505
|
exitCode: ExitCode.success
|
|
1467
1506
|
};
|
|
1468
1507
|
};
|
|
1469
1508
|
//#endregion
|
|
1470
1509
|
//#region src/commands/environments.ts
|
|
1510
|
+
const ENVIRONMENT_HEADER_ROW = [
|
|
1511
|
+
"SLUG",
|
|
1512
|
+
"NAME",
|
|
1513
|
+
"LIVE"
|
|
1514
|
+
];
|
|
1471
1515
|
const environmentColumns = (environment) => environment.live ? [
|
|
1472
1516
|
environment.slug,
|
|
1473
1517
|
environment.name,
|
|
1474
1518
|
"live"
|
|
1475
1519
|
] : [environment.slug, environment.name];
|
|
1476
|
-
const environmentLines = (projectSlug, environments) => environments.length === 0 ? [`No environments found in ${projectSlug}.`] :
|
|
1477
|
-
const listEnvironments = async (context) => {
|
|
1520
|
+
const environmentLines = (projectSlug, environments, options) => environments.length === 0 ? [`No environments found in ${projectSlug}.`] : alignedListing(ENVIRONMENT_HEADER_ROW, environments.map(environmentColumns), options);
|
|
1521
|
+
const listEnvironments = async (context, _commandArguments, options) => {
|
|
1478
1522
|
const { client, project } = await connectToLinkedProject(context);
|
|
1479
1523
|
const { parsedBody: environments, responseBody } = await client.listEnvironments(project.id);
|
|
1480
1524
|
return {
|
|
1481
1525
|
json: responseBody,
|
|
1482
|
-
text: [...environmentLines(project.slug, environments), ""].join("\n"),
|
|
1526
|
+
text: [...environmentLines(project.slug, environments, options), ""].join("\n"),
|
|
1483
1527
|
exitCode: ExitCode.success
|
|
1484
1528
|
};
|
|
1485
1529
|
};
|
|
@@ -1665,6 +1709,7 @@ const NOT_FOUND_MESSAGES = {
|
|
|
1665
1709
|
ENVIRONMENT_NOT_FOUND: ({ project, environment }) => `No environment in ${project.slug} has the slug "${environment.slug}" any more.`,
|
|
1666
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
|
|
1667
1711
|
};
|
|
1712
|
+
const isEvaluationNotFoundCode = (code) => Object.hasOwn(NOT_FOUND_MESSAGES, code);
|
|
1668
1713
|
const requestEvaluation = async (client, requested) => {
|
|
1669
1714
|
try {
|
|
1670
1715
|
return await client.evaluateConfig(requested.project.id, requested.configKey, {
|
|
@@ -1672,7 +1717,7 @@ const requestEvaluation = async (client, requested) => {
|
|
|
1672
1717
|
...contextFieldsOf(requested.contextForm)
|
|
1673
1718
|
});
|
|
1674
1719
|
} catch (error) {
|
|
1675
|
-
if (error instanceof
|
|
1720
|
+
if (error instanceof ResourceNotFoundError && isEvaluationNotFoundCode(error.code)) throw new CliError(NOT_FOUND_MESSAGES[error.code](requested, error));
|
|
1676
1721
|
throw error;
|
|
1677
1722
|
}
|
|
1678
1723
|
};
|
|
@@ -1875,15 +1920,16 @@ const logout = async (context) => {
|
|
|
1875
1920
|
};
|
|
1876
1921
|
//#endregion
|
|
1877
1922
|
//#region src/commands/projects.ts
|
|
1878
|
-
const
|
|
1879
|
-
const
|
|
1923
|
+
const PROJECT_HEADER_ROW = ["SLUG", "NAME"];
|
|
1924
|
+
const projectLines = (projects, options) => projects.length === 0 ? ["No projects found."] : alignedListing(PROJECT_HEADER_ROW, projects.map((project) => [project.slug, project.name]), options);
|
|
1925
|
+
const listProjects = async (context, _commandArguments, options) => {
|
|
1880
1926
|
const { parsedBody: projects, responseBody } = await new AdminApiClient({
|
|
1881
1927
|
baseUrl: context.baseUrl,
|
|
1882
1928
|
token: (await configuredApiToken(context)).token
|
|
1883
1929
|
}).listProjects();
|
|
1884
1930
|
return {
|
|
1885
1931
|
json: responseBody,
|
|
1886
|
-
text: [...projectLines(projects), ""].join("\n"),
|
|
1932
|
+
text: [...projectLines(projects, options), ""].join("\n"),
|
|
1887
1933
|
exitCode: ExitCode.success
|
|
1888
1934
|
};
|
|
1889
1935
|
};
|
|
@@ -1895,6 +1941,13 @@ const shortCommit = (commit) => commit.slice(0, SHORT_COMMIT_LENGTH);
|
|
|
1895
1941
|
//#region src/commands/repositories.ts
|
|
1896
1942
|
const MAX_SCANNED_REPOSITORIES_PER_PROJECT = 50;
|
|
1897
1943
|
const commitDescription = (commit) => commit === null ? "no commit" : shortCommit(commit);
|
|
1944
|
+
const SCANNED_REPOSITORY_HEADER_ROW = [
|
|
1945
|
+
"REPOSITORY",
|
|
1946
|
+
"COMMIT",
|
|
1947
|
+
"SCANNED",
|
|
1948
|
+
"REFERENCES",
|
|
1949
|
+
"DYNAMIC REFERENCES"
|
|
1950
|
+
];
|
|
1898
1951
|
const scannedRepositoryColumnsOf = (repository) => [
|
|
1899
1952
|
repository.name,
|
|
1900
1953
|
commitDescription(repository.commit),
|
|
@@ -1902,17 +1955,17 @@ const scannedRepositoryColumnsOf = (repository) => [
|
|
|
1902
1955
|
`${repository.referenceCount} ${plural(repository.referenceCount, "reference", "references")}`,
|
|
1903
1956
|
`${repository.dynamicReferenceCount} ${plural(repository.dynamicReferenceCount, "dynamic reference", "dynamic references")}`
|
|
1904
1957
|
];
|
|
1905
|
-
const scannedRepositoryLines = (projectSlug, repositories) => repositories.length === 0 ? [`No scanned repositories found in ${projectSlug}. Run configdirector scan --upload in a repository to upload its code references.`] : [
|
|
1906
|
-
...
|
|
1958
|
+
const scannedRepositoryLines = (projectSlug, repositories, options) => repositories.length === 0 ? [`No scanned repositories found in ${projectSlug}. Run configdirector scan --upload in a repository to upload its code references.`] : [
|
|
1959
|
+
...alignedListing(SCANNED_REPOSITORY_HEADER_ROW, repositories.map(scannedRepositoryColumnsOf), options),
|
|
1907
1960
|
"",
|
|
1908
1961
|
`${projectSlug} has ${repositories.length} of the ${MAX_SCANNED_REPOSITORIES_PER_PROJECT} scanned repositories a project can hold.`
|
|
1909
1962
|
];
|
|
1910
|
-
const listScannedRepositories = async (context) => {
|
|
1963
|
+
const listScannedRepositories = async (context, _commandArguments, options) => {
|
|
1911
1964
|
const { client, project } = await connectToLinkedProject(context);
|
|
1912
1965
|
const { parsedBody: repositories, responseBody } = await client.listScannedRepositories(project.id);
|
|
1913
1966
|
return {
|
|
1914
1967
|
json: responseBody,
|
|
1915
|
-
text: [...scannedRepositoryLines(project.slug, repositories), ""].join("\n"),
|
|
1968
|
+
text: [...scannedRepositoryLines(project.slug, repositories, options), ""].join("\n"),
|
|
1916
1969
|
exitCode: ExitCode.success
|
|
1917
1970
|
};
|
|
1918
1971
|
};
|
|
@@ -4601,17 +4654,22 @@ const segmentText = (segment, environmentSlugs) => [
|
|
|
4601
4654
|
//#endregion
|
|
4602
4655
|
//#region src/commands/segments.ts
|
|
4603
4656
|
const usageDescription = (segment) => `used by ${segment.usages.length} ${plural(segment.usages.length, "config", "configs")}`;
|
|
4604
|
-
const
|
|
4657
|
+
const SEGMENT_HEADER_ROW = [
|
|
4658
|
+
"KEY",
|
|
4659
|
+
"NAME",
|
|
4660
|
+
"USED BY"
|
|
4661
|
+
];
|
|
4662
|
+
const segmentLines = (projectSlug, segments, options) => segments.length === 0 ? [`No segments found in ${projectSlug}.`] : alignedListing(SEGMENT_HEADER_ROW, segments.map((segment) => [
|
|
4605
4663
|
segment.key,
|
|
4606
4664
|
segment.name,
|
|
4607
4665
|
usageDescription(segment)
|
|
4608
|
-
]));
|
|
4609
|
-
const listSegments = async (context) => {
|
|
4666
|
+
]), options);
|
|
4667
|
+
const listSegments = async (context, _commandArguments, options) => {
|
|
4610
4668
|
const { client, project } = await connectToLinkedProject(context);
|
|
4611
4669
|
const { parsedBody: segments, responseBody } = await client.listSegments(project.id);
|
|
4612
4670
|
return {
|
|
4613
4671
|
json: responseBody,
|
|
4614
|
-
text: [...segmentLines(project.slug, segments), ""].join("\n"),
|
|
4672
|
+
text: [...segmentLines(project.slug, segments, options), ""].join("\n"),
|
|
4615
4673
|
exitCode: ExitCode.success
|
|
4616
4674
|
};
|
|
4617
4675
|
};
|
|
@@ -4958,6 +5016,7 @@ const validate = async (context, [path]) => {
|
|
|
4958
5016
|
//#endregion
|
|
4959
5017
|
//#region src/run.ts
|
|
4960
5018
|
const DEFAULT_BASE_URL = "https://api.configdirector.com";
|
|
5019
|
+
const NO_HEADERS_DESCRIPTION = "leave out the header row";
|
|
4961
5020
|
const SEGMENT_FILE_DESCRIPTION = "a JSON file holding the segment as the Admin API takes it, or - to read it from stdin (required)";
|
|
4962
5021
|
const withoutTrailingSlashes = (url) => {
|
|
4963
5022
|
let trimmedUrl = url;
|
|
@@ -5003,16 +5062,16 @@ const run = async (invocation) => {
|
|
|
5003
5062
|
program.command("validate").argument("[path]", "directory or file to check (default: the current directory)").description("check every config key the code reads against the project's configs, exiting 1 when one is wrong").action(commandAction(validate));
|
|
5004
5063
|
program.command("evaluate").argument("<key>", "the config's key").description("show the value a context gets for a config in an environment, and why").requiredOption("--environment <slug>", "slug of the environment whose targeting rules to evaluate (required)").option("--context-kind <kind>", "context kind of the context to evaluate for (default: user)").option("--context-id <identifier>", "identifier of the context").option("--context-name <name>", "name of the context").option("--context-trait <name=value>", "a trait of the context, as a string, or name:=<json> for a number, boolean, array or object; repeatable", repeatedOption).option("--context-json <json>", "the whole context as a JSON object, or @<file> to read it from a file").option("--saved-context <kind:identifier>", "a context ConfigDirector saved from SDK evaluations in the environment, such as user:123").addHelpText("after", "\nGive the context with the --context-* options, with --context-json, or with --saved-context. Without any of them, the config is evaluated for an empty context, as an SDK given none would.").action(commandAction(evaluate));
|
|
5005
5064
|
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));
|
|
5006
|
-
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").action(commandAction(listProjects));
|
|
5007
|
-
program.command("environments").description("list the project's environments").command("list").description("list the project's environments with their slugs, marking the live ones").action(commandAction(listEnvironments));
|
|
5065
|
+
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
|
+
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));
|
|
5008
5067
|
const configsCommand = program.command("configs").description("list, show, create and archive a project's configs");
|
|
5009
|
-
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)).action(commandAction(listConfigs));
|
|
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));
|
|
5010
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));
|
|
5011
|
-
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").action(commandAction(showConfigActivity));
|
|
5012
|
-
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
|
|
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));
|
|
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));
|
|
5013
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));
|
|
5014
5073
|
const segmentsCommand = program.command("segments").description("list, show, create, update and delete a project's segments");
|
|
5015
|
-
segmentsCommand.command("list").description("list the project's segments with how many configs use each").action(commandAction(listSegments));
|
|
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));
|
|
5016
5075
|
segmentsCommand.command("get").argument("<key>", "the segment's key").description("show a segment's condition groups and its environment overrides").action(commandAction(getSegment));
|
|
5017
5076
|
segmentsCommand.command("create").description("create a segment from a JSON file").requiredOption("--segment <file>", SEGMENT_FILE_DESCRIPTION).addHelpText("after", "\nThe file holds {\"key\": ..., \"name\": ..., \"groups\": [...], \"overrides\": {...}}, like the output of segments get --json, so you can edit that output and send it. groups lists the condition groups, and a context is in the segment when every condition of any one group matches. overrides is optional: it maps an environment's id, which environments list --json shows, to the condition groups used there instead. A condition without an id gets a new one.").action(commandAction(createSegment));
|
|
5018
5077
|
segmentsCommand.command("update").argument("<key>", "the segment's key").description("replace a segment's key, name and condition groups with a JSON file, and its environment overrides when the file has them").requiredOption("--segment <file>", SEGMENT_FILE_DESCRIPTION).addHelpText("after", "\nThe file takes the same form as for segments create, and the segment keeps its id whatever id the file holds. Its key, name and condition groups are replaced, so a different key renames it. Without overrides in the file, the environment overrides stay as they are; with \"overrides\": {} they are all removed, and a map leaves exactly those environments overridden. A condition without an id gets a new one.").action(commandAction(updateSegment));
|
|
@@ -5021,7 +5080,7 @@ const run = async (invocation) => {
|
|
|
5021
5080
|
targetingRulesCommand.command("set").argument("<key>", "the config's key, or an old key of it").description("replace a config's targeting rules in one environment with the rules of a JSON file").requiredOption("--environment <slug>", "slug of the environment whose targeting rules to replace (required)").requiredOption("--rules <file>", "a JSON file holding defaultValue and rules as the Admin API takes them, or - to read it from stdin (required)").addHelpText("after", "\nThe file holds {\"defaultValue\": ..., \"rules\": [...]}, like an entry of targets in configs get --json, so you can edit that output and write it back. Every rule of the environment is replaced. A rule, condition or percentage without an id gets a new one. configdirector targeting-rules reference lists the condition kinds, attributes and operators.").action(commandAction(setTargetingRules));
|
|
5022
5081
|
targetingRulesCommand.command("reference").description("show the format targeting rules take: the condition kinds, the attributes a condition can test and the operators").action(commandAction(showTargetingRulesReference));
|
|
5023
5082
|
const repositoriesCommand = program.command("repositories").description("list a project's scanned repositories and remove one");
|
|
5024
|
-
repositoriesCommand.command("list").description("list the repositories whose scan was uploaded to the project, with their commit, scan time and reference counts").action(commandAction(listScannedRepositories));
|
|
5083
|
+
repositoriesCommand.command("list").description("list the repositories whose scan was uploaded to the project, with their commit, scan time and reference counts").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listScannedRepositories));
|
|
5025
5084
|
repositoriesCommand.command("remove").argument("<name>", "the repository's name, as repositories list shows it, such as ConfigDirector/cli").description("remove a scanned repository and its code references from the project").addHelpText("after", "\nThe repository is removed with no confirmation, and the dashboard stops showing its code references. The next scan --upload from it adds it back.").action(commandAction(removeScannedRepository));
|
|
5026
5085
|
try {
|
|
5027
5086
|
await program.parseAsync(invocation.commandLineArguments, { from: "user" });
|
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": {
|