@configdirector/cli 0.8.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.8.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,96 +225,350 @@ 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) => {
312
- const file = resolve(workingDirectory, path);
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) => {
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.`);
315
574
  return {
@@ -333,617 +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
- });
682
- const configFields = {
683
- key: v.string(),
684
- deprecatedKeys: v.array(v.object({ key: v.string() })),
685
- role: v.string(),
686
- lifetime: v.string(),
687
- state: v.string(),
688
- client: v.boolean(),
689
- 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"
690
882
  };
691
- const EnumConfigSchema = v.object({
692
- ...configFields,
693
- type: v.literal("enum"),
694
- typeOptions: v.object({
695
- valueType: v.string(),
696
- values: v.array(v.string())
697
- })
698
- });
699
- const ConfigSchema = v.variant("type", [EnumConfigSchema, v.object({
700
- ...configFields,
701
- type: v.pipe(v.string(), v.notValue("enum"))
702
- })]);
703
- const isEnumConfig = (config) => config.type === "enum";
704
- const EnvironmentSchema = v.object({
705
- id: v.string(),
706
- slug: v.string(),
707
- name: v.string(),
708
- live: v.boolean()
709
- });
710
- const ConfigActivitySchema = v.object({ latestEvaluations: v.array(v.object({
711
- environment: v.object({ slug: v.string() }),
712
- source: v.string(),
713
- sdkName: v.nullable(v.string()),
714
- sdkVersion: v.nullable(v.string()),
715
- time: v.pipe(v.string(), v.isoTimestamp())
716
- })) });
717
- const TagSchema = v.object({ id: v.string() });
718
- const tagFilterQuery = (tagFilter) => {
719
- switch (tagFilter.form) {
720
- case "every-config": return "";
721
- case "tagged": return `?${new URLSearchParams(tagFilter.tagIds.map((tagId) => ["tagIds", tagId]))}`;
722
- case "untagged": return "?untagged=true";
723
- }
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;
724
887
  };
725
- const MissingResourceBodySchema = v.strictObject({});
726
- const readJsonBody = async (response) => {
727
- try {
728
- return await response.json();
729
- } catch {
730
- 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)}`;
731
895
  }
732
896
  };
733
- const describeStatus = (response) => `HTTP ${response.status}`;
734
- const failureFor = (url, response, body) => {
735
- switch (response.status) {
736
- case 400: {
737
- if (v.is(RepositoryLimitReachedBodySchema, body)) return new RepositoryLimitReachedError();
738
- const validationError = v.safeParse(ApiValidationErrorBodySchema, body);
739
- return validationError.success ? new ApiValidationError(validationError.output.errors) : new UnexpectedApiResponseError(url, describeStatus(response));
740
- }
741
- case 403: {
742
- const authorizationError = v.safeParse(ApiAuthorizationErrorBodySchema, body);
743
- return authorizationError.success ? new ApiAuthorizationError(authorizationError.output.code, authorizationError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
744
- }
745
- case 404: {
746
- const notFoundError = v.safeParse(EvaluationNotFoundBodySchema, body);
747
- return notFoundError.success ? new EvaluationNotFoundError(notFoundError.output.code, notFoundError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
748
- }
749
- case 429: {
750
- const retryAfterSeconds = response.headers.get("retry-after");
751
- return v.is(ServiceSuspendedBodySchema, body) && retryAfterSeconds !== null ? new ServiceSuspendedError(retryAfterSeconds) : new UnexpectedApiResponseError(url, describeStatus(response));
752
- }
753
- default: return new UnexpectedApiResponseError(url, describeStatus(response));
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)}`;
901
+ };
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 ")];
907
+ };
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"] : [];
754
919
  }
755
920
  };
756
- const isMissingResource = (received) => received.response.status === 404 && v.is(MissingResourceBodySchema, received.body);
757
- const parsedResponseOf = ({ url, response, body }, schema) => {
758
- if (!response.ok) throw failureFor(url, response, body);
759
- const parsedBody = v.safeParse(schema, body);
760
- if (!parsedBody.success) throw new UnexpectedApiResponseError(url, `an ${describeStatus(response)} response the CLI cannot read`);
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
+ };
761
996
  return {
762
- responseBody: body,
763
- parsedBody: parsedBody.output
997
+ client: true,
998
+ server: true
764
999
  };
765
1000
  };
766
- var AdminApiClient = class {
767
- constructor(connection) {
768
- this.connection = connection;
769
- }
770
- listProjects() {
771
- return this.get("/v1/projects", v.array(ProjectSchema));
772
- }
773
- async listConfigs(projectId, tagFilter) {
774
- const { parsedBody: configs, responseBody } = await this.get(`/v1/projects/${encodeURIComponent(projectId)}/configs${tagFilterQuery(tagFilter)}`, v.array(ConfigSchema));
775
- const receivedConfigs = v.parse(v.array(v.unknown()), responseBody);
776
- return configs.map((config, index) => ({
777
- config,
778
- receivedConfig: receivedConfigs[index]
779
- }));
780
- }
781
- listEnvironments(projectId) {
782
- return this.get(`/v1/projects/${encodeURIComponent(projectId)}/environments`, v.array(EnvironmentSchema));
783
- }
784
- getConfig(projectId, configKey) {
785
- return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, ConfigWithTargetingRulesSchema);
786
- }
787
- getConfigActivity(projectId, configKey) {
788
- return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/activity`, ConfigActivitySchema);
789
- }
790
- listSegments(projectId) {
791
- return this.get(`/v1/projects/${encodeURIComponent(projectId)}/segments`, v.array(SegmentSchema));
792
- }
793
- getSegment(projectId, segmentKey) {
794
- return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, SegmentDefinitionSchema);
795
- }
796
- async findTagByName(projectId, name) {
797
- return (await this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/tags/by-name?${new URLSearchParams({ name })}`, TagSchema))?.parsedBody;
798
- }
799
- createConfig(projectId, request) {
800
- return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs`, CreatedConfigSchema, {
801
- method: "POST",
802
- body: request
803
- });
804
- }
805
- async evaluateConfig(projectId, configKey, request) {
806
- const { responseBody, parsedBody } = await this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/evaluate`, ConfigEvaluationSchema, {
807
- method: "POST",
808
- body: request
809
- });
810
- return {
811
- evaluation: parsedBody,
812
- responseBody
813
- };
814
- }
815
- getTargetingRulesReference() {
816
- return this.get("/v1/targeting-rules-reference", TargetingRulesReferenceSchema);
817
- }
818
- async replaceTargetingRules(projectId, configKey, request) {
819
- return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/targets`, v.unknown(), {
820
- method: "PUT",
821
- body: request
822
- }) === void 0 ? "not-found" : "replaced";
823
- }
824
- uploadRepositoryScan(projectId, repositoryName, upload) {
825
- return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryName)}`, ScannedRepositorySchema, {
826
- method: "PUT",
827
- body: upload
828
- });
829
- }
830
- listScannedRepositories(projectId) {
831
- return this.get(`/v1/projects/${encodeURIComponent(projectId)}/repositories`, v.array(ListedScannedRepositorySchema));
832
- }
833
- async removeScannedRepository(projectId, repositoryName) {
834
- return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryName)}`, v.unknown(), { method: "DELETE" }) === void 0 ? "not-found" : "removed";
835
- }
836
- async archiveConfig(projectId, configKey) {
837
- return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, v.unknown(), { method: "DELETE" }) === void 0 ? "not-found" : "archived";
838
- }
839
- get(path, schema) {
840
- return this.requestJson(path, schema, { method: "GET" });
841
- }
842
- getIfFound(path, schema) {
843
- return this.requestIfFound(path, schema, { method: "GET" });
844
- }
845
- async requestIfFound(path, schema, request) {
846
- const received = await this.send(path, request);
847
- return isMissingResource(received) ? void 0 : parsedResponseOf(received, schema);
848
- }
849
- async requestJson(path, schema, request) {
850
- return parsedResponseOf(await this.send(path, request), schema);
851
- }
852
- async send(path, request) {
853
- const url = `${this.connection.baseUrl}${path}`;
854
- const response = await this.fetchResponse(url, request);
855
- return {
856
- url,
857
- response,
858
- body: await readJsonBody(response)
859
- };
860
- }
861
- async fetchResponse(url, request) {
862
- const headers = {
863
- Accept: "application/json",
864
- Authorization: `Bearer ${this.connection.token}`,
865
- "User-Agent": `configdirector-cli/${version}`,
866
- "X-SourceClient": "cli"
867
- };
868
- try {
869
- return await fetch(url, "body" in request ? {
870
- method: request.method,
871
- headers: {
872
- ...headers,
873
- "Content-Type": "application/json"
874
- },
875
- body: JSON.stringify(request.body)
876
- } : {
877
- method: request.method,
878
- headers
879
- });
880
- } catch (error) {
881
- throw new ApiUnreachableError(this.connection.baseUrl, error);
882
- }
883
- }
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;
1005
+ };
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;
1012
+ };
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;
884
1027
  };
885
- //#endregion
886
- //#region src/saved-api-token.ts
887
- const SavedApiTokenSchema = v.object({ token: v.string() });
888
- const platformConfigDirectory = (invocation) => {
889
- switch (invocation.platform) {
890
- case "darwin": return join(invocation.homeDirectory, "Library", "Application Support");
891
- case "win32": return nonEmptyVariable(invocation, "APPDATA") ?? join(invocation.homeDirectory, "AppData", "Roaming");
892
- default: return join(invocation.homeDirectory, ".config");
893
- }
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
+ };
894
1037
  };
895
- const userConfigDirectory = (invocation) => nonEmptyVariable(invocation, "XDG_CONFIG_HOME") ?? platformConfigDirectory(invocation);
896
- const savedApiTokenFile = (invocation) => join(userConfigDirectory(invocation), "configdirector", "config.json");
897
- const readSavedApiToken = async (invocation) => {
898
- const file = savedApiTokenFile(invocation);
899
- const contents = await readFileIfPresent(file);
900
- if (contents === void 0) return;
901
- const savedToken = v.safeParse(SavedApiTokenSchema, parseJson(contents));
902
- if (!savedToken.success) throw new CliError(`${file} does not hold a saved API token. Run \`configdirector login\` again.`);
903
- return savedToken.output.token;
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
+ };
904
1046
  };
905
- const saveApiToken = async (invocation, token) => {
906
- const file = savedApiTokenFile(invocation);
907
- await mkdir(dirname(file), {
908
- recursive: true,
909
- mode: 448
910
- });
911
- const handle = await open(file, "w", 384);
912
- try {
913
- await handle.chmod(384);
914
- await handle.writeFile(`${JSON.stringify({ token }, null, 2)}\n`);
915
- } finally {
916
- await handle.close();
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
917
1115
  }
918
- return file;
919
1116
  };
920
- //#endregion
921
- //#region src/configured-api-token.ts
922
- const configuredApiToken = async (context) => {
923
- const token = context.tokenOption ?? nonEmptyVariable(context.invocation, "CONFIGDIRECTOR_TOKEN") ?? await readSavedApiToken(context.invocation);
924
- if (!token) throw new CliError("No API token found. Set CONFIGDIRECTOR_TOKEN, pass --token, or run `configdirector login`.");
925
- 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))}.`);
926
1125
  };
927
- //#endregion
928
- //#region src/project-settings.ts
929
- const ProjectSettingsSchema = v.object({
930
- project: v.optional(v.string()),
931
- include: v.optional(v.array(v.string())),
932
- exclude: v.optional(v.array(v.string())),
933
- ignoreKeys: v.optional(v.array(v.string()))
934
- });
935
- const projectSettingsFile = (workingDirectory) => join(workingDirectory, "configdirector.json");
936
- const describeIssue = (issue) => {
937
- const path = v.getDotPath(issue);
938
- 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;
939
1131
  };
940
- const readProjectSettings = async (workingDirectory) => {
941
- const file = projectSettingsFile(workingDirectory);
942
- const contents = await readFileIfPresent(file);
943
- if (contents === void 0) return;
944
- const settings = v.safeParse(ProjectSettingsSchema, parseJson(contents));
945
- if (!settings.success) throw new CliError(`${file} is not valid (${settings.issues.map(describeIssue).join("; ")}).`);
946
- 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
+ };
947
1169
  };
948
1170
  //#endregion
949
1171
  //#region src/project-selection.ts
@@ -962,24 +1184,38 @@ const findProjectBySlug = async (client, slug) => {
962
1184
  if (project === void 0) throw new CliError(`No project in the API token's organization has the slug "${slug}".`);
963
1185
  return project;
964
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
+ };
965
1202
  const connectToLinkedProject = async (context) => {
966
- 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);
967
1206
  const client = new AdminApiClient({
968
1207
  baseUrl: context.baseUrl,
969
- token: await configuredApiToken(context)
1208
+ token: apiToken.token
970
1209
  });
1210
+ const project = await findProjectBySlug(client, projectSlug);
1211
+ refuseProjectOfAnotherOrganization(project, settings, apiToken);
971
1212
  return {
972
1213
  client,
973
- project: await findProjectBySlug(client, projectSlug)
1214
+ project,
1215
+ settings
974
1216
  };
975
1217
  };
976
1218
  //#endregion
977
- //#region src/utc-minute.ts
978
- const utcMinute = (time) => {
979
- const isoTime = new Date(time).toISOString();
980
- return `${isoTime.slice(0, 10)} ${isoTime.slice(11, 16)} UTC`;
981
- };
982
- //#endregion
983
1219
  //#region src/commands/configs.ts
984
1220
  const LIFECYCLE_STATES = [
985
1221
  "new",
@@ -1347,24 +1583,75 @@ const evaluate = async (context, [configKey], options) => {
1347
1583
  };
1348
1584
  //#endregion
1349
1585
  //#region src/commands/init.ts
1350
- const refuseExistingFile = async (file) => {
1351
- 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
+ };
1352
1600
  };
1353
- const init = async (context) => {
1354
- const settingsFile = projectSettingsFile(context.invocation.workingDirectory);
1355
- await refuseExistingFile(settingsFile);
1601
+ const newSettingsFile = (context) => {
1356
1602
  const projectSlug = requireProjectSlug(context);
1357
- 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({
1358
1620
  baseUrl: context.baseUrl,
1359
- token: await configuredApiToken(context)
1360
- }), projectSlug);
1361
- const settings = {
1362
- project: project.slug,
1363
- include: ["**"],
1364
- exclude: [],
1365
- 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
1366
1627
  };
1367
- 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`);
1368
1655
  return {
1369
1656
  json: {
1370
1657
  settingsFile,
@@ -1372,9 +1659,10 @@ const init = async (context) => {
1372
1659
  id: project.id,
1373
1660
  slug: project.slug,
1374
1661
  name: project.name
1375
- }
1662
+ },
1663
+ organizationId: project.organizationId
1376
1664
  },
1377
- 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`,
1378
1666
  exitCode: ExitCode.success
1379
1667
  };
1380
1668
  };
@@ -1435,16 +1723,42 @@ const readPastedApiToken = async (invocation) => {
1435
1723
  };
1436
1724
  //#endregion
1437
1725
  //#region src/commands/login.ts
1726
+ const PERMISSION_TO_FIND_PROJECTS = "projects:read";
1438
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.");
1439
1729
  const token = context.tokenOption ?? await readPastedApiToken(context.invocation);
1440
- await new AdminApiClient({
1730
+ const { parsedBody: apiToken } = await new AdminApiClient({
1441
1731
  baseUrl: context.baseUrl,
1442
1732
  token
1443
- }).listProjects();
1444
- 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);
1445
1758
  return {
1446
- json: { tokenFile: file },
1447
- 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
+ `,
1448
1762
  exitCode: ExitCode.success
1449
1763
  };
1450
1764
  };
@@ -1454,7 +1768,7 @@ const projectLines = (projects) => projects.length === 0 ? ["No projects found."
1454
1768
  const listProjects = async (context) => {
1455
1769
  const { parsedBody: projects, responseBody } = await new AdminApiClient({
1456
1770
  baseUrl: context.baseUrl,
1457
- token: await configuredApiToken(context)
1771
+ token: (await configuredApiToken(context)).token
1458
1772
  }).listProjects();
1459
1773
  return {
1460
1774
  json: responseBody,
@@ -1530,6 +1844,25 @@ const languageOfExtension = {
1530
1844
  ".go": "go"
1531
1845
  };
1532
1846
  const isSourceExtension = (extension) => Object.hasOwn(languageOfExtension, extension);
1847
+ const snippetLanguageOfExtension = {
1848
+ ".js": "javascript",
1849
+ ".jsx": "jsx",
1850
+ ".mjs": "javascript",
1851
+ ".cjs": "javascript",
1852
+ ".ts": "typescript",
1853
+ ".tsx": "tsx",
1854
+ ".mts": "typescript",
1855
+ ".cts": "typescript",
1856
+ ".vue": "vue",
1857
+ ".svelte": "svelte",
1858
+ ".dart": "dart",
1859
+ ".swift": "swift",
1860
+ ".kt": "kotlin",
1861
+ ".java": "java",
1862
+ ".py": "python",
1863
+ ".cs": "csharp",
1864
+ ".go": "go"
1865
+ };
1533
1866
  const languageSyntax = {
1534
1867
  javascript: {
1535
1868
  lineComment: "//",
@@ -3587,6 +3920,43 @@ const sdkCallMatches = (code, callShapes) => {
3587
3920
  return matches;
3588
3921
  };
3589
3922
  //#endregion
3923
+ //#region src/scan/snippets.ts
3924
+ const MAXIMUM_LINE_LENGTH = 250;
3925
+ const linesOf = (contents) => {
3926
+ const lines = contents.split("\n");
3927
+ return lines.at(-1) === "" ? lines.slice(0, -1) : lines;
3928
+ };
3929
+ const withoutCarriageReturn = (line) => line.endsWith("\r") ? line.slice(0, -1) : line;
3930
+ const snippetOfLines = (fileLines, line, contextLines, language) => {
3931
+ const firstLine = Math.max(1, line - contextLines);
3932
+ const lastLine = Math.min(fileLines.length, line + contextLines);
3933
+ const lines = [];
3934
+ const truncatedLines = [];
3935
+ for (let lineNumber = firstLine; lineNumber <= lastLine; lineNumber += 1) {
3936
+ const text = withoutCarriageReturn(fileLines[lineNumber - 1]);
3937
+ if (text.length > MAXIMUM_LINE_LENGTH) truncatedLines.push(lineNumber);
3938
+ lines.push(text.slice(0, MAXIMUM_LINE_LENGTH));
3939
+ }
3940
+ return {
3941
+ firstLine,
3942
+ lines,
3943
+ language,
3944
+ truncatedLines
3945
+ };
3946
+ };
3947
+ const snippetCutterFor = (file, cut) => {
3948
+ switch (cut.kind) {
3949
+ case "no-snippets": return () => void 0;
3950
+ case "around-each-reference": {
3951
+ let fileLines;
3952
+ return (line) => {
3953
+ fileLines ??= linesOf(file.contents);
3954
+ return snippetOfLines(fileLines, line, cut.contextLines, snippetLanguageOfExtension[file.extension]);
3955
+ };
3956
+ }
3957
+ }
3958
+ };
3959
+ //#endregion
3590
3960
  //#region src/scan/test-files.ts
3591
3961
  const TEST_DIRECTORY_NAMES = new Set([
3592
3962
  "test",
@@ -3644,12 +4014,13 @@ const matchesIn = (file, code, callShapes, projectKeys, constantNameOfToken) =>
3644
4014
  };
3645
4015
  const matchOutcomes = (matches, constantIndex) => {
3646
4016
  const constantsReadByCalls = new Set(matches.flatMap((match) => match.kind === "named" && constantIndex.keyOf(match.name) !== void 0 ? [match.name] : []));
3647
- return matches.map(({ file, ...match }) => {
4017
+ return matches.map(({ file, snippet, ...match }) => {
3648
4018
  switch (match.kind) {
3649
4019
  case "direct": return {
3650
4020
  file,
3651
4021
  kind: "direct",
3652
- reference: match.reference
4022
+ reference: match.reference,
4023
+ snippet
3653
4024
  };
3654
4025
  case "dynamic": return {
3655
4026
  file,
@@ -3668,7 +4039,8 @@ const matchOutcomes = (matches, constantIndex) => {
3668
4039
  reference: {
3669
4040
  ...match.reference,
3670
4041
  key
3671
- }
4042
+ },
4043
+ snippet
3672
4044
  };
3673
4045
  }
3674
4046
  case "indirect": return match.constantName !== void 0 && constantsReadByCalls.has(match.constantName) ? {
@@ -3677,7 +4049,8 @@ const matchOutcomes = (matches, constantIndex) => {
3677
4049
  } : {
3678
4050
  file,
3679
4051
  kind: "indirect",
3680
- reference: match.reference
4052
+ reference: match.reference,
4053
+ snippet
3681
4054
  };
3682
4055
  }
3683
4056
  });
@@ -3762,7 +4135,7 @@ const sdkFamiliesUsedIn = (highestDirectory) => {
3762
4135
  return usedFamilies;
3763
4136
  };
3764
4137
  };
3765
- const scanCodeReferences = async (scope, projectKeys) => {
4138
+ const scanCodeReferences = async (scope, projectKeys, snippetCut) => {
3766
4139
  const scan = {
3767
4140
  filesScanned: 0,
3768
4141
  references: [],
@@ -3781,9 +4154,11 @@ const scanCodeReferences = async (scope, projectKeys) => {
3781
4154
  const constantAssignments = constantAssignmentsIn(code);
3782
4155
  for (const assignment of constantAssignments) constantIndex.add(assignment);
3783
4156
  const constantNameOfToken = new Map(constantAssignments.map((assignment) => [assignment.tokenIndex, assignment.name]));
4157
+ const snippetAround = snippetCutterFor(file, snippetCut);
3784
4158
  matches.push(...matchesIn(file, code, callShapes, projectKeys, constantNameOfToken).map((match) => ({
3785
4159
  ...match,
3786
- file: file.displayPath
4160
+ file: file.displayPath,
4161
+ snippet: snippetAround(match.reference.line)
3787
4162
  })));
3788
4163
  }
3789
4164
  for (const outcome of matchOutcomes(matches, constantIndex)) switch (outcome.kind) {
@@ -3791,14 +4166,16 @@ const scanCodeReferences = async (scope, projectKeys) => {
3791
4166
  scan.references.push({
3792
4167
  ...outcome.reference,
3793
4168
  kind: "direct",
3794
- file: outcome.file
4169
+ file: outcome.file,
4170
+ snippet: outcome.snippet
3795
4171
  });
3796
4172
  break;
3797
4173
  case "indirect":
3798
4174
  scan.references.push({
3799
4175
  ...outcome.reference,
3800
4176
  kind: "indirect",
3801
- file: outcome.file
4177
+ file: outcome.file,
4178
+ snippet: outcome.snippet
3802
4179
  });
3803
4180
  break;
3804
4181
  case "dynamic":
@@ -3813,29 +4190,22 @@ const scanCodeReferences = async (scope, projectKeys) => {
3813
4190
  };
3814
4191
  //#endregion
3815
4192
  //#region src/project-scan.ts
3816
- const scanProject = async (context, root) => {
4193
+ const connectToScannableProject = async (context) => {
3817
4194
  const { workingDirectory } = context.invocation;
3818
- const settings = await readProjectSettings(workingDirectory);
3819
- const projectSlug = requireLinkedProjectSlug(context, settings);
3820
- const client = new AdminApiClient({
3821
- baseUrl: context.baseUrl,
3822
- token: await configuredApiToken(context)
3823
- });
3824
- const project = await findProjectBySlug(client, projectSlug);
4195
+ const { client, project, settings } = await connectToLinkedProject(context);
3825
4196
  const configs = (await client.listConfigs(project.id, { form: "every-config" })).map((listedConfig) => listedConfig.config);
3826
4197
  const projectKeys = new Set(configs.flatMap((config) => [config.key, ...config.deprecatedKeys.map((deprecatedKey) => deprecatedKey.key)]));
3827
- const codeReferenceScan = await scanCodeReferences({
3828
- root,
3829
- workingDirectory,
3830
- include: settings?.include ?? "everyFile",
3831
- exclude: settings?.exclude ?? []
3832
- }, projectKeys);
3833
4198
  return {
3834
4199
  client,
3835
4200
  project,
3836
4201
  configs,
3837
4202
  ignoredKeys: new Set(settings?.ignoreKeys ?? []),
3838
- codeReferenceScan
4203
+ codeReferencesIn: (root, snippetCut) => scanCodeReferences({
4204
+ root,
4205
+ workingDirectory,
4206
+ include: settings?.include ?? "everyFile",
4207
+ exclude: settings?.exclude ?? []
4208
+ }, projectKeys, snippetCut)
3839
4209
  };
3840
4210
  };
3841
4211
  //#endregion
@@ -3945,14 +4315,16 @@ const referenceJson = (reference) => {
3945
4315
  ...reference.argument === void 0 ? {} : { argument: reference.argument },
3946
4316
  ...reference.inCodeDefault === void 0 ? {} : { default: reference.inCodeDefault },
3947
4317
  ...reference.getterType === void 0 ? {} : { getterType: reference.getterType },
3948
- test: reference.test
4318
+ test: reference.test,
4319
+ ...reference.snippet === void 0 ? {} : { snippet: reference.snippet }
3949
4320
  };
3950
4321
  case "indirect": return {
3951
4322
  key: reference.key,
3952
4323
  file: reference.file,
3953
4324
  line: reference.line,
3954
4325
  kind: reference.kind,
3955
- test: reference.test
4326
+ test: reference.test,
4327
+ ...reference.snippet === void 0 ? {} : { snippet: reference.snippet }
3956
4328
  };
3957
4329
  }
3958
4330
  };
@@ -4010,9 +4382,24 @@ const scanText = (scan, scannedLocation) => {
4010
4382
  };
4011
4383
  const shortCommitText = (commit) => commit === null ? "" : ` at ${shortCommit(commit)}`;
4012
4384
  const uploadLine = (projectSlug, repository, leftOutCount) => `Uploaded the scan of ${repository.name}${shortCommitText(repository.commit)} to ${projectSlug}: ${repository.referenceCount} ${plural(repository.referenceCount, "reference", "references")} and ${repository.dynamicReferenceCount} ${plural(repository.dynamicReferenceCount, "dynamic reference", "dynamic references")}, with ${leftOutCount} left out for a key that cannot be a config key.`;
4013
- const uploadScan = async (context, root, repositoryOption) => {
4385
+ const snippetsSentTo = (project, requestedSnippetCut) => project.storeCodeSnippets ? {
4386
+ kind: "as-requested",
4387
+ snippetCut: requestedSnippetCut
4388
+ } : {
4389
+ kind: "project-stores-none",
4390
+ snippetCut: { kind: "no-snippets" }
4391
+ };
4392
+ const snippetsSentText = (snippetsSent) => {
4393
+ switch (snippetsSent.kind) {
4394
+ case "as-requested": return "";
4395
+ case "project-stores-none": return "Code snippets were not sent: this project does not store them.\n";
4396
+ }
4397
+ };
4398
+ const uploadScan = async (context, root, repositoryOption, requestedSnippetCut) => {
4014
4399
  const repository = repositoryIdentityOf(await readGitCheckout(scanRootDirectory(root), context.invocation.environmentVariables), repositoryOption, root.description);
4015
- const { client, project, codeReferenceScan } = await scanProject(context, root);
4400
+ const { client, project, codeReferencesIn } = await connectToScannableProject(context);
4401
+ const snippetsSent = snippetsSentTo(project, requestedSnippetCut);
4402
+ const codeReferenceScan = await codeReferencesIn(root, snippetsSent.snippetCut);
4016
4403
  if (codeReferenceScan.filesScanned === 0) throw new CliError(`No source files to scan in ${root.description}, so nothing was uploaded and ${project.slug} keeps its code references for ${repository.name}. Check the path given to scan, and include and exclude in configdirector.json.`);
4017
4404
  const configKeyReferences = codeReferenceScan.references.filter((reference) => fitsKeyGrammar(reference.key));
4018
4405
  const leftOutCount = codeReferenceScan.references.length - configKeyReferences.length;
@@ -4026,14 +4413,26 @@ const uploadScan = async (context, root, repositoryOption) => {
4026
4413
  });
4027
4414
  return {
4028
4415
  json: responseBody,
4029
- text: `${scanText(codeReferenceScan, root.description)}\n${uploadLine(project.slug, scannedRepository, leftOutCount)}\n`,
4416
+ text: `${scanText(codeReferenceScan, root.description)}\n${uploadLine(project.slug, scannedRepository, leftOutCount)}\n` + snippetsSentText(snippetsSent),
4030
4417
  exitCode: ExitCode.success
4031
4418
  };
4032
4419
  };
4420
+ const contextLinesOf = (text) => {
4421
+ if (text === void 0) return 2;
4422
+ const contextLines = /^\d+$/.test(text) ? Number(text) : void 0;
4423
+ if (contextLines === void 0 || contextLines > 5) throw new CliError(`--context-lines takes a whole number from 0 to 5, not ${text}.`);
4424
+ return contextLines;
4425
+ };
4426
+ const snippetCutOf = (options) => options.snippets ? {
4427
+ kind: "around-each-reference",
4428
+ contextLines: contextLinesOf(options.contextLines)
4429
+ } : { kind: "no-snippets" };
4033
4430
  const scan = async (context, [path], options) => {
4431
+ const snippetCut = snippetCutOf(options);
4034
4432
  const root = await scanRootAt(context.invocation.workingDirectory, path);
4035
- if (options.upload) return uploadScan(context, root, options.repository);
4036
- const { project, codeReferenceScan } = await scanProject(context, root);
4433
+ if (options.upload) return uploadScan(context, root, options.repository, snippetCut);
4434
+ const { project, codeReferencesIn } = await connectToScannableProject(context);
4435
+ const codeReferenceScan = await codeReferencesIn(root, snippetCut);
4037
4436
  return {
4038
4437
  json: scanJson(project.slug, codeReferenceScan),
4039
4438
  text: scanText(codeReferenceScan, root.description),
@@ -4166,7 +4565,7 @@ const setTargetingRules = async (context, [configKey], options) => {
4166
4565
  const showTargetingRulesReference = async (context) => {
4167
4566
  const { parsedBody: reference, responseBody } = await new AdminApiClient({
4168
4567
  baseUrl: context.baseUrl,
4169
- token: await configuredApiToken(context)
4568
+ token: (await configuredApiToken(context)).token
4170
4569
  }).getTargetingRulesReference();
4171
4570
  return {
4172
4571
  json: responseBody,
@@ -4370,7 +4769,8 @@ const validateText = (projectSlug, scan, scannedLocation, check) => {
4370
4769
  };
4371
4770
  const validate = async (context, [path]) => {
4372
4771
  const root = await scanRootAt(context.invocation.workingDirectory, path);
4373
- const { project, configs, ignoredKeys, codeReferenceScan } = await scanProject(context, root);
4772
+ const { project, configs, ignoredKeys, codeReferencesIn } = await connectToScannableProject(context);
4773
+ const codeReferenceScan = await codeReferencesIn(root, { kind: "no-snippets" });
4374
4774
  const check = checkCodeReferences(codeReferenceScan.references, configs, ignoredKeys);
4375
4775
  return {
4376
4776
  json: {
@@ -4397,7 +4797,8 @@ const runCommand = async (invocation, command, actionArguments) => {
4397
4797
  invocation,
4398
4798
  baseUrl: withoutTrailingSlashes(options.baseUrl ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_BASE_URL") ?? DEFAULT_BASE_URL),
4399
4799
  tokenOption: options.token,
4400
- projectSlug: options.project ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_PROJECT")
4800
+ projectSlug: options.project ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_PROJECT"),
4801
+ organizationOption: options.organization ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_ORGANIZATION")
4401
4802
  }, parsedCommand.processedArgs, parsedCommand.opts());
4402
4803
  invocation.stdout.write(options.json ? `${JSON.stringify(output.json, null, 2)}\n` : output.text);
4403
4804
  return output.exitCode;
@@ -4416,15 +4817,17 @@ const run = async (invocation) => {
4416
4817
  const commandAction = (command) => async (...actionArguments) => {
4417
4818
  exitCode = await runCommand(invocation, command, actionArguments);
4418
4819
  };
4419
- 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({
4420
4821
  writeOut: (text) => invocation.stdout.write(text),
4421
4822
  writeErr: (text) => invocation.stderr.write(text)
4422
4823
  });
4423
- 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));
4424
4826
  program.command("init").description("write configdirector.json for a project in the current directory").action(commandAction(init));
4425
- 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)").action(commandAction(scan));
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));
4426
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));
4427
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));
4428
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));
4429
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));
4430
4833
  const configsCommand = program.command("configs").description("list, show, create and archive a project's configs");