@configdirector/cli 0.9.0-beta.1 → 0.10.0-beta.1

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.
@@ -2,8 +2,8 @@
2
2
  import { homedir } from "node:os";
3
3
  import { Command, CommanderError, Option } from "commander";
4
4
  import * as v from "valibot";
5
- import { access, mkdir, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
6
5
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { mkdir, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
7
7
  import { createInterface } from "node:readline";
8
8
  import ignore from "ignore";
9
9
  import picomatch from "picomatch";
@@ -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.9.0-beta.1";
15
+ var version = "0.10.0-beta.1";
16
16
  //#endregion
17
17
  //#region src/CliError.ts
18
18
  var CliError = class extends Error {
@@ -100,10 +100,15 @@ var UnexpectedApiResponseError = class extends CliError {
100
100
  }
101
101
  };
102
102
  //#endregion
103
- //#region src/aligned-columns.ts
104
- const COLUMN_GAP = " ";
105
- const widestCellOf = (rows, column) => Math.max(0, ...rows.map((row) => row[column]?.length ?? 0));
106
- const alignedColumns = (rows) => rows.map((row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widestCellOf(rows, column))).join(COLUMN_GAP));
103
+ //#region src/admin-api/config-creation.ts
104
+ const CreatedConfigSchema = v.object({
105
+ key: v.string(),
106
+ type: v.string(),
107
+ role: v.string(),
108
+ lifetime: v.string(),
109
+ client: v.boolean(),
110
+ server: v.boolean()
111
+ });
107
112
  //#endregion
108
113
  //#region src/admin-api/targeting-rules.ts
109
114
  const RuleValueSchema = v.union([
@@ -220,95 +225,349 @@ const ConfigWithTargetingRulesSchema = v.intersect([v.object({
220
225
  targets: v.array(TargetingRulesInEnvironmentSchema)
221
226
  }), ConfigTypeSchema]);
222
227
  //#endregion
223
- //#region src/old-key-lines.ts
224
- const oldKeyLines = (requestedKey, currentKey) => requestedKey === currentKey ? [] : [`${requestedKey} is an old key of ${currentKey}.`];
225
- //#endregion
226
- //#region src/rule-sentences.ts
227
- const TRAIT_POINTER_PREFIX = "/";
228
- const ATTRIBUTE_LABELS = {
229
- identifier: "context identifier",
230
- kind: "context kind",
231
- name: "context name",
232
- appName: "app name",
233
- appVersion: "app version"
234
- };
235
- const formatPercentage = (value) => Number(value.toFixed(3)).toString();
236
- const subjectOf = (condition) => {
237
- if (condition.attribute === "traits" && condition.trait != null) return condition.trait.startsWith(TRAIT_POINTER_PREFIX) ? condition.trait.slice(1) : condition.trait;
238
- return ATTRIBUTE_LABELS[condition.attribute] ?? condition.attribute;
239
- };
240
- const segmentNameOf = (segmentId, segments) => segments[segmentId]?.name ?? segmentId;
241
- const describeAttributeCondition = (condition) => `${subjectOf(condition)} ${condition.operator} ${condition.targetValues.map(String).join(", ")}`;
242
- const describeConditionGroup = (group) => group.map(describeAttributeCondition).join(" and ");
243
- const describeCondition = (condition, segments) => {
244
- switch (condition.kind) {
245
- case "attribute": return describeAttributeCondition(condition);
246
- case "segment": return `${condition.operator} segment ${segmentNameOf(condition.segmentId, segments)}`;
247
- }
248
- };
249
- const describePercentages = (percentages) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${String(percentage.value)}`).join(", ");
250
- const describeRule = (rule, segments) => {
251
- if (rule.type === "percentage") return describePercentages(rule.percentages);
252
- return `${rule.conditions.map((condition) => describeCondition(condition, segments)).join(" and ")}: ${rule.target === "value" ? String(rule.value) : describePercentages(rule.percentages)}`;
228
+ //#region src/admin-api/scanned-repositories.ts
229
+ const scannedRepositoryFields = {
230
+ name: v.string(),
231
+ commit: v.pipe(v.string(), v.transform((commit) => commit === "" ? null : commit)),
232
+ referenceCount: v.number(),
233
+ dynamicReferenceCount: v.number()
253
234
  };
235
+ const ScannedRepositorySchema = v.object(scannedRepositoryFields);
236
+ const ListedScannedRepositorySchema = v.object({
237
+ ...scannedRepositoryFields,
238
+ scannedAt: v.pipe(v.string(), v.isoTimestamp())
239
+ });
254
240
  //#endregion
255
- //#region src/configs/config-text.ts
256
- const boundsDescriptions = (bounds) => {
257
- const limits = [bounds?.min, bounds?.max].flatMap((bound) => bound === void 0 ? [] : [`${bound.relation} ${bound.value}`]);
258
- return limits.length === 0 ? [] : [limits.join(" and ")];
241
+ //#region src/admin-api/segments.ts
242
+ const SegmentSchema = v.object({
243
+ id: v.string(),
244
+ key: v.string(),
245
+ name: v.string(),
246
+ usages: v.array(v.unknown())
247
+ });
248
+ const SegmentDefinitionSchema = v.object({
249
+ key: v.string(),
250
+ name: v.string(),
251
+ groups: v.array(ConditionGroupSchema),
252
+ overrides: v.record(v.string(), v.array(ConditionGroupSchema))
253
+ });
254
+ //#endregion
255
+ //#region src/admin-api/targeting-rules-reference.ts
256
+ const TargetingRulesReferenceSchema = v.object({
257
+ notes: v.array(v.string()),
258
+ conditionKinds: v.array(v.string()),
259
+ conditionAttributes: v.array(v.string()),
260
+ contextKindOperators: v.array(v.string()),
261
+ segmentOperators: v.array(v.string()),
262
+ operatorsByTargetType: v.record(v.string(), v.array(v.string()))
263
+ });
264
+ //#endregion
265
+ //#region src/admin-api/config-evaluation.ts
266
+ const CheckedOutcomeSchema = v.picklist(["matched", "not-matched"]);
267
+ const AttributeConditionExplanationSchema = v.object({
268
+ conditionId: v.string(),
269
+ kind: v.literal("attribute"),
270
+ outcome: CheckedOutcomeSchema,
271
+ resolvedValue: v.optional(v.unknown()),
272
+ resolvedType: v.picklist([
273
+ "absent",
274
+ "scalar",
275
+ "array",
276
+ "object",
277
+ "unknown-attribute"
278
+ ])
279
+ });
280
+ const SegmentConditionExplanationSchema = v.object({
281
+ conditionId: v.string(),
282
+ kind: v.literal("segment"),
283
+ outcome: CheckedOutcomeSchema,
284
+ segmentId: v.string(),
285
+ segmentFound: v.boolean(),
286
+ matchedGroupIndex: v.optional(v.number())
287
+ });
288
+ const ConditionExplanationSchema = v.union([
289
+ v.object({
290
+ conditionId: v.string(),
291
+ outcome: v.literal("not-evaluated")
292
+ }),
293
+ AttributeConditionExplanationSchema,
294
+ SegmentConditionExplanationSchema
295
+ ]);
296
+ const BucketSchema = v.object({
297
+ defaultedToPredefinedBucket: v.boolean(),
298
+ assignedPercentage: v.number(),
299
+ shares: v.array(v.object({
300
+ percentageId: v.string(),
301
+ from: v.number(),
302
+ to: v.number()
303
+ })),
304
+ selectedPercentageId: v.optional(v.string())
305
+ });
306
+ const RuleExplanationSchema = v.object({
307
+ ruleId: v.string(),
308
+ outcome: v.picklist([
309
+ "matched",
310
+ "not-matched",
311
+ "not-evaluated",
312
+ "errored"
313
+ ]),
314
+ conditions: v.array(ConditionExplanationSchema),
315
+ bucket: v.optional(BucketSchema)
316
+ });
317
+ const ConfigEvaluationSchema = v.object({
318
+ key: v.string(),
319
+ environment: v.object({ name: v.string() }),
320
+ context: v.nullable(v.object({
321
+ kind: v.string(),
322
+ identifier: v.nullable(v.string())
323
+ })),
324
+ value: v.optional(v.string()),
325
+ servedBy: v.variant("kind", [v.object({
326
+ kind: v.literal("rule"),
327
+ ruleId: v.string()
328
+ }), v.object({ kind: v.literal("default") })]),
329
+ rules: v.array(RuleExplanationSchema),
330
+ targetingRules: v.object({
331
+ rules: v.array(RuleSchema),
332
+ defaultValue: v.string()
333
+ }),
334
+ segments: v.record(v.string(), v.object({ name: v.string() }))
335
+ });
336
+ //#endregion
337
+ //#region src/admin-api/AdminApiClient.ts
338
+ const ProjectSchema = v.object({
339
+ id: v.string(),
340
+ name: v.string(),
341
+ slug: v.string(),
342
+ organizationId: v.string(),
343
+ storeCodeSnippets: v.optional(v.boolean(), true)
344
+ });
345
+ const DescribedApiTokenSchema = v.object({
346
+ name: v.string(),
347
+ organization: v.object({
348
+ id: v.string(),
349
+ name: v.string()
350
+ }),
351
+ permissions: v.array(v.string()),
352
+ expiresAt: v.nullable(v.string())
353
+ });
354
+ const configFields = {
355
+ key: v.string(),
356
+ deprecatedKeys: v.array(v.object({ key: v.string() })),
357
+ role: v.string(),
358
+ lifetime: v.string(),
359
+ state: v.string(),
360
+ client: v.boolean(),
361
+ server: v.boolean()
259
362
  };
260
- const enumValuesDescription = ({ valueType, values }) => valueType === "number" ? `one of the numbers ${values.join(", ")}` : `one of ${values.join(", ")}`;
261
- const timespanRangeDescriptions = (range) => [`${range.min} to ${range.max} ${range.unit}`, ...range.allowFractions ? ["fractions allowed"] : []];
262
- const typeConstraintDescriptions = (config) => {
263
- if (!hasTypeConstraints(config)) return [];
264
- switch (config.type) {
265
- case "integer":
266
- case "float": return boundsDescriptions(config.typeOptions);
267
- case "enum": return [enumValuesDescription(config.typeOptions)];
268
- case "timespan": return timespanRangeDescriptions(config.typeOptions);
269
- case "url": return config.typeOptions?.secureOnly ? ["HTTPS only"] : [];
270
- case "json": return config.typeOptions?.useSchema ? ["checked against a JSON schema"] : [];
363
+ const EnumConfigSchema = v.object({
364
+ ...configFields,
365
+ type: v.literal("enum"),
366
+ typeOptions: v.object({
367
+ valueType: v.string(),
368
+ values: v.array(v.string())
369
+ })
370
+ });
371
+ const ConfigSchema = v.variant("type", [EnumConfigSchema, v.object({
372
+ ...configFields,
373
+ type: v.pipe(v.string(), v.notValue("enum"))
374
+ })]);
375
+ const isEnumConfig = (config) => config.type === "enum";
376
+ const EnvironmentSchema = v.object({
377
+ id: v.string(),
378
+ slug: v.string(),
379
+ name: v.string(),
380
+ live: v.boolean()
381
+ });
382
+ const ConfigActivitySchema = v.object({ latestEvaluations: v.array(v.object({
383
+ environment: v.object({ slug: v.string() }),
384
+ source: v.string(),
385
+ sdkName: v.nullable(v.string()),
386
+ sdkVersion: v.nullable(v.string()),
387
+ time: v.pipe(v.string(), v.isoTimestamp())
388
+ })) });
389
+ const TagSchema = v.object({ id: v.string() });
390
+ const tagFilterQuery = (tagFilter) => {
391
+ switch (tagFilter.form) {
392
+ case "every-config": return "";
393
+ case "tagged": return `?${new URLSearchParams(tagFilter.tagIds.map((tagId) => ["tagIds", tagId]))}`;
394
+ case "untagged": return "?untagged=true";
271
395
  }
272
396
  };
273
- const availabilityDescription = (config) => {
274
- if (config.client && config.server) return "client and server SDKs";
275
- if (config.client) return "client-only";
276
- if (config.server) return "server-only";
277
- return "served to no SDK";
278
- };
279
- const settingLines = (config) => [
280
- ...config.description ? [`Description: ${config.description}`] : [],
281
- `Type: ${[config.type, ...typeConstraintDescriptions(config)].join(", ")}`,
282
- `Role: ${config.role}`,
283
- `Lifetime: ${config.lifetime}`,
284
- `Availability: ${availabilityDescription(config)}`,
285
- `Lifecycle state: ${config.state}`,
286
- ...config.tags.length === 0 ? [] : [`Tags: ${config.tags.map((tag) => tag.name).join(", ")}`]
287
- ];
288
- const environmentHeading = (targetingRules) => targetingRules.environment.live ? `${targetingRules.environment.slug} (live)` : targetingRules.environment.slug;
289
- const targetingRuleLines = (targetingRules, segments) => [
290
- environmentHeading(targetingRules),
291
- ...targetingRules.rules.map((rule, index) => ` ${index + 1}. ${describeRule(rule, segments)}`),
292
- ` Default targeting rule: ${targetingRules.defaultValue}`
293
- ];
294
- const configText = (config, requestedKey, segments) => [
295
- ...oldKeyLines(requestedKey, config.key),
296
- config.key,
297
- ...settingLines(config).map((line) => ` ${line}`),
298
- ...config.targets.flatMap((targetingRules) => ["", ...targetingRuleLines(targetingRules, segments)]),
299
- ""
300
- ].join("\n");
301
- //#endregion
302
- //#region src/file-contents.ts
303
- const readFileIfPresent = async (file) => {
397
+ const MissingResourceBodySchema = v.strictObject({});
398
+ const readJsonBody = async (response) => {
304
399
  try {
305
- return await readFile(file, "utf8");
306
- } catch (error) {
307
- if (error.code === "ENOENT") return;
308
- throw error;
400
+ return await response.json();
401
+ } catch {
402
+ return;
309
403
  }
310
404
  };
311
- const readOptionFile = async (argumentLabel, path, workingDirectory) => {
405
+ const describeStatus = (response) => `HTTP ${response.status}`;
406
+ const failureFor = (url, response, body) => {
407
+ switch (response.status) {
408
+ case 400: {
409
+ if (v.is(RepositoryLimitReachedBodySchema, body)) return new RepositoryLimitReachedError();
410
+ const validationError = v.safeParse(ApiValidationErrorBodySchema, body);
411
+ return validationError.success ? new ApiValidationError(validationError.output.errors) : new UnexpectedApiResponseError(url, describeStatus(response));
412
+ }
413
+ case 403: {
414
+ const authorizationError = v.safeParse(ApiAuthorizationErrorBodySchema, body);
415
+ return authorizationError.success ? new ApiAuthorizationError(authorizationError.output.code, authorizationError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
416
+ }
417
+ case 404: {
418
+ const notFoundError = v.safeParse(EvaluationNotFoundBodySchema, body);
419
+ return notFoundError.success ? new EvaluationNotFoundError(notFoundError.output.code, notFoundError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
420
+ }
421
+ case 429: {
422
+ const retryAfterSeconds = response.headers.get("retry-after");
423
+ return v.is(ServiceSuspendedBodySchema, body) && retryAfterSeconds !== null ? new ServiceSuspendedError(retryAfterSeconds) : new UnexpectedApiResponseError(url, describeStatus(response));
424
+ }
425
+ default: return new UnexpectedApiResponseError(url, describeStatus(response));
426
+ }
427
+ };
428
+ const isMissingResource = (received) => received.response.status === 404 && v.is(MissingResourceBodySchema, received.body);
429
+ const parsedResponseOf = ({ url, response, body }, schema) => {
430
+ if (!response.ok) throw failureFor(url, response, body);
431
+ const parsedBody = v.safeParse(schema, body);
432
+ if (!parsedBody.success) throw new UnexpectedApiResponseError(url, `an ${describeStatus(response)} response the CLI cannot read`);
433
+ return {
434
+ responseBody: body,
435
+ parsedBody: parsedBody.output
436
+ };
437
+ };
438
+ var AdminApiClient = class {
439
+ constructor(connection) {
440
+ this.connection = connection;
441
+ }
442
+ describeApiToken() {
443
+ return this.get("/v1/api-token", DescribedApiTokenSchema);
444
+ }
445
+ listProjects() {
446
+ return this.get("/v1/projects", v.array(ProjectSchema));
447
+ }
448
+ async listConfigs(projectId, tagFilter) {
449
+ const { parsedBody: configs, responseBody } = await this.get(`/v1/projects/${encodeURIComponent(projectId)}/configs${tagFilterQuery(tagFilter)}`, v.array(ConfigSchema));
450
+ const receivedConfigs = v.parse(v.array(v.unknown()), responseBody);
451
+ return configs.map((config, index) => ({
452
+ config,
453
+ receivedConfig: receivedConfigs[index]
454
+ }));
455
+ }
456
+ listEnvironments(projectId) {
457
+ return this.get(`/v1/projects/${encodeURIComponent(projectId)}/environments`, v.array(EnvironmentSchema));
458
+ }
459
+ getConfig(projectId, configKey) {
460
+ return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, ConfigWithTargetingRulesSchema);
461
+ }
462
+ getConfigActivity(projectId, configKey) {
463
+ return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/activity`, ConfigActivitySchema);
464
+ }
465
+ listSegments(projectId) {
466
+ return this.get(`/v1/projects/${encodeURIComponent(projectId)}/segments`, v.array(SegmentSchema));
467
+ }
468
+ getSegment(projectId, segmentKey) {
469
+ return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, SegmentDefinitionSchema);
470
+ }
471
+ async findTagByName(projectId, name) {
472
+ return (await this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/tags/by-name?${new URLSearchParams({ name })}`, TagSchema))?.parsedBody;
473
+ }
474
+ createConfig(projectId, request) {
475
+ return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs`, CreatedConfigSchema, {
476
+ method: "POST",
477
+ body: request
478
+ });
479
+ }
480
+ async evaluateConfig(projectId, configKey, request) {
481
+ const { responseBody, parsedBody } = await this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/evaluate`, ConfigEvaluationSchema, {
482
+ method: "POST",
483
+ body: request
484
+ });
485
+ return {
486
+ evaluation: parsedBody,
487
+ responseBody
488
+ };
489
+ }
490
+ getTargetingRulesReference() {
491
+ return this.get("/v1/targeting-rules-reference", TargetingRulesReferenceSchema);
492
+ }
493
+ async replaceTargetingRules(projectId, configKey, request) {
494
+ return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/targets`, v.unknown(), {
495
+ method: "PUT",
496
+ body: request
497
+ }) === void 0 ? "not-found" : "replaced";
498
+ }
499
+ uploadRepositoryScan(projectId, repositoryName, upload) {
500
+ return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryName)}`, ScannedRepositorySchema, {
501
+ method: "PUT",
502
+ body: upload
503
+ });
504
+ }
505
+ listScannedRepositories(projectId) {
506
+ return this.get(`/v1/projects/${encodeURIComponent(projectId)}/repositories`, v.array(ListedScannedRepositorySchema));
507
+ }
508
+ async removeScannedRepository(projectId, repositoryName) {
509
+ return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryName)}`, v.unknown(), { method: "DELETE" }) === void 0 ? "not-found" : "removed";
510
+ }
511
+ async archiveConfig(projectId, configKey) {
512
+ return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, v.unknown(), { method: "DELETE" }) === void 0 ? "not-found" : "archived";
513
+ }
514
+ get(path, schema) {
515
+ return this.requestJson(path, schema, { method: "GET" });
516
+ }
517
+ getIfFound(path, schema) {
518
+ return this.requestIfFound(path, schema, { method: "GET" });
519
+ }
520
+ async requestIfFound(path, schema, request) {
521
+ const received = await this.send(path, request);
522
+ return isMissingResource(received) ? void 0 : parsedResponseOf(received, schema);
523
+ }
524
+ async requestJson(path, schema, request) {
525
+ return parsedResponseOf(await this.send(path, request), schema);
526
+ }
527
+ async send(path, request) {
528
+ const url = `${this.connection.baseUrl}${path}`;
529
+ const response = await this.fetchResponse(url, request);
530
+ return {
531
+ url,
532
+ response,
533
+ body: await readJsonBody(response)
534
+ };
535
+ }
536
+ async fetchResponse(url, request) {
537
+ const headers = {
538
+ Accept: "application/json",
539
+ Authorization: `Bearer ${this.connection.token}`,
540
+ "User-Agent": `configdirector-cli/${version}`,
541
+ "X-SourceClient": "cli"
542
+ };
543
+ try {
544
+ return await fetch(url, "body" in request ? {
545
+ method: request.method,
546
+ headers: {
547
+ ...headers,
548
+ "Content-Type": "application/json"
549
+ },
550
+ body: JSON.stringify(request.body)
551
+ } : {
552
+ method: request.method,
553
+ headers
554
+ });
555
+ } catch (error) {
556
+ throw new ApiUnreachableError(this.connection.baseUrl, error);
557
+ }
558
+ }
559
+ };
560
+ //#endregion
561
+ //#region src/file-contents.ts
562
+ const readFileIfPresent = async (file) => {
563
+ try {
564
+ return await readFile(file, "utf8");
565
+ } catch (error) {
566
+ if (error.code === "ENOENT") return;
567
+ throw error;
568
+ }
569
+ };
570
+ const readOptionFile = async (argumentLabel, path, workingDirectory) => {
312
571
  const file = resolve(workingDirectory, path);
313
572
  const contents = await readFileIfPresent(file);
314
573
  if (contents === void 0) throw new CliError(`${argumentLabel} names ${file}, which does not exist.`);
@@ -333,618 +592,580 @@ const parseJson = (contents) => {
333
592
  const pathExists = (path) => stat(path).then(() => true, () => false);
334
593
  const fileExists = (path) => stat(path).then((stats) => stats.isFile(), () => false);
335
594
  //#endregion
336
- //#region src/configs/new-config.ts
337
- const CONFIG_TYPES = [
338
- "boolean",
339
- "string",
340
- "integer",
341
- "float",
342
- "timespan",
343
- "enum",
344
- "url",
345
- "json"
346
- ];
347
- const CONFIG_ROLES = [
348
- "config",
349
- "flag",
350
- "kill-switch",
351
- "experiment"
352
- ];
353
- const CONFIG_LIFETIMES = ["permanent", "temporary"];
354
- const TIMESPAN_UNITS = [
355
- "milliseconds",
356
- "seconds",
357
- "minutes"
358
- ];
359
- const ENUM_VALUE_TYPES = ["string", "number"];
360
- const DEFAULT_ENUM_VALUE_TYPE = "string";
361
- const TYPE_CONSTRAINT_OPTION_NAMES = {
362
- min: "--min",
363
- max: "--max",
364
- exclusiveMin: "--exclusive-min",
365
- exclusiveMax: "--exclusive-max",
366
- unit: "--unit",
367
- allowFractions: "--allow-fractions",
368
- allowedValue: "--allowed-value",
369
- enumValueType: "--enum-value-type",
370
- httpsOnly: "--https-only",
371
- jsonSchema: "--json-schema"
595
+ //#region src/project-settings.ts
596
+ const ProjectSettingsSchema = v.looseObject({
597
+ project: v.optional(v.string()),
598
+ organization: v.optional(v.string()),
599
+ include: v.optional(v.array(v.string())),
600
+ exclude: v.optional(v.array(v.string())),
601
+ ignoreKeys: v.optional(v.array(v.string()))
602
+ });
603
+ const projectSettingsFile = (workingDirectory) => join(workingDirectory, "configdirector.json");
604
+ const describeIssue = (issue) => {
605
+ const path = v.getDotPath(issue);
606
+ return path === null ? issue.message : `${path}: ${issue.message}`;
372
607
  };
373
- const availabilityOf = (options) => {
374
- if (options.clientOnly) return {
375
- client: true,
376
- server: false
608
+ const projectSettingsOf = (file, contents) => {
609
+ const settings = v.safeParse(ProjectSettingsSchema, parseJson(contents));
610
+ if (!settings.success) throw new CliError(`${file} is not valid (${settings.issues.map(describeIssue).join("; ")}).`);
611
+ return settings.output;
612
+ };
613
+ const readProjectSettings = async (workingDirectory) => {
614
+ const file = projectSettingsFile(workingDirectory);
615
+ const contents = await readFileIfPresent(file);
616
+ return contents === void 0 ? void 0 : projectSettingsOf(file, contents);
617
+ };
618
+ //#endregion
619
+ //#region src/sign-ins/chosen-sign-in.ts
620
+ const organizationReferenceOf = (context, settings) => {
621
+ if (context.organizationOption !== void 0) return {
622
+ from: "option",
623
+ reference: context.organizationOption
377
624
  };
378
- if (options.serverOnly) return {
379
- client: false,
380
- server: true
625
+ if (settings?.organization !== void 0) return {
626
+ from: "settings-file",
627
+ reference: settings.organization
381
628
  };
629
+ return { from: "nowhere" };
630
+ };
631
+ const describeSignIn = (signIn) => `${signIn.organization.name} (${signIn.organization.id})`;
632
+ const describeSignIns = (signIns) => signIns.map(describeSignIn).join(", ");
633
+ const sameName = (name, otherName) => name.localeCompare(otherName, void 0, { sensitivity: "accent" }) === 0;
634
+ const signInWithId = (signIns, organizationId) => signIns.find((signIn) => signIn.organization.id === organizationId);
635
+ const signInNamedByOption = (signIns, reference) => {
636
+ const signInWithReferenceAsId = signInWithId(signIns, reference);
637
+ if (signInWithReferenceAsId !== void 0) return signInWithReferenceAsId;
638
+ const signInsWithName = signIns.filter((signIn) => sameName(signIn.organization.name, reference));
639
+ const [onlySignInWithName] = signInsWithName;
640
+ if (signInsWithName.length > 1) throw new CliError(`${signInsWithName.length} CLI sign-ins are named "${reference}": ${describeSignIns(signInsWithName)}. Name the organization by its id.`);
641
+ if (onlySignInWithName === void 0) throw new CliError(`No CLI sign-in is for the organization "${reference}". The CLI sign-ins are for ${describeSignIns(signIns)}.`);
642
+ return onlySignInWithName;
643
+ };
644
+ const signInNamedBySettingsFile = (signIns, organizationId) => {
645
+ const signIn = signInWithId(signIns, organizationId);
646
+ if (signIn === void 0) throw new CliError(`No CLI sign-in is for the organization ${organizationId}, which configdirector.json names. The CLI sign-ins are for ${describeSignIns(signIns)}. Run \`configdirector login\` with a token of that organization.`);
647
+ return signIn;
648
+ };
649
+ const onlySignIn = (signIns) => {
650
+ const [firstSignIn] = signIns;
651
+ if (firstSignIn === void 0) return { outcome: "no-sign-in" };
652
+ if (signIns.length > 1) throw new CliError(`There are CLI sign-ins for ${describeSignIns(signIns)}. Name the organization with --organization, CONFIGDIRECTOR_ORGANIZATION or \`organization\` in configdirector.json, which \`configdirector init\` writes.`);
382
653
  return {
383
- client: true,
384
- server: true
654
+ outcome: "chosen",
655
+ signIn: firstSignIn
385
656
  };
386
657
  };
387
- const BOOLEAN_VALUES = new Map([["true", true], ["false", false]]);
388
- const numberOf = (text) => {
389
- const value = parseJson(text);
390
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
391
- };
392
- const constraintNumberOf = (options, option) => {
393
- const text = options[option];
394
- if (text === void 0) return;
395
- const value = numberOf(text);
396
- if (value === void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES[option]} takes a number, not ${text}.`);
397
- return value;
658
+ const chosenSignIn = (signIns, organizationReference) => {
659
+ if (signIns.length === 0) return { outcome: "no-sign-in" };
660
+ switch (organizationReference.from) {
661
+ case "option": return {
662
+ outcome: "chosen",
663
+ signIn: signInNamedByOption(signIns, organizationReference.reference)
664
+ };
665
+ case "settings-file": return {
666
+ outcome: "chosen",
667
+ signIn: signInNamedBySettingsFile(signIns, organizationReference.reference)
668
+ };
669
+ case "nowhere": return onlySignIn(signIns);
670
+ }
398
671
  };
399
- const boundOf = (options, option, relation) => {
400
- const value = constraintNumberOf(options, option);
401
- return value === void 0 ? void 0 : {
402
- relation,
403
- value
404
- };
672
+ //#endregion
673
+ //#region src/user-config-directory.ts
674
+ const platformConfigDirectory = (invocation) => {
675
+ switch (invocation.platform) {
676
+ case "darwin": return join(invocation.homeDirectory, "Library", "Application Support");
677
+ case "win32": return nonEmptyVariable(invocation, "APPDATA") ?? join(invocation.homeDirectory, "AppData", "Roaming");
678
+ default: return join(invocation.homeDirectory, ".config");
679
+ }
405
680
  };
406
- const numericBoundsOf = (options) => ({
407
- min: boundOf(options, "min", ">=") ?? boundOf(options, "exclusiveMin", ">"),
408
- max: boundOf(options, "max", "<=") ?? boundOf(options, "exclusiveMax", "<")
409
- });
410
- const jsonTextOf = async ({ argumentLabel, text, workingDirectory }) => {
411
- const jsonText = (await atFileArgumentText(argumentLabel, text, workingDirectory)).trim();
412
- return parseJson(jsonText) === void 0 ? void 0 : jsonText;
413
- };
414
- const jsonSchemaOptionsOf = async (options, workingDirectory) => {
415
- if (options.jsonSchema === void 0) return;
416
- const { file, contents } = await readOptionFile(TYPE_CONSTRAINT_OPTION_NAMES.jsonSchema, options.jsonSchema, workingDirectory);
417
- const schema = parseJson(contents);
418
- if (schema === void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES.jsonSchema} names ${file}, which is not JSON.`);
681
+ const userConfigDirectory = (invocation) => join(nonEmptyVariable(invocation, "XDG_CONFIG_HOME") ?? platformConfigDirectory(invocation), "configdirector");
682
+ //#endregion
683
+ //#region src/sign-ins/config-json-token.ts
684
+ const ConfigJsonSchema = v.object({ token: v.string() });
685
+ const configJsonFile = (invocation) => join(userConfigDirectory(invocation), "config.json");
686
+ const readConfigJsonToken = async (invocation) => {
687
+ const file = configJsonFile(invocation);
688
+ const contents = await readFileIfPresent(file);
689
+ if (contents === void 0) return;
690
+ const configJson = v.safeParse(ConfigJsonSchema, parseJson(contents));
691
+ if (!configJson.success) throw new CliError(`${file} does not hold a saved API token. Delete it and run \`configdirector login\`.`);
419
692
  return {
420
- useSchema: true,
421
- schema
693
+ file,
694
+ token: configJson.output.token
422
695
  };
423
696
  };
424
- const timespanRangeOf = async (options) => {
425
- if (options.unit === void 0 || options.min === void 0 || options.max === void 0) throw new CliError("--type timespan needs --unit, --min and --max.");
426
- return {
427
- unit: options.unit,
428
- min: constraintNumberOf(options, "min"),
429
- max: constraintNumberOf(options, "max"),
430
- allowFractions: options.allowFractions
431
- };
697
+ const deleteConfigJson = (configJsonToken) => rm(configJsonToken.file, { force: true });
698
+ //#endregion
699
+ //#region src/sign-ins/sign-ins-file.ts
700
+ const OrganizationSchema = v.object({
701
+ id: v.string(),
702
+ name: v.string()
703
+ });
704
+ const SignInSchema = v.object({
705
+ organization: OrganizationSchema,
706
+ token: v.string()
707
+ });
708
+ const SignInsFileSchema = v.object({ signIns: v.array(SignInSchema) });
709
+ const signInsFile = (invocation) => join(userConfigDirectory(invocation), "sign-ins.json");
710
+ const readSignInsFile = async (invocation) => {
711
+ const file = signInsFile(invocation);
712
+ const contents = await readFileIfPresent(file);
713
+ if (contents === void 0) return [];
714
+ const parsedFile = v.safeParse(SignInsFileSchema, parseJson(contents));
715
+ if (!parsedFile.success) throw new CliError(`${file} does not hold CLI sign-ins. Delete it and run \`configdirector login\` again.`);
716
+ return parsedFile.output.signIns;
432
717
  };
433
- const NUMERIC_BOUND_OPTIONS = [
434
- "min",
435
- "max",
436
- "exclusiveMin",
437
- "exclusiveMax"
438
- ];
439
- const CONFIG_TYPE_RULES = {
440
- boolean: {
441
- constraintOptions: [],
442
- valueDescription: "true or false",
443
- valueOf: async ({ text }) => BOOLEAN_VALUES.get(text),
444
- typeOptionsOf: async () => void 0
445
- },
446
- string: {
447
- constraintOptions: [],
448
- valueDescription: "text",
449
- valueOf: async ({ text }) => text,
450
- typeOptionsOf: async () => void 0
451
- },
452
- integer: {
453
- constraintOptions: NUMERIC_BOUND_OPTIONS,
454
- valueDescription: "a number",
455
- valueOf: async ({ text }) => numberOf(text),
456
- typeOptionsOf: async (options) => ({
457
- isInteger: true,
458
- ...numericBoundsOf(options)
459
- })
460
- },
461
- float: {
462
- constraintOptions: NUMERIC_BOUND_OPTIONS,
463
- valueDescription: "a number",
464
- valueOf: async ({ text }) => numberOf(text),
465
- typeOptionsOf: async (options) => ({
466
- isInteger: false,
467
- ...numericBoundsOf(options)
468
- })
469
- },
470
- timespan: {
471
- constraintOptions: [
472
- "min",
473
- "max",
474
- "unit",
475
- "allowFractions"
476
- ],
477
- valueDescription: "a number",
478
- valueOf: async ({ text }) => numberOf(text),
479
- typeOptionsOf: timespanRangeOf
480
- },
481
- enum: {
482
- constraintOptions: ["allowedValue", "enumValueType"],
483
- valueDescription: "text",
484
- valueOf: async ({ text }) => text,
485
- typeOptionsOf: async (options) => ({
486
- values: options.allowedValue ?? [],
487
- valueType: options.enumValueType ?? DEFAULT_ENUM_VALUE_TYPE
488
- })
489
- },
490
- url: {
491
- constraintOptions: ["httpsOnly"],
492
- valueDescription: "text",
493
- valueOf: async ({ text }) => text,
494
- typeOptionsOf: async (options) => ({ secureOnly: options.httpsOnly === true })
495
- },
496
- json: {
497
- constraintOptions: ["jsonSchema"],
498
- valueDescription: "JSON or @<file>",
499
- valueOf: jsonTextOf,
500
- typeOptionsOf: jsonSchemaOptionsOf
718
+ const writeSignInsFile = async (invocation, signIns) => {
719
+ const file = signInsFile(invocation);
720
+ await mkdir(dirname(file), {
721
+ recursive: true,
722
+ mode: 448
723
+ });
724
+ const handle = await open(file, "w", 384);
725
+ try {
726
+ await handle.chmod(384);
727
+ await handle.writeFile(`${JSON.stringify({ signIns }, null, 2)}\n`);
728
+ } finally {
729
+ await handle.close();
501
730
  }
731
+ return file;
502
732
  };
503
- const typeAlternatives = (types) => types.length === 1 ? `${types[0]}` : `${types.slice(0, -1).join(", ")} or ${types.at(-1)}`;
504
- const typesTaking = (option) => CONFIG_TYPES.filter((type) => CONFIG_TYPE_RULES[type].constraintOptions.includes(option));
505
- const typeConstraintHelpGroup = (option) => `Options for --type ${typeAlternatives(typesTaking(option))}:`;
506
- const TYPE_CONSTRAINT_OPTIONS = Object.keys(TYPE_CONSTRAINT_OPTION_NAMES);
507
- const rejectOptionsOfOtherTypes = (options) => {
508
- const { constraintOptions } = CONFIG_TYPE_RULES[options.type];
509
- const otherTypeOption = TYPE_CONSTRAINT_OPTIONS.find((option) => options[option] !== void 0 && !constraintOptions.includes(option));
510
- if (otherTypeOption !== void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES[otherTypeOption]} cannot be used with --type ${options.type}. It applies to --type ${typeAlternatives(typesTaking(otherTypeOption))}.`);
733
+ //#endregion
734
+ //#region src/sign-ins/sign-ins.ts
735
+ const isFor = (signIn, organization) => signIn.organization.id === organization.id;
736
+ const withSignIn = (signIns, signIn) => signIns.some((savedSignIn) => isFor(savedSignIn, signIn.organization)) ? signIns.map((savedSignIn) => isFor(savedSignIn, signIn.organization) ? signIn : savedSignIn) : [...signIns, signIn];
737
+ const withoutSignIn = (signIns, organization) => signIns.filter((savedSignIn) => !isFor(savedSignIn, organization));
738
+ const configJsonTokenMoveOf = async (context, configJsonToken) => {
739
+ const client = new AdminApiClient({
740
+ baseUrl: context.baseUrl,
741
+ token: configJsonToken.token
742
+ });
743
+ try {
744
+ const { parsedBody: apiToken } = await client.describeApiToken();
745
+ return {
746
+ outcome: "moved",
747
+ signIn: {
748
+ organization: apiToken.organization,
749
+ token: configJsonToken.token
750
+ }
751
+ };
752
+ } catch (error) {
753
+ if (error instanceof ApiAuthorizationError) return {
754
+ outcome: "rejected",
755
+ code: error.code
756
+ };
757
+ throw error;
758
+ }
511
759
  };
512
- const typedValueOf = async (type, argument) => {
513
- const rules = CONFIG_TYPE_RULES[type];
514
- const value = await rules.valueOf(argument);
515
- if (value === void 0) throw new CliError(`${argument.argumentLabel} takes ${rules.valueDescription} with --type ${type}, not ${argument.text}.`);
516
- return value;
760
+ const withConfigJsonTokenMoved = async (context, signIns) => {
761
+ const configJsonToken = await readConfigJsonToken(context.invocation);
762
+ if (configJsonToken === void 0) return signIns;
763
+ const move = await configJsonTokenMoveOf(context, configJsonToken);
764
+ switch (move.outcome) {
765
+ case "moved": {
766
+ const movedSignIns = withSignIn(signIns, move.signIn);
767
+ await writeSignInsFile(context.invocation, movedSignIns);
768
+ await deleteConfigJson(configJsonToken);
769
+ return movedSignIns;
770
+ }
771
+ case "rejected":
772
+ await deleteConfigJson(configJsonToken);
773
+ context.invocation.stderr.write(`The API token saved by an earlier version in ${configJsonToken.file} was rejected (${move.code}), so the file was deleted. Run \`configdirector login\` to sign in again.
774
+ `);
775
+ return signIns;
776
+ }
517
777
  };
518
- const VARIATION_SEPARATOR = "=";
519
- const variationOf = async (type, variation, workingDirectory) => {
520
- const separatorIndex = variation.indexOf(VARIATION_SEPARATOR);
521
- const name = variation.slice(0, separatorIndex);
522
- if (separatorIndex === -1 || name === "") throw new CliError(`--variation takes name=value, not ${variation}.`);
523
- const text = variation.slice(separatorIndex + 1);
524
- return {
525
- name,
526
- value: await typedValueOf(type, {
527
- argumentLabel: `--variation ${name}`,
528
- text,
529
- workingDirectory
530
- })
531
- };
778
+ const savedSignIns = async (context) => withConfigJsonTokenMoved(context, await readSignInsFile(context.invocation));
779
+ const saveSignIn = async (context, signIn) => writeSignInsFile(context.invocation, withSignIn(await savedSignIns(context), signIn));
780
+ const removeSignIn = async (context, signIns, organization) => writeSignInsFile(context.invocation, withoutSignIn(signIns, organization));
781
+ //#endregion
782
+ //#region src/configured-api-token.ts
783
+ const describeApiTokenSource = (source) => {
784
+ switch (source.from) {
785
+ case "token-option": return "--token";
786
+ case "token-variable": return "CONFIGDIRECTOR_TOKEN";
787
+ case "sign-in": return `the CLI sign-in for ${source.signIn.organization.name}`;
788
+ }
532
789
  };
533
- const variationsOf = async (options, workingDirectory) => {
534
- const variations = [];
535
- for (const variation of options.variation ?? []) variations.push(await variationOf(options.type, variation, workingDirectory));
536
- return variations;
790
+ const refuseOrganizationWithExplicitToken = (context) => {
791
+ if (context.organizationOption !== void 0) throw new CliError("--organization and CONFIGDIRECTOR_ORGANIZATION pick a CLI sign-in, so neither can be used together with --token or CONFIGDIRECTOR_TOKEN.");
537
792
  };
538
- const newConfigOf = async (options, workingDirectory) => {
539
- rejectOptionsOfOtherTypes(options);
540
- return {
541
- key: options.key,
542
- description: options.description,
543
- role: options.role,
544
- lifetime: options.lifetime,
545
- type: options.type,
546
- typeOptions: await CONFIG_TYPE_RULES[options.type].typeOptionsOf(options, workingDirectory),
547
- ...availabilityOf(options),
548
- variations: await variationsOf(options, workingDirectory),
549
- defaultValue: await typedValueOf(options.type, {
550
- argumentLabel: "--default-value",
551
- text: options.defaultValue,
552
- workingDirectory
553
- })
554
- };
793
+ const explicitApiToken = (context) => {
794
+ if (context.tokenOption !== void 0) {
795
+ refuseOrganizationWithExplicitToken(context);
796
+ return {
797
+ token: context.tokenOption,
798
+ source: { from: "token-option" }
799
+ };
800
+ }
801
+ const tokenVariable = nonEmptyVariable(context.invocation, "CONFIGDIRECTOR_TOKEN");
802
+ if (tokenVariable !== void 0) {
803
+ refuseOrganizationWithExplicitToken(context);
804
+ return {
805
+ token: tokenVariable,
806
+ source: { from: "token-variable" }
807
+ };
808
+ }
555
809
  };
556
- //#endregion
557
- //#region src/admin-api/config-creation.ts
558
- const CreatedConfigSchema = v.object({
559
- key: v.string(),
560
- type: v.string(),
561
- role: v.string(),
562
- lifetime: v.string(),
563
- client: v.boolean(),
564
- server: v.boolean()
810
+ const signInApiToken = (signIn) => ({
811
+ token: signIn.token,
812
+ source: {
813
+ from: "sign-in",
814
+ signIn
815
+ }
565
816
  });
817
+ const chosenSignInApiToken = (signIns, organizationReference) => {
818
+ const choice = chosenSignIn(signIns, organizationReference);
819
+ switch (choice.outcome) {
820
+ case "chosen": return signInApiToken(choice.signIn);
821
+ case "no-sign-in": throw new CliError("No API token found. Set CONFIGDIRECTOR_TOKEN, pass --token, or run `configdirector login`.");
822
+ }
823
+ };
824
+ const apiTokensToSearch = async (context) => {
825
+ const explicitToken = explicitApiToken(context);
826
+ if (explicitToken !== void 0) return [explicitToken];
827
+ const signIns = await savedSignIns(context);
828
+ const organizationReference = organizationReferenceOf(context, void 0);
829
+ if (organizationReference.from === "nowhere" && signIns.length > 0) return signIns.map(signInApiToken);
830
+ return [chosenSignInApiToken(signIns, organizationReference)];
831
+ };
832
+ const configuredApiToken = async (context) => {
833
+ const explicitToken = explicitApiToken(context);
834
+ if (explicitToken !== void 0) return explicitToken;
835
+ const settings = await readProjectSettings(context.invocation.workingDirectory);
836
+ return chosenSignInApiToken(await savedSignIns(context), organizationReferenceOf(context, settings));
837
+ };
566
838
  //#endregion
567
- //#region src/admin-api/scanned-repositories.ts
568
- const scannedRepositoryFields = {
569
- name: v.string(),
570
- commit: v.pipe(v.string(), v.transform((commit) => commit === "" ? null : commit)),
571
- referenceCount: v.number(),
572
- dynamicReferenceCount: v.number()
839
+ //#region src/utc-minute.ts
840
+ const utcMinute = (time) => {
841
+ const isoTime = new Date(time).toISOString();
842
+ return `${isoTime.slice(0, 10)} ${isoTime.slice(11, 16)} UTC`;
573
843
  };
574
- const ScannedRepositorySchema = v.object(scannedRepositoryFields);
575
- const ListedScannedRepositorySchema = v.object({
576
- ...scannedRepositoryFields,
577
- scannedAt: v.pipe(v.string(), v.isoTimestamp())
578
- });
579
844
  //#endregion
580
- //#region src/admin-api/segments.ts
581
- const SegmentSchema = v.object({
582
- id: v.string(),
583
- key: v.string(),
584
- name: v.string(),
585
- usages: v.array(v.unknown())
586
- });
587
- const SegmentDefinitionSchema = v.object({
588
- key: v.string(),
589
- name: v.string(),
590
- groups: v.array(ConditionGroupSchema),
591
- overrides: v.record(v.string(), v.array(ConditionGroupSchema))
592
- });
845
+ //#region src/commands/api-token.ts
846
+ const apiTokenLines = (apiToken, source) => [
847
+ `Organization: ${apiToken.organization.name} (${apiToken.organization.id})`,
848
+ `Name: ${apiToken.name}`,
849
+ `Permissions: ${apiToken.permissions.length === 0 ? "none" : apiToken.permissions.join(", ")}`,
850
+ `Expires: ${apiToken.expiresAt === null ? "never" : utcMinute(apiToken.expiresAt)}`,
851
+ `Source: ${describeApiTokenSource(source)}`
852
+ ];
853
+ const getApiToken = async (context) => {
854
+ const { token, source } = await configuredApiToken(context);
855
+ const { parsedBody: apiToken, responseBody } = await new AdminApiClient({
856
+ baseUrl: context.baseUrl,
857
+ token
858
+ }).describeApiToken();
859
+ return {
860
+ json: responseBody,
861
+ text: [...apiTokenLines(apiToken, source), ""].join("\n"),
862
+ exitCode: ExitCode.success
863
+ };
864
+ };
593
865
  //#endregion
594
- //#region src/admin-api/targeting-rules-reference.ts
595
- const TargetingRulesReferenceSchema = v.object({
596
- notes: v.array(v.string()),
597
- conditionKinds: v.array(v.string()),
598
- conditionAttributes: v.array(v.string()),
599
- contextKindOperators: v.array(v.string()),
600
- segmentOperators: v.array(v.string()),
601
- operatorsByTargetType: v.record(v.string(), v.array(v.string()))
602
- });
866
+ //#region src/aligned-columns.ts
867
+ const COLUMN_GAP = " ";
868
+ const widestCellOf = (rows, column) => Math.max(0, ...rows.map((row) => row[column]?.length ?? 0));
869
+ const alignedColumns = (rows) => rows.map((row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widestCellOf(rows, column))).join(COLUMN_GAP));
603
870
  //#endregion
604
- //#region src/admin-api/config-evaluation.ts
605
- const CheckedOutcomeSchema = v.picklist(["matched", "not-matched"]);
606
- const AttributeConditionExplanationSchema = v.object({
607
- conditionId: v.string(),
608
- kind: v.literal("attribute"),
609
- outcome: CheckedOutcomeSchema,
610
- resolvedValue: v.optional(v.unknown()),
611
- resolvedType: v.picklist([
612
- "absent",
613
- "scalar",
614
- "array",
615
- "object",
616
- "unknown-attribute"
617
- ])
618
- });
619
- const SegmentConditionExplanationSchema = v.object({
620
- conditionId: v.string(),
621
- kind: v.literal("segment"),
622
- outcome: CheckedOutcomeSchema,
623
- segmentId: v.string(),
624
- segmentFound: v.boolean(),
625
- matchedGroupIndex: v.optional(v.number())
626
- });
627
- const ConditionExplanationSchema = v.union([
628
- v.object({
629
- conditionId: v.string(),
630
- outcome: v.literal("not-evaluated")
631
- }),
632
- AttributeConditionExplanationSchema,
633
- SegmentConditionExplanationSchema
634
- ]);
635
- const BucketSchema = v.object({
636
- defaultedToPredefinedBucket: v.boolean(),
637
- assignedPercentage: v.number(),
638
- shares: v.array(v.object({
639
- percentageId: v.string(),
640
- from: v.number(),
641
- to: v.number()
642
- })),
643
- selectedPercentageId: v.optional(v.string())
644
- });
645
- const RuleExplanationSchema = v.object({
646
- ruleId: v.string(),
647
- outcome: v.picklist([
648
- "matched",
649
- "not-matched",
650
- "not-evaluated",
651
- "errored"
652
- ]),
653
- conditions: v.array(ConditionExplanationSchema),
654
- bucket: v.optional(BucketSchema)
655
- });
656
- const ConfigEvaluationSchema = v.object({
657
- key: v.string(),
658
- environment: v.object({ name: v.string() }),
659
- context: v.nullable(v.object({
660
- kind: v.string(),
661
- identifier: v.nullable(v.string())
662
- })),
663
- value: v.optional(v.string()),
664
- servedBy: v.variant("kind", [v.object({
665
- kind: v.literal("rule"),
666
- ruleId: v.string()
667
- }), v.object({ kind: v.literal("default") })]),
668
- rules: v.array(RuleExplanationSchema),
669
- targetingRules: v.object({
670
- rules: v.array(RuleSchema),
671
- defaultValue: v.string()
672
- }),
673
- segments: v.record(v.string(), v.object({ name: v.string() }))
674
- });
871
+ //#region src/old-key-lines.ts
872
+ const oldKeyLines = (requestedKey, currentKey) => requestedKey === currentKey ? [] : [`${requestedKey} is an old key of ${currentKey}.`];
675
873
  //#endregion
676
- //#region src/admin-api/AdminApiClient.ts
677
- const ProjectSchema = v.object({
678
- id: v.string(),
679
- name: v.string(),
680
- slug: v.string(),
681
- storeCodeSnippets: v.optional(v.boolean(), true)
682
- });
683
- const configFields = {
684
- key: v.string(),
685
- deprecatedKeys: v.array(v.object({ key: v.string() })),
686
- role: v.string(),
687
- lifetime: v.string(),
688
- state: v.string(),
689
- client: v.boolean(),
690
- server: v.boolean()
874
+ //#region src/rule-sentences.ts
875
+ const TRAIT_POINTER_PREFIX = "/";
876
+ const ATTRIBUTE_LABELS = {
877
+ identifier: "context identifier",
878
+ kind: "context kind",
879
+ name: "context name",
880
+ appName: "app name",
881
+ appVersion: "app version"
691
882
  };
692
- const EnumConfigSchema = v.object({
693
- ...configFields,
694
- type: v.literal("enum"),
695
- typeOptions: v.object({
696
- valueType: v.string(),
697
- values: v.array(v.string())
698
- })
699
- });
700
- const ConfigSchema = v.variant("type", [EnumConfigSchema, v.object({
701
- ...configFields,
702
- type: v.pipe(v.string(), v.notValue("enum"))
703
- })]);
704
- const isEnumConfig = (config) => config.type === "enum";
705
- const EnvironmentSchema = v.object({
706
- id: v.string(),
707
- slug: v.string(),
708
- name: v.string(),
709
- live: v.boolean()
710
- });
711
- const ConfigActivitySchema = v.object({ latestEvaluations: v.array(v.object({
712
- environment: v.object({ slug: v.string() }),
713
- source: v.string(),
714
- sdkName: v.nullable(v.string()),
715
- sdkVersion: v.nullable(v.string()),
716
- time: v.pipe(v.string(), v.isoTimestamp())
717
- })) });
718
- const TagSchema = v.object({ id: v.string() });
719
- const tagFilterQuery = (tagFilter) => {
720
- switch (tagFilter.form) {
721
- case "every-config": return "";
722
- case "tagged": return `?${new URLSearchParams(tagFilter.tagIds.map((tagId) => ["tagIds", tagId]))}`;
723
- case "untagged": return "?untagged=true";
724
- }
883
+ const formatPercentage = (value) => Number(value.toFixed(3)).toString();
884
+ const subjectOf = (condition) => {
885
+ if (condition.attribute === "traits" && condition.trait != null) return condition.trait.startsWith(TRAIT_POINTER_PREFIX) ? condition.trait.slice(1) : condition.trait;
886
+ return ATTRIBUTE_LABELS[condition.attribute] ?? condition.attribute;
725
887
  };
726
- const MissingResourceBodySchema = v.strictObject({});
727
- const readJsonBody = async (response) => {
728
- try {
729
- return await response.json();
730
- } catch {
731
- return;
888
+ const segmentNameOf = (segmentId, segments) => segments[segmentId]?.name ?? segmentId;
889
+ const describeAttributeCondition = (condition) => `${subjectOf(condition)} ${condition.operator} ${condition.targetValues.map(String).join(", ")}`;
890
+ const describeConditionGroup = (group) => group.map(describeAttributeCondition).join(" and ");
891
+ const describeCondition = (condition, segments) => {
892
+ switch (condition.kind) {
893
+ case "attribute": return describeAttributeCondition(condition);
894
+ case "segment": return `${condition.operator} segment ${segmentNameOf(condition.segmentId, segments)}`;
732
895
  }
733
896
  };
734
- const describeStatus = (response) => `HTTP ${response.status}`;
735
- const failureFor = (url, response, body) => {
736
- switch (response.status) {
737
- case 400: {
738
- if (v.is(RepositoryLimitReachedBodySchema, body)) return new RepositoryLimitReachedError();
739
- const validationError = v.safeParse(ApiValidationErrorBodySchema, body);
740
- return validationError.success ? new ApiValidationError(validationError.output.errors) : new UnexpectedApiResponseError(url, describeStatus(response));
741
- }
742
- case 403: {
743
- const authorizationError = v.safeParse(ApiAuthorizationErrorBodySchema, body);
744
- return authorizationError.success ? new ApiAuthorizationError(authorizationError.output.code, authorizationError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
745
- }
746
- case 404: {
747
- const notFoundError = v.safeParse(EvaluationNotFoundBodySchema, body);
748
- return notFoundError.success ? new EvaluationNotFoundError(notFoundError.output.code, notFoundError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
749
- }
750
- case 429: {
751
- const retryAfterSeconds = response.headers.get("retry-after");
752
- return v.is(ServiceSuspendedBodySchema, body) && retryAfterSeconds !== null ? new ServiceSuspendedError(retryAfterSeconds) : new UnexpectedApiResponseError(url, describeStatus(response));
753
- }
754
- default: return new UnexpectedApiResponseError(url, describeStatus(response));
755
- }
897
+ const describePercentages = (percentages) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${String(percentage.value)}`).join(", ");
898
+ const describeRule = (rule, segments) => {
899
+ if (rule.type === "percentage") return describePercentages(rule.percentages);
900
+ return `${rule.conditions.map((condition) => describeCondition(condition, segments)).join(" and ")}: ${rule.target === "value" ? String(rule.value) : describePercentages(rule.percentages)}`;
756
901
  };
757
- const isMissingResource = (received) => received.response.status === 404 && v.is(MissingResourceBodySchema, received.body);
758
- const parsedResponseOf = ({ url, response, body }, schema) => {
759
- if (!response.ok) throw failureFor(url, response, body);
760
- const parsedBody = v.safeParse(schema, body);
761
- if (!parsedBody.success) throw new UnexpectedApiResponseError(url, `an ${describeStatus(response)} response the CLI cannot read`);
762
- return {
763
- responseBody: body,
764
- parsedBody: parsedBody.output
765
- };
902
+ //#endregion
903
+ //#region src/configs/config-text.ts
904
+ const boundsDescriptions = (bounds) => {
905
+ const limits = [bounds?.min, bounds?.max].flatMap((bound) => bound === void 0 ? [] : [`${bound.relation} ${bound.value}`]);
906
+ return limits.length === 0 ? [] : [limits.join(" and ")];
766
907
  };
767
- var AdminApiClient = class {
768
- constructor(connection) {
769
- this.connection = connection;
770
- }
771
- listProjects() {
772
- return this.get("/v1/projects", v.array(ProjectSchema));
773
- }
774
- async listConfigs(projectId, tagFilter) {
775
- const { parsedBody: configs, responseBody } = await this.get(`/v1/projects/${encodeURIComponent(projectId)}/configs${tagFilterQuery(tagFilter)}`, v.array(ConfigSchema));
776
- const receivedConfigs = v.parse(v.array(v.unknown()), responseBody);
777
- return configs.map((config, index) => ({
778
- config,
779
- receivedConfig: receivedConfigs[index]
780
- }));
781
- }
782
- listEnvironments(projectId) {
783
- return this.get(`/v1/projects/${encodeURIComponent(projectId)}/environments`, v.array(EnvironmentSchema));
784
- }
785
- getConfig(projectId, configKey) {
786
- return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, ConfigWithTargetingRulesSchema);
787
- }
788
- getConfigActivity(projectId, configKey) {
789
- return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/activity`, ConfigActivitySchema);
790
- }
791
- listSegments(projectId) {
792
- return this.get(`/v1/projects/${encodeURIComponent(projectId)}/segments`, v.array(SegmentSchema));
793
- }
794
- getSegment(projectId, segmentKey) {
795
- return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, SegmentDefinitionSchema);
796
- }
797
- async findTagByName(projectId, name) {
798
- return (await this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/tags/by-name?${new URLSearchParams({ name })}`, TagSchema))?.parsedBody;
799
- }
800
- createConfig(projectId, request) {
801
- return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs`, CreatedConfigSchema, {
802
- method: "POST",
803
- body: request
804
- });
805
- }
806
- async evaluateConfig(projectId, configKey, request) {
807
- const { responseBody, parsedBody } = await this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/evaluate`, ConfigEvaluationSchema, {
808
- method: "POST",
809
- body: request
810
- });
811
- return {
812
- evaluation: parsedBody,
813
- responseBody
814
- };
815
- }
816
- getTargetingRulesReference() {
817
- return this.get("/v1/targeting-rules-reference", TargetingRulesReferenceSchema);
818
- }
819
- async replaceTargetingRules(projectId, configKey, request) {
820
- return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/targets`, v.unknown(), {
821
- method: "PUT",
822
- body: request
823
- }) === void 0 ? "not-found" : "replaced";
824
- }
825
- uploadRepositoryScan(projectId, repositoryName, upload) {
826
- return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryName)}`, ScannedRepositorySchema, {
827
- method: "PUT",
828
- body: upload
829
- });
830
- }
831
- listScannedRepositories(projectId) {
832
- return this.get(`/v1/projects/${encodeURIComponent(projectId)}/repositories`, v.array(ListedScannedRepositorySchema));
833
- }
834
- async removeScannedRepository(projectId, repositoryName) {
835
- return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryName)}`, v.unknown(), { method: "DELETE" }) === void 0 ? "not-found" : "removed";
836
- }
837
- async archiveConfig(projectId, configKey) {
838
- return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, v.unknown(), { method: "DELETE" }) === void 0 ? "not-found" : "archived";
839
- }
840
- get(path, schema) {
841
- return this.requestJson(path, schema, { method: "GET" });
842
- }
843
- getIfFound(path, schema) {
844
- return this.requestIfFound(path, schema, { method: "GET" });
845
- }
846
- async requestIfFound(path, schema, request) {
847
- const received = await this.send(path, request);
848
- return isMissingResource(received) ? void 0 : parsedResponseOf(received, schema);
849
- }
850
- async requestJson(path, schema, request) {
851
- return parsedResponseOf(await this.send(path, request), schema);
852
- }
853
- async send(path, request) {
854
- const url = `${this.connection.baseUrl}${path}`;
855
- const response = await this.fetchResponse(url, request);
856
- return {
857
- url,
858
- response,
859
- body: await readJsonBody(response)
860
- };
861
- }
862
- async fetchResponse(url, request) {
863
- const headers = {
864
- Accept: "application/json",
865
- Authorization: `Bearer ${this.connection.token}`,
866
- "User-Agent": `configdirector-cli/${version}`,
867
- "X-SourceClient": "cli"
868
- };
869
- try {
870
- return await fetch(url, "body" in request ? {
871
- method: request.method,
872
- headers: {
873
- ...headers,
874
- "Content-Type": "application/json"
875
- },
876
- body: JSON.stringify(request.body)
877
- } : {
878
- method: request.method,
879
- headers
880
- });
881
- } catch (error) {
882
- throw new ApiUnreachableError(this.connection.baseUrl, error);
883
- }
908
+ const enumValuesDescription = ({ valueType, values }) => valueType === "number" ? `one of the numbers ${values.join(", ")}` : `one of ${values.join(", ")}`;
909
+ const timespanRangeDescriptions = (range) => [`${range.min} to ${range.max} ${range.unit}`, ...range.allowFractions ? ["fractions allowed"] : []];
910
+ const typeConstraintDescriptions = (config) => {
911
+ if (!hasTypeConstraints(config)) return [];
912
+ switch (config.type) {
913
+ case "integer":
914
+ case "float": return boundsDescriptions(config.typeOptions);
915
+ case "enum": return [enumValuesDescription(config.typeOptions)];
916
+ case "timespan": return timespanRangeDescriptions(config.typeOptions);
917
+ case "url": return config.typeOptions?.secureOnly ? ["HTTPS only"] : [];
918
+ case "json": return config.typeOptions?.useSchema ? ["checked against a JSON schema"] : [];
884
919
  }
885
920
  };
886
- //#endregion
887
- //#region src/saved-api-token.ts
888
- const SavedApiTokenSchema = v.object({ token: v.string() });
889
- const platformConfigDirectory = (invocation) => {
890
- switch (invocation.platform) {
891
- case "darwin": return join(invocation.homeDirectory, "Library", "Application Support");
892
- case "win32": return nonEmptyVariable(invocation, "APPDATA") ?? join(invocation.homeDirectory, "AppData", "Roaming");
893
- default: return join(invocation.homeDirectory, ".config");
894
- }
921
+ const availabilityDescription = (config) => {
922
+ if (config.client && config.server) return "client and server SDKs";
923
+ if (config.client) return "client-only";
924
+ if (config.server) return "server-only";
925
+ return "served to no SDK";
926
+ };
927
+ const settingLines = (config) => [
928
+ ...config.description ? [`Description: ${config.description}`] : [],
929
+ `Type: ${[config.type, ...typeConstraintDescriptions(config)].join(", ")}`,
930
+ `Role: ${config.role}`,
931
+ `Lifetime: ${config.lifetime}`,
932
+ `Availability: ${availabilityDescription(config)}`,
933
+ `Lifecycle state: ${config.state}`,
934
+ ...config.tags.length === 0 ? [] : [`Tags: ${config.tags.map((tag) => tag.name).join(", ")}`]
935
+ ];
936
+ const environmentHeading = (targetingRules) => targetingRules.environment.live ? `${targetingRules.environment.slug} (live)` : targetingRules.environment.slug;
937
+ const targetingRuleLines = (targetingRules, segments) => [
938
+ environmentHeading(targetingRules),
939
+ ...targetingRules.rules.map((rule, index) => ` ${index + 1}. ${describeRule(rule, segments)}`),
940
+ ` Default targeting rule: ${targetingRules.defaultValue}`
941
+ ];
942
+ const configText = (config, requestedKey, segments) => [
943
+ ...oldKeyLines(requestedKey, config.key),
944
+ config.key,
945
+ ...settingLines(config).map((line) => ` ${line}`),
946
+ ...config.targets.flatMap((targetingRules) => ["", ...targetingRuleLines(targetingRules, segments)]),
947
+ ""
948
+ ].join("\n");
949
+ //#endregion
950
+ //#region src/configs/new-config.ts
951
+ const CONFIG_TYPES = [
952
+ "boolean",
953
+ "string",
954
+ "integer",
955
+ "float",
956
+ "timespan",
957
+ "enum",
958
+ "url",
959
+ "json"
960
+ ];
961
+ const CONFIG_ROLES = [
962
+ "config",
963
+ "flag",
964
+ "kill-switch",
965
+ "experiment"
966
+ ];
967
+ const CONFIG_LIFETIMES = ["permanent", "temporary"];
968
+ const TIMESPAN_UNITS = [
969
+ "milliseconds",
970
+ "seconds",
971
+ "minutes"
972
+ ];
973
+ const ENUM_VALUE_TYPES = ["string", "number"];
974
+ const DEFAULT_ENUM_VALUE_TYPE = "string";
975
+ const TYPE_CONSTRAINT_OPTION_NAMES = {
976
+ min: "--min",
977
+ max: "--max",
978
+ exclusiveMin: "--exclusive-min",
979
+ exclusiveMax: "--exclusive-max",
980
+ unit: "--unit",
981
+ allowFractions: "--allow-fractions",
982
+ allowedValue: "--allowed-value",
983
+ enumValueType: "--enum-value-type",
984
+ httpsOnly: "--https-only",
985
+ jsonSchema: "--json-schema"
986
+ };
987
+ const availabilityOf = (options) => {
988
+ if (options.clientOnly) return {
989
+ client: true,
990
+ server: false
991
+ };
992
+ if (options.serverOnly) return {
993
+ client: false,
994
+ server: true
995
+ };
996
+ return {
997
+ client: true,
998
+ server: true
999
+ };
1000
+ };
1001
+ const BOOLEAN_VALUES = new Map([["true", true], ["false", false]]);
1002
+ const numberOf = (text) => {
1003
+ const value = parseJson(text);
1004
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
895
1005
  };
896
- const userConfigDirectory = (invocation) => nonEmptyVariable(invocation, "XDG_CONFIG_HOME") ?? platformConfigDirectory(invocation);
897
- const savedApiTokenFile = (invocation) => join(userConfigDirectory(invocation), "configdirector", "config.json");
898
- const readSavedApiToken = async (invocation) => {
899
- const file = savedApiTokenFile(invocation);
900
- const contents = await readFileIfPresent(file);
901
- if (contents === void 0) return;
902
- const savedToken = v.safeParse(SavedApiTokenSchema, parseJson(contents));
903
- if (!savedToken.success) throw new CliError(`${file} does not hold a saved API token. Run \`configdirector login\` again.`);
904
- return savedToken.output.token;
1006
+ const constraintNumberOf = (options, option) => {
1007
+ const text = options[option];
1008
+ if (text === void 0) return;
1009
+ const value = numberOf(text);
1010
+ if (value === void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES[option]} takes a number, not ${text}.`);
1011
+ return value;
905
1012
  };
906
- const saveApiToken = async (invocation, token) => {
907
- const file = savedApiTokenFile(invocation);
908
- await mkdir(dirname(file), {
909
- recursive: true,
910
- mode: 448
911
- });
912
- const handle = await open(file, "w", 384);
913
- try {
914
- await handle.chmod(384);
915
- await handle.writeFile(`${JSON.stringify({ token }, null, 2)}\n`);
916
- } finally {
917
- await handle.close();
1013
+ const boundOf = (options, option, relation) => {
1014
+ const value = constraintNumberOf(options, option);
1015
+ return value === void 0 ? void 0 : {
1016
+ relation,
1017
+ value
1018
+ };
1019
+ };
1020
+ const numericBoundsOf = (options) => ({
1021
+ min: boundOf(options, "min", ">=") ?? boundOf(options, "exclusiveMin", ">"),
1022
+ max: boundOf(options, "max", "<=") ?? boundOf(options, "exclusiveMax", "<")
1023
+ });
1024
+ const jsonTextOf = async ({ argumentLabel, text, workingDirectory }) => {
1025
+ const jsonText = (await atFileArgumentText(argumentLabel, text, workingDirectory)).trim();
1026
+ return parseJson(jsonText) === void 0 ? void 0 : jsonText;
1027
+ };
1028
+ const jsonSchemaOptionsOf = async (options, workingDirectory) => {
1029
+ if (options.jsonSchema === void 0) return;
1030
+ const { file, contents } = await readOptionFile(TYPE_CONSTRAINT_OPTION_NAMES.jsonSchema, options.jsonSchema, workingDirectory);
1031
+ const schema = parseJson(contents);
1032
+ if (schema === void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES.jsonSchema} names ${file}, which is not JSON.`);
1033
+ return {
1034
+ useSchema: true,
1035
+ schema
1036
+ };
1037
+ };
1038
+ const timespanRangeOf = async (options) => {
1039
+ if (options.unit === void 0 || options.min === void 0 || options.max === void 0) throw new CliError("--type timespan needs --unit, --min and --max.");
1040
+ return {
1041
+ unit: options.unit,
1042
+ min: constraintNumberOf(options, "min"),
1043
+ max: constraintNumberOf(options, "max"),
1044
+ allowFractions: options.allowFractions
1045
+ };
1046
+ };
1047
+ const NUMERIC_BOUND_OPTIONS = [
1048
+ "min",
1049
+ "max",
1050
+ "exclusiveMin",
1051
+ "exclusiveMax"
1052
+ ];
1053
+ const CONFIG_TYPE_RULES = {
1054
+ boolean: {
1055
+ constraintOptions: [],
1056
+ valueDescription: "true or false",
1057
+ valueOf: async ({ text }) => BOOLEAN_VALUES.get(text),
1058
+ typeOptionsOf: async () => void 0
1059
+ },
1060
+ string: {
1061
+ constraintOptions: [],
1062
+ valueDescription: "text",
1063
+ valueOf: async ({ text }) => text,
1064
+ typeOptionsOf: async () => void 0
1065
+ },
1066
+ integer: {
1067
+ constraintOptions: NUMERIC_BOUND_OPTIONS,
1068
+ valueDescription: "a number",
1069
+ valueOf: async ({ text }) => numberOf(text),
1070
+ typeOptionsOf: async (options) => ({
1071
+ isInteger: true,
1072
+ ...numericBoundsOf(options)
1073
+ })
1074
+ },
1075
+ float: {
1076
+ constraintOptions: NUMERIC_BOUND_OPTIONS,
1077
+ valueDescription: "a number",
1078
+ valueOf: async ({ text }) => numberOf(text),
1079
+ typeOptionsOf: async (options) => ({
1080
+ isInteger: false,
1081
+ ...numericBoundsOf(options)
1082
+ })
1083
+ },
1084
+ timespan: {
1085
+ constraintOptions: [
1086
+ "min",
1087
+ "max",
1088
+ "unit",
1089
+ "allowFractions"
1090
+ ],
1091
+ valueDescription: "a number",
1092
+ valueOf: async ({ text }) => numberOf(text),
1093
+ typeOptionsOf: timespanRangeOf
1094
+ },
1095
+ enum: {
1096
+ constraintOptions: ["allowedValue", "enumValueType"],
1097
+ valueDescription: "text",
1098
+ valueOf: async ({ text }) => text,
1099
+ typeOptionsOf: async (options) => ({
1100
+ values: options.allowedValue ?? [],
1101
+ valueType: options.enumValueType ?? DEFAULT_ENUM_VALUE_TYPE
1102
+ })
1103
+ },
1104
+ url: {
1105
+ constraintOptions: ["httpsOnly"],
1106
+ valueDescription: "text",
1107
+ valueOf: async ({ text }) => text,
1108
+ typeOptionsOf: async (options) => ({ secureOnly: options.httpsOnly === true })
1109
+ },
1110
+ json: {
1111
+ constraintOptions: ["jsonSchema"],
1112
+ valueDescription: "JSON or @<file>",
1113
+ valueOf: jsonTextOf,
1114
+ typeOptionsOf: jsonSchemaOptionsOf
918
1115
  }
919
- return file;
920
1116
  };
921
- //#endregion
922
- //#region src/configured-api-token.ts
923
- const configuredApiToken = async (context) => {
924
- const token = context.tokenOption ?? nonEmptyVariable(context.invocation, "CONFIGDIRECTOR_TOKEN") ?? await readSavedApiToken(context.invocation);
925
- if (!token) throw new CliError("No API token found. Set CONFIGDIRECTOR_TOKEN, pass --token, or run `configdirector login`.");
926
- return token;
1117
+ const typeAlternatives = (types) => types.length === 1 ? `${types[0]}` : `${types.slice(0, -1).join(", ")} or ${types.at(-1)}`;
1118
+ const typesTaking = (option) => CONFIG_TYPES.filter((type) => CONFIG_TYPE_RULES[type].constraintOptions.includes(option));
1119
+ const typeConstraintHelpGroup = (option) => `Options for --type ${typeAlternatives(typesTaking(option))}:`;
1120
+ const TYPE_CONSTRAINT_OPTIONS = Object.keys(TYPE_CONSTRAINT_OPTION_NAMES);
1121
+ const rejectOptionsOfOtherTypes = (options) => {
1122
+ const { constraintOptions } = CONFIG_TYPE_RULES[options.type];
1123
+ const otherTypeOption = TYPE_CONSTRAINT_OPTIONS.find((option) => options[option] !== void 0 && !constraintOptions.includes(option));
1124
+ if (otherTypeOption !== void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES[otherTypeOption]} cannot be used with --type ${options.type}. It applies to --type ${typeAlternatives(typesTaking(otherTypeOption))}.`);
927
1125
  };
928
- //#endregion
929
- //#region src/project-settings.ts
930
- const ProjectSettingsSchema = v.object({
931
- project: v.optional(v.string()),
932
- include: v.optional(v.array(v.string())),
933
- exclude: v.optional(v.array(v.string())),
934
- ignoreKeys: v.optional(v.array(v.string()))
935
- });
936
- const projectSettingsFile = (workingDirectory) => join(workingDirectory, "configdirector.json");
937
- const describeIssue = (issue) => {
938
- const path = v.getDotPath(issue);
939
- return path === null ? issue.message : `${path}: ${issue.message}`;
1126
+ const typedValueOf = async (type, argument) => {
1127
+ const rules = CONFIG_TYPE_RULES[type];
1128
+ const value = await rules.valueOf(argument);
1129
+ if (value === void 0) throw new CliError(`${argument.argumentLabel} takes ${rules.valueDescription} with --type ${type}, not ${argument.text}.`);
1130
+ return value;
940
1131
  };
941
- const readProjectSettings = async (workingDirectory) => {
942
- const file = projectSettingsFile(workingDirectory);
943
- const contents = await readFileIfPresent(file);
944
- if (contents === void 0) return;
945
- const settings = v.safeParse(ProjectSettingsSchema, parseJson(contents));
946
- if (!settings.success) throw new CliError(`${file} is not valid (${settings.issues.map(describeIssue).join("; ")}).`);
947
- return settings.output;
1132
+ const VARIATION_SEPARATOR = "=";
1133
+ const variationOf = async (type, variation, workingDirectory) => {
1134
+ const separatorIndex = variation.indexOf(VARIATION_SEPARATOR);
1135
+ const name = variation.slice(0, separatorIndex);
1136
+ if (separatorIndex === -1 || name === "") throw new CliError(`--variation takes name=value, not ${variation}.`);
1137
+ const text = variation.slice(separatorIndex + 1);
1138
+ return {
1139
+ name,
1140
+ value: await typedValueOf(type, {
1141
+ argumentLabel: `--variation ${name}`,
1142
+ text,
1143
+ workingDirectory
1144
+ })
1145
+ };
1146
+ };
1147
+ const variationsOf = async (options, workingDirectory) => {
1148
+ const variations = [];
1149
+ for (const variation of options.variation ?? []) variations.push(await variationOf(options.type, variation, workingDirectory));
1150
+ return variations;
1151
+ };
1152
+ const newConfigOf = async (options, workingDirectory) => {
1153
+ rejectOptionsOfOtherTypes(options);
1154
+ return {
1155
+ key: options.key,
1156
+ description: options.description,
1157
+ role: options.role,
1158
+ lifetime: options.lifetime,
1159
+ type: options.type,
1160
+ typeOptions: await CONFIG_TYPE_RULES[options.type].typeOptionsOf(options, workingDirectory),
1161
+ ...availabilityOf(options),
1162
+ variations: await variationsOf(options, workingDirectory),
1163
+ defaultValue: await typedValueOf(options.type, {
1164
+ argumentLabel: "--default-value",
1165
+ text: options.defaultValue,
1166
+ workingDirectory
1167
+ })
1168
+ };
948
1169
  };
949
1170
  //#endregion
950
1171
  //#region src/project-selection.ts
@@ -963,24 +1184,38 @@ const findProjectBySlug = async (client, slug) => {
963
1184
  if (project === void 0) throw new CliError(`No project in the API token's organization has the slug "${slug}".`);
964
1185
  return project;
965
1186
  };
1187
+ const projectOrganizationCheck = (project, settings) => {
1188
+ if (settings?.organization === void 0) return { verdict: "not-checked" };
1189
+ return project.organizationId === settings.organization ? { verdict: "same" } : {
1190
+ verdict: "another-organization",
1191
+ linkedOrganizationId: settings.organization
1192
+ };
1193
+ };
1194
+ const refuseProjectOfAnotherOrganization = (project, settings, apiToken) => {
1195
+ const check = projectOrganizationCheck(project, settings);
1196
+ switch (check.verdict) {
1197
+ case "not-checked":
1198
+ case "same": return;
1199
+ case "another-organization": throw new CliError(`The token from ${describeApiTokenSource(apiToken.source)} belongs to the organization ${project.organizationId}, whose project "${project.slug}" is not the one this directory links to: configdirector.json names the organization ${check.linkedOrganizationId}. Use a token of that organization, or run the command somewhere else.`);
1200
+ }
1201
+ };
966
1202
  const connectToLinkedProject = async (context) => {
967
- const projectSlug = requireLinkedProjectSlug(context, await readProjectSettings(context.invocation.workingDirectory));
1203
+ const settings = await readProjectSettings(context.invocation.workingDirectory);
1204
+ const projectSlug = requireLinkedProjectSlug(context, settings);
1205
+ const apiToken = await configuredApiToken(context);
968
1206
  const client = new AdminApiClient({
969
1207
  baseUrl: context.baseUrl,
970
- token: await configuredApiToken(context)
1208
+ token: apiToken.token
971
1209
  });
1210
+ const project = await findProjectBySlug(client, projectSlug);
1211
+ refuseProjectOfAnotherOrganization(project, settings, apiToken);
972
1212
  return {
973
1213
  client,
974
- project: await findProjectBySlug(client, projectSlug)
1214
+ project,
1215
+ settings
975
1216
  };
976
1217
  };
977
1218
  //#endregion
978
- //#region src/utc-minute.ts
979
- const utcMinute = (time) => {
980
- const isoTime = new Date(time).toISOString();
981
- return `${isoTime.slice(0, 10)} ${isoTime.slice(11, 16)} UTC`;
982
- };
983
- //#endregion
984
1219
  //#region src/commands/configs.ts
985
1220
  const LIFECYCLE_STATES = [
986
1221
  "new",
@@ -1348,24 +1583,75 @@ const evaluate = async (context, [configKey], options) => {
1348
1583
  };
1349
1584
  //#endregion
1350
1585
  //#region src/commands/init.ts
1351
- const refuseExistingFile = async (file) => {
1352
- if (await access(file).then(() => true, () => false)) throw new CliError(`${file} already exists. init never overwrites it; edit the file instead.`);
1586
+ const existingFileWithoutOrganization = (context, file, contents) => {
1587
+ const settings = projectSettingsOf(file, contents);
1588
+ if (settings.organization !== void 0) throw new CliError(`${file} already exists. init never overwrites it; edit the file instead.`);
1589
+ const projectSlug = settings.project ?? requireProjectSlug(context);
1590
+ if (context.projectSlug !== void 0 && context.projectSlug !== projectSlug) throw new CliError(`${file} links the project ${projectSlug}, but --project or CONFIGDIRECTOR_PROJECT names ${context.projectSlug}. init only adds the organization to an existing file; edit the file to link another project.`);
1591
+ const otherFields = Object.fromEntries(Object.entries(settings).filter(([name]) => name !== "project"));
1592
+ return {
1593
+ projectSlug,
1594
+ fieldsOf: (organizationId) => ({
1595
+ project: projectSlug,
1596
+ organization: organizationId,
1597
+ ...otherFields
1598
+ })
1599
+ };
1353
1600
  };
1354
- const init = async (context) => {
1355
- const settingsFile = projectSettingsFile(context.invocation.workingDirectory);
1356
- await refuseExistingFile(settingsFile);
1601
+ const newSettingsFile = (context) => {
1357
1602
  const projectSlug = requireProjectSlug(context);
1358
- const project = await findProjectBySlug(new AdminApiClient({
1603
+ return {
1604
+ projectSlug,
1605
+ fieldsOf: (organizationId) => ({
1606
+ project: projectSlug,
1607
+ organization: organizationId,
1608
+ include: ["**"],
1609
+ exclude: [],
1610
+ ignoreKeys: []
1611
+ })
1612
+ };
1613
+ };
1614
+ const settingsFileToWrite = async (context, file) => {
1615
+ const contents = await readFileIfPresent(file);
1616
+ return contents === void 0 ? newSettingsFile(context) : existingFileWithoutOrganization(context, file, contents);
1617
+ };
1618
+ const projectFoundWith = async (context, apiToken, slug) => {
1619
+ const { parsedBody: projects } = await new AdminApiClient({
1359
1620
  baseUrl: context.baseUrl,
1360
- token: await configuredApiToken(context)
1361
- }), projectSlug);
1362
- const settings = {
1363
- project: project.slug,
1364
- include: ["**"],
1365
- exclude: [],
1366
- ignoreKeys: []
1621
+ token: apiToken.token
1622
+ }).listProjects();
1623
+ const project = projects.find((listedProject) => listedProject.slug === slug);
1624
+ return project === void 0 ? void 0 : {
1625
+ apiToken,
1626
+ project
1367
1627
  };
1368
- await writeFile(settingsFile, `${JSON.stringify(settings, null, 2)}\n`, { flag: "wx" });
1628
+ };
1629
+ const describeOrganizationOf = ({ apiToken, project }) => apiToken.source.from === "sign-in" ? apiToken.source.signIn.organization.name : project.organizationId;
1630
+ const signInsSearched = (apiTokens) => apiTokens.flatMap((apiToken) => apiToken.source.from === "sign-in" ? [apiToken.source.signIn] : []);
1631
+ const onlyProjectFound = (slug, apiTokens, projectsFound) => {
1632
+ const [firstProjectFound] = projectsFound;
1633
+ if (projectsFound.length > 1) throw new CliError(`The organizations ${projectsFound.map(describeOrganizationOf).join(" and ")} both have a project with the slug "${slug}". Name one with --organization.`);
1634
+ if (firstProjectFound === void 0) {
1635
+ const signIns = signInsSearched(apiTokens);
1636
+ throw new CliError(signIns.length === 0 ? `No project in the API token's organization has the slug "${slug}".` : `No project has the slug "${slug}" in the organizations signed in to: ${describeSignIns(signIns)}.`);
1637
+ }
1638
+ return firstProjectFound;
1639
+ };
1640
+ const findProjectAcrossOrganizations = async (context, slug) => {
1641
+ const apiTokens = await apiTokensToSearch(context);
1642
+ const projectsFound = [];
1643
+ for (const apiToken of apiTokens) {
1644
+ const projectFound = await projectFoundWith(context, apiToken, slug);
1645
+ if (projectFound !== void 0) projectsFound.push(projectFound);
1646
+ }
1647
+ return onlyProjectFound(slug, apiTokens, projectsFound);
1648
+ };
1649
+ const init = async (context) => {
1650
+ const settingsFile = projectSettingsFile(context.invocation.workingDirectory);
1651
+ const fileToWrite = await settingsFileToWrite(context, settingsFile);
1652
+ const projectFound = await findProjectAcrossOrganizations(context, fileToWrite.projectSlug);
1653
+ const { project } = projectFound;
1654
+ await writeFile(settingsFile, `${JSON.stringify(fileToWrite.fieldsOf(project.organizationId), null, 2)}\n`);
1369
1655
  return {
1370
1656
  json: {
1371
1657
  settingsFile,
@@ -1373,9 +1659,10 @@ const init = async (context) => {
1373
1659
  id: project.id,
1374
1660
  slug: project.slug,
1375
1661
  name: project.name
1376
- }
1662
+ },
1663
+ organizationId: project.organizationId
1377
1664
  },
1378
- text: `Linked this directory to the project ${project.name} (${project.slug}) in ${settingsFile}\n`,
1665
+ text: `Linked this directory to the project ${project.name} (${project.slug}) of the organization ${describeOrganizationOf(projectFound)} in ${settingsFile}\n`,
1379
1666
  exitCode: ExitCode.success
1380
1667
  };
1381
1668
  };
@@ -1436,16 +1723,42 @@ const readPastedApiToken = async (invocation) => {
1436
1723
  };
1437
1724
  //#endregion
1438
1725
  //#region src/commands/login.ts
1726
+ const PERMISSION_TO_FIND_PROJECTS = "projects:read";
1439
1727
  const login = async (context) => {
1728
+ if (context.organizationOption !== void 0) throw new CliError("login saves the sign-in for the token's own organization, so it takes no --organization or CONFIGDIRECTOR_ORGANIZATION.");
1440
1729
  const token = context.tokenOption ?? await readPastedApiToken(context.invocation);
1441
- await new AdminApiClient({
1730
+ const { parsedBody: apiToken } = await new AdminApiClient({
1442
1731
  baseUrl: context.baseUrl,
1443
1732
  token
1444
- }).listProjects();
1445
- const file = await saveApiToken(context.invocation, token);
1733
+ }).describeApiToken();
1734
+ if (!apiToken.permissions.includes(PERMISSION_TO_FIND_PROJECTS)) throw new CliError(`The API token lacks the ${PERMISSION_TO_FIND_PROJECTS} permission, which the CLI needs to find a project. Create a token with it on the dashboard's API tokens page.`);
1735
+ const file = await saveSignIn(context, {
1736
+ organization: apiToken.organization,
1737
+ token
1738
+ });
1739
+ return {
1740
+ json: {
1741
+ signInsFile: file,
1742
+ organization: apiToken.organization
1743
+ },
1744
+ text: `Signed in to ${apiToken.organization.name}. The CLI sign-in is saved in ${file}\n`,
1745
+ exitCode: ExitCode.success
1746
+ };
1747
+ };
1748
+ //#endregion
1749
+ //#region src/commands/logout.ts
1750
+ const logout = async (context) => {
1751
+ if (context.tokenOption !== void 0) throw new CliError("logout removes a CLI sign-in and sends no token, so it takes no --token.");
1752
+ const settings = await readProjectSettings(context.invocation.workingDirectory);
1753
+ const signIns = await savedSignIns(context);
1754
+ const choice = chosenSignIn(signIns, organizationReferenceOf(context, settings));
1755
+ if (choice.outcome === "no-sign-in") throw new CliError("There is no CLI sign-in on this machine to remove; `configdirector login` adds one.");
1756
+ const { signIn } = choice;
1757
+ await removeSignIn(context, signIns, signIn.organization);
1446
1758
  return {
1447
- json: { tokenFile: file },
1448
- text: `Logged in. The API token is saved in ${file}\n`,
1759
+ json: { removedSignIn: { organization: signIn.organization } },
1760
+ text: `Signed out of ${signIn.organization.name}. The API token keeps working until it is revoked on the dashboard's API tokens page.
1761
+ `,
1449
1762
  exitCode: ExitCode.success
1450
1763
  };
1451
1764
  };
@@ -1455,7 +1768,7 @@ const projectLines = (projects) => projects.length === 0 ? ["No projects found."
1455
1768
  const listProjects = async (context) => {
1456
1769
  const { parsedBody: projects, responseBody } = await new AdminApiClient({
1457
1770
  baseUrl: context.baseUrl,
1458
- token: await configuredApiToken(context)
1771
+ token: (await configuredApiToken(context)).token
1459
1772
  }).listProjects();
1460
1773
  return {
1461
1774
  json: responseBody,
@@ -3879,13 +4192,7 @@ const scanCodeReferences = async (scope, projectKeys, snippetCut) => {
3879
4192
  //#region src/project-scan.ts
3880
4193
  const connectToScannableProject = async (context) => {
3881
4194
  const { workingDirectory } = context.invocation;
3882
- const settings = await readProjectSettings(workingDirectory);
3883
- const projectSlug = requireLinkedProjectSlug(context, settings);
3884
- const client = new AdminApiClient({
3885
- baseUrl: context.baseUrl,
3886
- token: await configuredApiToken(context)
3887
- });
3888
- const project = await findProjectBySlug(client, projectSlug);
4195
+ const { client, project, settings } = await connectToLinkedProject(context);
3889
4196
  const configs = (await client.listConfigs(project.id, { form: "every-config" })).map((listedConfig) => listedConfig.config);
3890
4197
  const projectKeys = new Set(configs.flatMap((config) => [config.key, ...config.deprecatedKeys.map((deprecatedKey) => deprecatedKey.key)]));
3891
4198
  return {
@@ -4258,7 +4565,7 @@ const setTargetingRules = async (context, [configKey], options) => {
4258
4565
  const showTargetingRulesReference = async (context) => {
4259
4566
  const { parsedBody: reference, responseBody } = await new AdminApiClient({
4260
4567
  baseUrl: context.baseUrl,
4261
- token: await configuredApiToken(context)
4568
+ token: (await configuredApiToken(context)).token
4262
4569
  }).getTargetingRulesReference();
4263
4570
  return {
4264
4571
  json: responseBody,
@@ -4490,7 +4797,8 @@ const runCommand = async (invocation, command, actionArguments) => {
4490
4797
  invocation,
4491
4798
  baseUrl: withoutTrailingSlashes(options.baseUrl ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_BASE_URL") ?? DEFAULT_BASE_URL),
4492
4799
  tokenOption: options.token,
4493
- projectSlug: options.project ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_PROJECT")
4800
+ projectSlug: options.project ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_PROJECT"),
4801
+ organizationOption: options.organization ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_ORGANIZATION")
4494
4802
  }, parsedCommand.processedArgs, parsedCommand.opts());
4495
4803
  invocation.stdout.write(options.json ? `${JSON.stringify(output.json, null, 2)}\n` : output.text);
4496
4804
  return output.exitCode;
@@ -4509,15 +4817,17 @@ const run = async (invocation) => {
4509
4817
  const commandAction = (command) => async (...actionArguments) => {
4510
4818
  exitCode = await runCommand(invocation, command, actionArguments);
4511
4819
  };
4512
- const program = new Command("configdirector").description("Work with ConfigDirector configs from the terminal and CI.").addHelpText("afterAll", "\nBeta: commands and output may change before 1.0.").configureHelp({ showGlobalOptions: true }).version(version).option("--token <token>", "API token, used instead of CONFIGDIRECTOR_TOKEN and the token saved by login").option("--base-url <url>", `Admin API host, used instead of CONFIGDIRECTOR_BASE_URL (default ${DEFAULT_BASE_URL})`).option("--project <slug>", "project slug, used instead of CONFIGDIRECTOR_PROJECT").option("--json", "print JSON on stdout").exitOverride().configureOutput({
4820
+ const program = new Command("configdirector").description("Work with ConfigDirector configs from the terminal and CI.").addHelpText("afterAll", "\nBeta: commands and output may change before 1.0.").configureHelp({ showGlobalOptions: true }).version(version).option("--token <token>", "API token, used instead of CONFIGDIRECTOR_TOKEN and the CLI sign-ins saved by login").option("--base-url <url>", `Admin API host, used instead of CONFIGDIRECTOR_BASE_URL (default ${DEFAULT_BASE_URL})`).option("--project <slug>", "project slug, used instead of CONFIGDIRECTOR_PROJECT").option("--organization <name or id>", "organization whose CLI sign-in to use, by its saved name or its id, used instead of CONFIGDIRECTOR_ORGANIZATION and `organization` in configdirector.json").option("--json", "print JSON on stdout").exitOverride().configureOutput({
4513
4821
  writeOut: (text) => invocation.stdout.write(text),
4514
4822
  writeErr: (text) => invocation.stderr.write(text)
4515
4823
  });
4516
- program.command("login").description("save an API token for later runs, after checking it can list projects").action(commandAction(login));
4824
+ program.command("login").description("save an API token as the CLI sign-in for its organization, after checking it can find projects").action(commandAction(login));
4825
+ program.command("logout").description("remove the CLI sign-in this directory's commands would use; the token itself keeps working").action(commandAction(logout));
4517
4826
  program.command("init").description("write configdirector.json for a project in the current directory").action(commandAction(init));
4518
4827
  program.command("scan").argument("[path]", "directory or file to scan (default: the current directory)").description("list where the code reads config keys through a ConfigDirector SDK").option("--upload", "send the code references to the project, replacing those it holds for this repository").option("--repository <name>", "the repository name to upload under (default: the path of the git remote origin, such as ConfigDirector/cli)").addOption(new Option("--context-lines <number>", `how many lines of code to include before and after each reference, from 0 to 5 (default: 2)`).conflicts("snippets")).option("--no-snippets", "leave out the lines of code around each reference").action(commandAction(scan));
4519
4828
  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));
4520
4829
  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));
4830
+ 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));
4521
4831
  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));
4522
4832
  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));
4523
4833
  const configsCommand = program.command("configs").description("list, show, create and archive a project's configs");