@configdirector/cli 0.1.0-beta.2 → 0.8.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 +63 -0
- package/README.md +210 -10
- package/dist/configdirector.mjs +1633 -80
- package/package.json +1 -1
package/dist/configdirector.mjs
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { Command, CommanderError } from "commander";
|
|
4
|
-
import { access, mkdir, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { Command, CommanderError, Option } from "commander";
|
|
5
4
|
import * as v from "valibot";
|
|
5
|
+
import { access, mkdir, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
6
6
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { createInterface } from "node:readline";
|
|
8
8
|
import ignore from "ignore";
|
|
9
9
|
import picomatch from "picomatch";
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { text } from "node:stream/consumers";
|
|
10
14
|
//#region package.json
|
|
11
|
-
var version = "0.
|
|
15
|
+
var version = "0.8.0-beta.1";
|
|
12
16
|
//#endregion
|
|
13
17
|
//#region src/CliError.ts
|
|
14
18
|
var CliError = class extends Error {
|
|
@@ -38,15 +42,45 @@ const ApiValidationErrorBodySchema = v.object({ errors: v.array(v.object({
|
|
|
38
42
|
message: v.string()
|
|
39
43
|
})) });
|
|
40
44
|
const ServiceSuspendedBodySchema = v.object({ code: v.literal("SERVICE_SUSPENDED") });
|
|
45
|
+
const RepositoryLimitReachedBodySchema = v.object({ code: v.literal("REPOSITORY_LIMIT_REACHED") });
|
|
46
|
+
const EvaluationNotFoundBodySchema = v.object({
|
|
47
|
+
error: v.string(),
|
|
48
|
+
code: v.picklist([
|
|
49
|
+
"CONFIG_NOT_FOUND",
|
|
50
|
+
"ENVIRONMENT_NOT_FOUND",
|
|
51
|
+
"CONTEXT_NOT_FOUND"
|
|
52
|
+
])
|
|
53
|
+
});
|
|
41
54
|
const describeFieldError = (fieldError) => fieldError.name === void 0 ? fieldError.message : `${fieldError.name}: ${fieldError.message}`;
|
|
42
55
|
var ApiAuthorizationError = class extends CliError {
|
|
43
56
|
constructor(code, apiMessage) {
|
|
44
57
|
super(`The Admin API refused the request: ${apiMessage} (${code})`);
|
|
58
|
+
this.code = code;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const ifPermitted = async (request) => {
|
|
62
|
+
try {
|
|
63
|
+
return await request;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof ApiAuthorizationError && error.code === "INSUFFICIENT_PERMISSIONS") return;
|
|
66
|
+
throw error;
|
|
45
67
|
}
|
|
46
68
|
};
|
|
47
69
|
var ApiValidationError = class extends CliError {
|
|
48
|
-
constructor(
|
|
49
|
-
super(`The Admin API rejected the request:\n${
|
|
70
|
+
constructor(fieldErrors) {
|
|
71
|
+
super(`The Admin API rejected the request:\n${fieldErrors.map((error) => ` ${describeFieldError(error)}`).join("\n")}`);
|
|
72
|
+
this.fieldErrors = fieldErrors;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
var EvaluationNotFoundError = class extends CliError {
|
|
76
|
+
constructor(code, apiMessage) {
|
|
77
|
+
super(`The Admin API could not evaluate the config: ${apiMessage} (${code})`);
|
|
78
|
+
this.code = code;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
var RepositoryLimitReachedError = class extends CliError {
|
|
82
|
+
constructor() {
|
|
83
|
+
super("The project already holds its maximum of scanned repositories, so the Admin API refused the upload (REPOSITORY_LIMIT_REACHED). Remove a repository it no longer needs with `configdirector repositories remove <name>`, then upload again.");
|
|
50
84
|
}
|
|
51
85
|
};
|
|
52
86
|
var ServiceSuspendedError = class extends CliError {
|
|
@@ -66,6 +100,579 @@ var UnexpectedApiResponseError = class extends CliError {
|
|
|
66
100
|
}
|
|
67
101
|
};
|
|
68
102
|
//#endregion
|
|
103
|
+
//#region src/aligned-columns.ts
|
|
104
|
+
const COLUMN_GAP = " ";
|
|
105
|
+
const widestCellOf = (rows, column) => Math.max(0, ...rows.map((row) => row[column]?.length ?? 0));
|
|
106
|
+
const alignedColumns = (rows) => rows.map((row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widestCellOf(rows, column))).join(COLUMN_GAP));
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/admin-api/targeting-rules.ts
|
|
109
|
+
const RuleValueSchema = v.union([
|
|
110
|
+
v.string(),
|
|
111
|
+
v.number(),
|
|
112
|
+
v.boolean()
|
|
113
|
+
]);
|
|
114
|
+
const attributeConditionEntries = {
|
|
115
|
+
attribute: v.string(),
|
|
116
|
+
trait: v.nullish(v.string()),
|
|
117
|
+
operator: v.string(),
|
|
118
|
+
targetValues: v.array(v.unknown())
|
|
119
|
+
};
|
|
120
|
+
const AttributeConditionSchema = v.object(attributeConditionEntries);
|
|
121
|
+
const ConditionGroupSchema = v.array(AttributeConditionSchema);
|
|
122
|
+
const SegmentConditionSchema = v.object({
|
|
123
|
+
id: v.string(),
|
|
124
|
+
kind: v.literal("segment"),
|
|
125
|
+
operator: v.string(),
|
|
126
|
+
segmentId: v.string()
|
|
127
|
+
});
|
|
128
|
+
const ConditionSchema = v.variant("kind", [v.object({
|
|
129
|
+
id: v.string(),
|
|
130
|
+
kind: v.literal("attribute"),
|
|
131
|
+
...attributeConditionEntries
|
|
132
|
+
}), SegmentConditionSchema]);
|
|
133
|
+
const PercentageSchema = v.object({
|
|
134
|
+
percentage: v.number(),
|
|
135
|
+
value: RuleValueSchema
|
|
136
|
+
});
|
|
137
|
+
const RuleSchema = v.variant("type", [v.variant("target", [v.object({
|
|
138
|
+
id: v.string(),
|
|
139
|
+
type: v.literal("conditional"),
|
|
140
|
+
conditions: v.array(ConditionSchema),
|
|
141
|
+
target: v.literal("value"),
|
|
142
|
+
value: RuleValueSchema
|
|
143
|
+
}), v.object({
|
|
144
|
+
id: v.string(),
|
|
145
|
+
type: v.literal("conditional"),
|
|
146
|
+
conditions: v.array(ConditionSchema),
|
|
147
|
+
target: v.literal("percentage"),
|
|
148
|
+
percentages: v.array(PercentageSchema)
|
|
149
|
+
})]), v.object({
|
|
150
|
+
id: v.string(),
|
|
151
|
+
type: v.literal("percentage"),
|
|
152
|
+
percentages: v.array(PercentageSchema)
|
|
153
|
+
})]);
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/admin-api/config-with-targeting-rules.ts
|
|
156
|
+
const TargetingRulesInEnvironmentSchema = v.object({
|
|
157
|
+
environment: v.object({
|
|
158
|
+
slug: v.string(),
|
|
159
|
+
live: v.boolean()
|
|
160
|
+
}),
|
|
161
|
+
rules: v.array(RuleSchema),
|
|
162
|
+
defaultValue: v.string()
|
|
163
|
+
});
|
|
164
|
+
const NumericBoundSchema = v.object({
|
|
165
|
+
relation: v.string(),
|
|
166
|
+
value: v.number()
|
|
167
|
+
});
|
|
168
|
+
const NumericBoundsSchema = v.object({
|
|
169
|
+
min: v.optional(NumericBoundSchema),
|
|
170
|
+
max: v.optional(NumericBoundSchema)
|
|
171
|
+
});
|
|
172
|
+
const EnumValuesSchema = v.object({
|
|
173
|
+
valueType: v.string(),
|
|
174
|
+
values: v.array(v.string())
|
|
175
|
+
});
|
|
176
|
+
const TimespanRangeSchema = v.object({
|
|
177
|
+
unit: v.string(),
|
|
178
|
+
allowFractions: v.nullish(v.boolean()),
|
|
179
|
+
min: v.number(),
|
|
180
|
+
max: v.number()
|
|
181
|
+
});
|
|
182
|
+
const ConstrainedTypeSchema = v.variant("type", [
|
|
183
|
+
v.object({
|
|
184
|
+
type: v.literal("integer"),
|
|
185
|
+
typeOptions: v.nullish(NumericBoundsSchema)
|
|
186
|
+
}),
|
|
187
|
+
v.object({
|
|
188
|
+
type: v.literal("float"),
|
|
189
|
+
typeOptions: v.nullish(NumericBoundsSchema)
|
|
190
|
+
}),
|
|
191
|
+
v.object({
|
|
192
|
+
type: v.literal("enum"),
|
|
193
|
+
typeOptions: EnumValuesSchema
|
|
194
|
+
}),
|
|
195
|
+
v.object({
|
|
196
|
+
type: v.literal("timespan"),
|
|
197
|
+
typeOptions: TimespanRangeSchema
|
|
198
|
+
}),
|
|
199
|
+
v.object({
|
|
200
|
+
type: v.literal("url"),
|
|
201
|
+
typeOptions: v.nullish(v.object({ secureOnly: v.nullish(v.boolean()) }))
|
|
202
|
+
}),
|
|
203
|
+
v.object({
|
|
204
|
+
type: v.literal("json"),
|
|
205
|
+
typeOptions: v.nullish(v.object({ useSchema: v.optional(v.boolean()) }))
|
|
206
|
+
})
|
|
207
|
+
]);
|
|
208
|
+
const CONSTRAINED_TYPE_NAMES = new Set(ConstrainedTypeSchema.options.map((option) => option.entries.type.literal));
|
|
209
|
+
const ConfigTypeSchema = v.union([ConstrainedTypeSchema, v.object({ type: v.pipe(v.string(), v.notValues([...CONSTRAINED_TYPE_NAMES])) })]);
|
|
210
|
+
const hasTypeConstraints = (configType) => CONSTRAINED_TYPE_NAMES.has(configType.type);
|
|
211
|
+
const ConfigWithTargetingRulesSchema = v.intersect([v.object({
|
|
212
|
+
key: v.string(),
|
|
213
|
+
description: v.nullish(v.string()),
|
|
214
|
+
role: v.string(),
|
|
215
|
+
lifetime: v.string(),
|
|
216
|
+
state: v.string(),
|
|
217
|
+
client: v.boolean(),
|
|
218
|
+
server: v.boolean(),
|
|
219
|
+
tags: v.array(v.object({ name: v.string() })),
|
|
220
|
+
targets: v.array(TargetingRulesInEnvironmentSchema)
|
|
221
|
+
}), ConfigTypeSchema]);
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/old-key-lines.ts
|
|
224
|
+
const oldKeyLines = (requestedKey, currentKey) => requestedKey === currentKey ? [] : [`${requestedKey} is an old key of ${currentKey}.`];
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region src/rule-sentences.ts
|
|
227
|
+
const TRAIT_POINTER_PREFIX = "/";
|
|
228
|
+
const ATTRIBUTE_LABELS = {
|
|
229
|
+
identifier: "context identifier",
|
|
230
|
+
kind: "context kind",
|
|
231
|
+
name: "context name",
|
|
232
|
+
appName: "app name",
|
|
233
|
+
appVersion: "app version"
|
|
234
|
+
};
|
|
235
|
+
const formatPercentage = (value) => Number(value.toFixed(3)).toString();
|
|
236
|
+
const subjectOf = (condition) => {
|
|
237
|
+
if (condition.attribute === "traits" && condition.trait != null) return condition.trait.startsWith(TRAIT_POINTER_PREFIX) ? condition.trait.slice(1) : condition.trait;
|
|
238
|
+
return ATTRIBUTE_LABELS[condition.attribute] ?? condition.attribute;
|
|
239
|
+
};
|
|
240
|
+
const segmentNameOf = (segmentId, segments) => segments[segmentId]?.name ?? segmentId;
|
|
241
|
+
const describeAttributeCondition = (condition) => `${subjectOf(condition)} ${condition.operator} ${condition.targetValues.map(String).join(", ")}`;
|
|
242
|
+
const describeConditionGroup = (group) => group.map(describeAttributeCondition).join(" and ");
|
|
243
|
+
const describeCondition = (condition, segments) => {
|
|
244
|
+
switch (condition.kind) {
|
|
245
|
+
case "attribute": return describeAttributeCondition(condition);
|
|
246
|
+
case "segment": return `${condition.operator} segment ${segmentNameOf(condition.segmentId, segments)}`;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const describePercentages = (percentages) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${String(percentage.value)}`).join(", ");
|
|
250
|
+
const describeRule = (rule, segments) => {
|
|
251
|
+
if (rule.type === "percentage") return describePercentages(rule.percentages);
|
|
252
|
+
return `${rule.conditions.map((condition) => describeCondition(condition, segments)).join(" and ")}: ${rule.target === "value" ? String(rule.value) : describePercentages(rule.percentages)}`;
|
|
253
|
+
};
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region src/configs/config-text.ts
|
|
256
|
+
const boundsDescriptions = (bounds) => {
|
|
257
|
+
const limits = [bounds?.min, bounds?.max].flatMap((bound) => bound === void 0 ? [] : [`${bound.relation} ${bound.value}`]);
|
|
258
|
+
return limits.length === 0 ? [] : [limits.join(" and ")];
|
|
259
|
+
};
|
|
260
|
+
const enumValuesDescription = ({ valueType, values }) => valueType === "number" ? `one of the numbers ${values.join(", ")}` : `one of ${values.join(", ")}`;
|
|
261
|
+
const timespanRangeDescriptions = (range) => [`${range.min} to ${range.max} ${range.unit}`, ...range.allowFractions ? ["fractions allowed"] : []];
|
|
262
|
+
const typeConstraintDescriptions = (config) => {
|
|
263
|
+
if (!hasTypeConstraints(config)) return [];
|
|
264
|
+
switch (config.type) {
|
|
265
|
+
case "integer":
|
|
266
|
+
case "float": return boundsDescriptions(config.typeOptions);
|
|
267
|
+
case "enum": return [enumValuesDescription(config.typeOptions)];
|
|
268
|
+
case "timespan": return timespanRangeDescriptions(config.typeOptions);
|
|
269
|
+
case "url": return config.typeOptions?.secureOnly ? ["HTTPS only"] : [];
|
|
270
|
+
case "json": return config.typeOptions?.useSchema ? ["checked against a JSON schema"] : [];
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
const availabilityDescription = (config) => {
|
|
274
|
+
if (config.client && config.server) return "client and server SDKs";
|
|
275
|
+
if (config.client) return "client-only";
|
|
276
|
+
if (config.server) return "server-only";
|
|
277
|
+
return "served to no SDK";
|
|
278
|
+
};
|
|
279
|
+
const settingLines = (config) => [
|
|
280
|
+
...config.description ? [`Description: ${config.description}`] : [],
|
|
281
|
+
`Type: ${[config.type, ...typeConstraintDescriptions(config)].join(", ")}`,
|
|
282
|
+
`Role: ${config.role}`,
|
|
283
|
+
`Lifetime: ${config.lifetime}`,
|
|
284
|
+
`Availability: ${availabilityDescription(config)}`,
|
|
285
|
+
`Lifecycle state: ${config.state}`,
|
|
286
|
+
...config.tags.length === 0 ? [] : [`Tags: ${config.tags.map((tag) => tag.name).join(", ")}`]
|
|
287
|
+
];
|
|
288
|
+
const environmentHeading = (targetingRules) => targetingRules.environment.live ? `${targetingRules.environment.slug} (live)` : targetingRules.environment.slug;
|
|
289
|
+
const targetingRuleLines = (targetingRules, segments) => [
|
|
290
|
+
environmentHeading(targetingRules),
|
|
291
|
+
...targetingRules.rules.map((rule, index) => ` ${index + 1}. ${describeRule(rule, segments)}`),
|
|
292
|
+
` Default targeting rule: ${targetingRules.defaultValue}`
|
|
293
|
+
];
|
|
294
|
+
const configText = (config, requestedKey, segments) => [
|
|
295
|
+
...oldKeyLines(requestedKey, config.key),
|
|
296
|
+
config.key,
|
|
297
|
+
...settingLines(config).map((line) => ` ${line}`),
|
|
298
|
+
...config.targets.flatMap((targetingRules) => ["", ...targetingRuleLines(targetingRules, segments)]),
|
|
299
|
+
""
|
|
300
|
+
].join("\n");
|
|
301
|
+
//#endregion
|
|
302
|
+
//#region src/file-contents.ts
|
|
303
|
+
const readFileIfPresent = async (file) => {
|
|
304
|
+
try {
|
|
305
|
+
return await readFile(file, "utf8");
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (error.code === "ENOENT") return;
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
const readOptionFile = async (argumentLabel, path, workingDirectory) => {
|
|
312
|
+
const file = resolve(workingDirectory, path);
|
|
313
|
+
const contents = await readFileIfPresent(file);
|
|
314
|
+
if (contents === void 0) throw new CliError(`${argumentLabel} names ${file}, which does not exist.`);
|
|
315
|
+
return {
|
|
316
|
+
file,
|
|
317
|
+
contents
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
const FILE_ARGUMENT_MARKER = "@";
|
|
321
|
+
const atFileArgumentText = async (argumentLabel, argument, workingDirectory) => {
|
|
322
|
+
if (!argument.startsWith(FILE_ARGUMENT_MARKER)) return argument;
|
|
323
|
+
return (await readOptionFile(argumentLabel, argument.slice(1), workingDirectory)).contents;
|
|
324
|
+
};
|
|
325
|
+
const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
326
|
+
const parseJson = (contents) => {
|
|
327
|
+
try {
|
|
328
|
+
return JSON.parse(contents);
|
|
329
|
+
} catch {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const pathExists = (path) => stat(path).then(() => true, () => false);
|
|
334
|
+
const fileExists = (path) => stat(path).then((stats) => stats.isFile(), () => false);
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/configs/new-config.ts
|
|
337
|
+
const CONFIG_TYPES = [
|
|
338
|
+
"boolean",
|
|
339
|
+
"string",
|
|
340
|
+
"integer",
|
|
341
|
+
"float",
|
|
342
|
+
"timespan",
|
|
343
|
+
"enum",
|
|
344
|
+
"url",
|
|
345
|
+
"json"
|
|
346
|
+
];
|
|
347
|
+
const CONFIG_ROLES = [
|
|
348
|
+
"config",
|
|
349
|
+
"flag",
|
|
350
|
+
"kill-switch",
|
|
351
|
+
"experiment"
|
|
352
|
+
];
|
|
353
|
+
const CONFIG_LIFETIMES = ["permanent", "temporary"];
|
|
354
|
+
const TIMESPAN_UNITS = [
|
|
355
|
+
"milliseconds",
|
|
356
|
+
"seconds",
|
|
357
|
+
"minutes"
|
|
358
|
+
];
|
|
359
|
+
const ENUM_VALUE_TYPES = ["string", "number"];
|
|
360
|
+
const DEFAULT_ENUM_VALUE_TYPE = "string";
|
|
361
|
+
const TYPE_CONSTRAINT_OPTION_NAMES = {
|
|
362
|
+
min: "--min",
|
|
363
|
+
max: "--max",
|
|
364
|
+
exclusiveMin: "--exclusive-min",
|
|
365
|
+
exclusiveMax: "--exclusive-max",
|
|
366
|
+
unit: "--unit",
|
|
367
|
+
allowFractions: "--allow-fractions",
|
|
368
|
+
allowedValue: "--allowed-value",
|
|
369
|
+
enumValueType: "--enum-value-type",
|
|
370
|
+
httpsOnly: "--https-only",
|
|
371
|
+
jsonSchema: "--json-schema"
|
|
372
|
+
};
|
|
373
|
+
const availabilityOf = (options) => {
|
|
374
|
+
if (options.clientOnly) return {
|
|
375
|
+
client: true,
|
|
376
|
+
server: false
|
|
377
|
+
};
|
|
378
|
+
if (options.serverOnly) return {
|
|
379
|
+
client: false,
|
|
380
|
+
server: true
|
|
381
|
+
};
|
|
382
|
+
return {
|
|
383
|
+
client: true,
|
|
384
|
+
server: true
|
|
385
|
+
};
|
|
386
|
+
};
|
|
387
|
+
const BOOLEAN_VALUES = new Map([["true", true], ["false", false]]);
|
|
388
|
+
const numberOf = (text) => {
|
|
389
|
+
const value = parseJson(text);
|
|
390
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
391
|
+
};
|
|
392
|
+
const constraintNumberOf = (options, option) => {
|
|
393
|
+
const text = options[option];
|
|
394
|
+
if (text === void 0) return;
|
|
395
|
+
const value = numberOf(text);
|
|
396
|
+
if (value === void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES[option]} takes a number, not ${text}.`);
|
|
397
|
+
return value;
|
|
398
|
+
};
|
|
399
|
+
const boundOf = (options, option, relation) => {
|
|
400
|
+
const value = constraintNumberOf(options, option);
|
|
401
|
+
return value === void 0 ? void 0 : {
|
|
402
|
+
relation,
|
|
403
|
+
value
|
|
404
|
+
};
|
|
405
|
+
};
|
|
406
|
+
const numericBoundsOf = (options) => ({
|
|
407
|
+
min: boundOf(options, "min", ">=") ?? boundOf(options, "exclusiveMin", ">"),
|
|
408
|
+
max: boundOf(options, "max", "<=") ?? boundOf(options, "exclusiveMax", "<")
|
|
409
|
+
});
|
|
410
|
+
const jsonTextOf = async ({ argumentLabel, text, workingDirectory }) => {
|
|
411
|
+
const jsonText = (await atFileArgumentText(argumentLabel, text, workingDirectory)).trim();
|
|
412
|
+
return parseJson(jsonText) === void 0 ? void 0 : jsonText;
|
|
413
|
+
};
|
|
414
|
+
const jsonSchemaOptionsOf = async (options, workingDirectory) => {
|
|
415
|
+
if (options.jsonSchema === void 0) return;
|
|
416
|
+
const { file, contents } = await readOptionFile(TYPE_CONSTRAINT_OPTION_NAMES.jsonSchema, options.jsonSchema, workingDirectory);
|
|
417
|
+
const schema = parseJson(contents);
|
|
418
|
+
if (schema === void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES.jsonSchema} names ${file}, which is not JSON.`);
|
|
419
|
+
return {
|
|
420
|
+
useSchema: true,
|
|
421
|
+
schema
|
|
422
|
+
};
|
|
423
|
+
};
|
|
424
|
+
const timespanRangeOf = async (options) => {
|
|
425
|
+
if (options.unit === void 0 || options.min === void 0 || options.max === void 0) throw new CliError("--type timespan needs --unit, --min and --max.");
|
|
426
|
+
return {
|
|
427
|
+
unit: options.unit,
|
|
428
|
+
min: constraintNumberOf(options, "min"),
|
|
429
|
+
max: constraintNumberOf(options, "max"),
|
|
430
|
+
allowFractions: options.allowFractions
|
|
431
|
+
};
|
|
432
|
+
};
|
|
433
|
+
const NUMERIC_BOUND_OPTIONS = [
|
|
434
|
+
"min",
|
|
435
|
+
"max",
|
|
436
|
+
"exclusiveMin",
|
|
437
|
+
"exclusiveMax"
|
|
438
|
+
];
|
|
439
|
+
const CONFIG_TYPE_RULES = {
|
|
440
|
+
boolean: {
|
|
441
|
+
constraintOptions: [],
|
|
442
|
+
valueDescription: "true or false",
|
|
443
|
+
valueOf: async ({ text }) => BOOLEAN_VALUES.get(text),
|
|
444
|
+
typeOptionsOf: async () => void 0
|
|
445
|
+
},
|
|
446
|
+
string: {
|
|
447
|
+
constraintOptions: [],
|
|
448
|
+
valueDescription: "text",
|
|
449
|
+
valueOf: async ({ text }) => text,
|
|
450
|
+
typeOptionsOf: async () => void 0
|
|
451
|
+
},
|
|
452
|
+
integer: {
|
|
453
|
+
constraintOptions: NUMERIC_BOUND_OPTIONS,
|
|
454
|
+
valueDescription: "a number",
|
|
455
|
+
valueOf: async ({ text }) => numberOf(text),
|
|
456
|
+
typeOptionsOf: async (options) => ({
|
|
457
|
+
isInteger: true,
|
|
458
|
+
...numericBoundsOf(options)
|
|
459
|
+
})
|
|
460
|
+
},
|
|
461
|
+
float: {
|
|
462
|
+
constraintOptions: NUMERIC_BOUND_OPTIONS,
|
|
463
|
+
valueDescription: "a number",
|
|
464
|
+
valueOf: async ({ text }) => numberOf(text),
|
|
465
|
+
typeOptionsOf: async (options) => ({
|
|
466
|
+
isInteger: false,
|
|
467
|
+
...numericBoundsOf(options)
|
|
468
|
+
})
|
|
469
|
+
},
|
|
470
|
+
timespan: {
|
|
471
|
+
constraintOptions: [
|
|
472
|
+
"min",
|
|
473
|
+
"max",
|
|
474
|
+
"unit",
|
|
475
|
+
"allowFractions"
|
|
476
|
+
],
|
|
477
|
+
valueDescription: "a number",
|
|
478
|
+
valueOf: async ({ text }) => numberOf(text),
|
|
479
|
+
typeOptionsOf: timespanRangeOf
|
|
480
|
+
},
|
|
481
|
+
enum: {
|
|
482
|
+
constraintOptions: ["allowedValue", "enumValueType"],
|
|
483
|
+
valueDescription: "text",
|
|
484
|
+
valueOf: async ({ text }) => text,
|
|
485
|
+
typeOptionsOf: async (options) => ({
|
|
486
|
+
values: options.allowedValue ?? [],
|
|
487
|
+
valueType: options.enumValueType ?? DEFAULT_ENUM_VALUE_TYPE
|
|
488
|
+
})
|
|
489
|
+
},
|
|
490
|
+
url: {
|
|
491
|
+
constraintOptions: ["httpsOnly"],
|
|
492
|
+
valueDescription: "text",
|
|
493
|
+
valueOf: async ({ text }) => text,
|
|
494
|
+
typeOptionsOf: async (options) => ({ secureOnly: options.httpsOnly === true })
|
|
495
|
+
},
|
|
496
|
+
json: {
|
|
497
|
+
constraintOptions: ["jsonSchema"],
|
|
498
|
+
valueDescription: "JSON or @<file>",
|
|
499
|
+
valueOf: jsonTextOf,
|
|
500
|
+
typeOptionsOf: jsonSchemaOptionsOf
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
const typeAlternatives = (types) => types.length === 1 ? `${types[0]}` : `${types.slice(0, -1).join(", ")} or ${types.at(-1)}`;
|
|
504
|
+
const typesTaking = (option) => CONFIG_TYPES.filter((type) => CONFIG_TYPE_RULES[type].constraintOptions.includes(option));
|
|
505
|
+
const typeConstraintHelpGroup = (option) => `Options for --type ${typeAlternatives(typesTaking(option))}:`;
|
|
506
|
+
const TYPE_CONSTRAINT_OPTIONS = Object.keys(TYPE_CONSTRAINT_OPTION_NAMES);
|
|
507
|
+
const rejectOptionsOfOtherTypes = (options) => {
|
|
508
|
+
const { constraintOptions } = CONFIG_TYPE_RULES[options.type];
|
|
509
|
+
const otherTypeOption = TYPE_CONSTRAINT_OPTIONS.find((option) => options[option] !== void 0 && !constraintOptions.includes(option));
|
|
510
|
+
if (otherTypeOption !== void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES[otherTypeOption]} cannot be used with --type ${options.type}. It applies to --type ${typeAlternatives(typesTaking(otherTypeOption))}.`);
|
|
511
|
+
};
|
|
512
|
+
const typedValueOf = async (type, argument) => {
|
|
513
|
+
const rules = CONFIG_TYPE_RULES[type];
|
|
514
|
+
const value = await rules.valueOf(argument);
|
|
515
|
+
if (value === void 0) throw new CliError(`${argument.argumentLabel} takes ${rules.valueDescription} with --type ${type}, not ${argument.text}.`);
|
|
516
|
+
return value;
|
|
517
|
+
};
|
|
518
|
+
const VARIATION_SEPARATOR = "=";
|
|
519
|
+
const variationOf = async (type, variation, workingDirectory) => {
|
|
520
|
+
const separatorIndex = variation.indexOf(VARIATION_SEPARATOR);
|
|
521
|
+
const name = variation.slice(0, separatorIndex);
|
|
522
|
+
if (separatorIndex === -1 || name === "") throw new CliError(`--variation takes name=value, not ${variation}.`);
|
|
523
|
+
const text = variation.slice(separatorIndex + 1);
|
|
524
|
+
return {
|
|
525
|
+
name,
|
|
526
|
+
value: await typedValueOf(type, {
|
|
527
|
+
argumentLabel: `--variation ${name}`,
|
|
528
|
+
text,
|
|
529
|
+
workingDirectory
|
|
530
|
+
})
|
|
531
|
+
};
|
|
532
|
+
};
|
|
533
|
+
const variationsOf = async (options, workingDirectory) => {
|
|
534
|
+
const variations = [];
|
|
535
|
+
for (const variation of options.variation ?? []) variations.push(await variationOf(options.type, variation, workingDirectory));
|
|
536
|
+
return variations;
|
|
537
|
+
};
|
|
538
|
+
const newConfigOf = async (options, workingDirectory) => {
|
|
539
|
+
rejectOptionsOfOtherTypes(options);
|
|
540
|
+
return {
|
|
541
|
+
key: options.key,
|
|
542
|
+
description: options.description,
|
|
543
|
+
role: options.role,
|
|
544
|
+
lifetime: options.lifetime,
|
|
545
|
+
type: options.type,
|
|
546
|
+
typeOptions: await CONFIG_TYPE_RULES[options.type].typeOptionsOf(options, workingDirectory),
|
|
547
|
+
...availabilityOf(options),
|
|
548
|
+
variations: await variationsOf(options, workingDirectory),
|
|
549
|
+
defaultValue: await typedValueOf(options.type, {
|
|
550
|
+
argumentLabel: "--default-value",
|
|
551
|
+
text: options.defaultValue,
|
|
552
|
+
workingDirectory
|
|
553
|
+
})
|
|
554
|
+
};
|
|
555
|
+
};
|
|
556
|
+
//#endregion
|
|
557
|
+
//#region src/admin-api/config-creation.ts
|
|
558
|
+
const CreatedConfigSchema = v.object({
|
|
559
|
+
key: v.string(),
|
|
560
|
+
type: v.string(),
|
|
561
|
+
role: v.string(),
|
|
562
|
+
lifetime: v.string(),
|
|
563
|
+
client: v.boolean(),
|
|
564
|
+
server: v.boolean()
|
|
565
|
+
});
|
|
566
|
+
//#endregion
|
|
567
|
+
//#region src/admin-api/scanned-repositories.ts
|
|
568
|
+
const scannedRepositoryFields = {
|
|
569
|
+
name: v.string(),
|
|
570
|
+
commit: v.pipe(v.string(), v.transform((commit) => commit === "" ? null : commit)),
|
|
571
|
+
referenceCount: v.number(),
|
|
572
|
+
dynamicReferenceCount: v.number()
|
|
573
|
+
};
|
|
574
|
+
const ScannedRepositorySchema = v.object(scannedRepositoryFields);
|
|
575
|
+
const ListedScannedRepositorySchema = v.object({
|
|
576
|
+
...scannedRepositoryFields,
|
|
577
|
+
scannedAt: v.pipe(v.string(), v.isoTimestamp())
|
|
578
|
+
});
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region src/admin-api/segments.ts
|
|
581
|
+
const SegmentSchema = v.object({
|
|
582
|
+
id: v.string(),
|
|
583
|
+
key: v.string(),
|
|
584
|
+
name: v.string(),
|
|
585
|
+
usages: v.array(v.unknown())
|
|
586
|
+
});
|
|
587
|
+
const SegmentDefinitionSchema = v.object({
|
|
588
|
+
key: v.string(),
|
|
589
|
+
name: v.string(),
|
|
590
|
+
groups: v.array(ConditionGroupSchema),
|
|
591
|
+
overrides: v.record(v.string(), v.array(ConditionGroupSchema))
|
|
592
|
+
});
|
|
593
|
+
//#endregion
|
|
594
|
+
//#region src/admin-api/targeting-rules-reference.ts
|
|
595
|
+
const TargetingRulesReferenceSchema = v.object({
|
|
596
|
+
notes: v.array(v.string()),
|
|
597
|
+
conditionKinds: v.array(v.string()),
|
|
598
|
+
conditionAttributes: v.array(v.string()),
|
|
599
|
+
contextKindOperators: v.array(v.string()),
|
|
600
|
+
segmentOperators: v.array(v.string()),
|
|
601
|
+
operatorsByTargetType: v.record(v.string(), v.array(v.string()))
|
|
602
|
+
});
|
|
603
|
+
//#endregion
|
|
604
|
+
//#region src/admin-api/config-evaluation.ts
|
|
605
|
+
const CheckedOutcomeSchema = v.picklist(["matched", "not-matched"]);
|
|
606
|
+
const AttributeConditionExplanationSchema = v.object({
|
|
607
|
+
conditionId: v.string(),
|
|
608
|
+
kind: v.literal("attribute"),
|
|
609
|
+
outcome: CheckedOutcomeSchema,
|
|
610
|
+
resolvedValue: v.optional(v.unknown()),
|
|
611
|
+
resolvedType: v.picklist([
|
|
612
|
+
"absent",
|
|
613
|
+
"scalar",
|
|
614
|
+
"array",
|
|
615
|
+
"object",
|
|
616
|
+
"unknown-attribute"
|
|
617
|
+
])
|
|
618
|
+
});
|
|
619
|
+
const SegmentConditionExplanationSchema = v.object({
|
|
620
|
+
conditionId: v.string(),
|
|
621
|
+
kind: v.literal("segment"),
|
|
622
|
+
outcome: CheckedOutcomeSchema,
|
|
623
|
+
segmentId: v.string(),
|
|
624
|
+
segmentFound: v.boolean(),
|
|
625
|
+
matchedGroupIndex: v.optional(v.number())
|
|
626
|
+
});
|
|
627
|
+
const ConditionExplanationSchema = v.union([
|
|
628
|
+
v.object({
|
|
629
|
+
conditionId: v.string(),
|
|
630
|
+
outcome: v.literal("not-evaluated")
|
|
631
|
+
}),
|
|
632
|
+
AttributeConditionExplanationSchema,
|
|
633
|
+
SegmentConditionExplanationSchema
|
|
634
|
+
]);
|
|
635
|
+
const BucketSchema = v.object({
|
|
636
|
+
defaultedToPredefinedBucket: v.boolean(),
|
|
637
|
+
assignedPercentage: v.number(),
|
|
638
|
+
shares: v.array(v.object({
|
|
639
|
+
percentageId: v.string(),
|
|
640
|
+
from: v.number(),
|
|
641
|
+
to: v.number()
|
|
642
|
+
})),
|
|
643
|
+
selectedPercentageId: v.optional(v.string())
|
|
644
|
+
});
|
|
645
|
+
const RuleExplanationSchema = v.object({
|
|
646
|
+
ruleId: v.string(),
|
|
647
|
+
outcome: v.picklist([
|
|
648
|
+
"matched",
|
|
649
|
+
"not-matched",
|
|
650
|
+
"not-evaluated",
|
|
651
|
+
"errored"
|
|
652
|
+
]),
|
|
653
|
+
conditions: v.array(ConditionExplanationSchema),
|
|
654
|
+
bucket: v.optional(BucketSchema)
|
|
655
|
+
});
|
|
656
|
+
const ConfigEvaluationSchema = v.object({
|
|
657
|
+
key: v.string(),
|
|
658
|
+
environment: v.object({ name: v.string() }),
|
|
659
|
+
context: v.nullable(v.object({
|
|
660
|
+
kind: v.string(),
|
|
661
|
+
identifier: v.nullable(v.string())
|
|
662
|
+
})),
|
|
663
|
+
value: v.optional(v.string()),
|
|
664
|
+
servedBy: v.variant("kind", [v.object({
|
|
665
|
+
kind: v.literal("rule"),
|
|
666
|
+
ruleId: v.string()
|
|
667
|
+
}), v.object({ kind: v.literal("default") })]),
|
|
668
|
+
rules: v.array(RuleExplanationSchema),
|
|
669
|
+
targetingRules: v.object({
|
|
670
|
+
rules: v.array(RuleSchema),
|
|
671
|
+
defaultValue: v.string()
|
|
672
|
+
}),
|
|
673
|
+
segments: v.record(v.string(), v.object({ name: v.string() }))
|
|
674
|
+
});
|
|
675
|
+
//#endregion
|
|
69
676
|
//#region src/admin-api/AdminApiClient.ts
|
|
70
677
|
const ProjectSchema = v.object({
|
|
71
678
|
id: v.string(),
|
|
@@ -75,6 +682,9 @@ const ProjectSchema = v.object({
|
|
|
75
682
|
const configFields = {
|
|
76
683
|
key: v.string(),
|
|
77
684
|
deprecatedKeys: v.array(v.object({ key: v.string() })),
|
|
685
|
+
role: v.string(),
|
|
686
|
+
lifetime: v.string(),
|
|
687
|
+
state: v.string(),
|
|
78
688
|
client: v.boolean(),
|
|
79
689
|
server: v.boolean()
|
|
80
690
|
};
|
|
@@ -91,6 +701,28 @@ const ConfigSchema = v.variant("type", [EnumConfigSchema, v.object({
|
|
|
91
701
|
type: v.pipe(v.string(), v.notValue("enum"))
|
|
92
702
|
})]);
|
|
93
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
|
+
}
|
|
724
|
+
};
|
|
725
|
+
const MissingResourceBodySchema = v.strictObject({});
|
|
94
726
|
const readJsonBody = async (response) => {
|
|
95
727
|
try {
|
|
96
728
|
return await response.json();
|
|
@@ -102,6 +734,7 @@ const describeStatus = (response) => `HTTP ${response.status}`;
|
|
|
102
734
|
const failureFor = (url, response, body) => {
|
|
103
735
|
switch (response.status) {
|
|
104
736
|
case 400: {
|
|
737
|
+
if (v.is(RepositoryLimitReachedBodySchema, body)) return new RepositoryLimitReachedError();
|
|
105
738
|
const validationError = v.safeParse(ApiValidationErrorBodySchema, body);
|
|
106
739
|
return validationError.success ? new ApiValidationError(validationError.output.errors) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
107
740
|
}
|
|
@@ -109,6 +742,10 @@ const failureFor = (url, response, body) => {
|
|
|
109
742
|
const authorizationError = v.safeParse(ApiAuthorizationErrorBodySchema, body);
|
|
110
743
|
return authorizationError.success ? new ApiAuthorizationError(authorizationError.output.code, authorizationError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
111
744
|
}
|
|
745
|
+
case 404: {
|
|
746
|
+
const notFoundError = v.safeParse(EvaluationNotFoundBodySchema, body);
|
|
747
|
+
return notFoundError.success ? new EvaluationNotFoundError(notFoundError.output.code, notFoundError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
748
|
+
}
|
|
112
749
|
case 429: {
|
|
113
750
|
const retryAfterSeconds = response.headers.get("retry-after");
|
|
114
751
|
return v.is(ServiceSuspendedBodySchema, body) && retryAfterSeconds !== null ? new ServiceSuspendedError(retryAfterSeconds) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
@@ -116,6 +753,16 @@ const failureFor = (url, response, body) => {
|
|
|
116
753
|
default: return new UnexpectedApiResponseError(url, describeStatus(response));
|
|
117
754
|
}
|
|
118
755
|
};
|
|
756
|
+
const isMissingResource = (received) => received.response.status === 404 && v.is(MissingResourceBodySchema, received.body);
|
|
757
|
+
const parsedResponseOf = ({ url, response, body }, schema) => {
|
|
758
|
+
if (!response.ok) throw failureFor(url, response, body);
|
|
759
|
+
const parsedBody = v.safeParse(schema, body);
|
|
760
|
+
if (!parsedBody.success) throw new UnexpectedApiResponseError(url, `an ${describeStatus(response)} response the CLI cannot read`);
|
|
761
|
+
return {
|
|
762
|
+
responseBody: body,
|
|
763
|
+
parsedBody: parsedBody.output
|
|
764
|
+
};
|
|
765
|
+
};
|
|
119
766
|
var AdminApiClient = class {
|
|
120
767
|
constructor(connection) {
|
|
121
768
|
this.connection = connection;
|
|
@@ -123,51 +770,119 @@ var AdminApiClient = class {
|
|
|
123
770
|
listProjects() {
|
|
124
771
|
return this.get("/v1/projects", v.array(ProjectSchema));
|
|
125
772
|
}
|
|
126
|
-
listConfigs(projectId) {
|
|
127
|
-
|
|
773
|
+
async listConfigs(projectId, tagFilter) {
|
|
774
|
+
const { parsedBody: configs, responseBody } = await this.get(`/v1/projects/${encodeURIComponent(projectId)}/configs${tagFilterQuery(tagFilter)}`, v.array(ConfigSchema));
|
|
775
|
+
const receivedConfigs = v.parse(v.array(v.unknown()), responseBody);
|
|
776
|
+
return configs.map((config, index) => ({
|
|
777
|
+
config,
|
|
778
|
+
receivedConfig: receivedConfigs[index]
|
|
779
|
+
}));
|
|
780
|
+
}
|
|
781
|
+
listEnvironments(projectId) {
|
|
782
|
+
return this.get(`/v1/projects/${encodeURIComponent(projectId)}/environments`, v.array(EnvironmentSchema));
|
|
783
|
+
}
|
|
784
|
+
getConfig(projectId, configKey) {
|
|
785
|
+
return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, ConfigWithTargetingRulesSchema);
|
|
786
|
+
}
|
|
787
|
+
getConfigActivity(projectId, configKey) {
|
|
788
|
+
return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/activity`, ConfigActivitySchema);
|
|
789
|
+
}
|
|
790
|
+
listSegments(projectId) {
|
|
791
|
+
return this.get(`/v1/projects/${encodeURIComponent(projectId)}/segments`, v.array(SegmentSchema));
|
|
792
|
+
}
|
|
793
|
+
getSegment(projectId, segmentKey) {
|
|
794
|
+
return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, SegmentDefinitionSchema);
|
|
795
|
+
}
|
|
796
|
+
async findTagByName(projectId, name) {
|
|
797
|
+
return (await this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/tags/by-name?${new URLSearchParams({ name })}`, TagSchema))?.parsedBody;
|
|
798
|
+
}
|
|
799
|
+
createConfig(projectId, request) {
|
|
800
|
+
return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs`, CreatedConfigSchema, {
|
|
801
|
+
method: "POST",
|
|
802
|
+
body: request
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
async evaluateConfig(projectId, configKey, request) {
|
|
806
|
+
const { responseBody, parsedBody } = await this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/evaluate`, ConfigEvaluationSchema, {
|
|
807
|
+
method: "POST",
|
|
808
|
+
body: request
|
|
809
|
+
});
|
|
810
|
+
return {
|
|
811
|
+
evaluation: parsedBody,
|
|
812
|
+
responseBody
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
getTargetingRulesReference() {
|
|
816
|
+
return this.get("/v1/targeting-rules-reference", TargetingRulesReferenceSchema);
|
|
817
|
+
}
|
|
818
|
+
async replaceTargetingRules(projectId, configKey, request) {
|
|
819
|
+
return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/targets`, v.unknown(), {
|
|
820
|
+
method: "PUT",
|
|
821
|
+
body: request
|
|
822
|
+
}) === void 0 ? "not-found" : "replaced";
|
|
128
823
|
}
|
|
129
|
-
|
|
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) {
|
|
130
853
|
const url = `${this.connection.baseUrl}${path}`;
|
|
131
|
-
const response = await this.fetchResponse(url);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
async fetchResponse(url) {
|
|
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
|
+
};
|
|
139
868
|
try {
|
|
140
|
-
return await fetch(url,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
});
|
|
146
880
|
} catch (error) {
|
|
147
881
|
throw new ApiUnreachableError(this.connection.baseUrl, error);
|
|
148
882
|
}
|
|
149
883
|
}
|
|
150
884
|
};
|
|
151
885
|
//#endregion
|
|
152
|
-
//#region src/file-contents.ts
|
|
153
|
-
const readFileIfPresent = async (file) => {
|
|
154
|
-
try {
|
|
155
|
-
return await readFile(file, "utf8");
|
|
156
|
-
} catch (error) {
|
|
157
|
-
if (error.code === "ENOENT") return;
|
|
158
|
-
throw error;
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
const parseJson = (contents) => {
|
|
162
|
-
try {
|
|
163
|
-
return JSON.parse(contents);
|
|
164
|
-
} catch {
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
};
|
|
168
|
-
const pathExists = (path) => stat(path).then(() => true, () => false);
|
|
169
|
-
const fileExists = (path) => stat(path).then((stats) => stats.isFile(), () => false);
|
|
170
|
-
//#endregion
|
|
171
886
|
//#region src/saved-api-token.ts
|
|
172
887
|
const SavedApiTokenSchema = v.object({ token: v.string() });
|
|
173
888
|
const platformConfigDirectory = (invocation) => {
|
|
@@ -210,22 +925,6 @@ const configuredApiToken = async (context) => {
|
|
|
210
925
|
return token;
|
|
211
926
|
};
|
|
212
927
|
//#endregion
|
|
213
|
-
//#region src/project-selection.ts
|
|
214
|
-
const requireProjectSlug = (context) => {
|
|
215
|
-
if (context.projectSlug === void 0) throw new CliError("Name the project with --project <slug> or CONFIGDIRECTOR_PROJECT.");
|
|
216
|
-
return context.projectSlug;
|
|
217
|
-
};
|
|
218
|
-
const requireLinkedProjectSlug = (context, settings) => {
|
|
219
|
-
const slug = context.projectSlug ?? settings?.project;
|
|
220
|
-
if (slug === void 0) throw new CliError("Name the project with --project <slug>, CONFIGDIRECTOR_PROJECT, or `project` in configdirector.json, which `configdirector init` writes.");
|
|
221
|
-
return slug;
|
|
222
|
-
};
|
|
223
|
-
const findProjectBySlug = async (client, slug) => {
|
|
224
|
-
const project = (await client.listProjects()).find((listedProject) => listedProject.slug === slug);
|
|
225
|
-
if (project === void 0) throw new CliError(`No project in the API token's organization has the slug "${slug}".`);
|
|
226
|
-
return project;
|
|
227
|
-
};
|
|
228
|
-
//#endregion
|
|
229
928
|
//#region src/project-settings.ts
|
|
230
929
|
const ProjectSettingsSchema = v.object({
|
|
231
930
|
project: v.optional(v.string()),
|
|
@@ -247,6 +946,406 @@ const readProjectSettings = async (workingDirectory) => {
|
|
|
247
946
|
return settings.output;
|
|
248
947
|
};
|
|
249
948
|
//#endregion
|
|
949
|
+
//#region src/project-selection.ts
|
|
950
|
+
const requireProjectSlug = (context) => {
|
|
951
|
+
if (context.projectSlug === void 0) throw new CliError("Name the project with --project <slug> or CONFIGDIRECTOR_PROJECT.");
|
|
952
|
+
return context.projectSlug;
|
|
953
|
+
};
|
|
954
|
+
const requireLinkedProjectSlug = (context, settings) => {
|
|
955
|
+
const slug = context.projectSlug ?? settings?.project;
|
|
956
|
+
if (slug === void 0) throw new CliError("Name the project with --project <slug>, CONFIGDIRECTOR_PROJECT, or `project` in configdirector.json, which `configdirector init` writes.");
|
|
957
|
+
return slug;
|
|
958
|
+
};
|
|
959
|
+
const findProjectBySlug = async (client, slug) => {
|
|
960
|
+
const { parsedBody: projects } = await client.listProjects();
|
|
961
|
+
const project = projects.find((listedProject) => listedProject.slug === slug);
|
|
962
|
+
if (project === void 0) throw new CliError(`No project in the API token's organization has the slug "${slug}".`);
|
|
963
|
+
return project;
|
|
964
|
+
};
|
|
965
|
+
const connectToLinkedProject = async (context) => {
|
|
966
|
+
const projectSlug = requireLinkedProjectSlug(context, await readProjectSettings(context.invocation.workingDirectory));
|
|
967
|
+
const client = new AdminApiClient({
|
|
968
|
+
baseUrl: context.baseUrl,
|
|
969
|
+
token: await configuredApiToken(context)
|
|
970
|
+
});
|
|
971
|
+
return {
|
|
972
|
+
client,
|
|
973
|
+
project: await findProjectBySlug(client, projectSlug)
|
|
974
|
+
};
|
|
975
|
+
};
|
|
976
|
+
//#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
|
+
//#region src/commands/configs.ts
|
|
984
|
+
const LIFECYCLE_STATES = [
|
|
985
|
+
"new",
|
|
986
|
+
"in-use",
|
|
987
|
+
"settled",
|
|
988
|
+
"archive-ready",
|
|
989
|
+
"stale"
|
|
990
|
+
];
|
|
991
|
+
const tagIdNamed = async (client, project, name) => {
|
|
992
|
+
const tag = await client.findTagByName(project.id, name);
|
|
993
|
+
if (tag === void 0) throw new CliError(`No tag in ${project.slug} has the name "${name}".`);
|
|
994
|
+
return tag.id;
|
|
995
|
+
};
|
|
996
|
+
const tagIdsNamed = async (client, project, names) => {
|
|
997
|
+
const tagIds = [];
|
|
998
|
+
for (const name of names) tagIds.push(await tagIdNamed(client, project, name));
|
|
999
|
+
return tagIds;
|
|
1000
|
+
};
|
|
1001
|
+
const tagFilterOf = async (client, project, options) => {
|
|
1002
|
+
if (options.untagged) return { form: "untagged" };
|
|
1003
|
+
if (options.tag === void 0) return { form: "every-config" };
|
|
1004
|
+
return {
|
|
1005
|
+
form: "tagged",
|
|
1006
|
+
tagIds: await tagIdsNamed(client, project, options.tag)
|
|
1007
|
+
};
|
|
1008
|
+
};
|
|
1009
|
+
const configsInState = (listedConfigs, state) => state === void 0 ? listedConfigs : listedConfigs.filter((listedConfig) => listedConfig.config.state === state);
|
|
1010
|
+
const summaryColumnsOf = (config) => [
|
|
1011
|
+
config.key,
|
|
1012
|
+
config.type,
|
|
1013
|
+
config.role,
|
|
1014
|
+
config.lifetime,
|
|
1015
|
+
config.state
|
|
1016
|
+
];
|
|
1017
|
+
const configLines = (projectSlug, configs) => configs.length === 0 ? [`No configs found in ${projectSlug}.`] : alignedColumns(configs.map(summaryColumnsOf));
|
|
1018
|
+
const listConfigs = async (context, _commandArguments, options) => {
|
|
1019
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1020
|
+
const listedConfigs = configsInState(await client.listConfigs(project.id, await tagFilterOf(client, project, options)), options.state);
|
|
1021
|
+
return {
|
|
1022
|
+
json: listedConfigs.map((listedConfig) => listedConfig.receivedConfig),
|
|
1023
|
+
text: [...configLines(project.slug, listedConfigs.map((listedConfig) => listedConfig.config)), ""].join("\n"),
|
|
1024
|
+
exitCode: ExitCode.success
|
|
1025
|
+
};
|
|
1026
|
+
};
|
|
1027
|
+
const referencesSegments = (config) => config.targets.some((targetingRules) => targetingRules.rules.some((rule) => rule.type === "conditional" && rule.conditions.some((condition) => condition.kind === "segment")));
|
|
1028
|
+
const segmentNamesFor = async (client, project, config) => {
|
|
1029
|
+
if (!referencesSegments(config)) return {};
|
|
1030
|
+
const segments = await ifPermitted(client.listSegments(project.id));
|
|
1031
|
+
return Object.fromEntries((segments?.parsedBody ?? []).map((segment) => [segment.id, segment]));
|
|
1032
|
+
};
|
|
1033
|
+
const getConfig = async (context, [configKey]) => {
|
|
1034
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1035
|
+
const receivedConfig = await client.getConfig(project.id, configKey);
|
|
1036
|
+
if (receivedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
1037
|
+
const { parsedBody: config, responseBody } = receivedConfig;
|
|
1038
|
+
return {
|
|
1039
|
+
json: responseBody,
|
|
1040
|
+
text: configText(config, configKey, await segmentNamesFor(client, project, config)),
|
|
1041
|
+
exitCode: ExitCode.success
|
|
1042
|
+
};
|
|
1043
|
+
};
|
|
1044
|
+
const createdConfigLines = (projectSlug, createdConfig, newConfig) => {
|
|
1045
|
+
const settings = [
|
|
1046
|
+
createdConfig.type,
|
|
1047
|
+
createdConfig.role,
|
|
1048
|
+
createdConfig.lifetime,
|
|
1049
|
+
availabilityDescription(createdConfig)
|
|
1050
|
+
];
|
|
1051
|
+
return [`Created ${createdConfig.key} in ${projectSlug}: ${settings.join(", ")}.`, `Default targeting rule in every environment: ${String(newConfig.defaultValue)}`];
|
|
1052
|
+
};
|
|
1053
|
+
const keyTakenLines = (projectSlug, configKey, existingConfig) => [`${projectSlug} already has a config with the key ${configKey}, so no config was created:`, ...alignedColumns([summaryColumnsOf(existingConfig)])];
|
|
1054
|
+
const rejectsKey = (error) => error instanceof ApiValidationError && error.fieldErrors.some((fieldError) => fieldError.name === "key");
|
|
1055
|
+
const requestCreation = async (client, project, request) => {
|
|
1056
|
+
try {
|
|
1057
|
+
const { parsedBody: createdConfig, responseBody } = await client.createConfig(project.id, request);
|
|
1058
|
+
return {
|
|
1059
|
+
outcome: "created",
|
|
1060
|
+
createdConfig,
|
|
1061
|
+
responseBody
|
|
1062
|
+
};
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
if (!rejectsKey(error)) throw error;
|
|
1065
|
+
const existingConfig = await client.getConfig(project.id, request.key);
|
|
1066
|
+
if (existingConfig === void 0) throw error;
|
|
1067
|
+
return {
|
|
1068
|
+
outcome: "key-taken",
|
|
1069
|
+
existingConfig: existingConfig.parsedBody,
|
|
1070
|
+
responseBody: existingConfig.responseBody
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
const creationOutput = (project, newConfig, creation) => {
|
|
1075
|
+
switch (creation.outcome) {
|
|
1076
|
+
case "created": return {
|
|
1077
|
+
json: creation.responseBody,
|
|
1078
|
+
text: [...createdConfigLines(project.slug, creation.createdConfig, newConfig), ""].join("\n"),
|
|
1079
|
+
exitCode: ExitCode.success
|
|
1080
|
+
};
|
|
1081
|
+
case "key-taken": return {
|
|
1082
|
+
json: creation.responseBody,
|
|
1083
|
+
text: [...keyTakenLines(project.slug, newConfig.key, creation.existingConfig), ""].join("\n"),
|
|
1084
|
+
exitCode: ExitCode.problemsFound
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
const createConfig = async (context, _commandArguments, options) => {
|
|
1089
|
+
const newConfig = await newConfigOf(options, context.invocation.workingDirectory);
|
|
1090
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1091
|
+
return creationOutput(project, newConfig, await requestCreation(client, project, {
|
|
1092
|
+
...newConfig,
|
|
1093
|
+
tagIds: await tagIdsNamed(client, project, options.tag ?? [])
|
|
1094
|
+
}));
|
|
1095
|
+
};
|
|
1096
|
+
const archiveConfig = async (context, [configKey]) => {
|
|
1097
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1098
|
+
if (await client.archiveConfig(project.id, configKey) === "not-found") throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
1099
|
+
return {
|
|
1100
|
+
json: { archivedConfigKey: configKey },
|
|
1101
|
+
text: [`Archived ${configKey} in ${project.slug}. It can be restored from the dashboard for 30 days.`, ""].join("\n"),
|
|
1102
|
+
exitCode: ExitCode.success
|
|
1103
|
+
};
|
|
1104
|
+
};
|
|
1105
|
+
const sdkDescription = (evaluation) => [evaluation.sdkName ?? "unknown SDK", ...evaluation.sdkVersion === null ? [] : [evaluation.sdkVersion]].join(" ");
|
|
1106
|
+
const activityLines = (configKey, activity) => activity.latestEvaluations.length === 0 ? [`No SDK evaluations of ${configKey} found.`] : alignedColumns(activity.latestEvaluations.map((evaluation) => [
|
|
1107
|
+
evaluation.environment.slug,
|
|
1108
|
+
evaluation.source,
|
|
1109
|
+
sdkDescription(evaluation),
|
|
1110
|
+
utcMinute(evaluation.time)
|
|
1111
|
+
]));
|
|
1112
|
+
const showConfigActivity = async (context, [configKey]) => {
|
|
1113
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1114
|
+
const receivedActivity = await client.getConfigActivity(project.id, configKey);
|
|
1115
|
+
if (receivedActivity === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
1116
|
+
return {
|
|
1117
|
+
json: receivedActivity.responseBody,
|
|
1118
|
+
text: [...activityLines(configKey, receivedActivity.parsedBody), ""].join("\n"),
|
|
1119
|
+
exitCode: ExitCode.success
|
|
1120
|
+
};
|
|
1121
|
+
};
|
|
1122
|
+
//#endregion
|
|
1123
|
+
//#region src/commands/environments.ts
|
|
1124
|
+
const environmentColumns = (environment) => environment.live ? [
|
|
1125
|
+
environment.slug,
|
|
1126
|
+
environment.name,
|
|
1127
|
+
"live"
|
|
1128
|
+
] : [environment.slug, environment.name];
|
|
1129
|
+
const environmentLines = (projectSlug, environments) => environments.length === 0 ? [`No environments found in ${projectSlug}.`] : alignedColumns(environments.map(environmentColumns));
|
|
1130
|
+
const listEnvironments = async (context) => {
|
|
1131
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1132
|
+
const { parsedBody: environments, responseBody } = await client.listEnvironments(project.id);
|
|
1133
|
+
return {
|
|
1134
|
+
json: responseBody,
|
|
1135
|
+
text: [...environmentLines(project.slug, environments), ""].join("\n"),
|
|
1136
|
+
exitCode: ExitCode.success
|
|
1137
|
+
};
|
|
1138
|
+
};
|
|
1139
|
+
//#endregion
|
|
1140
|
+
//#region src/environment-selection.ts
|
|
1141
|
+
const environmentSlugsSentence = (environments) => environments.length === 0 ? "The project has no environments." : `Its environments are ${environments.map((environment) => environment.slug).join(", ")}.`;
|
|
1142
|
+
const findEnvironmentBySlug = async (client, project, slug) => {
|
|
1143
|
+
const { parsedBody: environments } = await client.listEnvironments(project.id);
|
|
1144
|
+
const environment = environments.find((listedEnvironment) => listedEnvironment.slug === slug);
|
|
1145
|
+
if (environment === void 0) throw new CliError(`No environment in ${project.slug} has the slug "${slug}". ${environmentSlugsSentence(environments)}`);
|
|
1146
|
+
return environment;
|
|
1147
|
+
};
|
|
1148
|
+
//#endregion
|
|
1149
|
+
//#region src/evaluate/context-form.ts
|
|
1150
|
+
const DEFAULT_CONTEXT_KIND = "user";
|
|
1151
|
+
const TYPED_TRAIT_MARKER = ":";
|
|
1152
|
+
const traitUsageError = (trait) => new CliError(`--context-trait ${trait} is not a trait. Write name=value for a string, or name:=<json> for a typed value, as in plan=pro or beta:=true.`);
|
|
1153
|
+
const parseTraitJson = (trait, json) => {
|
|
1154
|
+
try {
|
|
1155
|
+
return JSON.parse(json);
|
|
1156
|
+
} catch {
|
|
1157
|
+
throw new CliError(`--context-trait ${trait} has a value after := that is not JSON. Write name=value for a string.`);
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
const traitEntryOf = (trait) => {
|
|
1161
|
+
const separatorIndex = trait.indexOf("=");
|
|
1162
|
+
if (separatorIndex === -1) throw traitUsageError(trait);
|
|
1163
|
+
const isTyped = trait[separatorIndex - 1] === TYPED_TRAIT_MARKER;
|
|
1164
|
+
const name = trait.slice(0, isTyped ? separatorIndex - 1 : separatorIndex);
|
|
1165
|
+
if (name === "") throw traitUsageError(trait);
|
|
1166
|
+
const value = trait.slice(separatorIndex + 1);
|
|
1167
|
+
return [name, isTyped ? parseTraitJson(trait, value) : value];
|
|
1168
|
+
};
|
|
1169
|
+
const contextJsonUsageError = () => new CliError("--context-json takes the context as a JSON object, as in {\"kind\":\"user\",\"identifier\":\"123\"}, or @<file>.");
|
|
1170
|
+
const contextOfJson = async (contextJson, workingDirectory) => {
|
|
1171
|
+
const context = parseJson(await atFileArgumentText("--context-json", contextJson, workingDirectory));
|
|
1172
|
+
if (!isJsonObject(context)) throw contextJsonUsageError();
|
|
1173
|
+
return context;
|
|
1174
|
+
};
|
|
1175
|
+
const inlineContextOf = (options) => ({
|
|
1176
|
+
kind: options.contextKind ?? DEFAULT_CONTEXT_KIND,
|
|
1177
|
+
identifier: options.contextId,
|
|
1178
|
+
name: options.contextName,
|
|
1179
|
+
traits: options.contextTrait === void 0 ? void 0 : Object.fromEntries(options.contextTrait.map(traitEntryOf))
|
|
1180
|
+
});
|
|
1181
|
+
const SAVED_CONTEXT_SEPARATOR = ":";
|
|
1182
|
+
const savedContextReferenceOf = (savedContext) => {
|
|
1183
|
+
const separatorIndex = savedContext.indexOf(SAVED_CONTEXT_SEPARATOR);
|
|
1184
|
+
const kind = savedContext.slice(0, separatorIndex);
|
|
1185
|
+
const identifier = savedContext.slice(separatorIndex + 1);
|
|
1186
|
+
if (separatorIndex === -1 || kind === "" || identifier === "") throw new CliError(`--saved-context takes <kind>:<identifier>, as in user:123, not ${savedContext}.`);
|
|
1187
|
+
return {
|
|
1188
|
+
kind,
|
|
1189
|
+
identifier
|
|
1190
|
+
};
|
|
1191
|
+
};
|
|
1192
|
+
const givenInlineOptionNames = (options) => [
|
|
1193
|
+
...options.contextKind === void 0 ? [] : ["--context-kind"],
|
|
1194
|
+
...options.contextId === void 0 ? [] : ["--context-id"],
|
|
1195
|
+
...options.contextName === void 0 ? [] : ["--context-name"],
|
|
1196
|
+
...options.contextTrait === void 0 ? [] : ["--context-trait"]
|
|
1197
|
+
];
|
|
1198
|
+
const givenContextDoors = (options) => {
|
|
1199
|
+
const { savedContext, contextJson } = options;
|
|
1200
|
+
const inlineOptionNames = givenInlineOptionNames(options);
|
|
1201
|
+
return [
|
|
1202
|
+
...savedContext === void 0 ? [] : [{
|
|
1203
|
+
optionNames: ["--saved-context"],
|
|
1204
|
+
contextForm: async () => ({
|
|
1205
|
+
form: "saved",
|
|
1206
|
+
reference: savedContextReferenceOf(savedContext)
|
|
1207
|
+
})
|
|
1208
|
+
}],
|
|
1209
|
+
...contextJson === void 0 ? [] : [{
|
|
1210
|
+
optionNames: ["--context-json"],
|
|
1211
|
+
contextForm: async (workingDirectory) => ({
|
|
1212
|
+
form: "inline",
|
|
1213
|
+
context: await contextOfJson(contextJson, workingDirectory)
|
|
1214
|
+
})
|
|
1215
|
+
}],
|
|
1216
|
+
...inlineOptionNames.length === 0 ? [] : [{
|
|
1217
|
+
optionNames: inlineOptionNames,
|
|
1218
|
+
contextForm: async () => ({
|
|
1219
|
+
form: "inline",
|
|
1220
|
+
context: inlineContextOf(options)
|
|
1221
|
+
})
|
|
1222
|
+
}]
|
|
1223
|
+
];
|
|
1224
|
+
};
|
|
1225
|
+
const contextFormOf = async (options, workingDirectory) => {
|
|
1226
|
+
const [door, ...otherDoors] = givenContextDoors(options);
|
|
1227
|
+
if (door === void 0) return { form: "empty" };
|
|
1228
|
+
if (otherDoors.length > 0) throw new CliError(`${door.optionNames.join(", ")} cannot be combined with ${otherDoors.flatMap((otherDoor) => otherDoor.optionNames).join(", ")}. Give the context one way: --saved-context, --context-json, or the --context-kind, --context-id, --context-name and --context-trait options.`);
|
|
1229
|
+
return door.contextForm(workingDirectory);
|
|
1230
|
+
};
|
|
1231
|
+
//#endregion
|
|
1232
|
+
//#region src/evaluate/evaluation-text.ts
|
|
1233
|
+
const RESOLVED_VALUE_DESCRIPTIONS = {
|
|
1234
|
+
absent: () => "absent",
|
|
1235
|
+
"unknown-attribute": () => "unknown to the evaluator",
|
|
1236
|
+
scalar: (resolvedValue) => JSON.stringify(resolvedValue),
|
|
1237
|
+
array: (resolvedValue) => JSON.stringify(resolvedValue),
|
|
1238
|
+
object: (resolvedValue) => JSON.stringify(resolvedValue)
|
|
1239
|
+
};
|
|
1240
|
+
const contextDescription = (evaluation, contextForm) => {
|
|
1241
|
+
const evaluatedContext = evaluation.context;
|
|
1242
|
+
if (evaluatedContext === null) return "an empty context";
|
|
1243
|
+
if (evaluatedContext.identifier === null) return `a ${evaluatedContext.kind} context without an identifier`;
|
|
1244
|
+
const identity = `${evaluatedContext.kind} ${evaluatedContext.identifier}`;
|
|
1245
|
+
return contextForm.form === "saved" ? `saved context ${identity}` : identity;
|
|
1246
|
+
};
|
|
1247
|
+
const servedByDescription = (evaluation) => {
|
|
1248
|
+
const { servedBy } = evaluation;
|
|
1249
|
+
switch (servedBy.kind) {
|
|
1250
|
+
case "rule": return `served by rule ${evaluation.rules.findIndex((rule) => rule.ruleId === servedBy.ruleId) + 1}`;
|
|
1251
|
+
case "default": return "served by the default targeting rule";
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
const describeShareRange = (from, to) => `${formatPercentage(from)} to ${formatPercentage(to)}`;
|
|
1255
|
+
const bucketPlacement = (bucket) => {
|
|
1256
|
+
const assigned = `bucket ${bucket.assignedPercentage.toFixed(1)}`;
|
|
1257
|
+
const selectedShare = bucket.shares.find((share) => share.percentageId === bucket.selectedPercentageId);
|
|
1258
|
+
if (selectedShare !== void 0) return `${assigned} is in ${describeShareRange(selectedShare.from, selectedShare.to)}`;
|
|
1259
|
+
return `${assigned} is outside ${describeShareRange(0, bucket.shares.at(-1)?.to ?? 0)}`;
|
|
1260
|
+
};
|
|
1261
|
+
const describeBucket = (bucket) => bucket.defaultedToPredefinedBucket ? `no identifier, so ${bucketPlacement(bucket)}` : bucketPlacement(bucket);
|
|
1262
|
+
const withBucket = (outcome, bucket) => bucket === void 0 ? outcome : `${outcome}: ${describeBucket(bucket)}`;
|
|
1263
|
+
const conditionsOf = (rule) => rule?.type === "conditional" ? rule.conditions : [];
|
|
1264
|
+
const segmentMismatchEvidence = (explanation) => {
|
|
1265
|
+
if (!explanation.segmentFound) return "the segment was not found";
|
|
1266
|
+
return explanation.matchedGroupIndex === void 0 ? "no group matched" : `group ${explanation.matchedGroupIndex + 1} matched`;
|
|
1267
|
+
};
|
|
1268
|
+
const mismatchEvidence = (failingCondition) => {
|
|
1269
|
+
switch (failingCondition.kind) {
|
|
1270
|
+
case "attribute": return `it is ${RESOLVED_VALUE_DESCRIPTIONS[failingCondition.resolvedType](failingCondition.resolvedValue)}`;
|
|
1271
|
+
case "segment": return segmentMismatchEvidence(failingCondition);
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
1274
|
+
const isFailingCondition = (condition) => condition.outcome === "not-matched";
|
|
1275
|
+
const failingConditionDescription = (explanation, conditions, segments) => {
|
|
1276
|
+
const failingCondition = explanation.conditions.find(isFailingCondition);
|
|
1277
|
+
const condition = conditions.find((ruleCondition) => ruleCondition.id === failingCondition?.conditionId);
|
|
1278
|
+
return failingCondition === void 0 || condition === void 0 ? void 0 : `${describeCondition(condition, segments)}, but ${mismatchEvidence(failingCondition)}`;
|
|
1279
|
+
};
|
|
1280
|
+
const describeNotMatched = (explanation, conditions, segments) => {
|
|
1281
|
+
const failingCondition = failingConditionDescription(explanation, conditions, segments);
|
|
1282
|
+
return failingCondition === void 0 ? withBucket("not matched", explanation.bucket) : `not matched: ${failingCondition}`;
|
|
1283
|
+
};
|
|
1284
|
+
const RULE_OUTCOME_DESCRIPTIONS = {
|
|
1285
|
+
matched: (explanation) => withBucket("matched", explanation.bucket),
|
|
1286
|
+
"not-matched": describeNotMatched,
|
|
1287
|
+
"not-evaluated": () => "not evaluated",
|
|
1288
|
+
errored: () => "errored"
|
|
1289
|
+
};
|
|
1290
|
+
const ruleLines = (evaluation) => {
|
|
1291
|
+
const rulesById = new Map(evaluation.targetingRules.rules.map((rule) => [rule.id, rule]));
|
|
1292
|
+
return alignedColumns(evaluation.rules.map((explanation, index) => {
|
|
1293
|
+
const rule = rulesById.get(explanation.ruleId);
|
|
1294
|
+
return [`${index + 1}. ${rule === void 0 ? explanation.ruleId : describeRule(rule, evaluation.segments)}`, RULE_OUTCOME_DESCRIPTIONS[explanation.outcome](explanation, conditionsOf(rule), evaluation.segments)];
|
|
1295
|
+
}));
|
|
1296
|
+
};
|
|
1297
|
+
const evaluationText = (evaluation, requested) => [
|
|
1298
|
+
...oldKeyLines(requested.configKey, evaluation.key),
|
|
1299
|
+
`${evaluation.key} in ${evaluation.environment.name} for ${contextDescription(evaluation, requested.contextForm)}`,
|
|
1300
|
+
"",
|
|
1301
|
+
`${evaluation.value ?? "no value"}, ${servedByDescription(evaluation)}`,
|
|
1302
|
+
"",
|
|
1303
|
+
...ruleLines(evaluation),
|
|
1304
|
+
`Default targeting rule: ${evaluation.targetingRules.defaultValue}`,
|
|
1305
|
+
""
|
|
1306
|
+
].join("\n");
|
|
1307
|
+
//#endregion
|
|
1308
|
+
//#region src/commands/evaluate.ts
|
|
1309
|
+
const contextFieldsOf = (contextForm) => {
|
|
1310
|
+
switch (contextForm.form) {
|
|
1311
|
+
case "inline": return { context: contextForm.context };
|
|
1312
|
+
case "saved": return { savedContext: contextForm.reference };
|
|
1313
|
+
case "empty": return {};
|
|
1314
|
+
}
|
|
1315
|
+
};
|
|
1316
|
+
const NOT_FOUND_MESSAGES = {
|
|
1317
|
+
CONFIG_NOT_FOUND: ({ project, configKey }) => `No config in ${project.slug} has the key "${configKey}".`,
|
|
1318
|
+
ENVIRONMENT_NOT_FOUND: ({ project, environment }) => `No environment in ${project.slug} has the slug "${environment.slug}" any more.`,
|
|
1319
|
+
CONTEXT_NOT_FOUND: ({ environment, contextForm }, error) => contextForm.form === "saved" ? `No context saved in ${environment.slug} has the context kind ${contextForm.reference.kind} and the identifier "${contextForm.reference.identifier}".` : error.message
|
|
1320
|
+
};
|
|
1321
|
+
const requestEvaluation = async (client, requested) => {
|
|
1322
|
+
try {
|
|
1323
|
+
return await client.evaluateConfig(requested.project.id, requested.configKey, {
|
|
1324
|
+
environmentId: requested.environment.id,
|
|
1325
|
+
...contextFieldsOf(requested.contextForm)
|
|
1326
|
+
});
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
if (error instanceof EvaluationNotFoundError) throw new CliError(NOT_FOUND_MESSAGES[error.code](requested, error));
|
|
1329
|
+
throw error;
|
|
1330
|
+
}
|
|
1331
|
+
};
|
|
1332
|
+
const evaluate = async (context, [configKey], options) => {
|
|
1333
|
+
const contextForm = await contextFormOf(options, context.invocation.workingDirectory);
|
|
1334
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1335
|
+
const requested = {
|
|
1336
|
+
project,
|
|
1337
|
+
configKey,
|
|
1338
|
+
environment: await findEnvironmentBySlug(client, project, options.environment),
|
|
1339
|
+
contextForm
|
|
1340
|
+
};
|
|
1341
|
+
const { evaluation, responseBody } = await requestEvaluation(client, requested);
|
|
1342
|
+
return {
|
|
1343
|
+
json: responseBody,
|
|
1344
|
+
text: evaluationText(evaluation, requested),
|
|
1345
|
+
exitCode: ExitCode.success
|
|
1346
|
+
};
|
|
1347
|
+
};
|
|
1348
|
+
//#endregion
|
|
250
1349
|
//#region src/commands/init.ts
|
|
251
1350
|
const refuseExistingFile = async (file) => {
|
|
252
1351
|
if (await access(file).then(() => true, () => false)) throw new CliError(`${file} already exists. init never overwrites it; edit the file instead.`);
|
|
@@ -350,9 +1449,66 @@ const login = async (context) => {
|
|
|
350
1449
|
};
|
|
351
1450
|
};
|
|
352
1451
|
//#endregion
|
|
1452
|
+
//#region src/commands/projects.ts
|
|
1453
|
+
const projectLines = (projects) => projects.length === 0 ? ["No projects found."] : alignedColumns(projects.map((project) => [project.slug, project.name]));
|
|
1454
|
+
const listProjects = async (context) => {
|
|
1455
|
+
const { parsedBody: projects, responseBody } = await new AdminApiClient({
|
|
1456
|
+
baseUrl: context.baseUrl,
|
|
1457
|
+
token: await configuredApiToken(context)
|
|
1458
|
+
}).listProjects();
|
|
1459
|
+
return {
|
|
1460
|
+
json: responseBody,
|
|
1461
|
+
text: [...projectLines(projects), ""].join("\n"),
|
|
1462
|
+
exitCode: ExitCode.success
|
|
1463
|
+
};
|
|
1464
|
+
};
|
|
1465
|
+
//#endregion
|
|
353
1466
|
//#region src/plural.ts
|
|
354
1467
|
const plural = (count, singular, pluralForm) => count === 1 ? singular : pluralForm;
|
|
355
1468
|
//#endregion
|
|
1469
|
+
//#region src/scanned-repository/short-commit.ts
|
|
1470
|
+
const SHORT_COMMIT_LENGTH = 7;
|
|
1471
|
+
const shortCommit = (commit) => commit.slice(0, SHORT_COMMIT_LENGTH);
|
|
1472
|
+
//#endregion
|
|
1473
|
+
//#region src/commands/repositories.ts
|
|
1474
|
+
const MAX_SCANNED_REPOSITORIES_PER_PROJECT = 50;
|
|
1475
|
+
const commitDescription = (commit) => commit === null ? "no commit" : shortCommit(commit);
|
|
1476
|
+
const scannedRepositoryColumnsOf = (repository) => [
|
|
1477
|
+
repository.name,
|
|
1478
|
+
commitDescription(repository.commit),
|
|
1479
|
+
utcMinute(repository.scannedAt),
|
|
1480
|
+
`${repository.referenceCount} ${plural(repository.referenceCount, "reference", "references")}`,
|
|
1481
|
+
`${repository.dynamicReferenceCount} ${plural(repository.dynamicReferenceCount, "dynamic reference", "dynamic references")}`
|
|
1482
|
+
];
|
|
1483
|
+
const scannedRepositoryLines = (projectSlug, repositories) => repositories.length === 0 ? [`No scanned repositories found in ${projectSlug}. Run configdirector scan --upload in a repository to upload its code references.`] : [
|
|
1484
|
+
...alignedColumns(repositories.map(scannedRepositoryColumnsOf)),
|
|
1485
|
+
"",
|
|
1486
|
+
`${projectSlug} has ${repositories.length} of the ${MAX_SCANNED_REPOSITORIES_PER_PROJECT} scanned repositories a project can hold.`
|
|
1487
|
+
];
|
|
1488
|
+
const listScannedRepositories = async (context) => {
|
|
1489
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1490
|
+
const { parsedBody: repositories, responseBody } = await client.listScannedRepositories(project.id);
|
|
1491
|
+
return {
|
|
1492
|
+
json: responseBody,
|
|
1493
|
+
text: [...scannedRepositoryLines(project.slug, repositories), ""].join("\n"),
|
|
1494
|
+
exitCode: ExitCode.success
|
|
1495
|
+
};
|
|
1496
|
+
};
|
|
1497
|
+
const scannedRepositoryNamesSentence = (repositories) => repositories.length === 0 ? "The project has no scanned repositories." : `Its scanned repositories are ${repositories.map((repository) => repository.name).join(", ")}.`;
|
|
1498
|
+
const scannedRepositoryNotFoundError = async (client, project, repositoryName) => {
|
|
1499
|
+
const repositories = await ifPermitted(client.listScannedRepositories(project.id));
|
|
1500
|
+
return new CliError([`No scanned repository in ${project.slug} has the name "${repositoryName}".`, ...repositories === void 0 ? [] : [scannedRepositoryNamesSentence(repositories.parsedBody)]].join(" "));
|
|
1501
|
+
};
|
|
1502
|
+
const removeScannedRepository = async (context, [repositoryName]) => {
|
|
1503
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1504
|
+
if (await client.removeScannedRepository(project.id, repositoryName) === "not-found") throw await scannedRepositoryNotFoundError(client, project, repositoryName);
|
|
1505
|
+
return {
|
|
1506
|
+
json: { removedRepository: repositoryName },
|
|
1507
|
+
text: [`Removed ${repositoryName} and its code references from ${project.slug}.`, ""].join("\n"),
|
|
1508
|
+
exitCode: ExitCode.success
|
|
1509
|
+
};
|
|
1510
|
+
};
|
|
1511
|
+
//#endregion
|
|
356
1512
|
//#region src/scan/source-languages.ts
|
|
357
1513
|
const languageOfExtension = {
|
|
358
1514
|
".js": "javascript",
|
|
@@ -824,6 +1980,7 @@ const scanRootAt = async (workingDirectory, path) => {
|
|
|
824
1980
|
description: path ?? "the current directory"
|
|
825
1981
|
};
|
|
826
1982
|
};
|
|
1983
|
+
const scanRootDirectory = (root) => root.isDirectory ? root.path : dirname(root.path);
|
|
827
1984
|
const gitignoreRulesIn = async (directory) => {
|
|
828
1985
|
const contents = await readFileIfPresent(join(directory, ".gitignore"));
|
|
829
1986
|
return contents === void 0 ? [] : [{
|
|
@@ -1077,6 +2234,10 @@ const DART_RE_EXPORT_DIRECTIVES = ["export"];
|
|
|
1077
2234
|
const SWIFT_PACKAGE_MANIFEST = "Package.swift";
|
|
1078
2235
|
const SWIFT_PACKAGE_TARGETS_DIRECTORIES = ["Sources", "Tests"];
|
|
1079
2236
|
const XCODE_PROJECT_EXTENSION = ".xcodeproj";
|
|
2237
|
+
const JVM_SOURCE_EXTENSIONS = [".kt", ".java"];
|
|
2238
|
+
const CSHARP_PROJECT_EXTENSION = ".csproj";
|
|
2239
|
+
const CSHARP_SOURCE_EXTENSION = ".cs";
|
|
2240
|
+
const CSHARP_BUILD_OUTPUT_DIRECTORIES = ["bin", "obj"];
|
|
1080
2241
|
const stringAt = (code, index) => code.token(index)?.kind === "string" ? plainStringContent(code, {
|
|
1081
2242
|
first: index,
|
|
1082
2243
|
last: index
|
|
@@ -1245,6 +2406,63 @@ const goPackageNameIn = (code) => {
|
|
|
1245
2406
|
const name = code.token(1);
|
|
1246
2407
|
return code.isIdentifier(0, "package") && name?.kind === "identifier" ? name.text : void 0;
|
|
1247
2408
|
};
|
|
2409
|
+
const isCapitalized = (name) => name.charAt(0) >= "A" && name.charAt(0) <= "Z";
|
|
2410
|
+
const capitalizedNamesIn = (code) => new Set(code.tokens.flatMap((token) => token.kind === "identifier" && isCapitalized(token.text) ? [token.text] : []));
|
|
2411
|
+
const dottedNameAt = (code, start) => {
|
|
2412
|
+
const segments = [];
|
|
2413
|
+
let index = start;
|
|
2414
|
+
for (let token = code.token(index); token?.kind === "identifier"; token = code.token(index)) {
|
|
2415
|
+
segments.push(token.text);
|
|
2416
|
+
if (!code.isPunctuation(index + 1, ".")) break;
|
|
2417
|
+
index += 2;
|
|
2418
|
+
}
|
|
2419
|
+
return {
|
|
2420
|
+
segments,
|
|
2421
|
+
endsWithWildcard: code.isPunctuation(index - 1, ".") && code.isPunctuation(index, "*")
|
|
2422
|
+
};
|
|
2423
|
+
};
|
|
2424
|
+
const jvmPackagePathIn = (code) => {
|
|
2425
|
+
const packageIndex = code.tokens.findIndex((_token, index) => code.startsLine(index) && code.isIdentifier(index, "package"));
|
|
2426
|
+
return packageIndex === -1 ? [] : dottedNameAt(code, packageIndex + 1).segments;
|
|
2427
|
+
};
|
|
2428
|
+
const jvmImportsIn = (code) => code.tokens.flatMap((_token, index) => {
|
|
2429
|
+
if (!code.startsLine(index) || !code.isIdentifier(index, "import")) return [];
|
|
2430
|
+
const isStaticImport = code.isIdentifier(index + 1, "static");
|
|
2431
|
+
const { segments, endsWithWildcard } = dottedNameAt(code, isStaticImport ? index + 2 : index + 1);
|
|
2432
|
+
return [endsWithWildcard && !isStaticImport ? {
|
|
2433
|
+
kind: "wholePackage",
|
|
2434
|
+
packagePath: segments
|
|
2435
|
+
} : {
|
|
2436
|
+
kind: "classOrMember",
|
|
2437
|
+
path: segments
|
|
2438
|
+
}];
|
|
2439
|
+
});
|
|
2440
|
+
const jvmSourceRootOf = (directory, packagePath) => {
|
|
2441
|
+
const sourceRoot = ancestorDirectory(directory, packagePath.length);
|
|
2442
|
+
return join(sourceRoot, ...packagePath) === directory ? sourceRoot : void 0;
|
|
2443
|
+
};
|
|
2444
|
+
const jvmClassFileCandidates = (sourceRoot, importPath) => importPath.flatMap((_segment, index) => {
|
|
2445
|
+
const classPath = join(sourceRoot, ...importPath.slice(0, importPath.length - index));
|
|
2446
|
+
return JVM_SOURCE_EXTENSIONS.map((extension) => classPath + extension);
|
|
2447
|
+
});
|
|
2448
|
+
const csharpProjectRootDeclaredIn = async (directory) => {
|
|
2449
|
+
return (await readdir(directory, { withFileTypes: true }).catch(() => [])).some((entry) => entry.isFile() && extname(entry.name) === CSHARP_PROJECT_EXTENSION) ? directory : void 0;
|
|
2450
|
+
};
|
|
2451
|
+
const isSearchedForHolderClasses = (directoryName) => !CSHARP_BUILD_OUTPUT_DIRECTORIES.includes(directoryName) && !EXCLUDED_DIRECTORY_NAMES.has(directoryName);
|
|
2452
|
+
const csharpFilesUnder = async (directory) => {
|
|
2453
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
2454
|
+
const files = entries.filter((entry) => entry.isFile() && extname(entry.name) === CSHARP_SOURCE_EXTENSION).map((entry) => join(directory, entry.name));
|
|
2455
|
+
const filesOfSubdirectories = await Promise.all(entries.filter((entry) => entry.isDirectory() && isSearchedForHolderClasses(entry.name)).map((entry) => csharpFilesUnder(join(directory, entry.name))));
|
|
2456
|
+
return [...files, ...filesOfSubdirectories.flat()];
|
|
2457
|
+
};
|
|
2458
|
+
const filesOfCsharpClassName = (files) => {
|
|
2459
|
+
const filesOfClassName = /* @__PURE__ */ new Map();
|
|
2460
|
+
for (const file of files) {
|
|
2461
|
+
const className = basename(file, CSHARP_SOURCE_EXTENSION);
|
|
2462
|
+
filesOfClassName.set(className, [...filesOfClassName.get(className) ?? [], file]);
|
|
2463
|
+
}
|
|
2464
|
+
return filesOfClassName;
|
|
2465
|
+
};
|
|
1248
2466
|
const goPackageNameOfFile = async (file) => {
|
|
1249
2467
|
const contents = await readFileWithinSizeLimit(file);
|
|
1250
2468
|
return contents === void 0 ? void 0 : goPackageNameIn(new SourceCode(contents, "go"));
|
|
@@ -1253,14 +2471,17 @@ var LocalImports = class {
|
|
|
1253
2471
|
dartPackages;
|
|
1254
2472
|
goModules;
|
|
1255
2473
|
swiftProjects;
|
|
2474
|
+
csharpProjectRoots;
|
|
1256
2475
|
pathAliases;
|
|
1257
|
-
|
|
2476
|
+
filesOfDirectory = /* @__PURE__ */ new Map();
|
|
2477
|
+
csharpClassFilesOfProject = /* @__PURE__ */ new Map();
|
|
1258
2478
|
fileExistence = /* @__PURE__ */ new Map();
|
|
1259
2479
|
constructor(highestDirectory) {
|
|
1260
2480
|
this.highestDirectory = highestDirectory;
|
|
1261
2481
|
this.dartPackages = new NearestDeclarations(highestDirectory, dartPackageDeclaredIn);
|
|
1262
2482
|
this.goModules = new NearestDeclarations(highestDirectory, goModuleDeclaredIn);
|
|
1263
2483
|
this.swiftProjects = new NearestDeclarations(highestDirectory, swiftProjectDeclaredIn);
|
|
2484
|
+
this.csharpProjectRoots = new NearestDeclarations(highestDirectory, csharpProjectRootDeclaredIn);
|
|
1264
2485
|
this.pathAliases = new PathAliases(highestDirectory);
|
|
1265
2486
|
}
|
|
1266
2487
|
filesImportedBy(code, file) {
|
|
@@ -1287,6 +2508,18 @@ var LocalImports = class {
|
|
|
1287
2508
|
case "csharp": return Promise.resolve([]);
|
|
1288
2509
|
}
|
|
1289
2510
|
}
|
|
2511
|
+
holderClassFilesReferencedBy(code, file) {
|
|
2512
|
+
switch (code.language) {
|
|
2513
|
+
case "kotlin":
|
|
2514
|
+
case "java": return this.jvmHolderClassFilesReferencedBy(code, file);
|
|
2515
|
+
case "csharp": return this.csharpHolderClassFilesReferencedBy(code, file);
|
|
2516
|
+
case "javascript":
|
|
2517
|
+
case "python":
|
|
2518
|
+
case "dart":
|
|
2519
|
+
case "go":
|
|
2520
|
+
case "swift": return Promise.resolve([]);
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
1290
2523
|
filesReExportedBy(code, file) {
|
|
1291
2524
|
switch (code.language) {
|
|
1292
2525
|
case "javascript": return this.javascriptFilesAt(javascriptReExportSpecifiers(code), file);
|
|
@@ -1380,11 +2613,53 @@ var LocalImports = class {
|
|
|
1380
2613
|
case "xcodeProject": return project.root;
|
|
1381
2614
|
}
|
|
1382
2615
|
}
|
|
1383
|
-
|
|
1384
|
-
const
|
|
2616
|
+
async jvmHolderClassFilesReferencedBy(code, file) {
|
|
2617
|
+
const directory = dirname(file);
|
|
2618
|
+
const classNames = capitalizedNamesIn(code);
|
|
2619
|
+
const sourceRoot = jvmSourceRootOf(directory, jvmPackagePathIn(code));
|
|
2620
|
+
return [...sourceRoot === void 0 ? [] : await this.jvmClassFilesImportedFrom(sourceRoot, jvmImportsIn(code), classNames), ...await this.jvmClassFilesIn(directory, classNames)];
|
|
2621
|
+
}
|
|
2622
|
+
async jvmClassFilesImportedFrom(sourceRoot, imports, classNames) {
|
|
2623
|
+
const files = [];
|
|
2624
|
+
for (const jvmImport of imports) switch (jvmImport.kind) {
|
|
2625
|
+
case "classOrMember": {
|
|
2626
|
+
const importedFile = await this.firstExistingFile(jvmClassFileCandidates(sourceRoot, jvmImport.path));
|
|
2627
|
+
if (importedFile !== void 0) files.push(importedFile);
|
|
2628
|
+
break;
|
|
2629
|
+
}
|
|
2630
|
+
case "wholePackage":
|
|
2631
|
+
files.push(...await this.jvmClassFilesIn(join(sourceRoot, ...jvmImport.packagePath), classNames));
|
|
2632
|
+
break;
|
|
2633
|
+
}
|
|
2634
|
+
return files;
|
|
2635
|
+
}
|
|
2636
|
+
async jvmClassFilesIn(directory, classNames) {
|
|
2637
|
+
return (await this.filesIn(directory)).filter((file) => {
|
|
2638
|
+
const extension = extname(file);
|
|
2639
|
+
return JVM_SOURCE_EXTENSIONS.includes(extension) && classNames.has(basename(file, extension));
|
|
2640
|
+
});
|
|
2641
|
+
}
|
|
2642
|
+
async csharpHolderClassFilesReferencedBy(code, file) {
|
|
2643
|
+
const projectRoot = await this.csharpProjectRoots.declarationAbove(file);
|
|
2644
|
+
if (projectRoot === void 0) return [];
|
|
2645
|
+
const filesOfClassName = await this.csharpClassFilesIn(projectRoot);
|
|
2646
|
+
return [...capitalizedNamesIn(code)].flatMap((className) => filesOfClassName.get(className) ?? []);
|
|
2647
|
+
}
|
|
2648
|
+
csharpClassFilesIn(projectRoot) {
|
|
2649
|
+
const classFilesListedBefore = this.csharpClassFilesOfProject.get(projectRoot);
|
|
2650
|
+
if (classFilesListedBefore !== void 0) return classFilesListedBefore;
|
|
2651
|
+
const classFiles = csharpFilesUnder(projectRoot).then(filesOfCsharpClassName);
|
|
2652
|
+
this.csharpClassFilesOfProject.set(projectRoot, classFiles);
|
|
2653
|
+
return classFiles;
|
|
2654
|
+
}
|
|
2655
|
+
async goFilesIn(directory) {
|
|
2656
|
+
return (await this.filesIn(directory)).filter((file) => extname(file) === ".go");
|
|
2657
|
+
}
|
|
2658
|
+
filesIn(directory) {
|
|
2659
|
+
const filesListedBefore = this.filesOfDirectory.get(directory);
|
|
1385
2660
|
if (filesListedBefore !== void 0) return filesListedBefore;
|
|
1386
|
-
const files = readdir(directory, { withFileTypes: true }).then((entries) => entries.filter((entry) => entry.isFile()
|
|
1387
|
-
this.
|
|
2661
|
+
const files = readdir(directory, { withFileTypes: true }).then((entries) => entries.filter((entry) => entry.isFile()).map((entry) => join(directory, entry.name)), () => []);
|
|
2662
|
+
this.filesOfDirectory.set(directory, files);
|
|
1388
2663
|
return files;
|
|
1389
2664
|
}
|
|
1390
2665
|
};
|
|
@@ -2457,19 +3732,24 @@ const sdkFamiliesUsedIn = (highestDirectory) => {
|
|
|
2457
3732
|
}
|
|
2458
3733
|
}
|
|
2459
3734
|
};
|
|
2460
|
-
const
|
|
2461
|
-
|
|
2462
|
-
return (await Promise.all(importedPaths.map((importedPath) => familiesReachedThrough(importedPath, RE_EXPORT_STEPS_FOLLOWED)))).flat();
|
|
3735
|
+
const familiesReachedThroughEach = async (paths) => {
|
|
3736
|
+
return (await Promise.all(paths.map((path) => familiesReachedThrough(path, RE_EXPORT_STEPS_FOLLOWED)))).flat();
|
|
2463
3737
|
};
|
|
3738
|
+
const familiesMarkedThroughImports = async (file, code) => familiesReachedThroughEach(await localImports.filesImportedBy(code, file.path));
|
|
3739
|
+
const familiesMarkedInHolderClasses = async (file, code) => familiesReachedThroughEach(await localImports.holderClassFilesReferencedBy(code, file.path));
|
|
2464
3740
|
const familiesMarkedInSamePackageOrTarget = async (file, code) => {
|
|
2465
3741
|
const familiesFoundBefore = familiesMarkedInPackageOrTargetOfFile.get(file.path);
|
|
2466
3742
|
if (familiesFoundBefore !== void 0) return familiesFoundBefore;
|
|
2467
3743
|
const filesOfPackageOrTarget = await localImports.filesOfSamePackageOrTarget(code, file.path);
|
|
2468
|
-
const families =
|
|
3744
|
+
const families = familiesReachedThroughEach(filesOfPackageOrTarget);
|
|
2469
3745
|
for (const fileOfPackageOrTarget of filesOfPackageOrTarget) familiesMarkedInPackageOrTargetOfFile.set(fileOfPackageOrTarget, families);
|
|
2470
3746
|
return families;
|
|
2471
3747
|
};
|
|
2472
|
-
const familiesMarkedByOtherFiles = async (file, code) => [
|
|
3748
|
+
const familiesMarkedByOtherFiles = async (file, code) => [
|
|
3749
|
+
...await familiesMarkedThroughImports(file, code),
|
|
3750
|
+
...await familiesMarkedInSamePackageOrTarget(file, code),
|
|
3751
|
+
...await familiesMarkedInHolderClasses(file, code)
|
|
3752
|
+
];
|
|
2473
3753
|
return async (file, code) => {
|
|
2474
3754
|
const familiesForExtension = familiesOfExtension(file.extension);
|
|
2475
3755
|
const markedHere = familiesWithMarkerIn(file.contents, familiesForExtension);
|
|
@@ -2533,17 +3813,16 @@ const scanCodeReferences = async (scope, projectKeys) => {
|
|
|
2533
3813
|
};
|
|
2534
3814
|
//#endregion
|
|
2535
3815
|
//#region src/project-scan.ts
|
|
2536
|
-
const scanProject = async (context,
|
|
3816
|
+
const scanProject = async (context, root) => {
|
|
2537
3817
|
const { workingDirectory } = context.invocation;
|
|
2538
3818
|
const settings = await readProjectSettings(workingDirectory);
|
|
2539
3819
|
const projectSlug = requireLinkedProjectSlug(context, settings);
|
|
2540
|
-
const root = await scanRootAt(workingDirectory, path);
|
|
2541
3820
|
const client = new AdminApiClient({
|
|
2542
3821
|
baseUrl: context.baseUrl,
|
|
2543
3822
|
token: await configuredApiToken(context)
|
|
2544
3823
|
});
|
|
2545
3824
|
const project = await findProjectBySlug(client, projectSlug);
|
|
2546
|
-
const configs = await client.listConfigs(project.id);
|
|
3825
|
+
const configs = (await client.listConfigs(project.id, { form: "every-config" })).map((listedConfig) => listedConfig.config);
|
|
2547
3826
|
const projectKeys = new Set(configs.flatMap((config) => [config.key, ...config.deprecatedKeys.map((deprecatedKey) => deprecatedKey.key)]));
|
|
2548
3827
|
const codeReferenceScan = await scanCodeReferences({
|
|
2549
3828
|
root,
|
|
@@ -2552,14 +3831,107 @@ const scanProject = async (context, path) => {
|
|
|
2552
3831
|
exclude: settings?.exclude ?? []
|
|
2553
3832
|
}, projectKeys);
|
|
2554
3833
|
return {
|
|
3834
|
+
client,
|
|
2555
3835
|
project,
|
|
2556
3836
|
configs,
|
|
2557
3837
|
ignoredKeys: new Set(settings?.ignoreKeys ?? []),
|
|
2558
|
-
scannedLocation: root.description,
|
|
2559
3838
|
codeReferenceScan
|
|
2560
3839
|
};
|
|
2561
3840
|
};
|
|
2562
3841
|
//#endregion
|
|
3842
|
+
//#region src/scanned-repository/git-checkout.ts
|
|
3843
|
+
const execFileAsync = promisify(execFile);
|
|
3844
|
+
const firstLineOf = (text) => text.trim().split("\n")[0] ?? "";
|
|
3845
|
+
const gitOutput = async (directory, environmentVariables, gitArguments) => {
|
|
3846
|
+
try {
|
|
3847
|
+
const { stdout } = await execFileAsync("git", gitArguments, {
|
|
3848
|
+
cwd: directory,
|
|
3849
|
+
env: { ...environmentVariables }
|
|
3850
|
+
});
|
|
3851
|
+
return {
|
|
3852
|
+
kind: "printed",
|
|
3853
|
+
text: stdout.trim()
|
|
3854
|
+
};
|
|
3855
|
+
} catch (error) {
|
|
3856
|
+
const failure = error;
|
|
3857
|
+
return failure.code === "ENOENT" ? { kind: "git-missing" } : {
|
|
3858
|
+
kind: "failed",
|
|
3859
|
+
message: firstLineOf(failure.stderr ?? "")
|
|
3860
|
+
};
|
|
3861
|
+
}
|
|
3862
|
+
};
|
|
3863
|
+
const readGitCheckout = async (directory, environmentVariables) => {
|
|
3864
|
+
const workTree = await gitOutput(directory, environmentVariables, ["rev-parse", "--is-inside-work-tree"]);
|
|
3865
|
+
switch (workTree.kind) {
|
|
3866
|
+
case "git-missing": return { kind: "git-missing" };
|
|
3867
|
+
case "failed": return {
|
|
3868
|
+
kind: "no-repository",
|
|
3869
|
+
gitMessage: workTree.message
|
|
3870
|
+
};
|
|
3871
|
+
case "printed": break;
|
|
3872
|
+
}
|
|
3873
|
+
const remoteUrl = await gitOutput(directory, environmentVariables, [
|
|
3874
|
+
"remote",
|
|
3875
|
+
"get-url",
|
|
3876
|
+
"origin"
|
|
3877
|
+
]);
|
|
3878
|
+
if (remoteUrl.kind !== "printed") return { kind: "no-origin-remote" };
|
|
3879
|
+
const commit = await gitOutput(directory, environmentVariables, ["rev-parse", "HEAD"]);
|
|
3880
|
+
return {
|
|
3881
|
+
kind: "origin-remote",
|
|
3882
|
+
remoteUrl: remoteUrl.text,
|
|
3883
|
+
commit: commit.kind === "printed" ? commit.text : ""
|
|
3884
|
+
};
|
|
3885
|
+
};
|
|
3886
|
+
//#endregion
|
|
3887
|
+
//#region src/scanned-repository/repository-identity.ts
|
|
3888
|
+
const URL_SCHEME_SEPARATOR = "://";
|
|
3889
|
+
const GIT_SUFFIX = ".git";
|
|
3890
|
+
const HTTP_PROTOCOLS = ["http:", "https:"];
|
|
3891
|
+
const carriesHttpCredentials = (url) => HTTP_PROTOCOLS.includes(url.protocol) && (url.username !== "" || url.password !== "");
|
|
3892
|
+
const withoutCredentials = (url) => {
|
|
3893
|
+
const urlWithoutCredentials = new URL(url);
|
|
3894
|
+
urlWithoutCredentials.username = "";
|
|
3895
|
+
urlWithoutCredentials.password = "";
|
|
3896
|
+
return urlWithoutCredentials.href;
|
|
3897
|
+
};
|
|
3898
|
+
const originRemoteOf = (remoteUrl) => {
|
|
3899
|
+
if (!remoteUrl.includes(URL_SCHEME_SEPARATOR)) return {
|
|
3900
|
+
path: remoteUrl.slice(remoteUrl.indexOf(":") + 1),
|
|
3901
|
+
uploadedUrl: remoteUrl
|
|
3902
|
+
};
|
|
3903
|
+
const url = new URL(remoteUrl);
|
|
3904
|
+
return {
|
|
3905
|
+
path: url.pathname.slice(1),
|
|
3906
|
+
uploadedUrl: carriesHttpCredentials(url) ? withoutCredentials(url) : remoteUrl
|
|
3907
|
+
};
|
|
3908
|
+
};
|
|
3909
|
+
const repositoryNameOf = (remote) => remote.path.endsWith(GIT_SUFFIX) ? remote.path.slice(0, -4) : remote.path;
|
|
3910
|
+
const unnamedCheckoutReason = (checkout, scannedLocation) => {
|
|
3911
|
+
switch (checkout.kind) {
|
|
3912
|
+
case "git-missing": return "git is not installed or not on the PATH";
|
|
3913
|
+
case "no-repository": return `git cannot read a repository in ${scannedLocation} (${checkout.gitMessage})`;
|
|
3914
|
+
case "no-origin-remote": return `the git repository in ${scannedLocation} has no remote named origin`;
|
|
3915
|
+
}
|
|
3916
|
+
};
|
|
3917
|
+
const unnamedRepositoryError = (checkout, scannedLocation) => new CliError(`scan --upload names the repository after its git remote origin, but ${unnamedCheckoutReason(checkout, scannedLocation)}. Name the repository with --repository <name>.`);
|
|
3918
|
+
const repositoryIdentityOf = (checkout, repositoryOption, scannedLocation) => {
|
|
3919
|
+
if (checkout.kind === "origin-remote") {
|
|
3920
|
+
const remote = originRemoteOf(checkout.remoteUrl);
|
|
3921
|
+
return {
|
|
3922
|
+
name: repositoryOption ?? repositoryNameOf(remote),
|
|
3923
|
+
remoteUrl: remote.uploadedUrl,
|
|
3924
|
+
commit: checkout.commit
|
|
3925
|
+
};
|
|
3926
|
+
}
|
|
3927
|
+
if (repositoryOption === void 0) throw unnamedRepositoryError(checkout, scannedLocation);
|
|
3928
|
+
return {
|
|
3929
|
+
name: repositoryOption,
|
|
3930
|
+
remoteUrl: "",
|
|
3931
|
+
commit: ""
|
|
3932
|
+
};
|
|
3933
|
+
};
|
|
3934
|
+
//#endregion
|
|
2563
3935
|
//#region src/commands/scan.ts
|
|
2564
3936
|
const referenceJson = (reference) => {
|
|
2565
3937
|
switch (reference.kind) {
|
|
@@ -2584,8 +3956,7 @@ const referenceJson = (reference) => {
|
|
|
2584
3956
|
};
|
|
2585
3957
|
}
|
|
2586
3958
|
};
|
|
2587
|
-
const
|
|
2588
|
-
project: projectSlug,
|
|
3959
|
+
const codeReferenceScanJson = (scan) => ({
|
|
2589
3960
|
filesScanned: scan.filesScanned,
|
|
2590
3961
|
references: scan.references.map(referenceJson),
|
|
2591
3962
|
dynamicReferences: scan.dynamicReferences.map((reference) => ({
|
|
@@ -2596,6 +3967,10 @@ const scanJson = (projectSlug, scan) => ({
|
|
|
2596
3967
|
argument: reference.argument
|
|
2597
3968
|
}))
|
|
2598
3969
|
});
|
|
3970
|
+
const scanJson = (projectSlug, scan) => ({
|
|
3971
|
+
project: projectSlug,
|
|
3972
|
+
...codeReferenceScanJson(scan)
|
|
3973
|
+
});
|
|
2599
3974
|
const DYNAMIC_REFERENCES_HEADING = "Dynamic references (calls whose key is not a string literal)";
|
|
2600
3975
|
const withCollapsedWhitespace = (text) => {
|
|
2601
3976
|
let collapsed = "";
|
|
@@ -2633,11 +4008,169 @@ const scanText = (scan, scannedLocation) => {
|
|
|
2633
4008
|
counts
|
|
2634
4009
|
].join("\n\n")}\n`;
|
|
2635
4010
|
};
|
|
2636
|
-
const
|
|
2637
|
-
|
|
4011
|
+
const shortCommitText = (commit) => commit === null ? "" : ` at ${shortCommit(commit)}`;
|
|
4012
|
+
const uploadLine = (projectSlug, repository, leftOutCount) => `Uploaded the scan of ${repository.name}${shortCommitText(repository.commit)} to ${projectSlug}: ${repository.referenceCount} ${plural(repository.referenceCount, "reference", "references")} and ${repository.dynamicReferenceCount} ${plural(repository.dynamicReferenceCount, "dynamic reference", "dynamic references")}, with ${leftOutCount} left out for a key that cannot be a config key.`;
|
|
4013
|
+
const uploadScan = async (context, root, repositoryOption) => {
|
|
4014
|
+
const repository = repositoryIdentityOf(await readGitCheckout(scanRootDirectory(root), context.invocation.environmentVariables), repositoryOption, root.description);
|
|
4015
|
+
const { client, project, codeReferenceScan } = await scanProject(context, root);
|
|
4016
|
+
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
|
+
const configKeyReferences = codeReferenceScan.references.filter((reference) => fitsKeyGrammar(reference.key));
|
|
4018
|
+
const leftOutCount = codeReferenceScan.references.length - configKeyReferences.length;
|
|
4019
|
+
const { parsedBody: scannedRepository, responseBody } = await client.uploadRepositoryScan(project.id, repository.name, {
|
|
4020
|
+
remoteUrl: repository.remoteUrl,
|
|
4021
|
+
commit: repository.commit,
|
|
4022
|
+
...codeReferenceScanJson({
|
|
4023
|
+
...codeReferenceScan,
|
|
4024
|
+
references: configKeyReferences
|
|
4025
|
+
})
|
|
4026
|
+
});
|
|
4027
|
+
return {
|
|
4028
|
+
json: responseBody,
|
|
4029
|
+
text: `${scanText(codeReferenceScan, root.description)}\n${uploadLine(project.slug, scannedRepository, leftOutCount)}\n`,
|
|
4030
|
+
exitCode: ExitCode.success
|
|
4031
|
+
};
|
|
4032
|
+
};
|
|
4033
|
+
const scan = async (context, [path], options) => {
|
|
4034
|
+
const root = await scanRootAt(context.invocation.workingDirectory, path);
|
|
4035
|
+
if (options.upload) return uploadScan(context, root, options.repository);
|
|
4036
|
+
const { project, codeReferenceScan } = await scanProject(context, root);
|
|
2638
4037
|
return {
|
|
2639
4038
|
json: scanJson(project.slug, codeReferenceScan),
|
|
2640
|
-
text: scanText(codeReferenceScan,
|
|
4039
|
+
text: scanText(codeReferenceScan, root.description),
|
|
4040
|
+
exitCode: ExitCode.success
|
|
4041
|
+
};
|
|
4042
|
+
};
|
|
4043
|
+
//#endregion
|
|
4044
|
+
//#region src/segments/segment-text.ts
|
|
4045
|
+
const conditionGroupLines = (heading, groups) => [
|
|
4046
|
+
"",
|
|
4047
|
+
heading,
|
|
4048
|
+
...groups.map((group, index) => ` ${index + 1}. ${describeConditionGroup(group)}`)
|
|
4049
|
+
];
|
|
4050
|
+
const segmentText = (segment, environmentSlugs) => [
|
|
4051
|
+
segment.key,
|
|
4052
|
+
` Name: ${segment.name}`,
|
|
4053
|
+
...conditionGroupLines("Project definition", segment.groups),
|
|
4054
|
+
...Object.entries(segment.overrides).flatMap(([environmentId, groups]) => conditionGroupLines(`Override in ${environmentSlugs.get(environmentId) ?? environmentId}`, groups)),
|
|
4055
|
+
""
|
|
4056
|
+
].join("\n");
|
|
4057
|
+
//#endregion
|
|
4058
|
+
//#region src/commands/segments.ts
|
|
4059
|
+
const usageDescription = (segment) => `used by ${segment.usages.length} ${plural(segment.usages.length, "config", "configs")}`;
|
|
4060
|
+
const segmentLines = (projectSlug, segments) => segments.length === 0 ? [`No segments found in ${projectSlug}.`] : alignedColumns(segments.map((segment) => [
|
|
4061
|
+
segment.key,
|
|
4062
|
+
segment.name,
|
|
4063
|
+
usageDescription(segment)
|
|
4064
|
+
]));
|
|
4065
|
+
const listSegments = async (context) => {
|
|
4066
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
4067
|
+
const { parsedBody: segments, responseBody } = await client.listSegments(project.id);
|
|
4068
|
+
return {
|
|
4069
|
+
json: responseBody,
|
|
4070
|
+
text: [...segmentLines(project.slug, segments), ""].join("\n"),
|
|
4071
|
+
exitCode: ExitCode.success
|
|
4072
|
+
};
|
|
4073
|
+
};
|
|
4074
|
+
const environmentSlugsFor = async (client, project, segment) => {
|
|
4075
|
+
if (Object.keys(segment.overrides).length === 0) return /* @__PURE__ */ new Map();
|
|
4076
|
+
const environments = await ifPermitted(client.listEnvironments(project.id));
|
|
4077
|
+
return new Map((environments?.parsedBody ?? []).map((environment) => [environment.id, environment.slug]));
|
|
4078
|
+
};
|
|
4079
|
+
const getSegment = async (context, [segmentKey]) => {
|
|
4080
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
4081
|
+
const receivedSegment = await client.getSegment(project.id, segmentKey);
|
|
4082
|
+
if (receivedSegment === void 0) throw new CliError(`No segment in ${project.slug} has the key "${segmentKey}".`);
|
|
4083
|
+
const { parsedBody: segment, responseBody } = receivedSegment;
|
|
4084
|
+
return {
|
|
4085
|
+
json: responseBody,
|
|
4086
|
+
text: segmentText(segment, await environmentSlugsFor(client, project, segment)),
|
|
4087
|
+
exitCode: ExitCode.success
|
|
4088
|
+
};
|
|
4089
|
+
};
|
|
4090
|
+
//#endregion
|
|
4091
|
+
//#region src/targeting-rules/reference-text.ts
|
|
4092
|
+
const headedListLines = (heading, items) => [heading, ...items.map((item) => ` ${item}`)];
|
|
4093
|
+
const operatorsByTargetTypeLines = (operatorsByTargetType) => ["Operators per target type:", ...Object.entries(operatorsByTargetType).flatMap(([targetType, operators]) => [` ${targetType}:`, ...operators.map((operator) => ` ${operator}`)])];
|
|
4094
|
+
const targetingRulesReferenceText = (reference) => [
|
|
4095
|
+
...reference.notes.flatMap((note) => [note, ""]),
|
|
4096
|
+
...headedListLines("Condition kinds:", reference.conditionKinds),
|
|
4097
|
+
"",
|
|
4098
|
+
...headedListLines("Attributes a condition can test:", reference.conditionAttributes),
|
|
4099
|
+
"",
|
|
4100
|
+
...headedListLines("Context kind operators:", reference.contextKindOperators),
|
|
4101
|
+
"",
|
|
4102
|
+
...headedListLines("Segment operators:", reference.segmentOperators),
|
|
4103
|
+
"",
|
|
4104
|
+
...operatorsByTargetTypeLines(reference.operatorsByTargetType),
|
|
4105
|
+
""
|
|
4106
|
+
].join("\n");
|
|
4107
|
+
//#endregion
|
|
4108
|
+
//#region src/targeting-rules/targeting-rules-file.ts
|
|
4109
|
+
const STANDARD_INPUT_ARGUMENT = "-";
|
|
4110
|
+
const readRulesSource = async (rulesArgument, invocation) => {
|
|
4111
|
+
if (rulesArgument === STANDARD_INPUT_ARGUMENT) return {
|
|
4112
|
+
origin: "--rules reads stdin",
|
|
4113
|
+
contents: await text(invocation.stdin)
|
|
4114
|
+
};
|
|
4115
|
+
const { file, contents } = await readOptionFile("--rules", rulesArgument, invocation.workingDirectory);
|
|
4116
|
+
return {
|
|
4117
|
+
origin: `--rules names ${file}`,
|
|
4118
|
+
contents
|
|
4119
|
+
};
|
|
4120
|
+
};
|
|
4121
|
+
const readTargetingRulesFile = async (rulesArgument, invocation) => {
|
|
4122
|
+
const { origin, contents } = await readRulesSource(rulesArgument, invocation);
|
|
4123
|
+
const targetingRules = parseJson(contents);
|
|
4124
|
+
if (!isJsonObject(targetingRules)) throw new CliError(`${origin}, which does not hold a JSON object. It takes {"defaultValue": ..., "rules": [...]}, like an entry of targets in configs get --json.`);
|
|
4125
|
+
return targetingRules;
|
|
4126
|
+
};
|
|
4127
|
+
const withId = (entry) => entry.id === void 0 ? {
|
|
4128
|
+
id: randomUUID(),
|
|
4129
|
+
...entry
|
|
4130
|
+
} : entry;
|
|
4131
|
+
const conditionOrPercentageWithId = (conditionOrPercentage) => isJsonObject(conditionOrPercentage) ? withId(conditionOrPercentage) : conditionOrPercentage;
|
|
4132
|
+
const conditionsOrPercentagesWithIds = (rule, field) => {
|
|
4133
|
+
const conditionsOrPercentages = rule[field];
|
|
4134
|
+
return Array.isArray(conditionsOrPercentages) ? { [field]: conditionsOrPercentages.map(conditionOrPercentageWithId) } : {};
|
|
4135
|
+
};
|
|
4136
|
+
const ruleWithIds = (rule) => isJsonObject(rule) ? {
|
|
4137
|
+
...withId(rule),
|
|
4138
|
+
...conditionsOrPercentagesWithIds(rule, "conditions"),
|
|
4139
|
+
...conditionsOrPercentagesWithIds(rule, "percentages")
|
|
4140
|
+
} : rule;
|
|
4141
|
+
const withGeneratedIds = (targetingRules) => Array.isArray(targetingRules.rules) ? {
|
|
4142
|
+
...targetingRules,
|
|
4143
|
+
rules: targetingRules.rules.map(ruleWithIds)
|
|
4144
|
+
} : targetingRules;
|
|
4145
|
+
//#endregion
|
|
4146
|
+
//#region src/commands/targeting-rules.ts
|
|
4147
|
+
const replacedRulesLine = (configKey, environment, targetingRules) => {
|
|
4148
|
+
const ruleCount = Array.isArray(targetingRules.rules) ? targetingRules.rules.length : 0;
|
|
4149
|
+
return `Replaced the targeting rules of ${configKey} in ${environment.slug} with ${ruleCount} ${plural(ruleCount, "rule", "rules")} and the default targeting rule value ${String(targetingRules.defaultValue)}.`;
|
|
4150
|
+
};
|
|
4151
|
+
const setTargetingRules = async (context, [configKey], options) => {
|
|
4152
|
+
const targetingRules = withGeneratedIds(await readTargetingRulesFile(options.rules, context.invocation));
|
|
4153
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
4154
|
+
const environment = await findEnvironmentBySlug(client, project, options.environment);
|
|
4155
|
+
const request = {
|
|
4156
|
+
...targetingRules,
|
|
4157
|
+
environmentId: environment.id
|
|
4158
|
+
};
|
|
4159
|
+
if (await client.replaceTargetingRules(project.id, configKey, request) === "not-found") throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
4160
|
+
return {
|
|
4161
|
+
json: request,
|
|
4162
|
+
text: [replacedRulesLine(configKey, environment, targetingRules), ""].join("\n"),
|
|
4163
|
+
exitCode: ExitCode.success
|
|
4164
|
+
};
|
|
4165
|
+
};
|
|
4166
|
+
const showTargetingRulesReference = async (context) => {
|
|
4167
|
+
const { parsedBody: reference, responseBody } = await new AdminApiClient({
|
|
4168
|
+
baseUrl: context.baseUrl,
|
|
4169
|
+
token: await configuredApiToken(context)
|
|
4170
|
+
}).getTargetingRulesReference();
|
|
4171
|
+
return {
|
|
4172
|
+
json: responseBody,
|
|
4173
|
+
text: targetingRulesReferenceText(reference),
|
|
2641
4174
|
exitCode: ExitCode.success
|
|
2642
4175
|
};
|
|
2643
4176
|
};
|
|
@@ -2836,14 +4369,15 @@ const validateText = (projectSlug, scan, scannedLocation, check) => {
|
|
|
2836
4369
|
}), countsText(scan, check)].join("\n\n")}\n`;
|
|
2837
4370
|
};
|
|
2838
4371
|
const validate = async (context, [path]) => {
|
|
2839
|
-
const
|
|
4372
|
+
const root = await scanRootAt(context.invocation.workingDirectory, path);
|
|
4373
|
+
const { project, configs, ignoredKeys, codeReferenceScan } = await scanProject(context, root);
|
|
2840
4374
|
const check = checkCodeReferences(codeReferenceScan.references, configs, ignoredKeys);
|
|
2841
4375
|
return {
|
|
2842
4376
|
json: {
|
|
2843
4377
|
...scanJson(project.slug, codeReferenceScan),
|
|
2844
4378
|
findings: check.findings.map(findingJson)
|
|
2845
4379
|
},
|
|
2846
|
-
text: validateText(project.slug, codeReferenceScan,
|
|
4380
|
+
text: validateText(project.slug, codeReferenceScan, root.description, check),
|
|
2847
4381
|
exitCode: check.findings.length === 0 ? ExitCode.success : ExitCode.problemsFound
|
|
2848
4382
|
};
|
|
2849
4383
|
};
|
|
@@ -2855,6 +4389,7 @@ const withoutTrailingSlashes = (url) => {
|
|
|
2855
4389
|
while (trimmedUrl.endsWith("/")) trimmedUrl = trimmedUrl.slice(0, -1);
|
|
2856
4390
|
return trimmedUrl;
|
|
2857
4391
|
};
|
|
4392
|
+
const repeatedOption = (value, values) => [...values ?? [], value];
|
|
2858
4393
|
const runCommand = async (invocation, command, actionArguments) => {
|
|
2859
4394
|
const parsedCommand = actionArguments.at(-1);
|
|
2860
4395
|
const options = parsedCommand.optsWithGlobals();
|
|
@@ -2863,7 +4398,7 @@ const runCommand = async (invocation, command, actionArguments) => {
|
|
|
2863
4398
|
baseUrl: withoutTrailingSlashes(options.baseUrl ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_BASE_URL") ?? DEFAULT_BASE_URL),
|
|
2864
4399
|
tokenOption: options.token,
|
|
2865
4400
|
projectSlug: options.project ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_PROJECT")
|
|
2866
|
-
}, parsedCommand.processedArgs);
|
|
4401
|
+
}, parsedCommand.processedArgs, parsedCommand.opts());
|
|
2867
4402
|
invocation.stdout.write(options.json ? `${JSON.stringify(output.json, null, 2)}\n` : output.text);
|
|
2868
4403
|
return output.exitCode;
|
|
2869
4404
|
};
|
|
@@ -2887,8 +4422,26 @@ const run = async (invocation) => {
|
|
|
2887
4422
|
});
|
|
2888
4423
|
program.command("login").description("save an API token for later runs, after checking it can list projects").action(commandAction(login));
|
|
2889
4424
|
program.command("init").description("write configdirector.json for a project in the current directory").action(commandAction(init));
|
|
2890
|
-
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").action(commandAction(scan));
|
|
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));
|
|
2891
4426
|
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
|
+
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));
|
|
4428
|
+
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
|
+
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
|
+
const configsCommand = program.command("configs").description("list, show, create and archive a project's configs");
|
|
4431
|
+
configsCommand.command("list").description("list the project's configs with their type, role, lifetime and lifecycle state").option("--tag <name>", "list only the configs that have this tag; repeatable, for configs with any of them", repeatedOption).addOption(new Option("--untagged", "list only the configs without tags").conflicts("tag")).addOption(new Option("--state <state>", "list only the configs in this lifecycle state").choices(LIFECYCLE_STATES)).action(commandAction(listConfigs));
|
|
4432
|
+
configsCommand.command("get").argument("<key>", "the config's key, or an old key of it").description("show a config's settings and its targeting rules in every environment").action(commandAction(getConfig));
|
|
4433
|
+
configsCommand.command("activity").argument("<key>", "the config's key, or an old key of it").description("show when SDKs last evaluated a config, per environment, SDK key kind, SDK name and SDK version").action(commandAction(showConfigActivity));
|
|
4434
|
+
configsCommand.command("create").description("create a config from options that mirror the Admin API's fields").requiredOption("--key <key>", "the config's key (required)").option("--description <text>", "a description shown with the config in the dashboard").addOption(new Option("--type <type>", "the type of the config's values (required)").choices(CONFIG_TYPES).makeOptionMandatory()).addOption(new Option("--role <role>", "what the config is for (required)").choices(CONFIG_ROLES).makeOptionMandatory()).addOption(new Option("--lifetime <lifetime>", "whether the config is expected to be removed (required)").choices(CONFIG_LIFETIMES).makeOptionMandatory()).requiredOption("--default-value <value>", "the default targeting rule value every environment starts with, in the type given by --type; not the in-code default value an application passes to the SDK (required)").option("--client-only", "serve the config to client SDKs only (default: client and server SDKs)").addOption(new Option("--server-only", "serve the config to server SDKs only (default: client and server SDKs)").conflicts("clientOnly")).option("--tag <name>", "assign the project's tag with this name; repeatable", repeatedOption).option("--variation <name=value>", "a variation of an experiment, its value in the type given by --type; repeatable", repeatedOption).addOption(new Option("--min <number>", "the smallest value allowed, included").helpGroup(typeConstraintHelpGroup("min"))).addOption(new Option("--max <number>", "the largest value allowed, included").helpGroup(typeConstraintHelpGroup("max"))).addOption(new Option("--exclusive-min <number>", "values must be greater than this number").conflicts("min").helpGroup(typeConstraintHelpGroup("exclusiveMin"))).addOption(new Option("--exclusive-max <number>", "values must be less than this number").conflicts("max").helpGroup(typeConstraintHelpGroup("exclusiveMax"))).addOption(new Option("--unit <unit>", "the unit of the number the SDKs receive (required)").choices(TIMESPAN_UNITS).helpGroup(typeConstraintHelpGroup("unit"))).addOption(new Option("--allow-fractions", "allow values with a fraction of the unit").helpGroup(typeConstraintHelpGroup("allowFractions"))).addOption(new Option("--allowed-value <value>", "a value the config can take; repeatable").argParser(repeatedOption).helpGroup(typeConstraintHelpGroup("allowedValue"))).addOption(new Option("--enum-value-type <type>", "whether the allowed values are strings or numbers (default: string)").choices(ENUM_VALUE_TYPES).helpGroup(typeConstraintHelpGroup("enumValueType"))).addOption(new Option("--https-only", "allow only HTTPS URLs").helpGroup(typeConstraintHelpGroup("httpsOnly"))).addOption(new Option("--json-schema <file>", "a file holding the JSON Schema every value must match").helpGroup(typeConstraintHelpGroup("jsonSchema"))).action(commandAction(createConfig));
|
|
4435
|
+
configsCommand.command("archive").argument("<key>", "the config's key, or an old key of it").description("archive a config, so SDKs stop receiving it; the dashboard can restore it for 30 days").addHelpText("after", "\nThe config is archived with no confirmation and no check of whether the code still reads its key, so run configdirector scan first. Applications that still read it get their in-code default value.").action(commandAction(archiveConfig));
|
|
4436
|
+
const segmentsCommand = program.command("segments").description("list a project's segments and show one segment");
|
|
4437
|
+
segmentsCommand.command("list").description("list the project's segments with how many configs use each").action(commandAction(listSegments));
|
|
4438
|
+
segmentsCommand.command("get").argument("<key>", "the segment's key").description("show a segment's condition groups and its environment overrides").action(commandAction(getSegment));
|
|
4439
|
+
const targetingRulesCommand = program.command("targeting-rules").description("replace a config's targeting rules in one environment and show the format they take");
|
|
4440
|
+
targetingRulesCommand.command("set").argument("<key>", "the config's key, or an old key of it").description("replace a config's targeting rules in one environment with the rules of a JSON file").requiredOption("--environment <slug>", "slug of the environment whose targeting rules to replace (required)").requiredOption("--rules <file>", "a JSON file holding defaultValue and rules as the Admin API takes them, or - to read it from stdin (required)").addHelpText("after", "\nThe file holds {\"defaultValue\": ..., \"rules\": [...]}, like an entry of targets in configs get --json, so you can edit that output and write it back. Every rule of the environment is replaced. A rule, condition or percentage without an id gets a new one. configdirector targeting-rules reference lists the condition kinds, attributes and operators.").action(commandAction(setTargetingRules));
|
|
4441
|
+
targetingRulesCommand.command("reference").description("show the format targeting rules take: the condition kinds, the attributes a condition can test and the operators").action(commandAction(showTargetingRulesReference));
|
|
4442
|
+
const repositoriesCommand = program.command("repositories").description("list a project's scanned repositories and remove one");
|
|
4443
|
+
repositoriesCommand.command("list").description("list the repositories whose scan was uploaded to the project, with their commit, scan time and reference counts").action(commandAction(listScannedRepositories));
|
|
4444
|
+
repositoriesCommand.command("remove").argument("<name>", "the repository's name, as repositories list shows it, such as ConfigDirector/cli").description("remove a scanned repository and its code references from the project").addHelpText("after", "\nThe repository is removed with no confirmation, and the dashboard stops showing its code references. The next scan --upload from it adds it back.").action(commandAction(removeScannedRepository));
|
|
2892
4445
|
try {
|
|
2893
4446
|
await program.parseAsync(invocation.commandLineArguments, { from: "user" });
|
|
2894
4447
|
return exitCode;
|