@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.
- package/CHANGELOG.md +25 -0
- package/README.md +26 -9
- package/dist/configdirector.mjs +1127 -724
- package/package.json +1 -1
- package/schema/scan-output.schema.json +55 -0
package/dist/configdirector.mjs
CHANGED
|
@@ -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.
|
|
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/
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
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/
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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/
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
274
|
-
|
|
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
|
|
306
|
-
} catch
|
|
307
|
-
|
|
308
|
-
throw error;
|
|
400
|
+
return await response.json();
|
|
401
|
+
} catch {
|
|
402
|
+
return;
|
|
309
403
|
}
|
|
310
404
|
};
|
|
311
|
-
const
|
|
312
|
-
|
|
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/
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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 (
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
384
|
-
|
|
654
|
+
outcome: "chosen",
|
|
655
|
+
signIn: firstSignIn
|
|
385
656
|
};
|
|
386
657
|
};
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
});
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
421
|
-
|
|
693
|
+
file,
|
|
694
|
+
token: configJson.output.token
|
|
422
695
|
};
|
|
423
696
|
};
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
const
|
|
506
|
-
const
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
const
|
|
510
|
-
|
|
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
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
|
534
|
-
|
|
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
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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/
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
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/
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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/
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
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/
|
|
605
|
-
const
|
|
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/
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
case
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
|
757
|
-
|
|
758
|
-
if (
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
763
|
-
|
|
997
|
+
client: true,
|
|
998
|
+
server: true
|
|
764
999
|
};
|
|
765
1000
|
};
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
const
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
921
|
-
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
|
|
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
|
|
941
|
-
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
return
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
1351
|
-
|
|
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
|
|
1354
|
-
const settingsFile = projectSettingsFile(context.invocation.workingDirectory);
|
|
1355
|
-
await refuseExistingFile(settingsFile);
|
|
1601
|
+
const newSettingsFile = (context) => {
|
|
1356
1602
|
const projectSlug = requireProjectSlug(context);
|
|
1357
|
-
|
|
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:
|
|
1360
|
-
})
|
|
1361
|
-
const
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
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
|
-
}).
|
|
1444
|
-
|
|
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: {
|
|
1447
|
-
text: `
|
|
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
|
|
4193
|
+
const connectToScannableProject = async (context) => {
|
|
3817
4194
|
const { workingDirectory } = context.invocation;
|
|
3818
|
-
const settings = await
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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");
|