@configdirector/cli 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ All notable changes to `@configdirector/cli` are documented here. The format fol
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.13.0] - 2026-10-27
8
+
9
+ ### Added
10
+
11
+ - `configdirector configs create --new-tag <name>`, repeatable, creates a tag with that name in the project's tag catalog and assigns it to the new config, so a token with `configs:create` and `tags:create` can create a config with tags the project does not have yet. `--tag <name>` still only finds tags the project already has, so a misspelled `--tag` never creates a tag, and the two can be given together. Every `--tag` name is looked up before any tag is created. A `--new-tag` name given twice, a name given with both options, or more than 20 tags in all exits with 2 before anything is sent. A `--new-tag` name a tag already has exits with 2, creates no config, and says to use `--tag` for it. When a later step fails, for example because the key is taken, the command names the tags it created, which stay in the project's tag catalog, so you can pass them with `--tag` next time.
12
+ - `brew install ConfigDirector/tap/configdirector` installs the CLI with Homebrew, which takes care of Node.js. The Homebrew formula is updated with every npm release, so `brew upgrade configdirector` gets the newest one.
13
+
14
+ ### Fixed
15
+
16
+ - `configdirector scan` finds configs read in Nuxt server code through `useConfigDirectorServerClient()`, the name `@configdirector/nuxt-sdk` uses from 1.11.0. Server code that calls `useConfigDirectorClient()`, as it did with 1.10.0 and earlier, is still found.
17
+
18
+ ## [0.12.0] - 2026-10-19
19
+
20
+ ### Added
21
+
22
+ - `configdirector segments create --segment <file>` creates a segment from a JSON file, or from stdin with `--segment -`. The file holds the segment's `key`, `name` and condition `groups`, exactly as the Admin API takes them, so you can save a segment with `segments get --json`, edit it and send it. `overrides` is optional and gives an environment condition groups of its own, keyed by the environment's ID, which `environments list --json` prints. A condition without an `id`, in the groups or in an override, gets a new one, and the IDs given are kept. The CLI checks only that the file holds a JSON object: ConfigDirector checks the segment, and when it refuses it, for example because another segment has its key, the command exits with 2 and lists its reasons. It prints `Created segment beta-testers in web-app.`, then the segment as `segments get` shows it, and `--json` prints the segment as the Admin API returned it. It needs `projects:read` and `segments:create`.
23
+ - `configdirector segments update <key> --segment <file>` replaces the key, the name and the condition groups of the segment the key names with those of the file, so a different `key` renames it, and the segment keeps its own ID whatever `id` the file holds. Without `overrides` in the file, the environment overrides stay as they are; `"overrides": {}` removes them all, and a map leaves exactly those environments overridden. It prints `Updated segment beta-testers in web-app.`, then the segment, and exits with 2 when no segment in the project has the key. It needs `projects:read`, `segments:read` and `segments:update`.
24
+ - `configdirector segments delete <key>` deletes a segment and its environment overrides with no confirmation, prints `Deleted segment beta-testers from web-app.`, and `--json` prints `{ "deletedSegmentKey": "beta-testers" }` with the key as given, as the MCP server's `delete_segment` tool does. While the targeting rules of a config use the segment, it is not deleted: the command exits with 2 and lists each config with the environments whose rules use it. It exits with 2 when no segment in the project has the key, and needs `projects:read` and `segments:delete`.
25
+ - With `environments:read`, the three commands name environments by their slug, and without it, by their ID. The changes show in the audit log with the source CLI. `segments create`, `update` and `delete` do what the MCP server's `create_segment`, `update_segment` and `delete_segment` tools do.
26
+
7
27
  ## [0.11.0] - 2026-10-19
8
28
 
9
29
  ### Changed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ConfigDirector CLI
2
2
 
3
- `configdirector` is the command line tool for [ConfigDirector](https://www.configdirector.com), the managed configuration and feature flag service. Commands and output may change before 1.0. This release signs in to one or more organizations, links a repository to a project, lists where the repository's code reads config keys, checks those keys against the project's configs, shows the value a context gets for a config and why, reads a project's environments, configs, targeting rules and segments, creates configs, replaces a config's targeting rules in an environment, archives configs, 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 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
 
@@ -12,6 +12,14 @@ npm install --global @configdirector/cli
12
12
 
13
13
  It needs Node.js 20 or later. If you installed `@configdirector/cli@beta` before 0.11.0, run this again: `@beta` stays on the last beta, 0.10.0-beta.1.
14
14
 
15
+ Or install it with Homebrew, which takes care of Node.js:
16
+
17
+ ```bash
18
+ brew install ConfigDirector/tap/configdirector
19
+ ```
20
+
21
+ The Homebrew formula is updated with every npm release, so `brew upgrade configdirector` gets the newest one.
22
+
15
23
  ## Sign in
16
24
 
17
25
  Create an API token in the ConfigDirector dashboard, then run:
@@ -220,7 +228,8 @@ Default targeting rule in every environment: false
220
228
  ```
221
229
 
222
230
  - `--key`, `--type` (`boolean`, `string`, `integer`, `float`, `timespan`, `enum`, `url` or `json`), `--role` (`config`, `flag`, `kill-switch` or `experiment`), `--lifetime` (`temporary` or `permanent`) 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.
223
- - `--description <text>`; `--client-only` or `--server-only`, without which client and server SDKs both receive the config; `--tag <name>`, repeatable, naming an existing tag of the project; and `--variation <name=value>`, repeatable, for an experiment's variations.
231
+ - `--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
+ - `--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.
224
233
  - 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.
225
234
  - Constraints, each accepted only by its types: `--min` and `--max` (a value may equal them) for an integer, float or timespan; `--exclusive-min` and `--exclusive-max` for an integer or float; `--unit` (`milliseconds`, `seconds` or `minutes`) and `--allow-fractions` for a timespan, which needs `--unit`, `--min` and `--max`; `--allowed-value`, repeatable, and `--enum-value-type string|number` (default `string`) for an enum; `--https-only` for a URL, without which any HTTP or HTTPS URL is accepted; and `--json-schema <file>` for a JSON config. An option of another type is a usage error naming the types it belongs to. A timespan without `--unit`, `--min` or `--max` is a usage error too.
226
235
 
@@ -231,7 +240,7 @@ configdirector configs create --key checkout-timeout --type timespan --role conf
231
240
 
232
241
  When the project already has a config with the key, as its current key or an old one, nothing is created: the command prints that config's key, type, role, lifetime and lifecycle state, and exits with 1. When ConfigDirector refuses the config for another reason, it exits with 2 and lists the reasons. With `--json`, it prints the created config as received, or the existing config when the key is taken.
233
242
 
234
- `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 `config-settings:update`. The config shows in the audit log with the source CLI.
243
+ `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.
235
244
 
236
245
  ## Change targeting rules
237
246
 
@@ -274,6 +283,59 @@ configdirector targeting-rules set new-checkout --environment production --rules
274
283
 
275
284
  `targeting-rules set` needs `projects:read`, `environments:read` and `config-targeting-rules:update`. `targeting-rules reference` needs only a valid token and no project.
276
285
 
286
+ ## Change segments
287
+
288
+ ```bash
289
+ configdirector segments create --segment beta-testers.json
290
+ configdirector segments update beta-testers --segment beta-testers.json
291
+ configdirector segments delete beta-testers
292
+ ```
293
+
294
+ `segments create` creates a segment from the file, or from stdin with `--segment -`, and `segments update <key>` replaces the segment the key names with it:
295
+
296
+ ```json
297
+ {
298
+ "key": "beta-testers",
299
+ "name": "Beta testers",
300
+ "groups": [
301
+ [
302
+ {
303
+ "kind": "attribute",
304
+ "attribute": "traits",
305
+ "trait": "/email",
306
+ "operator": "ends with any of",
307
+ "targetType": "text",
308
+ "targetValues": ["@example.com"]
309
+ }
310
+ ]
311
+ ],
312
+ "overrides": {
313
+ "7e6d5c4b-3a29-4810-8f7e-6d5c4b3a2918": [
314
+ [
315
+ {
316
+ "kind": "attribute",
317
+ "attribute": "traits",
318
+ "trait": "/email",
319
+ "operator": "ends with any of",
320
+ "targetType": "text",
321
+ "targetValues": ["@staff.example.com"]
322
+ }
323
+ ]
324
+ ]
325
+ }
326
+ }
327
+ ```
328
+
329
+ - The file holds the segment's `key`, `name` and condition `groups`, exactly as the Admin API takes them. `overrides` is optional and gives an environment condition groups of its own, keyed by the environment's ID, which `environments list --json` prints. `segments get --json` prints a segment in this shape, so you can edit it and send it back; fields the Admin API does not take, such as `usages`, are ignored.
330
+ - A condition without an `id`, in the groups or in an override, gets a new one, and the IDs given are kept.
331
+ - `update` replaces the key, the name and the groups, so a different `key` renames the segment, and the segment keeps its own ID whatever `id` the file holds. Without `overrides` in the file, the environment overrides stay as they are; `"overrides": {}` removes them all, and a map leaves exactly those environments overridden.
332
+ - The CLI checks only that the file holds a JSON object. ConfigDirector checks the segment, and when it refuses it, for example because another segment has its key, the command exits with 2 and lists its reasons.
333
+ - It asks for no confirmation. It prints `Created segment beta-testers in web-app.` or `Updated segment beta-testers in web-app.`, then the segment as `segments get` shows it. With `--json`, it prints the segment as the Admin API returned it.
334
+
335
+ `segments delete <key>` deletes a segment and its environment overrides, with no confirmation, and prints `Deleted segment beta-testers from web-app.`; `--json` prints `{ "deletedSegmentKey": "beta-testers" }` with the key as given. While the targeting rules of a config use the segment, in any environment, it is not deleted: the command exits with 2 and lists each config with the environments whose rules use it, so you can remove those segment conditions with `targeting-rules set` first. A key no segment has exits with 2 for `update` and `delete`.
336
+
337
+ `segments create` needs `projects:read` and `segments:create`, `segments update` needs `projects:read`, `segments:read` and `segments:update`, and `segments delete` needs `projects:read` and `segments:delete`. Without `environments:read`, they name environments by ID instead of by slug. The change shows in the audit log with the source CLI.
338
+
277
339
  ## Archive a config
278
340
 
279
341
  ```bash
@@ -300,7 +362,7 @@ It asks for no confirmation and does not check whether the code still reads the
300
362
  | `--project <slug>` | `CONFIGDIRECTOR_PROJECT` | Project slug. Without either, every command that reads a project uses the `project` in `configdirector.json`. |
301
363
  | `--json` | | Print JSON on stdout. Errors always go to stderr. |
302
364
 
303
- The command exits with 0 when it succeeds, 1 when `validate` found a problem or `configs create` found a config that already has the key, and 2 when it could not run: a usage mistake, a rejected token, a suspended service, an unreachable API, or something the command names that does not exist, such as a config key, an environment, a saved context, a segment key, a tag or a scanned repository. A CI job can therefore tell a wrong config key from an outage.
365
+ The command exits with 0 when it succeeds, 1 when `validate` found a problem or `configs create` found a config that already has the key, and 2 when it could not run: a usage mistake, a rejected token, a suspended service, an unreachable API, or something the command names that does not exist, such as a config key, an environment, a saved context, a segment key, a tag or a scanned repository. `segments delete` also exits with 2 when the targeting rules of a config use the segment. A CI job can therefore tell a wrong config key from an outage.
304
366
 
305
367
  ## License
306
368
 
@@ -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.11.0";
15
+ var version = "0.13.0";
16
16
  //#endregion
17
17
  //#region src/CliError.ts
18
18
  var CliError = class extends Error {
@@ -246,11 +246,20 @@ const SegmentSchema = v.object({
246
246
  usages: v.array(v.unknown())
247
247
  });
248
248
  const SegmentDefinitionSchema = v.object({
249
+ id: v.string(),
249
250
  key: v.string(),
250
251
  name: v.string(),
251
252
  groups: v.array(ConditionGroupSchema),
252
253
  overrides: v.record(v.string(), v.array(ConditionGroupSchema))
253
254
  });
255
+ const SegmentUsageSchema = v.object({
256
+ configKey: v.string(),
257
+ environmentIds: v.array(v.string())
258
+ });
259
+ const SegmentInUseBodySchema = v.object({
260
+ code: v.literal("SEGMENT_IN_USE"),
261
+ usages: v.array(SegmentUsageSchema)
262
+ });
254
263
  //#endregion
255
264
  //#region src/admin-api/targeting-rules-reference.ts
256
265
  const TargetingRulesReferenceSchema = v.object({
@@ -386,7 +395,10 @@ const ConfigActivitySchema = v.object({ latestEvaluations: v.array(v.object({
386
395
  sdkVersion: v.nullable(v.string()),
387
396
  time: v.pipe(v.string(), v.isoTimestamp())
388
397
  })) });
389
- const TagSchema = v.object({ id: v.string() });
398
+ const TagSchema = v.object({
399
+ id: v.string(),
400
+ name: v.string()
401
+ });
390
402
  const tagFilterQuery = (tagFilter) => {
391
403
  switch (tagFilter.form) {
392
404
  case "every-config": return "";
@@ -426,6 +438,16 @@ const failureFor = (url, response, body) => {
426
438
  }
427
439
  };
428
440
  const isMissingResource = (received) => received.response.status === 404 && v.is(MissingResourceBodySchema, received.body);
441
+ const isSegmentInUse = (received) => received.response.status === 409 && v.is(SegmentInUseBodySchema, received.body);
442
+ const segmentDeletionOutcomeOf = (received) => {
443
+ if (isMissingResource(received)) return { outcome: "not-found" };
444
+ if (isSegmentInUse(received)) return {
445
+ outcome: "in-use",
446
+ usages: received.body.usages
447
+ };
448
+ if (!received.response.ok) throw failureFor(received.url, received.response, received.body);
449
+ return { outcome: "deleted" };
450
+ };
429
451
  const parsedResponseOf = ({ url, response, body }, schema) => {
430
452
  if (!response.ok) throw failureFor(url, response, body);
431
453
  const parsedBody = v.safeParse(schema, body);
@@ -468,9 +490,30 @@ var AdminApiClient = class {
468
490
  getSegment(projectId, segmentKey) {
469
491
  return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, SegmentDefinitionSchema);
470
492
  }
493
+ createSegment(projectId, request) {
494
+ return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/segments`, SegmentDefinitionSchema, {
495
+ method: "POST",
496
+ body: request
497
+ });
498
+ }
499
+ updateSegment(projectId, segmentKey, request) {
500
+ return this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, SegmentDefinitionSchema, {
501
+ method: "PUT",
502
+ body: request
503
+ });
504
+ }
505
+ async deleteSegment(projectId, segmentKey) {
506
+ return segmentDeletionOutcomeOf(await this.send(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, { method: "DELETE" }));
507
+ }
471
508
  async findTagByName(projectId, name) {
472
509
  return (await this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/tags/by-name?${new URLSearchParams({ name })}`, TagSchema))?.parsedBody;
473
510
  }
511
+ async createTag(projectId, name) {
512
+ return (await this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/tags`, TagSchema, {
513
+ method: "POST",
514
+ body: { name }
515
+ })).parsedBody;
516
+ }
474
517
  createConfig(projectId, request) {
475
518
  return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs`, CreatedConfigSchema, {
476
519
  method: "POST",
@@ -1168,6 +1211,9 @@ const newConfigOf = async (options, workingDirectory) => {
1168
1211
  };
1169
1212
  };
1170
1213
  //#endregion
1214
+ //#region src/plural.ts
1215
+ const plural = (count, singular, pluralForm) => count === 1 ? singular : pluralForm;
1216
+ //#endregion
1171
1217
  //#region src/project-selection.ts
1172
1218
  const requireProjectSlug = (context) => {
1173
1219
  if (context.projectSlug === void 0) throw new CliError("Name the project with --project <slug> or CONFIGDIRECTOR_PROJECT.");
@@ -1321,13 +1367,78 @@ const creationOutput = (project, newConfig, creation) => {
1321
1367
  };
1322
1368
  }
1323
1369
  };
1370
+ const MAXIMUM_CONFIG_TAGS = 20;
1371
+ const sameTagName = (name, otherName) => name.toLowerCase() === otherName.toLowerCase();
1372
+ const configTagNamesOf = (options) => {
1373
+ const existingTagNames = options.tag ?? [];
1374
+ const newTagNames = options.newTag ?? [];
1375
+ const repeatedNewTagName = newTagNames.find((name, index) => newTagNames.slice(0, index).some((earlierName) => sameTagName(earlierName, name)));
1376
+ if (repeatedNewTagName !== void 0) throw new CliError(`--new-tag gives the tag name "${repeatedNewTagName}" more than once. Case doesn't matter in tag names.`);
1377
+ const nameInBothOptions = newTagNames.find((name) => existingTagNames.some((existingTagName) => sameTagName(existingTagName, name)));
1378
+ if (nameInBothOptions !== void 0) throw new CliError(`--tag and --new-tag both give the tag name "${nameInBothOptions}". Case doesn't matter in tag names. Use --tag for a tag the project has and --new-tag for a tag to create.`);
1379
+ const tagCount = existingTagNames.length + newTagNames.length;
1380
+ if (tagCount > MAXIMUM_CONFIG_TAGS) throw new CliError(`--tag and --new-tag give ${tagCount} tags, but a config can have at most ${MAXIMUM_CONFIG_TAGS}.`);
1381
+ return {
1382
+ existingTagNames,
1383
+ newTagNames
1384
+ };
1385
+ };
1386
+ const createNewTag = async (client, project, name) => {
1387
+ try {
1388
+ return await client.createTag(project.id, name);
1389
+ } catch (error) {
1390
+ if (error instanceof ApiValidationError) throw new CliError(`${error.message}\nTo assign a tag the project already has, use --tag instead of --new-tag.`);
1391
+ throw error;
1392
+ }
1393
+ };
1394
+ const CREATED_TAGS_LEFT_UNASSIGNED = {
1395
+ created: false,
1396
+ "key-taken": true,
1397
+ failed: true
1398
+ };
1399
+ const unassignedTagsNoticeOf = (createdTags, ending) => {
1400
+ const [firstTag, ...laterTags] = createdTags;
1401
+ return firstTag !== void 0 && CREATED_TAGS_LEFT_UNASSIGNED[ending] ? {
1402
+ form: "tags-left-unassigned",
1403
+ tags: [firstTag, ...laterTags]
1404
+ } : { form: "none" };
1405
+ };
1406
+ const quotedTagName = (tag) => `"${tag.name}"`;
1407
+ const quotedTagNames = ([firstTag, ...laterTags]) => {
1408
+ const lastTag = laterTags.at(-1);
1409
+ return lastTag === void 0 ? quotedTagName(firstTag) : `${[firstTag, ...laterTags.slice(0, -1)].map(quotedTagName).join(", ")} and ${quotedTagName(lastTag)}`;
1410
+ };
1411
+ const unassignedTagsText = (project, tags) => {
1412
+ const count = tags.length;
1413
+ return `Created the ${plural(count, "tag", "tags")} ${quotedTagNames(tags)}. ${plural(count, "It stays", "They stay")} in ${project.slug}'s tag catalog, so pass ${plural(count, "it", "them")} with --tag next time.`;
1414
+ };
1415
+ const writeUnassignedTagsNotice = (context, project, createdTags, ending) => {
1416
+ const notice = unassignedTagsNoticeOf(createdTags, ending);
1417
+ switch (notice.form) {
1418
+ case "none": return;
1419
+ case "tags-left-unassigned":
1420
+ context.invocation.stderr.write(`${unassignedTagsText(project, notice.tags)}\n`);
1421
+ return;
1422
+ }
1423
+ };
1324
1424
  const createConfig = async (context, _commandArguments, options) => {
1325
1425
  const newConfig = await newConfigOf(options, context.invocation.workingDirectory);
1426
+ const tagNames = configTagNamesOf(options);
1326
1427
  const { client, project } = await connectToLinkedProject(context);
1327
- return creationOutput(project, newConfig, await requestCreation(client, project, {
1328
- ...newConfig,
1329
- tagIds: await tagIdsNamed(client, project, options.tag ?? [])
1330
- }));
1428
+ const existingTagIds = await tagIdsNamed(client, project, tagNames.existingTagNames);
1429
+ const createdTags = [];
1430
+ try {
1431
+ for (const name of tagNames.newTagNames) createdTags.push(await createNewTag(client, project, name));
1432
+ const creation = await requestCreation(client, project, {
1433
+ ...newConfig,
1434
+ tagIds: [...existingTagIds, ...createdTags.map((tag) => tag.id)]
1435
+ });
1436
+ writeUnassignedTagsNotice(context, project, createdTags, creation.outcome);
1437
+ return creationOutput(project, newConfig, creation);
1438
+ } catch (error) {
1439
+ writeUnassignedTagsNotice(context, project, createdTags, "failed");
1440
+ throw error;
1441
+ }
1331
1442
  };
1332
1443
  const archiveConfig = async (context, [configKey]) => {
1333
1444
  const { client, project } = await connectToLinkedProject(context);
@@ -1777,9 +1888,6 @@ const listProjects = async (context) => {
1777
1888
  };
1778
1889
  };
1779
1890
  //#endregion
1780
- //#region src/plural.ts
1781
- const plural = (count, singular, pluralForm) => count === 1 ? singular : pluralForm;
1782
- //#endregion
1783
1891
  //#region src/scanned-repository/short-commit.ts
1784
1892
  const SHORT_COMMIT_LENGTH = 7;
1785
1893
  const shortCommit = (commit) => commit.slice(0, SHORT_COMMIT_LENGTH);
@@ -3257,7 +3365,7 @@ const sdkFamilies = {
3257
3365
  "nuxt-server": {
3258
3366
  sdkType: "server",
3259
3367
  extensions: javascriptExtensions,
3260
- importMarkers: ["useConfigDirectorClient"],
3368
+ importMarkers: ["useConfigDirectorServerClient", "useConfigDirectorClient"],
3261
3369
  projectArea: "nuxt-server",
3262
3370
  calls: javascriptClientCalls
3263
3371
  },
@@ -4440,6 +4548,42 @@ const scan = async (context, [path], options) => {
4440
4548
  };
4441
4549
  };
4442
4550
  //#endregion
4551
+ //#region src/request-file.ts
4552
+ const STANDARD_INPUT_ARGUMENT = "-";
4553
+ const readRequestSource = async (argumentLabel, argument, invocation) => {
4554
+ if (argument === STANDARD_INPUT_ARGUMENT) return {
4555
+ origin: `${argumentLabel} reads stdin`,
4556
+ contents: await text(invocation.stdin)
4557
+ };
4558
+ const { file, contents } = await readOptionFile(argumentLabel, argument, invocation.workingDirectory);
4559
+ return {
4560
+ origin: `${argumentLabel} names ${file}`,
4561
+ contents
4562
+ };
4563
+ };
4564
+ const readRequestFile = async (argumentLabel, argument, invocation, expectedFormatSentence) => {
4565
+ const { origin, contents } = await readRequestSource(argumentLabel, argument, invocation);
4566
+ const request = parseJson(contents);
4567
+ if (!isJsonObject(request)) throw new CliError(`${origin}, which does not hold a JSON object. ${expectedFormatSentence}`);
4568
+ return request;
4569
+ };
4570
+ const withId = (entry) => entry.id === void 0 ? {
4571
+ id: randomUUID(),
4572
+ ...entry
4573
+ } : entry;
4574
+ //#endregion
4575
+ //#region src/segments/segment-file.ts
4576
+ const readSegmentFile = (segmentArgument, invocation) => readRequestFile("--segment", segmentArgument, invocation, "It takes {\"key\": ..., \"name\": ..., \"groups\": [...]}, like the output of segments get --json.");
4577
+ const conditionWithId = (condition) => isJsonObject(condition) ? withId(condition) : condition;
4578
+ const conditionGroupWithIds = (group) => Array.isArray(group) ? group.map(conditionWithId) : group;
4579
+ const conditionGroupsWithIds = (groups) => groups.map(conditionGroupWithIds);
4580
+ const overridesWithIds = (overrides) => Object.fromEntries(Object.entries(overrides).map(([environmentId, groups]) => [environmentId, Array.isArray(groups) ? conditionGroupsWithIds(groups) : groups]));
4581
+ const withGeneratedConditionIds = (segment) => ({
4582
+ ...segment,
4583
+ ...Array.isArray(segment.groups) ? { groups: conditionGroupsWithIds(segment.groups) } : {},
4584
+ ...isJsonObject(segment.overrides) ? { overrides: overridesWithIds(segment.overrides) } : {}
4585
+ });
4586
+ //#endregion
4443
4587
  //#region src/segments/segment-text.ts
4444
4588
  const conditionGroupLines = (heading, groups) => [
4445
4589
  "",
@@ -4470,22 +4614,72 @@ const listSegments = async (context) => {
4470
4614
  exitCode: ExitCode.success
4471
4615
  };
4472
4616
  };
4473
- const environmentSlugsFor = async (client, project, segment) => {
4474
- if (Object.keys(segment.overrides).length === 0) return /* @__PURE__ */ new Map();
4617
+ const environmentSlugsById = async (client, project) => {
4475
4618
  const environments = await ifPermitted(client.listEnvironments(project.id));
4476
4619
  return new Map((environments?.parsedBody ?? []).map((environment) => [environment.id, environment.slug]));
4477
4620
  };
4621
+ const overrideEnvironmentSlugsOf = async (client, project, segment) => Object.keys(segment.overrides).length === 0 ? /* @__PURE__ */ new Map() : environmentSlugsById(client, project);
4622
+ const noSegmentWithKey = (project, segmentKey) => new CliError(`No segment in ${project.slug} has the key "${segmentKey}".`);
4478
4623
  const getSegment = async (context, [segmentKey]) => {
4479
4624
  const { client, project } = await connectToLinkedProject(context);
4480
4625
  const receivedSegment = await client.getSegment(project.id, segmentKey);
4481
- if (receivedSegment === void 0) throw new CliError(`No segment in ${project.slug} has the key "${segmentKey}".`);
4626
+ if (receivedSegment === void 0) throw noSegmentWithKey(project, segmentKey);
4482
4627
  const { parsedBody: segment, responseBody } = receivedSegment;
4483
4628
  return {
4484
4629
  json: responseBody,
4485
- text: segmentText(segment, await environmentSlugsFor(client, project, segment)),
4630
+ text: segmentText(segment, await overrideEnvironmentSlugsOf(client, project, segment)),
4631
+ exitCode: ExitCode.success
4632
+ };
4633
+ };
4634
+ const createSegment = async (context, _commandArguments, options) => {
4635
+ const requestedSegment = withGeneratedConditionIds(await readSegmentFile(options.segment, context.invocation));
4636
+ const { client, project } = await connectToLinkedProject(context);
4637
+ const { parsedBody: segment, responseBody } = await client.createSegment(project.id, requestedSegment);
4638
+ return {
4639
+ json: responseBody,
4640
+ text: [`Created segment ${segment.key} in ${project.slug}.`, segmentText(segment, await overrideEnvironmentSlugsOf(client, project, segment))].join("\n"),
4641
+ exitCode: ExitCode.success
4642
+ };
4643
+ };
4644
+ const updateSegment = async (context, [segmentKey], options) => {
4645
+ const requestedSegment = withGeneratedConditionIds(await readSegmentFile(options.segment, context.invocation));
4646
+ const { client, project } = await connectToLinkedProject(context);
4647
+ const currentSegment = await client.getSegment(project.id, segmentKey);
4648
+ if (currentSegment === void 0) throw noSegmentWithKey(project, segmentKey);
4649
+ const updatedSegment = await client.updateSegment(project.id, segmentKey, {
4650
+ ...requestedSegment,
4651
+ id: currentSegment.parsedBody.id
4652
+ });
4653
+ if (updatedSegment === void 0) throw noSegmentWithKey(project, segmentKey);
4654
+ const { parsedBody: segment, responseBody } = updatedSegment;
4655
+ return {
4656
+ json: responseBody,
4657
+ text: [`Updated segment ${segmentKey} in ${project.slug}.`, segmentText(segment, await overrideEnvironmentSlugsOf(client, project, segment))].join("\n"),
4486
4658
  exitCode: ExitCode.success
4487
4659
  };
4488
4660
  };
4661
+ const usageLine = (usage, environmentSlugs) => {
4662
+ const environmentNames = usage.environmentIds.map((environmentId) => environmentSlugs.get(environmentId) ?? environmentId);
4663
+ return ` ${usage.configKey} in ${environmentNames.join(", ")}`;
4664
+ };
4665
+ const segmentInUseError = (segmentKey, usages, environmentSlugs) => new CliError([
4666
+ `The targeting rules of ${usages.length} ${plural(usages.length, "config use", "configs use")} the segment ${segmentKey}, so it was not deleted:`,
4667
+ ...usages.map((usage) => usageLine(usage, environmentSlugs)),
4668
+ "Remove those segment conditions first, with configdirector targeting-rules set."
4669
+ ].join("\n"));
4670
+ const deleteSegment = async (context, [segmentKey]) => {
4671
+ const { client, project } = await connectToLinkedProject(context);
4672
+ const deletion = await client.deleteSegment(project.id, segmentKey);
4673
+ switch (deletion.outcome) {
4674
+ case "deleted": return {
4675
+ json: { deletedSegmentKey: segmentKey },
4676
+ text: [`Deleted segment ${segmentKey} from ${project.slug}.`, ""].join("\n"),
4677
+ exitCode: ExitCode.success
4678
+ };
4679
+ case "not-found": throw noSegmentWithKey(project, segmentKey);
4680
+ case "in-use": throw segmentInUseError(segmentKey, deletion.usages, await environmentSlugsById(client, project));
4681
+ }
4682
+ };
4489
4683
  //#endregion
4490
4684
  //#region src/targeting-rules/reference-text.ts
4491
4685
  const headedListLines = (heading, items) => [heading, ...items.map((item) => ` ${item}`)];
@@ -4505,28 +4699,7 @@ const targetingRulesReferenceText = (reference) => [
4505
4699
  ].join("\n");
4506
4700
  //#endregion
4507
4701
  //#region src/targeting-rules/targeting-rules-file.ts
4508
- const STANDARD_INPUT_ARGUMENT = "-";
4509
- const readRulesSource = async (rulesArgument, invocation) => {
4510
- if (rulesArgument === STANDARD_INPUT_ARGUMENT) return {
4511
- origin: "--rules reads stdin",
4512
- contents: await text(invocation.stdin)
4513
- };
4514
- const { file, contents } = await readOptionFile("--rules", rulesArgument, invocation.workingDirectory);
4515
- return {
4516
- origin: `--rules names ${file}`,
4517
- contents
4518
- };
4519
- };
4520
- const readTargetingRulesFile = async (rulesArgument, invocation) => {
4521
- const { origin, contents } = await readRulesSource(rulesArgument, invocation);
4522
- const targetingRules = parseJson(contents);
4523
- if (!isJsonObject(targetingRules)) throw new CliError(`${origin}, which does not hold a JSON object. It takes {"defaultValue": ..., "rules": [...]}, like an entry of targets in configs get --json.`);
4524
- return targetingRules;
4525
- };
4526
- const withId = (entry) => entry.id === void 0 ? {
4527
- id: randomUUID(),
4528
- ...entry
4529
- } : entry;
4702
+ const readTargetingRulesFile = (rulesArgument, invocation) => readRequestFile("--rules", rulesArgument, invocation, "It takes {\"defaultValue\": ..., \"rules\": [...]}, like an entry of targets in configs get --json.");
4530
4703
  const conditionOrPercentageWithId = (conditionOrPercentage) => isJsonObject(conditionOrPercentage) ? withId(conditionOrPercentage) : conditionOrPercentage;
4531
4704
  const conditionsOrPercentagesWithIds = (rule, field) => {
4532
4705
  const conditionsOrPercentages = rule[field];
@@ -4784,6 +4957,7 @@ const validate = async (context, [path]) => {
4784
4957
  //#endregion
4785
4958
  //#region src/run.ts
4786
4959
  const DEFAULT_BASE_URL = "https://api.configdirector.com";
4960
+ const SEGMENT_FILE_DESCRIPTION = "a JSON file holding the segment as the Admin API takes it, or - to read it from stdin (required)";
4787
4961
  const withoutTrailingSlashes = (url) => {
4788
4962
  let trimmedUrl = url;
4789
4963
  while (trimmedUrl.endsWith("/")) trimmedUrl = trimmedUrl.slice(0, -1);
@@ -4834,11 +5008,14 @@ const run = async (invocation) => {
4834
5008
  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));
4835
5009
  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));
4836
5010
  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));
4837
- 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)").choices(CONFIG_LIFETIMES).makeOptionMandatory()).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("--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));
5011
+ 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)").choices(CONFIG_LIFETIMES).makeOptionMandatory()).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));
4838
5012
  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));
4839
- const segmentsCommand = program.command("segments").description("list a project's segments and show one segment");
5013
+ const segmentsCommand = program.command("segments").description("list, show, create, update and delete a project's segments");
4840
5014
  segmentsCommand.command("list").description("list the project's segments with how many configs use each").action(commandAction(listSegments));
4841
5015
  segmentsCommand.command("get").argument("<key>", "the segment's key").description("show a segment's condition groups and its environment overrides").action(commandAction(getSegment));
5016
+ 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));
5017
+ 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));
5018
+ segmentsCommand.command("delete").argument("<key>", "the segment's key").description("delete a segment and its environment overrides").addHelpText("after", "\nThe segment is deleted with no confirmation. While the targeting rules of a config use it, the deletion is refused and the configs are listed with their environments.").action(commandAction(deleteSegment));
4842
5019
  const targetingRulesCommand = program.command("targeting-rules").description("replace a config's targeting rules in one environment and show the format they take");
4843
5020
  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));
4844
5021
  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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@configdirector/cli",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Command line tool for ConfigDirector, the managed configuration and feature flag service.",
5
5
  "license": "MIT",
6
6
  "author": "ConfigDirector",