@configdirector/cli 0.9.0-beta.1 → 0.10.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +22 -9
- package/dist/configdirector.mjs +1011 -701
- package/package.json +1 -1
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,95 +225,349 @@ 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
|
|
405
|
+
const describeStatus = (response) => `HTTP ${response.status}`;
|
|
406
|
+
const failureFor = (url, response, body) => {
|
|
407
|
+
switch (response.status) {
|
|
408
|
+
case 400: {
|
|
409
|
+
if (v.is(RepositoryLimitReachedBodySchema, body)) return new RepositoryLimitReachedError();
|
|
410
|
+
const validationError = v.safeParse(ApiValidationErrorBodySchema, body);
|
|
411
|
+
return validationError.success ? new ApiValidationError(validationError.output.errors) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
412
|
+
}
|
|
413
|
+
case 403: {
|
|
414
|
+
const authorizationError = v.safeParse(ApiAuthorizationErrorBodySchema, body);
|
|
415
|
+
return authorizationError.success ? new ApiAuthorizationError(authorizationError.output.code, authorizationError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
416
|
+
}
|
|
417
|
+
case 404: {
|
|
418
|
+
const notFoundError = v.safeParse(EvaluationNotFoundBodySchema, body);
|
|
419
|
+
return notFoundError.success ? new EvaluationNotFoundError(notFoundError.output.code, notFoundError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
420
|
+
}
|
|
421
|
+
case 429: {
|
|
422
|
+
const retryAfterSeconds = response.headers.get("retry-after");
|
|
423
|
+
return v.is(ServiceSuspendedBodySchema, body) && retryAfterSeconds !== null ? new ServiceSuspendedError(retryAfterSeconds) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
424
|
+
}
|
|
425
|
+
default: return new UnexpectedApiResponseError(url, describeStatus(response));
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
const isMissingResource = (received) => received.response.status === 404 && v.is(MissingResourceBodySchema, received.body);
|
|
429
|
+
const parsedResponseOf = ({ url, response, body }, schema) => {
|
|
430
|
+
if (!response.ok) throw failureFor(url, response, body);
|
|
431
|
+
const parsedBody = v.safeParse(schema, body);
|
|
432
|
+
if (!parsedBody.success) throw new UnexpectedApiResponseError(url, `an ${describeStatus(response)} response the CLI cannot read`);
|
|
433
|
+
return {
|
|
434
|
+
responseBody: body,
|
|
435
|
+
parsedBody: parsedBody.output
|
|
436
|
+
};
|
|
437
|
+
};
|
|
438
|
+
var AdminApiClient = class {
|
|
439
|
+
constructor(connection) {
|
|
440
|
+
this.connection = connection;
|
|
441
|
+
}
|
|
442
|
+
describeApiToken() {
|
|
443
|
+
return this.get("/v1/api-token", DescribedApiTokenSchema);
|
|
444
|
+
}
|
|
445
|
+
listProjects() {
|
|
446
|
+
return this.get("/v1/projects", v.array(ProjectSchema));
|
|
447
|
+
}
|
|
448
|
+
async listConfigs(projectId, tagFilter) {
|
|
449
|
+
const { parsedBody: configs, responseBody } = await this.get(`/v1/projects/${encodeURIComponent(projectId)}/configs${tagFilterQuery(tagFilter)}`, v.array(ConfigSchema));
|
|
450
|
+
const receivedConfigs = v.parse(v.array(v.unknown()), responseBody);
|
|
451
|
+
return configs.map((config, index) => ({
|
|
452
|
+
config,
|
|
453
|
+
receivedConfig: receivedConfigs[index]
|
|
454
|
+
}));
|
|
455
|
+
}
|
|
456
|
+
listEnvironments(projectId) {
|
|
457
|
+
return this.get(`/v1/projects/${encodeURIComponent(projectId)}/environments`, v.array(EnvironmentSchema));
|
|
458
|
+
}
|
|
459
|
+
getConfig(projectId, configKey) {
|
|
460
|
+
return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, ConfigWithTargetingRulesSchema);
|
|
461
|
+
}
|
|
462
|
+
getConfigActivity(projectId, configKey) {
|
|
463
|
+
return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/activity`, ConfigActivitySchema);
|
|
464
|
+
}
|
|
465
|
+
listSegments(projectId) {
|
|
466
|
+
return this.get(`/v1/projects/${encodeURIComponent(projectId)}/segments`, v.array(SegmentSchema));
|
|
467
|
+
}
|
|
468
|
+
getSegment(projectId, segmentKey) {
|
|
469
|
+
return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, SegmentDefinitionSchema);
|
|
470
|
+
}
|
|
471
|
+
async findTagByName(projectId, name) {
|
|
472
|
+
return (await this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/tags/by-name?${new URLSearchParams({ name })}`, TagSchema))?.parsedBody;
|
|
473
|
+
}
|
|
474
|
+
createConfig(projectId, request) {
|
|
475
|
+
return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs`, CreatedConfigSchema, {
|
|
476
|
+
method: "POST",
|
|
477
|
+
body: request
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
async evaluateConfig(projectId, configKey, request) {
|
|
481
|
+
const { responseBody, parsedBody } = await this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/evaluate`, ConfigEvaluationSchema, {
|
|
482
|
+
method: "POST",
|
|
483
|
+
body: request
|
|
484
|
+
});
|
|
485
|
+
return {
|
|
486
|
+
evaluation: parsedBody,
|
|
487
|
+
responseBody
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
getTargetingRulesReference() {
|
|
491
|
+
return this.get("/v1/targeting-rules-reference", TargetingRulesReferenceSchema);
|
|
492
|
+
}
|
|
493
|
+
async replaceTargetingRules(projectId, configKey, request) {
|
|
494
|
+
return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/targets`, v.unknown(), {
|
|
495
|
+
method: "PUT",
|
|
496
|
+
body: request
|
|
497
|
+
}) === void 0 ? "not-found" : "replaced";
|
|
498
|
+
}
|
|
499
|
+
uploadRepositoryScan(projectId, repositoryName, upload) {
|
|
500
|
+
return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryName)}`, ScannedRepositorySchema, {
|
|
501
|
+
method: "PUT",
|
|
502
|
+
body: upload
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
listScannedRepositories(projectId) {
|
|
506
|
+
return this.get(`/v1/projects/${encodeURIComponent(projectId)}/repositories`, v.array(ListedScannedRepositorySchema));
|
|
507
|
+
}
|
|
508
|
+
async removeScannedRepository(projectId, repositoryName) {
|
|
509
|
+
return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryName)}`, v.unknown(), { method: "DELETE" }) === void 0 ? "not-found" : "removed";
|
|
510
|
+
}
|
|
511
|
+
async archiveConfig(projectId, configKey) {
|
|
512
|
+
return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, v.unknown(), { method: "DELETE" }) === void 0 ? "not-found" : "archived";
|
|
513
|
+
}
|
|
514
|
+
get(path, schema) {
|
|
515
|
+
return this.requestJson(path, schema, { method: "GET" });
|
|
516
|
+
}
|
|
517
|
+
getIfFound(path, schema) {
|
|
518
|
+
return this.requestIfFound(path, schema, { method: "GET" });
|
|
519
|
+
}
|
|
520
|
+
async requestIfFound(path, schema, request) {
|
|
521
|
+
const received = await this.send(path, request);
|
|
522
|
+
return isMissingResource(received) ? void 0 : parsedResponseOf(received, schema);
|
|
523
|
+
}
|
|
524
|
+
async requestJson(path, schema, request) {
|
|
525
|
+
return parsedResponseOf(await this.send(path, request), schema);
|
|
526
|
+
}
|
|
527
|
+
async send(path, request) {
|
|
528
|
+
const url = `${this.connection.baseUrl}${path}`;
|
|
529
|
+
const response = await this.fetchResponse(url, request);
|
|
530
|
+
return {
|
|
531
|
+
url,
|
|
532
|
+
response,
|
|
533
|
+
body: await readJsonBody(response)
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
async fetchResponse(url, request) {
|
|
537
|
+
const headers = {
|
|
538
|
+
Accept: "application/json",
|
|
539
|
+
Authorization: `Bearer ${this.connection.token}`,
|
|
540
|
+
"User-Agent": `configdirector-cli/${version}`,
|
|
541
|
+
"X-SourceClient": "cli"
|
|
542
|
+
};
|
|
543
|
+
try {
|
|
544
|
+
return await fetch(url, "body" in request ? {
|
|
545
|
+
method: request.method,
|
|
546
|
+
headers: {
|
|
547
|
+
...headers,
|
|
548
|
+
"Content-Type": "application/json"
|
|
549
|
+
},
|
|
550
|
+
body: JSON.stringify(request.body)
|
|
551
|
+
} : {
|
|
552
|
+
method: request.method,
|
|
553
|
+
headers
|
|
554
|
+
});
|
|
555
|
+
} catch (error) {
|
|
556
|
+
throw new ApiUnreachableError(this.connection.baseUrl, error);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region src/file-contents.ts
|
|
562
|
+
const readFileIfPresent = async (file) => {
|
|
563
|
+
try {
|
|
564
|
+
return await readFile(file, "utf8");
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (error.code === "ENOENT") return;
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
const readOptionFile = async (argumentLabel, path, workingDirectory) => {
|
|
312
571
|
const file = resolve(workingDirectory, path);
|
|
313
572
|
const contents = await readFileIfPresent(file);
|
|
314
573
|
if (contents === void 0) throw new CliError(`${argumentLabel} names ${file}, which does not exist.`);
|
|
@@ -333,618 +592,580 @@ const parseJson = (contents) => {
|
|
|
333
592
|
const pathExists = (path) => stat(path).then(() => true, () => false);
|
|
334
593
|
const fileExists = (path) => stat(path).then((stats) => stats.isFile(), () => false);
|
|
335
594
|
//#endregion
|
|
336
|
-
//#region src/
|
|
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
|
-
key: v.string(),
|
|
685
|
-
deprecatedKeys: v.array(v.object({ key: v.string() })),
|
|
686
|
-
role: v.string(),
|
|
687
|
-
lifetime: v.string(),
|
|
688
|
-
state: v.string(),
|
|
689
|
-
client: v.boolean(),
|
|
690
|
-
server: v.boolean()
|
|
874
|
+
//#region src/rule-sentences.ts
|
|
875
|
+
const TRAIT_POINTER_PREFIX = "/";
|
|
876
|
+
const ATTRIBUTE_LABELS = {
|
|
877
|
+
identifier: "context identifier",
|
|
878
|
+
kind: "context kind",
|
|
879
|
+
name: "context name",
|
|
880
|
+
appName: "app name",
|
|
881
|
+
appVersion: "app version"
|
|
691
882
|
};
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
valueType: v.string(),
|
|
697
|
-
values: v.array(v.string())
|
|
698
|
-
})
|
|
699
|
-
});
|
|
700
|
-
const ConfigSchema = v.variant("type", [EnumConfigSchema, v.object({
|
|
701
|
-
...configFields,
|
|
702
|
-
type: v.pipe(v.string(), v.notValue("enum"))
|
|
703
|
-
})]);
|
|
704
|
-
const isEnumConfig = (config) => config.type === "enum";
|
|
705
|
-
const EnvironmentSchema = v.object({
|
|
706
|
-
id: v.string(),
|
|
707
|
-
slug: v.string(),
|
|
708
|
-
name: v.string(),
|
|
709
|
-
live: v.boolean()
|
|
710
|
-
});
|
|
711
|
-
const ConfigActivitySchema = v.object({ latestEvaluations: v.array(v.object({
|
|
712
|
-
environment: v.object({ slug: v.string() }),
|
|
713
|
-
source: v.string(),
|
|
714
|
-
sdkName: v.nullable(v.string()),
|
|
715
|
-
sdkVersion: v.nullable(v.string()),
|
|
716
|
-
time: v.pipe(v.string(), v.isoTimestamp())
|
|
717
|
-
})) });
|
|
718
|
-
const TagSchema = v.object({ id: v.string() });
|
|
719
|
-
const tagFilterQuery = (tagFilter) => {
|
|
720
|
-
switch (tagFilter.form) {
|
|
721
|
-
case "every-config": return "";
|
|
722
|
-
case "tagged": return `?${new URLSearchParams(tagFilter.tagIds.map((tagId) => ["tagIds", tagId]))}`;
|
|
723
|
-
case "untagged": return "?untagged=true";
|
|
724
|
-
}
|
|
883
|
+
const formatPercentage = (value) => Number(value.toFixed(3)).toString();
|
|
884
|
+
const subjectOf = (condition) => {
|
|
885
|
+
if (condition.attribute === "traits" && condition.trait != null) return condition.trait.startsWith(TRAIT_POINTER_PREFIX) ? condition.trait.slice(1) : condition.trait;
|
|
886
|
+
return ATTRIBUTE_LABELS[condition.attribute] ?? condition.attribute;
|
|
725
887
|
};
|
|
726
|
-
const
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
return;
|
|
888
|
+
const segmentNameOf = (segmentId, segments) => segments[segmentId]?.name ?? segmentId;
|
|
889
|
+
const describeAttributeCondition = (condition) => `${subjectOf(condition)} ${condition.operator} ${condition.targetValues.map(String).join(", ")}`;
|
|
890
|
+
const describeConditionGroup = (group) => group.map(describeAttributeCondition).join(" and ");
|
|
891
|
+
const describeCondition = (condition, segments) => {
|
|
892
|
+
switch (condition.kind) {
|
|
893
|
+
case "attribute": return describeAttributeCondition(condition);
|
|
894
|
+
case "segment": return `${condition.operator} segment ${segmentNameOf(condition.segmentId, segments)}`;
|
|
732
895
|
}
|
|
733
896
|
};
|
|
734
|
-
const
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
if (v.is(RepositoryLimitReachedBodySchema, body)) return new RepositoryLimitReachedError();
|
|
739
|
-
const validationError = v.safeParse(ApiValidationErrorBodySchema, body);
|
|
740
|
-
return validationError.success ? new ApiValidationError(validationError.output.errors) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
741
|
-
}
|
|
742
|
-
case 403: {
|
|
743
|
-
const authorizationError = v.safeParse(ApiAuthorizationErrorBodySchema, body);
|
|
744
|
-
return authorizationError.success ? new ApiAuthorizationError(authorizationError.output.code, authorizationError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
745
|
-
}
|
|
746
|
-
case 404: {
|
|
747
|
-
const notFoundError = v.safeParse(EvaluationNotFoundBodySchema, body);
|
|
748
|
-
return notFoundError.success ? new EvaluationNotFoundError(notFoundError.output.code, notFoundError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
749
|
-
}
|
|
750
|
-
case 429: {
|
|
751
|
-
const retryAfterSeconds = response.headers.get("retry-after");
|
|
752
|
-
return v.is(ServiceSuspendedBodySchema, body) && retryAfterSeconds !== null ? new ServiceSuspendedError(retryAfterSeconds) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
753
|
-
}
|
|
754
|
-
default: return new UnexpectedApiResponseError(url, describeStatus(response));
|
|
755
|
-
}
|
|
897
|
+
const describePercentages = (percentages) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${String(percentage.value)}`).join(", ");
|
|
898
|
+
const describeRule = (rule, segments) => {
|
|
899
|
+
if (rule.type === "percentage") return describePercentages(rule.percentages);
|
|
900
|
+
return `${rule.conditions.map((condition) => describeCondition(condition, segments)).join(" and ")}: ${rule.target === "value" ? String(rule.value) : describePercentages(rule.percentages)}`;
|
|
756
901
|
};
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
return {
|
|
763
|
-
responseBody: body,
|
|
764
|
-
parsedBody: parsedBody.output
|
|
765
|
-
};
|
|
902
|
+
//#endregion
|
|
903
|
+
//#region src/configs/config-text.ts
|
|
904
|
+
const boundsDescriptions = (bounds) => {
|
|
905
|
+
const limits = [bounds?.min, bounds?.max].flatMap((bound) => bound === void 0 ? [] : [`${bound.relation} ${bound.value}`]);
|
|
906
|
+
return limits.length === 0 ? [] : [limits.join(" and ")];
|
|
766
907
|
};
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
return
|
|
778
|
-
config,
|
|
779
|
-
receivedConfig: receivedConfigs[index]
|
|
780
|
-
}));
|
|
781
|
-
}
|
|
782
|
-
listEnvironments(projectId) {
|
|
783
|
-
return this.get(`/v1/projects/${encodeURIComponent(projectId)}/environments`, v.array(EnvironmentSchema));
|
|
784
|
-
}
|
|
785
|
-
getConfig(projectId, configKey) {
|
|
786
|
-
return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, ConfigWithTargetingRulesSchema);
|
|
787
|
-
}
|
|
788
|
-
getConfigActivity(projectId, configKey) {
|
|
789
|
-
return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/activity`, ConfigActivitySchema);
|
|
790
|
-
}
|
|
791
|
-
listSegments(projectId) {
|
|
792
|
-
return this.get(`/v1/projects/${encodeURIComponent(projectId)}/segments`, v.array(SegmentSchema));
|
|
793
|
-
}
|
|
794
|
-
getSegment(projectId, segmentKey) {
|
|
795
|
-
return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, SegmentDefinitionSchema);
|
|
796
|
-
}
|
|
797
|
-
async findTagByName(projectId, name) {
|
|
798
|
-
return (await this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/tags/by-name?${new URLSearchParams({ name })}`, TagSchema))?.parsedBody;
|
|
799
|
-
}
|
|
800
|
-
createConfig(projectId, request) {
|
|
801
|
-
return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs`, CreatedConfigSchema, {
|
|
802
|
-
method: "POST",
|
|
803
|
-
body: request
|
|
804
|
-
});
|
|
805
|
-
}
|
|
806
|
-
async evaluateConfig(projectId, configKey, request) {
|
|
807
|
-
const { responseBody, parsedBody } = await this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/evaluate`, ConfigEvaluationSchema, {
|
|
808
|
-
method: "POST",
|
|
809
|
-
body: request
|
|
810
|
-
});
|
|
811
|
-
return {
|
|
812
|
-
evaluation: parsedBody,
|
|
813
|
-
responseBody
|
|
814
|
-
};
|
|
815
|
-
}
|
|
816
|
-
getTargetingRulesReference() {
|
|
817
|
-
return this.get("/v1/targeting-rules-reference", TargetingRulesReferenceSchema);
|
|
818
|
-
}
|
|
819
|
-
async replaceTargetingRules(projectId, configKey, request) {
|
|
820
|
-
return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/targets`, v.unknown(), {
|
|
821
|
-
method: "PUT",
|
|
822
|
-
body: request
|
|
823
|
-
}) === void 0 ? "not-found" : "replaced";
|
|
824
|
-
}
|
|
825
|
-
uploadRepositoryScan(projectId, repositoryName, upload) {
|
|
826
|
-
return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryName)}`, ScannedRepositorySchema, {
|
|
827
|
-
method: "PUT",
|
|
828
|
-
body: upload
|
|
829
|
-
});
|
|
830
|
-
}
|
|
831
|
-
listScannedRepositories(projectId) {
|
|
832
|
-
return this.get(`/v1/projects/${encodeURIComponent(projectId)}/repositories`, v.array(ListedScannedRepositorySchema));
|
|
833
|
-
}
|
|
834
|
-
async removeScannedRepository(projectId, repositoryName) {
|
|
835
|
-
return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryName)}`, v.unknown(), { method: "DELETE" }) === void 0 ? "not-found" : "removed";
|
|
836
|
-
}
|
|
837
|
-
async archiveConfig(projectId, configKey) {
|
|
838
|
-
return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, v.unknown(), { method: "DELETE" }) === void 0 ? "not-found" : "archived";
|
|
839
|
-
}
|
|
840
|
-
get(path, schema) {
|
|
841
|
-
return this.requestJson(path, schema, { method: "GET" });
|
|
842
|
-
}
|
|
843
|
-
getIfFound(path, schema) {
|
|
844
|
-
return this.requestIfFound(path, schema, { method: "GET" });
|
|
845
|
-
}
|
|
846
|
-
async requestIfFound(path, schema, request) {
|
|
847
|
-
const received = await this.send(path, request);
|
|
848
|
-
return isMissingResource(received) ? void 0 : parsedResponseOf(received, schema);
|
|
849
|
-
}
|
|
850
|
-
async requestJson(path, schema, request) {
|
|
851
|
-
return parsedResponseOf(await this.send(path, request), schema);
|
|
852
|
-
}
|
|
853
|
-
async send(path, request) {
|
|
854
|
-
const url = `${this.connection.baseUrl}${path}`;
|
|
855
|
-
const response = await this.fetchResponse(url, request);
|
|
856
|
-
return {
|
|
857
|
-
url,
|
|
858
|
-
response,
|
|
859
|
-
body: await readJsonBody(response)
|
|
860
|
-
};
|
|
861
|
-
}
|
|
862
|
-
async fetchResponse(url, request) {
|
|
863
|
-
const headers = {
|
|
864
|
-
Accept: "application/json",
|
|
865
|
-
Authorization: `Bearer ${this.connection.token}`,
|
|
866
|
-
"User-Agent": `configdirector-cli/${version}`,
|
|
867
|
-
"X-SourceClient": "cli"
|
|
868
|
-
};
|
|
869
|
-
try {
|
|
870
|
-
return await fetch(url, "body" in request ? {
|
|
871
|
-
method: request.method,
|
|
872
|
-
headers: {
|
|
873
|
-
...headers,
|
|
874
|
-
"Content-Type": "application/json"
|
|
875
|
-
},
|
|
876
|
-
body: JSON.stringify(request.body)
|
|
877
|
-
} : {
|
|
878
|
-
method: request.method,
|
|
879
|
-
headers
|
|
880
|
-
});
|
|
881
|
-
} catch (error) {
|
|
882
|
-
throw new ApiUnreachableError(this.connection.baseUrl, error);
|
|
883
|
-
}
|
|
908
|
+
const enumValuesDescription = ({ valueType, values }) => valueType === "number" ? `one of the numbers ${values.join(", ")}` : `one of ${values.join(", ")}`;
|
|
909
|
+
const timespanRangeDescriptions = (range) => [`${range.min} to ${range.max} ${range.unit}`, ...range.allowFractions ? ["fractions allowed"] : []];
|
|
910
|
+
const typeConstraintDescriptions = (config) => {
|
|
911
|
+
if (!hasTypeConstraints(config)) return [];
|
|
912
|
+
switch (config.type) {
|
|
913
|
+
case "integer":
|
|
914
|
+
case "float": return boundsDescriptions(config.typeOptions);
|
|
915
|
+
case "enum": return [enumValuesDescription(config.typeOptions)];
|
|
916
|
+
case "timespan": return timespanRangeDescriptions(config.typeOptions);
|
|
917
|
+
case "url": return config.typeOptions?.secureOnly ? ["HTTPS only"] : [];
|
|
918
|
+
case "json": return config.typeOptions?.useSchema ? ["checked against a JSON schema"] : [];
|
|
884
919
|
}
|
|
885
920
|
};
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
}
|
|
921
|
+
const availabilityDescription = (config) => {
|
|
922
|
+
if (config.client && config.server) return "client and server SDKs";
|
|
923
|
+
if (config.client) return "client-only";
|
|
924
|
+
if (config.server) return "server-only";
|
|
925
|
+
return "served to no SDK";
|
|
926
|
+
};
|
|
927
|
+
const settingLines = (config) => [
|
|
928
|
+
...config.description ? [`Description: ${config.description}`] : [],
|
|
929
|
+
`Type: ${[config.type, ...typeConstraintDescriptions(config)].join(", ")}`,
|
|
930
|
+
`Role: ${config.role}`,
|
|
931
|
+
`Lifetime: ${config.lifetime}`,
|
|
932
|
+
`Availability: ${availabilityDescription(config)}`,
|
|
933
|
+
`Lifecycle state: ${config.state}`,
|
|
934
|
+
...config.tags.length === 0 ? [] : [`Tags: ${config.tags.map((tag) => tag.name).join(", ")}`]
|
|
935
|
+
];
|
|
936
|
+
const environmentHeading = (targetingRules) => targetingRules.environment.live ? `${targetingRules.environment.slug} (live)` : targetingRules.environment.slug;
|
|
937
|
+
const targetingRuleLines = (targetingRules, segments) => [
|
|
938
|
+
environmentHeading(targetingRules),
|
|
939
|
+
...targetingRules.rules.map((rule, index) => ` ${index + 1}. ${describeRule(rule, segments)}`),
|
|
940
|
+
` Default targeting rule: ${targetingRules.defaultValue}`
|
|
941
|
+
];
|
|
942
|
+
const configText = (config, requestedKey, segments) => [
|
|
943
|
+
...oldKeyLines(requestedKey, config.key),
|
|
944
|
+
config.key,
|
|
945
|
+
...settingLines(config).map((line) => ` ${line}`),
|
|
946
|
+
...config.targets.flatMap((targetingRules) => ["", ...targetingRuleLines(targetingRules, segments)]),
|
|
947
|
+
""
|
|
948
|
+
].join("\n");
|
|
949
|
+
//#endregion
|
|
950
|
+
//#region src/configs/new-config.ts
|
|
951
|
+
const CONFIG_TYPES = [
|
|
952
|
+
"boolean",
|
|
953
|
+
"string",
|
|
954
|
+
"integer",
|
|
955
|
+
"float",
|
|
956
|
+
"timespan",
|
|
957
|
+
"enum",
|
|
958
|
+
"url",
|
|
959
|
+
"json"
|
|
960
|
+
];
|
|
961
|
+
const CONFIG_ROLES = [
|
|
962
|
+
"config",
|
|
963
|
+
"flag",
|
|
964
|
+
"kill-switch",
|
|
965
|
+
"experiment"
|
|
966
|
+
];
|
|
967
|
+
const CONFIG_LIFETIMES = ["permanent", "temporary"];
|
|
968
|
+
const TIMESPAN_UNITS = [
|
|
969
|
+
"milliseconds",
|
|
970
|
+
"seconds",
|
|
971
|
+
"minutes"
|
|
972
|
+
];
|
|
973
|
+
const ENUM_VALUE_TYPES = ["string", "number"];
|
|
974
|
+
const DEFAULT_ENUM_VALUE_TYPE = "string";
|
|
975
|
+
const TYPE_CONSTRAINT_OPTION_NAMES = {
|
|
976
|
+
min: "--min",
|
|
977
|
+
max: "--max",
|
|
978
|
+
exclusiveMin: "--exclusive-min",
|
|
979
|
+
exclusiveMax: "--exclusive-max",
|
|
980
|
+
unit: "--unit",
|
|
981
|
+
allowFractions: "--allow-fractions",
|
|
982
|
+
allowedValue: "--allowed-value",
|
|
983
|
+
enumValueType: "--enum-value-type",
|
|
984
|
+
httpsOnly: "--https-only",
|
|
985
|
+
jsonSchema: "--json-schema"
|
|
986
|
+
};
|
|
987
|
+
const availabilityOf = (options) => {
|
|
988
|
+
if (options.clientOnly) return {
|
|
989
|
+
client: true,
|
|
990
|
+
server: false
|
|
991
|
+
};
|
|
992
|
+
if (options.serverOnly) return {
|
|
993
|
+
client: false,
|
|
994
|
+
server: true
|
|
995
|
+
};
|
|
996
|
+
return {
|
|
997
|
+
client: true,
|
|
998
|
+
server: true
|
|
999
|
+
};
|
|
1000
|
+
};
|
|
1001
|
+
const BOOLEAN_VALUES = new Map([["true", true], ["false", false]]);
|
|
1002
|
+
const numberOf = (text) => {
|
|
1003
|
+
const value = parseJson(text);
|
|
1004
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
895
1005
|
};
|
|
896
|
-
const
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
const savedToken = v.safeParse(SavedApiTokenSchema, parseJson(contents));
|
|
903
|
-
if (!savedToken.success) throw new CliError(`${file} does not hold a saved API token. Run \`configdirector login\` again.`);
|
|
904
|
-
return savedToken.output.token;
|
|
1006
|
+
const constraintNumberOf = (options, option) => {
|
|
1007
|
+
const text = options[option];
|
|
1008
|
+
if (text === void 0) return;
|
|
1009
|
+
const value = numberOf(text);
|
|
1010
|
+
if (value === void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES[option]} takes a number, not ${text}.`);
|
|
1011
|
+
return value;
|
|
905
1012
|
};
|
|
906
|
-
const
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1013
|
+
const boundOf = (options, option, relation) => {
|
|
1014
|
+
const value = constraintNumberOf(options, option);
|
|
1015
|
+
return value === void 0 ? void 0 : {
|
|
1016
|
+
relation,
|
|
1017
|
+
value
|
|
1018
|
+
};
|
|
1019
|
+
};
|
|
1020
|
+
const numericBoundsOf = (options) => ({
|
|
1021
|
+
min: boundOf(options, "min", ">=") ?? boundOf(options, "exclusiveMin", ">"),
|
|
1022
|
+
max: boundOf(options, "max", "<=") ?? boundOf(options, "exclusiveMax", "<")
|
|
1023
|
+
});
|
|
1024
|
+
const jsonTextOf = async ({ argumentLabel, text, workingDirectory }) => {
|
|
1025
|
+
const jsonText = (await atFileArgumentText(argumentLabel, text, workingDirectory)).trim();
|
|
1026
|
+
return parseJson(jsonText) === void 0 ? void 0 : jsonText;
|
|
1027
|
+
};
|
|
1028
|
+
const jsonSchemaOptionsOf = async (options, workingDirectory) => {
|
|
1029
|
+
if (options.jsonSchema === void 0) return;
|
|
1030
|
+
const { file, contents } = await readOptionFile(TYPE_CONSTRAINT_OPTION_NAMES.jsonSchema, options.jsonSchema, workingDirectory);
|
|
1031
|
+
const schema = parseJson(contents);
|
|
1032
|
+
if (schema === void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES.jsonSchema} names ${file}, which is not JSON.`);
|
|
1033
|
+
return {
|
|
1034
|
+
useSchema: true,
|
|
1035
|
+
schema
|
|
1036
|
+
};
|
|
1037
|
+
};
|
|
1038
|
+
const timespanRangeOf = async (options) => {
|
|
1039
|
+
if (options.unit === void 0 || options.min === void 0 || options.max === void 0) throw new CliError("--type timespan needs --unit, --min and --max.");
|
|
1040
|
+
return {
|
|
1041
|
+
unit: options.unit,
|
|
1042
|
+
min: constraintNumberOf(options, "min"),
|
|
1043
|
+
max: constraintNumberOf(options, "max"),
|
|
1044
|
+
allowFractions: options.allowFractions
|
|
1045
|
+
};
|
|
1046
|
+
};
|
|
1047
|
+
const NUMERIC_BOUND_OPTIONS = [
|
|
1048
|
+
"min",
|
|
1049
|
+
"max",
|
|
1050
|
+
"exclusiveMin",
|
|
1051
|
+
"exclusiveMax"
|
|
1052
|
+
];
|
|
1053
|
+
const CONFIG_TYPE_RULES = {
|
|
1054
|
+
boolean: {
|
|
1055
|
+
constraintOptions: [],
|
|
1056
|
+
valueDescription: "true or false",
|
|
1057
|
+
valueOf: async ({ text }) => BOOLEAN_VALUES.get(text),
|
|
1058
|
+
typeOptionsOf: async () => void 0
|
|
1059
|
+
},
|
|
1060
|
+
string: {
|
|
1061
|
+
constraintOptions: [],
|
|
1062
|
+
valueDescription: "text",
|
|
1063
|
+
valueOf: async ({ text }) => text,
|
|
1064
|
+
typeOptionsOf: async () => void 0
|
|
1065
|
+
},
|
|
1066
|
+
integer: {
|
|
1067
|
+
constraintOptions: NUMERIC_BOUND_OPTIONS,
|
|
1068
|
+
valueDescription: "a number",
|
|
1069
|
+
valueOf: async ({ text }) => numberOf(text),
|
|
1070
|
+
typeOptionsOf: async (options) => ({
|
|
1071
|
+
isInteger: true,
|
|
1072
|
+
...numericBoundsOf(options)
|
|
1073
|
+
})
|
|
1074
|
+
},
|
|
1075
|
+
float: {
|
|
1076
|
+
constraintOptions: NUMERIC_BOUND_OPTIONS,
|
|
1077
|
+
valueDescription: "a number",
|
|
1078
|
+
valueOf: async ({ text }) => numberOf(text),
|
|
1079
|
+
typeOptionsOf: async (options) => ({
|
|
1080
|
+
isInteger: false,
|
|
1081
|
+
...numericBoundsOf(options)
|
|
1082
|
+
})
|
|
1083
|
+
},
|
|
1084
|
+
timespan: {
|
|
1085
|
+
constraintOptions: [
|
|
1086
|
+
"min",
|
|
1087
|
+
"max",
|
|
1088
|
+
"unit",
|
|
1089
|
+
"allowFractions"
|
|
1090
|
+
],
|
|
1091
|
+
valueDescription: "a number",
|
|
1092
|
+
valueOf: async ({ text }) => numberOf(text),
|
|
1093
|
+
typeOptionsOf: timespanRangeOf
|
|
1094
|
+
},
|
|
1095
|
+
enum: {
|
|
1096
|
+
constraintOptions: ["allowedValue", "enumValueType"],
|
|
1097
|
+
valueDescription: "text",
|
|
1098
|
+
valueOf: async ({ text }) => text,
|
|
1099
|
+
typeOptionsOf: async (options) => ({
|
|
1100
|
+
values: options.allowedValue ?? [],
|
|
1101
|
+
valueType: options.enumValueType ?? DEFAULT_ENUM_VALUE_TYPE
|
|
1102
|
+
})
|
|
1103
|
+
},
|
|
1104
|
+
url: {
|
|
1105
|
+
constraintOptions: ["httpsOnly"],
|
|
1106
|
+
valueDescription: "text",
|
|
1107
|
+
valueOf: async ({ text }) => text,
|
|
1108
|
+
typeOptionsOf: async (options) => ({ secureOnly: options.httpsOnly === true })
|
|
1109
|
+
},
|
|
1110
|
+
json: {
|
|
1111
|
+
constraintOptions: ["jsonSchema"],
|
|
1112
|
+
valueDescription: "JSON or @<file>",
|
|
1113
|
+
valueOf: jsonTextOf,
|
|
1114
|
+
typeOptionsOf: jsonSchemaOptionsOf
|
|
918
1115
|
}
|
|
919
|
-
return file;
|
|
920
1116
|
};
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
const
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1117
|
+
const typeAlternatives = (types) => types.length === 1 ? `${types[0]}` : `${types.slice(0, -1).join(", ")} or ${types.at(-1)}`;
|
|
1118
|
+
const typesTaking = (option) => CONFIG_TYPES.filter((type) => CONFIG_TYPE_RULES[type].constraintOptions.includes(option));
|
|
1119
|
+
const typeConstraintHelpGroup = (option) => `Options for --type ${typeAlternatives(typesTaking(option))}:`;
|
|
1120
|
+
const TYPE_CONSTRAINT_OPTIONS = Object.keys(TYPE_CONSTRAINT_OPTION_NAMES);
|
|
1121
|
+
const rejectOptionsOfOtherTypes = (options) => {
|
|
1122
|
+
const { constraintOptions } = CONFIG_TYPE_RULES[options.type];
|
|
1123
|
+
const otherTypeOption = TYPE_CONSTRAINT_OPTIONS.find((option) => options[option] !== void 0 && !constraintOptions.includes(option));
|
|
1124
|
+
if (otherTypeOption !== void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES[otherTypeOption]} cannot be used with --type ${options.type}. It applies to --type ${typeAlternatives(typesTaking(otherTypeOption))}.`);
|
|
927
1125
|
};
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
const
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
exclude: v.optional(v.array(v.string())),
|
|
934
|
-
ignoreKeys: v.optional(v.array(v.string()))
|
|
935
|
-
});
|
|
936
|
-
const projectSettingsFile = (workingDirectory) => join(workingDirectory, "configdirector.json");
|
|
937
|
-
const describeIssue = (issue) => {
|
|
938
|
-
const path = v.getDotPath(issue);
|
|
939
|
-
return path === null ? issue.message : `${path}: ${issue.message}`;
|
|
1126
|
+
const typedValueOf = async (type, argument) => {
|
|
1127
|
+
const rules = CONFIG_TYPE_RULES[type];
|
|
1128
|
+
const value = await rules.valueOf(argument);
|
|
1129
|
+
if (value === void 0) throw new CliError(`${argument.argumentLabel} takes ${rules.valueDescription} with --type ${type}, not ${argument.text}.`);
|
|
1130
|
+
return value;
|
|
940
1131
|
};
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
const
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
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
|
+
};
|
|
948
1169
|
};
|
|
949
1170
|
//#endregion
|
|
950
1171
|
//#region src/project-selection.ts
|
|
@@ -963,24 +1184,38 @@ const findProjectBySlug = async (client, slug) => {
|
|
|
963
1184
|
if (project === void 0) throw new CliError(`No project in the API token's organization has the slug "${slug}".`);
|
|
964
1185
|
return project;
|
|
965
1186
|
};
|
|
1187
|
+
const projectOrganizationCheck = (project, settings) => {
|
|
1188
|
+
if (settings?.organization === void 0) return { verdict: "not-checked" };
|
|
1189
|
+
return project.organizationId === settings.organization ? { verdict: "same" } : {
|
|
1190
|
+
verdict: "another-organization",
|
|
1191
|
+
linkedOrganizationId: settings.organization
|
|
1192
|
+
};
|
|
1193
|
+
};
|
|
1194
|
+
const refuseProjectOfAnotherOrganization = (project, settings, apiToken) => {
|
|
1195
|
+
const check = projectOrganizationCheck(project, settings);
|
|
1196
|
+
switch (check.verdict) {
|
|
1197
|
+
case "not-checked":
|
|
1198
|
+
case "same": return;
|
|
1199
|
+
case "another-organization": throw new CliError(`The token from ${describeApiTokenSource(apiToken.source)} belongs to the organization ${project.organizationId}, whose project "${project.slug}" is not the one this directory links to: configdirector.json names the organization ${check.linkedOrganizationId}. Use a token of that organization, or run the command somewhere else.`);
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
966
1202
|
const connectToLinkedProject = async (context) => {
|
|
967
|
-
const
|
|
1203
|
+
const settings = await readProjectSettings(context.invocation.workingDirectory);
|
|
1204
|
+
const projectSlug = requireLinkedProjectSlug(context, settings);
|
|
1205
|
+
const apiToken = await configuredApiToken(context);
|
|
968
1206
|
const client = new AdminApiClient({
|
|
969
1207
|
baseUrl: context.baseUrl,
|
|
970
|
-
token:
|
|
1208
|
+
token: apiToken.token
|
|
971
1209
|
});
|
|
1210
|
+
const project = await findProjectBySlug(client, projectSlug);
|
|
1211
|
+
refuseProjectOfAnotherOrganization(project, settings, apiToken);
|
|
972
1212
|
return {
|
|
973
1213
|
client,
|
|
974
|
-
project
|
|
1214
|
+
project,
|
|
1215
|
+
settings
|
|
975
1216
|
};
|
|
976
1217
|
};
|
|
977
1218
|
//#endregion
|
|
978
|
-
//#region src/utc-minute.ts
|
|
979
|
-
const utcMinute = (time) => {
|
|
980
|
-
const isoTime = new Date(time).toISOString();
|
|
981
|
-
return `${isoTime.slice(0, 10)} ${isoTime.slice(11, 16)} UTC`;
|
|
982
|
-
};
|
|
983
|
-
//#endregion
|
|
984
1219
|
//#region src/commands/configs.ts
|
|
985
1220
|
const LIFECYCLE_STATES = [
|
|
986
1221
|
"new",
|
|
@@ -1348,24 +1583,75 @@ const evaluate = async (context, [configKey], options) => {
|
|
|
1348
1583
|
};
|
|
1349
1584
|
//#endregion
|
|
1350
1585
|
//#region src/commands/init.ts
|
|
1351
|
-
const
|
|
1352
|
-
|
|
1586
|
+
const existingFileWithoutOrganization = (context, file, contents) => {
|
|
1587
|
+
const settings = projectSettingsOf(file, contents);
|
|
1588
|
+
if (settings.organization !== void 0) throw new CliError(`${file} already exists. init never overwrites it; edit the file instead.`);
|
|
1589
|
+
const projectSlug = settings.project ?? requireProjectSlug(context);
|
|
1590
|
+
if (context.projectSlug !== void 0 && context.projectSlug !== projectSlug) throw new CliError(`${file} links the project ${projectSlug}, but --project or CONFIGDIRECTOR_PROJECT names ${context.projectSlug}. init only adds the organization to an existing file; edit the file to link another project.`);
|
|
1591
|
+
const otherFields = Object.fromEntries(Object.entries(settings).filter(([name]) => name !== "project"));
|
|
1592
|
+
return {
|
|
1593
|
+
projectSlug,
|
|
1594
|
+
fieldsOf: (organizationId) => ({
|
|
1595
|
+
project: projectSlug,
|
|
1596
|
+
organization: organizationId,
|
|
1597
|
+
...otherFields
|
|
1598
|
+
})
|
|
1599
|
+
};
|
|
1353
1600
|
};
|
|
1354
|
-
const
|
|
1355
|
-
const settingsFile = projectSettingsFile(context.invocation.workingDirectory);
|
|
1356
|
-
await refuseExistingFile(settingsFile);
|
|
1601
|
+
const newSettingsFile = (context) => {
|
|
1357
1602
|
const projectSlug = requireProjectSlug(context);
|
|
1358
|
-
|
|
1603
|
+
return {
|
|
1604
|
+
projectSlug,
|
|
1605
|
+
fieldsOf: (organizationId) => ({
|
|
1606
|
+
project: projectSlug,
|
|
1607
|
+
organization: organizationId,
|
|
1608
|
+
include: ["**"],
|
|
1609
|
+
exclude: [],
|
|
1610
|
+
ignoreKeys: []
|
|
1611
|
+
})
|
|
1612
|
+
};
|
|
1613
|
+
};
|
|
1614
|
+
const settingsFileToWrite = async (context, file) => {
|
|
1615
|
+
const contents = await readFileIfPresent(file);
|
|
1616
|
+
return contents === void 0 ? newSettingsFile(context) : existingFileWithoutOrganization(context, file, contents);
|
|
1617
|
+
};
|
|
1618
|
+
const projectFoundWith = async (context, apiToken, slug) => {
|
|
1619
|
+
const { parsedBody: projects } = await new AdminApiClient({
|
|
1359
1620
|
baseUrl: context.baseUrl,
|
|
1360
|
-
token:
|
|
1361
|
-
})
|
|
1362
|
-
const
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
ignoreKeys: []
|
|
1621
|
+
token: apiToken.token
|
|
1622
|
+
}).listProjects();
|
|
1623
|
+
const project = projects.find((listedProject) => listedProject.slug === slug);
|
|
1624
|
+
return project === void 0 ? void 0 : {
|
|
1625
|
+
apiToken,
|
|
1626
|
+
project
|
|
1367
1627
|
};
|
|
1368
|
-
|
|
1628
|
+
};
|
|
1629
|
+
const describeOrganizationOf = ({ apiToken, project }) => apiToken.source.from === "sign-in" ? apiToken.source.signIn.organization.name : project.organizationId;
|
|
1630
|
+
const signInsSearched = (apiTokens) => apiTokens.flatMap((apiToken) => apiToken.source.from === "sign-in" ? [apiToken.source.signIn] : []);
|
|
1631
|
+
const onlyProjectFound = (slug, apiTokens, projectsFound) => {
|
|
1632
|
+
const [firstProjectFound] = projectsFound;
|
|
1633
|
+
if (projectsFound.length > 1) throw new CliError(`The organizations ${projectsFound.map(describeOrganizationOf).join(" and ")} both have a project with the slug "${slug}". Name one with --organization.`);
|
|
1634
|
+
if (firstProjectFound === void 0) {
|
|
1635
|
+
const signIns = signInsSearched(apiTokens);
|
|
1636
|
+
throw new CliError(signIns.length === 0 ? `No project in the API token's organization has the slug "${slug}".` : `No project has the slug "${slug}" in the organizations signed in to: ${describeSignIns(signIns)}.`);
|
|
1637
|
+
}
|
|
1638
|
+
return firstProjectFound;
|
|
1639
|
+
};
|
|
1640
|
+
const findProjectAcrossOrganizations = async (context, slug) => {
|
|
1641
|
+
const apiTokens = await apiTokensToSearch(context);
|
|
1642
|
+
const projectsFound = [];
|
|
1643
|
+
for (const apiToken of apiTokens) {
|
|
1644
|
+
const projectFound = await projectFoundWith(context, apiToken, slug);
|
|
1645
|
+
if (projectFound !== void 0) projectsFound.push(projectFound);
|
|
1646
|
+
}
|
|
1647
|
+
return onlyProjectFound(slug, apiTokens, projectsFound);
|
|
1648
|
+
};
|
|
1649
|
+
const init = async (context) => {
|
|
1650
|
+
const settingsFile = projectSettingsFile(context.invocation.workingDirectory);
|
|
1651
|
+
const fileToWrite = await settingsFileToWrite(context, settingsFile);
|
|
1652
|
+
const projectFound = await findProjectAcrossOrganizations(context, fileToWrite.projectSlug);
|
|
1653
|
+
const { project } = projectFound;
|
|
1654
|
+
await writeFile(settingsFile, `${JSON.stringify(fileToWrite.fieldsOf(project.organizationId), null, 2)}\n`);
|
|
1369
1655
|
return {
|
|
1370
1656
|
json: {
|
|
1371
1657
|
settingsFile,
|
|
@@ -1373,9 +1659,10 @@ const init = async (context) => {
|
|
|
1373
1659
|
id: project.id,
|
|
1374
1660
|
slug: project.slug,
|
|
1375
1661
|
name: project.name
|
|
1376
|
-
}
|
|
1662
|
+
},
|
|
1663
|
+
organizationId: project.organizationId
|
|
1377
1664
|
},
|
|
1378
|
-
text: `Linked this directory to the project ${project.name} (${project.slug}) in ${settingsFile}\n`,
|
|
1665
|
+
text: `Linked this directory to the project ${project.name} (${project.slug}) of the organization ${describeOrganizationOf(projectFound)} in ${settingsFile}\n`,
|
|
1379
1666
|
exitCode: ExitCode.success
|
|
1380
1667
|
};
|
|
1381
1668
|
};
|
|
@@ -1436,16 +1723,42 @@ const readPastedApiToken = async (invocation) => {
|
|
|
1436
1723
|
};
|
|
1437
1724
|
//#endregion
|
|
1438
1725
|
//#region src/commands/login.ts
|
|
1726
|
+
const PERMISSION_TO_FIND_PROJECTS = "projects:read";
|
|
1439
1727
|
const login = async (context) => {
|
|
1728
|
+
if (context.organizationOption !== void 0) throw new CliError("login saves the sign-in for the token's own organization, so it takes no --organization or CONFIGDIRECTOR_ORGANIZATION.");
|
|
1440
1729
|
const token = context.tokenOption ?? await readPastedApiToken(context.invocation);
|
|
1441
|
-
await new AdminApiClient({
|
|
1730
|
+
const { parsedBody: apiToken } = await new AdminApiClient({
|
|
1442
1731
|
baseUrl: context.baseUrl,
|
|
1443
1732
|
token
|
|
1444
|
-
}).
|
|
1445
|
-
|
|
1733
|
+
}).describeApiToken();
|
|
1734
|
+
if (!apiToken.permissions.includes(PERMISSION_TO_FIND_PROJECTS)) throw new CliError(`The API token lacks the ${PERMISSION_TO_FIND_PROJECTS} permission, which the CLI needs to find a project. Create a token with it on the dashboard's API tokens page.`);
|
|
1735
|
+
const file = await saveSignIn(context, {
|
|
1736
|
+
organization: apiToken.organization,
|
|
1737
|
+
token
|
|
1738
|
+
});
|
|
1739
|
+
return {
|
|
1740
|
+
json: {
|
|
1741
|
+
signInsFile: file,
|
|
1742
|
+
organization: apiToken.organization
|
|
1743
|
+
},
|
|
1744
|
+
text: `Signed in to ${apiToken.organization.name}. The CLI sign-in is saved in ${file}\n`,
|
|
1745
|
+
exitCode: ExitCode.success
|
|
1746
|
+
};
|
|
1747
|
+
};
|
|
1748
|
+
//#endregion
|
|
1749
|
+
//#region src/commands/logout.ts
|
|
1750
|
+
const logout = async (context) => {
|
|
1751
|
+
if (context.tokenOption !== void 0) throw new CliError("logout removes a CLI sign-in and sends no token, so it takes no --token.");
|
|
1752
|
+
const settings = await readProjectSettings(context.invocation.workingDirectory);
|
|
1753
|
+
const signIns = await savedSignIns(context);
|
|
1754
|
+
const choice = chosenSignIn(signIns, organizationReferenceOf(context, settings));
|
|
1755
|
+
if (choice.outcome === "no-sign-in") throw new CliError("There is no CLI sign-in on this machine to remove; `configdirector login` adds one.");
|
|
1756
|
+
const { signIn } = choice;
|
|
1757
|
+
await removeSignIn(context, signIns, signIn.organization);
|
|
1446
1758
|
return {
|
|
1447
|
-
json: {
|
|
1448
|
-
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
|
+
`,
|
|
1449
1762
|
exitCode: ExitCode.success
|
|
1450
1763
|
};
|
|
1451
1764
|
};
|
|
@@ -1455,7 +1768,7 @@ const projectLines = (projects) => projects.length === 0 ? ["No projects found."
|
|
|
1455
1768
|
const listProjects = async (context) => {
|
|
1456
1769
|
const { parsedBody: projects, responseBody } = await new AdminApiClient({
|
|
1457
1770
|
baseUrl: context.baseUrl,
|
|
1458
|
-
token: await configuredApiToken(context)
|
|
1771
|
+
token: (await configuredApiToken(context)).token
|
|
1459
1772
|
}).listProjects();
|
|
1460
1773
|
return {
|
|
1461
1774
|
json: responseBody,
|
|
@@ -3879,13 +4192,7 @@ const scanCodeReferences = async (scope, projectKeys, snippetCut) => {
|
|
|
3879
4192
|
//#region src/project-scan.ts
|
|
3880
4193
|
const connectToScannableProject = async (context) => {
|
|
3881
4194
|
const { workingDirectory } = context.invocation;
|
|
3882
|
-
const settings = await
|
|
3883
|
-
const projectSlug = requireLinkedProjectSlug(context, settings);
|
|
3884
|
-
const client = new AdminApiClient({
|
|
3885
|
-
baseUrl: context.baseUrl,
|
|
3886
|
-
token: await configuredApiToken(context)
|
|
3887
|
-
});
|
|
3888
|
-
const project = await findProjectBySlug(client, projectSlug);
|
|
4195
|
+
const { client, project, settings } = await connectToLinkedProject(context);
|
|
3889
4196
|
const configs = (await client.listConfigs(project.id, { form: "every-config" })).map((listedConfig) => listedConfig.config);
|
|
3890
4197
|
const projectKeys = new Set(configs.flatMap((config) => [config.key, ...config.deprecatedKeys.map((deprecatedKey) => deprecatedKey.key)]));
|
|
3891
4198
|
return {
|
|
@@ -4258,7 +4565,7 @@ const setTargetingRules = async (context, [configKey], options) => {
|
|
|
4258
4565
|
const showTargetingRulesReference = async (context) => {
|
|
4259
4566
|
const { parsedBody: reference, responseBody } = await new AdminApiClient({
|
|
4260
4567
|
baseUrl: context.baseUrl,
|
|
4261
|
-
token: await configuredApiToken(context)
|
|
4568
|
+
token: (await configuredApiToken(context)).token
|
|
4262
4569
|
}).getTargetingRulesReference();
|
|
4263
4570
|
return {
|
|
4264
4571
|
json: responseBody,
|
|
@@ -4490,7 +4797,8 @@ const runCommand = async (invocation, command, actionArguments) => {
|
|
|
4490
4797
|
invocation,
|
|
4491
4798
|
baseUrl: withoutTrailingSlashes(options.baseUrl ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_BASE_URL") ?? DEFAULT_BASE_URL),
|
|
4492
4799
|
tokenOption: options.token,
|
|
4493
|
-
projectSlug: options.project ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_PROJECT")
|
|
4800
|
+
projectSlug: options.project ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_PROJECT"),
|
|
4801
|
+
organizationOption: options.organization ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_ORGANIZATION")
|
|
4494
4802
|
}, parsedCommand.processedArgs, parsedCommand.opts());
|
|
4495
4803
|
invocation.stdout.write(options.json ? `${JSON.stringify(output.json, null, 2)}\n` : output.text);
|
|
4496
4804
|
return output.exitCode;
|
|
@@ -4509,15 +4817,17 @@ const run = async (invocation) => {
|
|
|
4509
4817
|
const commandAction = (command) => async (...actionArguments) => {
|
|
4510
4818
|
exitCode = await runCommand(invocation, command, actionArguments);
|
|
4511
4819
|
};
|
|
4512
|
-
const program = new Command("configdirector").description("Work with ConfigDirector configs from the terminal and CI.").addHelpText("afterAll", "\nBeta: commands and output may change before 1.0.").configureHelp({ showGlobalOptions: true }).version(version).option("--token <token>", "API token, used instead of CONFIGDIRECTOR_TOKEN and the
|
|
4820
|
+
const program = new Command("configdirector").description("Work with ConfigDirector configs from the terminal and CI.").addHelpText("afterAll", "\nBeta: commands and output may change before 1.0.").configureHelp({ showGlobalOptions: true }).version(version).option("--token <token>", "API token, used instead of CONFIGDIRECTOR_TOKEN and the CLI sign-ins saved by login").option("--base-url <url>", `Admin API host, used instead of CONFIGDIRECTOR_BASE_URL (default ${DEFAULT_BASE_URL})`).option("--project <slug>", "project slug, used instead of CONFIGDIRECTOR_PROJECT").option("--organization <name or id>", "organization whose CLI sign-in to use, by its saved name or its id, used instead of CONFIGDIRECTOR_ORGANIZATION and `organization` in configdirector.json").option("--json", "print JSON on stdout").exitOverride().configureOutput({
|
|
4513
4821
|
writeOut: (text) => invocation.stdout.write(text),
|
|
4514
4822
|
writeErr: (text) => invocation.stderr.write(text)
|
|
4515
4823
|
});
|
|
4516
|
-
program.command("login").description("save an API token for
|
|
4824
|
+
program.command("login").description("save an API token as the CLI sign-in for its organization, after checking it can find projects").action(commandAction(login));
|
|
4825
|
+
program.command("logout").description("remove the CLI sign-in this directory's commands would use; the token itself keeps working").action(commandAction(logout));
|
|
4517
4826
|
program.command("init").description("write configdirector.json for a project in the current directory").action(commandAction(init));
|
|
4518
4827
|
program.command("scan").argument("[path]", "directory or file to scan (default: the current directory)").description("list where the code reads config keys through a ConfigDirector SDK").option("--upload", "send the code references to the project, replacing those it holds for this repository").option("--repository <name>", "the repository name to upload under (default: the path of the git remote origin, such as ConfigDirector/cli)").addOption(new Option("--context-lines <number>", `how many lines of code to include before and after each reference, from 0 to 5 (default: 2)`).conflicts("snippets")).option("--no-snippets", "leave out the lines of code around each reference").action(commandAction(scan));
|
|
4519
4828
|
program.command("validate").argument("[path]", "directory or file to check (default: the current directory)").description("check every config key the code reads against the project's configs, exiting 1 when one is wrong").action(commandAction(validate));
|
|
4520
4829
|
program.command("evaluate").argument("<key>", "the config's key").description("show the value a context gets for a config in an environment, and why").requiredOption("--environment <slug>", "slug of the environment whose targeting rules to evaluate (required)").option("--context-kind <kind>", "context kind of the context to evaluate for (default: user)").option("--context-id <identifier>", "identifier of the context").option("--context-name <name>", "name of the context").option("--context-trait <name=value>", "a trait of the context, as a string, or name:=<json> for a number, boolean, array or object; repeatable", repeatedOption).option("--context-json <json>", "the whole context as a JSON object, or @<file> to read it from a file").option("--saved-context <kind:identifier>", "a context ConfigDirector saved from SDK evaluations in the environment, such as user:123").addHelpText("after", "\nGive the context with the --context-* options, with --context-json, or with --saved-context. Without any of them, the config is evaluated for an empty context, as an SDK given none would.").action(commandAction(evaluate));
|
|
4830
|
+
program.command("api-token").description("describe the API token a command here would use").command("get").description("show the token's organization, name, permissions and expiration, and where it came from; never the token itself").action(commandAction(getApiToken));
|
|
4521
4831
|
program.command("projects").description("list the projects of the API token's organization").command("list").description("list the organization's projects, whatever project is selected").action(commandAction(listProjects));
|
|
4522
4832
|
program.command("environments").description("list the project's environments").command("list").description("list the project's environments with their slugs, marking the live ones").action(commandAction(listEnvironments));
|
|
4523
4833
|
const configsCommand = program.command("configs").description("list, show, create and archive a project's configs");
|