@configdirector/cli 0.1.0-beta.1 → 0.5.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 +46 -0
- package/README.md +153 -10
- package/dist/configdirector.mjs +1403 -69
- package/package.json +1 -1
package/dist/configdirector.mjs
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
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 { randomUUID } from "node:crypto";
|
|
11
|
+
import { text } from "node:stream/consumers";
|
|
10
12
|
//#region package.json
|
|
11
|
-
var version = "0.
|
|
13
|
+
var version = "0.5.0-beta.1";
|
|
12
14
|
//#endregion
|
|
13
15
|
//#region src/CliError.ts
|
|
14
16
|
var CliError = class extends Error {
|
|
@@ -38,15 +40,39 @@ const ApiValidationErrorBodySchema = v.object({ errors: v.array(v.object({
|
|
|
38
40
|
message: v.string()
|
|
39
41
|
})) });
|
|
40
42
|
const ServiceSuspendedBodySchema = v.object({ code: v.literal("SERVICE_SUSPENDED") });
|
|
43
|
+
const EvaluationNotFoundBodySchema = v.object({
|
|
44
|
+
error: v.string(),
|
|
45
|
+
code: v.picklist([
|
|
46
|
+
"CONFIG_NOT_FOUND",
|
|
47
|
+
"ENVIRONMENT_NOT_FOUND",
|
|
48
|
+
"CONTEXT_NOT_FOUND"
|
|
49
|
+
])
|
|
50
|
+
});
|
|
41
51
|
const describeFieldError = (fieldError) => fieldError.name === void 0 ? fieldError.message : `${fieldError.name}: ${fieldError.message}`;
|
|
42
52
|
var ApiAuthorizationError = class extends CliError {
|
|
43
53
|
constructor(code, apiMessage) {
|
|
44
54
|
super(`The Admin API refused the request: ${apiMessage} (${code})`);
|
|
55
|
+
this.code = code;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const ifPermitted = async (request) => {
|
|
59
|
+
try {
|
|
60
|
+
return await request;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error instanceof ApiAuthorizationError && error.code === "INSUFFICIENT_PERMISSIONS") return;
|
|
63
|
+
throw error;
|
|
45
64
|
}
|
|
46
65
|
};
|
|
47
66
|
var ApiValidationError = class extends CliError {
|
|
48
|
-
constructor(
|
|
49
|
-
super(`The Admin API rejected the request:\n${
|
|
67
|
+
constructor(fieldErrors) {
|
|
68
|
+
super(`The Admin API rejected the request:\n${fieldErrors.map((error) => ` ${describeFieldError(error)}`).join("\n")}`);
|
|
69
|
+
this.fieldErrors = fieldErrors;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
var EvaluationNotFoundError = class extends CliError {
|
|
73
|
+
constructor(code, apiMessage) {
|
|
74
|
+
super(`The Admin API could not evaluate the config: ${apiMessage} (${code})`);
|
|
75
|
+
this.code = code;
|
|
50
76
|
}
|
|
51
77
|
};
|
|
52
78
|
var ServiceSuspendedError = class extends CliError {
|
|
@@ -66,6 +92,566 @@ var UnexpectedApiResponseError = class extends CliError {
|
|
|
66
92
|
}
|
|
67
93
|
};
|
|
68
94
|
//#endregion
|
|
95
|
+
//#region src/aligned-columns.ts
|
|
96
|
+
const COLUMN_GAP = " ";
|
|
97
|
+
const widestCellOf = (rows, column) => Math.max(0, ...rows.map((row) => row[column]?.length ?? 0));
|
|
98
|
+
const alignedColumns = (rows) => rows.map((row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widestCellOf(rows, column))).join(COLUMN_GAP));
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/admin-api/targeting-rules.ts
|
|
101
|
+
const RuleValueSchema = v.union([
|
|
102
|
+
v.string(),
|
|
103
|
+
v.number(),
|
|
104
|
+
v.boolean()
|
|
105
|
+
]);
|
|
106
|
+
const attributeConditionEntries = {
|
|
107
|
+
attribute: v.string(),
|
|
108
|
+
trait: v.nullish(v.string()),
|
|
109
|
+
operator: v.string(),
|
|
110
|
+
targetValues: v.array(v.unknown())
|
|
111
|
+
};
|
|
112
|
+
const AttributeConditionSchema = v.object(attributeConditionEntries);
|
|
113
|
+
const ConditionGroupSchema = v.array(AttributeConditionSchema);
|
|
114
|
+
const SegmentConditionSchema = v.object({
|
|
115
|
+
id: v.string(),
|
|
116
|
+
kind: v.literal("segment"),
|
|
117
|
+
operator: v.string(),
|
|
118
|
+
segmentId: v.string()
|
|
119
|
+
});
|
|
120
|
+
const ConditionSchema = v.variant("kind", [v.object({
|
|
121
|
+
id: v.string(),
|
|
122
|
+
kind: v.literal("attribute"),
|
|
123
|
+
...attributeConditionEntries
|
|
124
|
+
}), SegmentConditionSchema]);
|
|
125
|
+
const PercentageSchema = v.object({
|
|
126
|
+
percentage: v.number(),
|
|
127
|
+
value: RuleValueSchema
|
|
128
|
+
});
|
|
129
|
+
const RuleSchema = v.variant("type", [v.variant("target", [v.object({
|
|
130
|
+
id: v.string(),
|
|
131
|
+
type: v.literal("conditional"),
|
|
132
|
+
conditions: v.array(ConditionSchema),
|
|
133
|
+
target: v.literal("value"),
|
|
134
|
+
value: RuleValueSchema
|
|
135
|
+
}), v.object({
|
|
136
|
+
id: v.string(),
|
|
137
|
+
type: v.literal("conditional"),
|
|
138
|
+
conditions: v.array(ConditionSchema),
|
|
139
|
+
target: v.literal("percentage"),
|
|
140
|
+
percentages: v.array(PercentageSchema)
|
|
141
|
+
})]), v.object({
|
|
142
|
+
id: v.string(),
|
|
143
|
+
type: v.literal("percentage"),
|
|
144
|
+
percentages: v.array(PercentageSchema)
|
|
145
|
+
})]);
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/admin-api/config-with-targeting-rules.ts
|
|
148
|
+
const TargetingRulesInEnvironmentSchema = v.object({
|
|
149
|
+
environment: v.object({
|
|
150
|
+
slug: v.string(),
|
|
151
|
+
live: v.boolean()
|
|
152
|
+
}),
|
|
153
|
+
rules: v.array(RuleSchema),
|
|
154
|
+
defaultValue: v.string()
|
|
155
|
+
});
|
|
156
|
+
const NumericBoundSchema = v.object({
|
|
157
|
+
relation: v.string(),
|
|
158
|
+
value: v.number()
|
|
159
|
+
});
|
|
160
|
+
const NumericBoundsSchema = v.object({
|
|
161
|
+
min: v.optional(NumericBoundSchema),
|
|
162
|
+
max: v.optional(NumericBoundSchema)
|
|
163
|
+
});
|
|
164
|
+
const EnumValuesSchema = v.object({
|
|
165
|
+
valueType: v.string(),
|
|
166
|
+
values: v.array(v.string())
|
|
167
|
+
});
|
|
168
|
+
const TimespanRangeSchema = v.object({
|
|
169
|
+
unit: v.string(),
|
|
170
|
+
allowFractions: v.nullish(v.boolean()),
|
|
171
|
+
min: v.number(),
|
|
172
|
+
max: v.number()
|
|
173
|
+
});
|
|
174
|
+
const ConstrainedTypeSchema = v.variant("type", [
|
|
175
|
+
v.object({
|
|
176
|
+
type: v.literal("integer"),
|
|
177
|
+
typeOptions: v.nullish(NumericBoundsSchema)
|
|
178
|
+
}),
|
|
179
|
+
v.object({
|
|
180
|
+
type: v.literal("float"),
|
|
181
|
+
typeOptions: v.nullish(NumericBoundsSchema)
|
|
182
|
+
}),
|
|
183
|
+
v.object({
|
|
184
|
+
type: v.literal("enum"),
|
|
185
|
+
typeOptions: EnumValuesSchema
|
|
186
|
+
}),
|
|
187
|
+
v.object({
|
|
188
|
+
type: v.literal("timespan"),
|
|
189
|
+
typeOptions: TimespanRangeSchema
|
|
190
|
+
}),
|
|
191
|
+
v.object({
|
|
192
|
+
type: v.literal("url"),
|
|
193
|
+
typeOptions: v.nullish(v.object({ secureOnly: v.nullish(v.boolean()) }))
|
|
194
|
+
}),
|
|
195
|
+
v.object({
|
|
196
|
+
type: v.literal("json"),
|
|
197
|
+
typeOptions: v.nullish(v.object({ useSchema: v.optional(v.boolean()) }))
|
|
198
|
+
})
|
|
199
|
+
]);
|
|
200
|
+
const CONSTRAINED_TYPE_NAMES = new Set(ConstrainedTypeSchema.options.map((option) => option.entries.type.literal));
|
|
201
|
+
const ConfigTypeSchema = v.union([ConstrainedTypeSchema, v.object({ type: v.pipe(v.string(), v.notValues([...CONSTRAINED_TYPE_NAMES])) })]);
|
|
202
|
+
const hasTypeConstraints = (configType) => CONSTRAINED_TYPE_NAMES.has(configType.type);
|
|
203
|
+
const ConfigWithTargetingRulesSchema = v.intersect([v.object({
|
|
204
|
+
key: v.string(),
|
|
205
|
+
description: v.nullish(v.string()),
|
|
206
|
+
role: v.string(),
|
|
207
|
+
lifetime: v.string(),
|
|
208
|
+
state: v.string(),
|
|
209
|
+
client: v.boolean(),
|
|
210
|
+
server: v.boolean(),
|
|
211
|
+
tags: v.array(v.object({ name: v.string() })),
|
|
212
|
+
targets: v.array(TargetingRulesInEnvironmentSchema)
|
|
213
|
+
}), ConfigTypeSchema]);
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region src/old-key-lines.ts
|
|
216
|
+
const oldKeyLines = (requestedKey, currentKey) => requestedKey === currentKey ? [] : [`${requestedKey} is an old key of ${currentKey}.`];
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/rule-sentences.ts
|
|
219
|
+
const TRAIT_POINTER_PREFIX = "/";
|
|
220
|
+
const ATTRIBUTE_LABELS = {
|
|
221
|
+
identifier: "context identifier",
|
|
222
|
+
kind: "context kind",
|
|
223
|
+
name: "context name",
|
|
224
|
+
appName: "app name",
|
|
225
|
+
appVersion: "app version"
|
|
226
|
+
};
|
|
227
|
+
const formatPercentage = (value) => Number(value.toFixed(3)).toString();
|
|
228
|
+
const subjectOf = (condition) => {
|
|
229
|
+
if (condition.attribute === "traits" && condition.trait != null) return condition.trait.startsWith(TRAIT_POINTER_PREFIX) ? condition.trait.slice(1) : condition.trait;
|
|
230
|
+
return ATTRIBUTE_LABELS[condition.attribute] ?? condition.attribute;
|
|
231
|
+
};
|
|
232
|
+
const segmentNameOf = (segmentId, segments) => segments[segmentId]?.name ?? segmentId;
|
|
233
|
+
const describeAttributeCondition = (condition) => `${subjectOf(condition)} ${condition.operator} ${condition.targetValues.map(String).join(", ")}`;
|
|
234
|
+
const describeConditionGroup = (group) => group.map(describeAttributeCondition).join(" and ");
|
|
235
|
+
const describeCondition = (condition, segments) => {
|
|
236
|
+
switch (condition.kind) {
|
|
237
|
+
case "attribute": return describeAttributeCondition(condition);
|
|
238
|
+
case "segment": return `${condition.operator} segment ${segmentNameOf(condition.segmentId, segments)}`;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const describePercentages = (percentages) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${String(percentage.value)}`).join(", ");
|
|
242
|
+
const describeRule = (rule, segments) => {
|
|
243
|
+
if (rule.type === "percentage") return describePercentages(rule.percentages);
|
|
244
|
+
return `${rule.conditions.map((condition) => describeCondition(condition, segments)).join(" and ")}: ${rule.target === "value" ? String(rule.value) : describePercentages(rule.percentages)}`;
|
|
245
|
+
};
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region src/configs/config-text.ts
|
|
248
|
+
const boundsDescriptions = (bounds) => {
|
|
249
|
+
const limits = [bounds?.min, bounds?.max].flatMap((bound) => bound === void 0 ? [] : [`${bound.relation} ${bound.value}`]);
|
|
250
|
+
return limits.length === 0 ? [] : [limits.join(" and ")];
|
|
251
|
+
};
|
|
252
|
+
const enumValuesDescription = ({ valueType, values }) => valueType === "number" ? `one of the numbers ${values.join(", ")}` : `one of ${values.join(", ")}`;
|
|
253
|
+
const timespanRangeDescriptions = (range) => [`${range.min} to ${range.max} ${range.unit}`, ...range.allowFractions ? ["fractions allowed"] : []];
|
|
254
|
+
const typeConstraintDescriptions = (config) => {
|
|
255
|
+
if (!hasTypeConstraints(config)) return [];
|
|
256
|
+
switch (config.type) {
|
|
257
|
+
case "integer":
|
|
258
|
+
case "float": return boundsDescriptions(config.typeOptions);
|
|
259
|
+
case "enum": return [enumValuesDescription(config.typeOptions)];
|
|
260
|
+
case "timespan": return timespanRangeDescriptions(config.typeOptions);
|
|
261
|
+
case "url": return config.typeOptions?.secureOnly ? ["HTTPS only"] : [];
|
|
262
|
+
case "json": return config.typeOptions?.useSchema ? ["checked against a JSON schema"] : [];
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
const availabilityDescription = (config) => {
|
|
266
|
+
if (config.client && config.server) return "client and server SDKs";
|
|
267
|
+
if (config.client) return "client-only";
|
|
268
|
+
if (config.server) return "server-only";
|
|
269
|
+
return "served to no SDK";
|
|
270
|
+
};
|
|
271
|
+
const settingLines = (config) => [
|
|
272
|
+
...config.description ? [`Description: ${config.description}`] : [],
|
|
273
|
+
`Type: ${[config.type, ...typeConstraintDescriptions(config)].join(", ")}`,
|
|
274
|
+
`Role: ${config.role}`,
|
|
275
|
+
`Lifetime: ${config.lifetime}`,
|
|
276
|
+
`Availability: ${availabilityDescription(config)}`,
|
|
277
|
+
`Lifecycle state: ${config.state}`,
|
|
278
|
+
...config.tags.length === 0 ? [] : [`Tags: ${config.tags.map((tag) => tag.name).join(", ")}`]
|
|
279
|
+
];
|
|
280
|
+
const environmentHeading = (targetingRules) => targetingRules.environment.live ? `${targetingRules.environment.slug} (live)` : targetingRules.environment.slug;
|
|
281
|
+
const targetingRuleLines = (targetingRules, segments) => [
|
|
282
|
+
environmentHeading(targetingRules),
|
|
283
|
+
...targetingRules.rules.map((rule, index) => ` ${index + 1}. ${describeRule(rule, segments)}`),
|
|
284
|
+
` Default targeting rule: ${targetingRules.defaultValue}`
|
|
285
|
+
];
|
|
286
|
+
const configText = (config, requestedKey, segments) => [
|
|
287
|
+
...oldKeyLines(requestedKey, config.key),
|
|
288
|
+
config.key,
|
|
289
|
+
...settingLines(config).map((line) => ` ${line}`),
|
|
290
|
+
...config.targets.flatMap((targetingRules) => ["", ...targetingRuleLines(targetingRules, segments)]),
|
|
291
|
+
""
|
|
292
|
+
].join("\n");
|
|
293
|
+
//#endregion
|
|
294
|
+
//#region src/file-contents.ts
|
|
295
|
+
const readFileIfPresent = async (file) => {
|
|
296
|
+
try {
|
|
297
|
+
return await readFile(file, "utf8");
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error.code === "ENOENT") return;
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
const readOptionFile = async (argumentLabel, path, workingDirectory) => {
|
|
304
|
+
const file = resolve(workingDirectory, path);
|
|
305
|
+
const contents = await readFileIfPresent(file);
|
|
306
|
+
if (contents === void 0) throw new CliError(`${argumentLabel} names ${file}, which does not exist.`);
|
|
307
|
+
return {
|
|
308
|
+
file,
|
|
309
|
+
contents
|
|
310
|
+
};
|
|
311
|
+
};
|
|
312
|
+
const FILE_ARGUMENT_MARKER = "@";
|
|
313
|
+
const atFileArgumentText = async (argumentLabel, argument, workingDirectory) => {
|
|
314
|
+
if (!argument.startsWith(FILE_ARGUMENT_MARKER)) return argument;
|
|
315
|
+
return (await readOptionFile(argumentLabel, argument.slice(1), workingDirectory)).contents;
|
|
316
|
+
};
|
|
317
|
+
const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
318
|
+
const parseJson = (contents) => {
|
|
319
|
+
try {
|
|
320
|
+
return JSON.parse(contents);
|
|
321
|
+
} catch {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
const pathExists = (path) => stat(path).then(() => true, () => false);
|
|
326
|
+
const fileExists = (path) => stat(path).then((stats) => stats.isFile(), () => false);
|
|
327
|
+
//#endregion
|
|
328
|
+
//#region src/configs/new-config.ts
|
|
329
|
+
const CONFIG_TYPES = [
|
|
330
|
+
"boolean",
|
|
331
|
+
"string",
|
|
332
|
+
"integer",
|
|
333
|
+
"float",
|
|
334
|
+
"timespan",
|
|
335
|
+
"enum",
|
|
336
|
+
"url",
|
|
337
|
+
"json"
|
|
338
|
+
];
|
|
339
|
+
const CONFIG_ROLES = [
|
|
340
|
+
"config",
|
|
341
|
+
"flag",
|
|
342
|
+
"kill-switch",
|
|
343
|
+
"experiment"
|
|
344
|
+
];
|
|
345
|
+
const CONFIG_LIFETIMES = ["permanent", "temporary"];
|
|
346
|
+
const TIMESPAN_UNITS = [
|
|
347
|
+
"milliseconds",
|
|
348
|
+
"seconds",
|
|
349
|
+
"minutes"
|
|
350
|
+
];
|
|
351
|
+
const ENUM_VALUE_TYPES = ["string", "number"];
|
|
352
|
+
const DEFAULT_ENUM_VALUE_TYPE = "string";
|
|
353
|
+
const TYPE_CONSTRAINT_OPTION_NAMES = {
|
|
354
|
+
min: "--min",
|
|
355
|
+
max: "--max",
|
|
356
|
+
exclusiveMin: "--exclusive-min",
|
|
357
|
+
exclusiveMax: "--exclusive-max",
|
|
358
|
+
unit: "--unit",
|
|
359
|
+
allowFractions: "--allow-fractions",
|
|
360
|
+
allowedValue: "--allowed-value",
|
|
361
|
+
enumValueType: "--enum-value-type",
|
|
362
|
+
httpsOnly: "--https-only",
|
|
363
|
+
jsonSchema: "--json-schema"
|
|
364
|
+
};
|
|
365
|
+
const availabilityOf = (options) => {
|
|
366
|
+
if (options.clientOnly) return {
|
|
367
|
+
client: true,
|
|
368
|
+
server: false
|
|
369
|
+
};
|
|
370
|
+
if (options.serverOnly) return {
|
|
371
|
+
client: false,
|
|
372
|
+
server: true
|
|
373
|
+
};
|
|
374
|
+
return {
|
|
375
|
+
client: true,
|
|
376
|
+
server: true
|
|
377
|
+
};
|
|
378
|
+
};
|
|
379
|
+
const BOOLEAN_VALUES = new Map([["true", true], ["false", false]]);
|
|
380
|
+
const numberOf = (text) => {
|
|
381
|
+
const value = parseJson(text);
|
|
382
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
383
|
+
};
|
|
384
|
+
const constraintNumberOf = (options, option) => {
|
|
385
|
+
const text = options[option];
|
|
386
|
+
if (text === void 0) return;
|
|
387
|
+
const value = numberOf(text);
|
|
388
|
+
if (value === void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES[option]} takes a number, not ${text}.`);
|
|
389
|
+
return value;
|
|
390
|
+
};
|
|
391
|
+
const boundOf = (options, option, relation) => {
|
|
392
|
+
const value = constraintNumberOf(options, option);
|
|
393
|
+
return value === void 0 ? void 0 : {
|
|
394
|
+
relation,
|
|
395
|
+
value
|
|
396
|
+
};
|
|
397
|
+
};
|
|
398
|
+
const numericBoundsOf = (options) => ({
|
|
399
|
+
min: boundOf(options, "min", ">=") ?? boundOf(options, "exclusiveMin", ">"),
|
|
400
|
+
max: boundOf(options, "max", "<=") ?? boundOf(options, "exclusiveMax", "<")
|
|
401
|
+
});
|
|
402
|
+
const jsonTextOf = async ({ argumentLabel, text, workingDirectory }) => {
|
|
403
|
+
const jsonText = (await atFileArgumentText(argumentLabel, text, workingDirectory)).trim();
|
|
404
|
+
return parseJson(jsonText) === void 0 ? void 0 : jsonText;
|
|
405
|
+
};
|
|
406
|
+
const jsonSchemaOptionsOf = async (options, workingDirectory) => {
|
|
407
|
+
if (options.jsonSchema === void 0) return;
|
|
408
|
+
const { file, contents } = await readOptionFile(TYPE_CONSTRAINT_OPTION_NAMES.jsonSchema, options.jsonSchema, workingDirectory);
|
|
409
|
+
const schema = parseJson(contents);
|
|
410
|
+
if (schema === void 0) throw new CliError(`${TYPE_CONSTRAINT_OPTION_NAMES.jsonSchema} names ${file}, which is not JSON.`);
|
|
411
|
+
return {
|
|
412
|
+
useSchema: true,
|
|
413
|
+
schema
|
|
414
|
+
};
|
|
415
|
+
};
|
|
416
|
+
const timespanRangeOf = async (options) => {
|
|
417
|
+
if (options.unit === void 0 || options.min === void 0 || options.max === void 0) throw new CliError("--type timespan needs --unit, --min and --max.");
|
|
418
|
+
return {
|
|
419
|
+
unit: options.unit,
|
|
420
|
+
min: constraintNumberOf(options, "min"),
|
|
421
|
+
max: constraintNumberOf(options, "max"),
|
|
422
|
+
allowFractions: options.allowFractions
|
|
423
|
+
};
|
|
424
|
+
};
|
|
425
|
+
const NUMERIC_BOUND_OPTIONS = [
|
|
426
|
+
"min",
|
|
427
|
+
"max",
|
|
428
|
+
"exclusiveMin",
|
|
429
|
+
"exclusiveMax"
|
|
430
|
+
];
|
|
431
|
+
const CONFIG_TYPE_RULES = {
|
|
432
|
+
boolean: {
|
|
433
|
+
constraintOptions: [],
|
|
434
|
+
valueDescription: "true or false",
|
|
435
|
+
valueOf: async ({ text }) => BOOLEAN_VALUES.get(text),
|
|
436
|
+
typeOptionsOf: async () => void 0
|
|
437
|
+
},
|
|
438
|
+
string: {
|
|
439
|
+
constraintOptions: [],
|
|
440
|
+
valueDescription: "text",
|
|
441
|
+
valueOf: async ({ text }) => text,
|
|
442
|
+
typeOptionsOf: async () => void 0
|
|
443
|
+
},
|
|
444
|
+
integer: {
|
|
445
|
+
constraintOptions: NUMERIC_BOUND_OPTIONS,
|
|
446
|
+
valueDescription: "a number",
|
|
447
|
+
valueOf: async ({ text }) => numberOf(text),
|
|
448
|
+
typeOptionsOf: async (options) => ({
|
|
449
|
+
isInteger: true,
|
|
450
|
+
...numericBoundsOf(options)
|
|
451
|
+
})
|
|
452
|
+
},
|
|
453
|
+
float: {
|
|
454
|
+
constraintOptions: NUMERIC_BOUND_OPTIONS,
|
|
455
|
+
valueDescription: "a number",
|
|
456
|
+
valueOf: async ({ text }) => numberOf(text),
|
|
457
|
+
typeOptionsOf: async (options) => ({
|
|
458
|
+
isInteger: false,
|
|
459
|
+
...numericBoundsOf(options)
|
|
460
|
+
})
|
|
461
|
+
},
|
|
462
|
+
timespan: {
|
|
463
|
+
constraintOptions: [
|
|
464
|
+
"min",
|
|
465
|
+
"max",
|
|
466
|
+
"unit",
|
|
467
|
+
"allowFractions"
|
|
468
|
+
],
|
|
469
|
+
valueDescription: "a number",
|
|
470
|
+
valueOf: async ({ text }) => numberOf(text),
|
|
471
|
+
typeOptionsOf: timespanRangeOf
|
|
472
|
+
},
|
|
473
|
+
enum: {
|
|
474
|
+
constraintOptions: ["allowedValue", "enumValueType"],
|
|
475
|
+
valueDescription: "text",
|
|
476
|
+
valueOf: async ({ text }) => text,
|
|
477
|
+
typeOptionsOf: async (options) => ({
|
|
478
|
+
values: options.allowedValue ?? [],
|
|
479
|
+
valueType: options.enumValueType ?? DEFAULT_ENUM_VALUE_TYPE
|
|
480
|
+
})
|
|
481
|
+
},
|
|
482
|
+
url: {
|
|
483
|
+
constraintOptions: ["httpsOnly"],
|
|
484
|
+
valueDescription: "text",
|
|
485
|
+
valueOf: async ({ text }) => text,
|
|
486
|
+
typeOptionsOf: async (options) => ({ secureOnly: options.httpsOnly === true })
|
|
487
|
+
},
|
|
488
|
+
json: {
|
|
489
|
+
constraintOptions: ["jsonSchema"],
|
|
490
|
+
valueDescription: "JSON or @<file>",
|
|
491
|
+
valueOf: jsonTextOf,
|
|
492
|
+
typeOptionsOf: jsonSchemaOptionsOf
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
const typeAlternatives = (types) => types.length === 1 ? `${types[0]}` : `${types.slice(0, -1).join(", ")} or ${types.at(-1)}`;
|
|
496
|
+
const typesTaking = (option) => CONFIG_TYPES.filter((type) => CONFIG_TYPE_RULES[type].constraintOptions.includes(option));
|
|
497
|
+
const typeConstraintHelpGroup = (option) => `Options for --type ${typeAlternatives(typesTaking(option))}:`;
|
|
498
|
+
const TYPE_CONSTRAINT_OPTIONS = Object.keys(TYPE_CONSTRAINT_OPTION_NAMES);
|
|
499
|
+
const rejectOptionsOfOtherTypes = (options) => {
|
|
500
|
+
const { constraintOptions } = CONFIG_TYPE_RULES[options.type];
|
|
501
|
+
const otherTypeOption = TYPE_CONSTRAINT_OPTIONS.find((option) => options[option] !== void 0 && !constraintOptions.includes(option));
|
|
502
|
+
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))}.`);
|
|
503
|
+
};
|
|
504
|
+
const typedValueOf = async (type, argument) => {
|
|
505
|
+
const rules = CONFIG_TYPE_RULES[type];
|
|
506
|
+
const value = await rules.valueOf(argument);
|
|
507
|
+
if (value === void 0) throw new CliError(`${argument.argumentLabel} takes ${rules.valueDescription} with --type ${type}, not ${argument.text}.`);
|
|
508
|
+
return value;
|
|
509
|
+
};
|
|
510
|
+
const VARIATION_SEPARATOR = "=";
|
|
511
|
+
const variationOf = async (type, variation, workingDirectory) => {
|
|
512
|
+
const separatorIndex = variation.indexOf(VARIATION_SEPARATOR);
|
|
513
|
+
const name = variation.slice(0, separatorIndex);
|
|
514
|
+
if (separatorIndex === -1 || name === "") throw new CliError(`--variation takes name=value, not ${variation}.`);
|
|
515
|
+
const text = variation.slice(separatorIndex + 1);
|
|
516
|
+
return {
|
|
517
|
+
name,
|
|
518
|
+
value: await typedValueOf(type, {
|
|
519
|
+
argumentLabel: `--variation ${name}`,
|
|
520
|
+
text,
|
|
521
|
+
workingDirectory
|
|
522
|
+
})
|
|
523
|
+
};
|
|
524
|
+
};
|
|
525
|
+
const variationsOf = async (options, workingDirectory) => {
|
|
526
|
+
const variations = [];
|
|
527
|
+
for (const variation of options.variation ?? []) variations.push(await variationOf(options.type, variation, workingDirectory));
|
|
528
|
+
return variations;
|
|
529
|
+
};
|
|
530
|
+
const newConfigOf = async (options, workingDirectory) => {
|
|
531
|
+
rejectOptionsOfOtherTypes(options);
|
|
532
|
+
return {
|
|
533
|
+
key: options.key,
|
|
534
|
+
description: options.description,
|
|
535
|
+
role: options.role,
|
|
536
|
+
lifetime: options.lifetime,
|
|
537
|
+
type: options.type,
|
|
538
|
+
typeOptions: await CONFIG_TYPE_RULES[options.type].typeOptionsOf(options, workingDirectory),
|
|
539
|
+
...availabilityOf(options),
|
|
540
|
+
variations: await variationsOf(options, workingDirectory),
|
|
541
|
+
defaultValue: await typedValueOf(options.type, {
|
|
542
|
+
argumentLabel: "--default-value",
|
|
543
|
+
text: options.defaultValue,
|
|
544
|
+
workingDirectory
|
|
545
|
+
})
|
|
546
|
+
};
|
|
547
|
+
};
|
|
548
|
+
//#endregion
|
|
549
|
+
//#region src/admin-api/config-creation.ts
|
|
550
|
+
const CreatedConfigSchema = v.object({
|
|
551
|
+
key: v.string(),
|
|
552
|
+
type: v.string(),
|
|
553
|
+
role: v.string(),
|
|
554
|
+
lifetime: v.string(),
|
|
555
|
+
client: v.boolean(),
|
|
556
|
+
server: v.boolean()
|
|
557
|
+
});
|
|
558
|
+
//#endregion
|
|
559
|
+
//#region src/admin-api/segments.ts
|
|
560
|
+
const SegmentSchema = v.object({
|
|
561
|
+
id: v.string(),
|
|
562
|
+
key: v.string(),
|
|
563
|
+
name: v.string(),
|
|
564
|
+
usages: v.array(v.unknown())
|
|
565
|
+
});
|
|
566
|
+
const SegmentDefinitionSchema = v.object({
|
|
567
|
+
key: v.string(),
|
|
568
|
+
name: v.string(),
|
|
569
|
+
groups: v.array(ConditionGroupSchema),
|
|
570
|
+
overrides: v.record(v.string(), v.array(ConditionGroupSchema))
|
|
571
|
+
});
|
|
572
|
+
//#endregion
|
|
573
|
+
//#region src/admin-api/targeting-rules-reference.ts
|
|
574
|
+
const TargetingRulesReferenceSchema = v.object({
|
|
575
|
+
notes: v.array(v.string()),
|
|
576
|
+
conditionKinds: v.array(v.string()),
|
|
577
|
+
conditionAttributes: v.array(v.string()),
|
|
578
|
+
contextKindOperators: v.array(v.string()),
|
|
579
|
+
segmentOperators: v.array(v.string()),
|
|
580
|
+
operatorsByTargetType: v.record(v.string(), v.array(v.string()))
|
|
581
|
+
});
|
|
582
|
+
//#endregion
|
|
583
|
+
//#region src/admin-api/config-evaluation.ts
|
|
584
|
+
const CheckedOutcomeSchema = v.picklist(["matched", "not-matched"]);
|
|
585
|
+
const AttributeConditionExplanationSchema = v.object({
|
|
586
|
+
conditionId: v.string(),
|
|
587
|
+
kind: v.literal("attribute"),
|
|
588
|
+
outcome: CheckedOutcomeSchema,
|
|
589
|
+
resolvedValue: v.optional(v.unknown()),
|
|
590
|
+
resolvedType: v.picklist([
|
|
591
|
+
"absent",
|
|
592
|
+
"scalar",
|
|
593
|
+
"array",
|
|
594
|
+
"object",
|
|
595
|
+
"unknown-attribute"
|
|
596
|
+
])
|
|
597
|
+
});
|
|
598
|
+
const SegmentConditionExplanationSchema = v.object({
|
|
599
|
+
conditionId: v.string(),
|
|
600
|
+
kind: v.literal("segment"),
|
|
601
|
+
outcome: CheckedOutcomeSchema,
|
|
602
|
+
segmentId: v.string(),
|
|
603
|
+
segmentFound: v.boolean(),
|
|
604
|
+
matchedGroupIndex: v.optional(v.number())
|
|
605
|
+
});
|
|
606
|
+
const ConditionExplanationSchema = v.union([
|
|
607
|
+
v.object({
|
|
608
|
+
conditionId: v.string(),
|
|
609
|
+
outcome: v.literal("not-evaluated")
|
|
610
|
+
}),
|
|
611
|
+
AttributeConditionExplanationSchema,
|
|
612
|
+
SegmentConditionExplanationSchema
|
|
613
|
+
]);
|
|
614
|
+
const BucketSchema = v.object({
|
|
615
|
+
defaultedToPredefinedBucket: v.boolean(),
|
|
616
|
+
assignedPercentage: v.number(),
|
|
617
|
+
shares: v.array(v.object({
|
|
618
|
+
percentageId: v.string(),
|
|
619
|
+
from: v.number(),
|
|
620
|
+
to: v.number()
|
|
621
|
+
})),
|
|
622
|
+
selectedPercentageId: v.optional(v.string())
|
|
623
|
+
});
|
|
624
|
+
const RuleExplanationSchema = v.object({
|
|
625
|
+
ruleId: v.string(),
|
|
626
|
+
outcome: v.picklist([
|
|
627
|
+
"matched",
|
|
628
|
+
"not-matched",
|
|
629
|
+
"not-evaluated",
|
|
630
|
+
"errored"
|
|
631
|
+
]),
|
|
632
|
+
conditions: v.array(ConditionExplanationSchema),
|
|
633
|
+
bucket: v.optional(BucketSchema)
|
|
634
|
+
});
|
|
635
|
+
const ConfigEvaluationSchema = v.object({
|
|
636
|
+
key: v.string(),
|
|
637
|
+
environment: v.object({ name: v.string() }),
|
|
638
|
+
context: v.nullable(v.object({
|
|
639
|
+
kind: v.string(),
|
|
640
|
+
identifier: v.nullable(v.string())
|
|
641
|
+
})),
|
|
642
|
+
value: v.optional(v.string()),
|
|
643
|
+
servedBy: v.variant("kind", [v.object({
|
|
644
|
+
kind: v.literal("rule"),
|
|
645
|
+
ruleId: v.string()
|
|
646
|
+
}), v.object({ kind: v.literal("default") })]),
|
|
647
|
+
rules: v.array(RuleExplanationSchema),
|
|
648
|
+
targetingRules: v.object({
|
|
649
|
+
rules: v.array(RuleSchema),
|
|
650
|
+
defaultValue: v.string()
|
|
651
|
+
}),
|
|
652
|
+
segments: v.record(v.string(), v.object({ name: v.string() }))
|
|
653
|
+
});
|
|
654
|
+
//#endregion
|
|
69
655
|
//#region src/admin-api/AdminApiClient.ts
|
|
70
656
|
const ProjectSchema = v.object({
|
|
71
657
|
id: v.string(),
|
|
@@ -75,6 +661,9 @@ const ProjectSchema = v.object({
|
|
|
75
661
|
const configFields = {
|
|
76
662
|
key: v.string(),
|
|
77
663
|
deprecatedKeys: v.array(v.object({ key: v.string() })),
|
|
664
|
+
role: v.string(),
|
|
665
|
+
lifetime: v.string(),
|
|
666
|
+
state: v.string(),
|
|
78
667
|
client: v.boolean(),
|
|
79
668
|
server: v.boolean()
|
|
80
669
|
};
|
|
@@ -91,6 +680,28 @@ const ConfigSchema = v.variant("type", [EnumConfigSchema, v.object({
|
|
|
91
680
|
type: v.pipe(v.string(), v.notValue("enum"))
|
|
92
681
|
})]);
|
|
93
682
|
const isEnumConfig = (config) => config.type === "enum";
|
|
683
|
+
const EnvironmentSchema = v.object({
|
|
684
|
+
id: v.string(),
|
|
685
|
+
slug: v.string(),
|
|
686
|
+
name: v.string(),
|
|
687
|
+
live: v.boolean()
|
|
688
|
+
});
|
|
689
|
+
const ConfigActivitySchema = v.object({ latestEvaluations: v.array(v.object({
|
|
690
|
+
environment: v.object({ slug: v.string() }),
|
|
691
|
+
source: v.string(),
|
|
692
|
+
sdkName: v.nullable(v.string()),
|
|
693
|
+
sdkVersion: v.nullable(v.string()),
|
|
694
|
+
time: v.pipe(v.string(), v.isoTimestamp())
|
|
695
|
+
})) });
|
|
696
|
+
const TagSchema = v.object({ id: v.string() });
|
|
697
|
+
const tagFilterQuery = (tagFilter) => {
|
|
698
|
+
switch (tagFilter.form) {
|
|
699
|
+
case "every-config": return "";
|
|
700
|
+
case "tagged": return `?${new URLSearchParams(tagFilter.tagIds.map((tagId) => ["tagIds", tagId]))}`;
|
|
701
|
+
case "untagged": return "?untagged=true";
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
const MissingResourceBodySchema = v.strictObject({});
|
|
94
705
|
const readJsonBody = async (response) => {
|
|
95
706
|
try {
|
|
96
707
|
return await response.json();
|
|
@@ -109,6 +720,10 @@ const failureFor = (url, response, body) => {
|
|
|
109
720
|
const authorizationError = v.safeParse(ApiAuthorizationErrorBodySchema, body);
|
|
110
721
|
return authorizationError.success ? new ApiAuthorizationError(authorizationError.output.code, authorizationError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
111
722
|
}
|
|
723
|
+
case 404: {
|
|
724
|
+
const notFoundError = v.safeParse(EvaluationNotFoundBodySchema, body);
|
|
725
|
+
return notFoundError.success ? new EvaluationNotFoundError(notFoundError.output.code, notFoundError.output.error) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
726
|
+
}
|
|
112
727
|
case 429: {
|
|
113
728
|
const retryAfterSeconds = response.headers.get("retry-after");
|
|
114
729
|
return v.is(ServiceSuspendedBodySchema, body) && retryAfterSeconds !== null ? new ServiceSuspendedError(retryAfterSeconds) : new UnexpectedApiResponseError(url, describeStatus(response));
|
|
@@ -116,6 +731,16 @@ const failureFor = (url, response, body) => {
|
|
|
116
731
|
default: return new UnexpectedApiResponseError(url, describeStatus(response));
|
|
117
732
|
}
|
|
118
733
|
};
|
|
734
|
+
const isMissingResource = (received) => received.response.status === 404 && v.is(MissingResourceBodySchema, received.body);
|
|
735
|
+
const parsedResponseOf = ({ url, response, body }, schema) => {
|
|
736
|
+
if (!response.ok) throw failureFor(url, response, body);
|
|
737
|
+
const parsedBody = v.safeParse(schema, body);
|
|
738
|
+
if (!parsedBody.success) throw new UnexpectedApiResponseError(url, `an ${describeStatus(response)} response the CLI cannot read`);
|
|
739
|
+
return {
|
|
740
|
+
responseBody: body,
|
|
741
|
+
parsedBody: parsedBody.output
|
|
742
|
+
};
|
|
743
|
+
};
|
|
119
744
|
var AdminApiClient = class {
|
|
120
745
|
constructor(connection) {
|
|
121
746
|
this.connection = connection;
|
|
@@ -123,51 +748,101 @@ var AdminApiClient = class {
|
|
|
123
748
|
listProjects() {
|
|
124
749
|
return this.get("/v1/projects", v.array(ProjectSchema));
|
|
125
750
|
}
|
|
126
|
-
listConfigs(projectId) {
|
|
127
|
-
|
|
751
|
+
async listConfigs(projectId, tagFilter) {
|
|
752
|
+
const { parsedBody: configs, responseBody } = await this.get(`/v1/projects/${encodeURIComponent(projectId)}/configs${tagFilterQuery(tagFilter)}`, v.array(ConfigSchema));
|
|
753
|
+
const receivedConfigs = v.parse(v.array(v.unknown()), responseBody);
|
|
754
|
+
return configs.map((config, index) => ({
|
|
755
|
+
config,
|
|
756
|
+
receivedConfig: receivedConfigs[index]
|
|
757
|
+
}));
|
|
758
|
+
}
|
|
759
|
+
listEnvironments(projectId) {
|
|
760
|
+
return this.get(`/v1/projects/${encodeURIComponent(projectId)}/environments`, v.array(EnvironmentSchema));
|
|
761
|
+
}
|
|
762
|
+
getConfig(projectId, configKey) {
|
|
763
|
+
return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}`, ConfigWithTargetingRulesSchema);
|
|
764
|
+
}
|
|
765
|
+
getConfigActivity(projectId, configKey) {
|
|
766
|
+
return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/activity`, ConfigActivitySchema);
|
|
767
|
+
}
|
|
768
|
+
listSegments(projectId) {
|
|
769
|
+
return this.get(`/v1/projects/${encodeURIComponent(projectId)}/segments`, v.array(SegmentSchema));
|
|
770
|
+
}
|
|
771
|
+
getSegment(projectId, segmentKey) {
|
|
772
|
+
return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, SegmentDefinitionSchema);
|
|
773
|
+
}
|
|
774
|
+
async findTagByName(projectId, name) {
|
|
775
|
+
return (await this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/tags/by-name?${new URLSearchParams({ name })}`, TagSchema))?.parsedBody;
|
|
776
|
+
}
|
|
777
|
+
createConfig(projectId, request) {
|
|
778
|
+
return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs`, CreatedConfigSchema, {
|
|
779
|
+
method: "POST",
|
|
780
|
+
body: request
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
async evaluateConfig(projectId, configKey, request) {
|
|
784
|
+
const { responseBody, parsedBody } = await this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/evaluate`, ConfigEvaluationSchema, {
|
|
785
|
+
method: "POST",
|
|
786
|
+
body: request
|
|
787
|
+
});
|
|
788
|
+
return {
|
|
789
|
+
evaluation: parsedBody,
|
|
790
|
+
responseBody
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
getTargetingRulesReference() {
|
|
794
|
+
return this.get("/v1/targeting-rules-reference", TargetingRulesReferenceSchema);
|
|
795
|
+
}
|
|
796
|
+
async replaceTargetingRules(projectId, configKey, request) {
|
|
797
|
+
return await this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/targets`, v.unknown(), {
|
|
798
|
+
method: "PUT",
|
|
799
|
+
body: request
|
|
800
|
+
}) === void 0 ? "not-found" : "replaced";
|
|
801
|
+
}
|
|
802
|
+
get(path, schema) {
|
|
803
|
+
return this.requestJson(path, schema, { method: "GET" });
|
|
804
|
+
}
|
|
805
|
+
getIfFound(path, schema) {
|
|
806
|
+
return this.requestIfFound(path, schema, { method: "GET" });
|
|
807
|
+
}
|
|
808
|
+
async requestIfFound(path, schema, request) {
|
|
809
|
+
const received = await this.send(path, request);
|
|
810
|
+
return isMissingResource(received) ? void 0 : parsedResponseOf(received, schema);
|
|
811
|
+
}
|
|
812
|
+
async requestJson(path, schema, request) {
|
|
813
|
+
return parsedResponseOf(await this.send(path, request), schema);
|
|
128
814
|
}
|
|
129
|
-
async
|
|
815
|
+
async send(path, request) {
|
|
130
816
|
const url = `${this.connection.baseUrl}${path}`;
|
|
131
|
-
const response = await this.fetchResponse(url);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
async fetchResponse(url) {
|
|
817
|
+
const response = await this.fetchResponse(url, request);
|
|
818
|
+
return {
|
|
819
|
+
url,
|
|
820
|
+
response,
|
|
821
|
+
body: await readJsonBody(response)
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
async fetchResponse(url, request) {
|
|
825
|
+
const headers = {
|
|
826
|
+
Accept: "application/json",
|
|
827
|
+
Authorization: `Bearer ${this.connection.token}`,
|
|
828
|
+
"User-Agent": `configdirector-cli/${version}`,
|
|
829
|
+
"X-SourceClient": "cli"
|
|
830
|
+
};
|
|
139
831
|
try {
|
|
140
|
-
return await fetch(url, { headers: {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
832
|
+
return await fetch(url, request.method === "GET" ? { headers } : {
|
|
833
|
+
method: request.method,
|
|
834
|
+
headers: {
|
|
835
|
+
...headers,
|
|
836
|
+
"Content-Type": "application/json"
|
|
837
|
+
},
|
|
838
|
+
body: JSON.stringify(request.body)
|
|
839
|
+
});
|
|
146
840
|
} catch (error) {
|
|
147
841
|
throw new ApiUnreachableError(this.connection.baseUrl, error);
|
|
148
842
|
}
|
|
149
843
|
}
|
|
150
844
|
};
|
|
151
845
|
//#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
846
|
//#region src/saved-api-token.ts
|
|
172
847
|
const SavedApiTokenSchema = v.object({ token: v.string() });
|
|
173
848
|
const platformConfigDirectory = (invocation) => {
|
|
@@ -210,22 +885,6 @@ const configuredApiToken = async (context) => {
|
|
|
210
885
|
return token;
|
|
211
886
|
};
|
|
212
887
|
//#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
888
|
//#region src/project-settings.ts
|
|
230
889
|
const ProjectSettingsSchema = v.object({
|
|
231
890
|
project: v.optional(v.string()),
|
|
@@ -247,6 +906,395 @@ const readProjectSettings = async (workingDirectory) => {
|
|
|
247
906
|
return settings.output;
|
|
248
907
|
};
|
|
249
908
|
//#endregion
|
|
909
|
+
//#region src/project-selection.ts
|
|
910
|
+
const requireProjectSlug = (context) => {
|
|
911
|
+
if (context.projectSlug === void 0) throw new CliError("Name the project with --project <slug> or CONFIGDIRECTOR_PROJECT.");
|
|
912
|
+
return context.projectSlug;
|
|
913
|
+
};
|
|
914
|
+
const requireLinkedProjectSlug = (context, settings) => {
|
|
915
|
+
const slug = context.projectSlug ?? settings?.project;
|
|
916
|
+
if (slug === void 0) throw new CliError("Name the project with --project <slug>, CONFIGDIRECTOR_PROJECT, or `project` in configdirector.json, which `configdirector init` writes.");
|
|
917
|
+
return slug;
|
|
918
|
+
};
|
|
919
|
+
const findProjectBySlug = async (client, slug) => {
|
|
920
|
+
const { parsedBody: projects } = await client.listProjects();
|
|
921
|
+
const project = projects.find((listedProject) => listedProject.slug === slug);
|
|
922
|
+
if (project === void 0) throw new CliError(`No project in the API token's organization has the slug "${slug}".`);
|
|
923
|
+
return project;
|
|
924
|
+
};
|
|
925
|
+
const connectToLinkedProject = async (context) => {
|
|
926
|
+
const projectSlug = requireLinkedProjectSlug(context, await readProjectSettings(context.invocation.workingDirectory));
|
|
927
|
+
const client = new AdminApiClient({
|
|
928
|
+
baseUrl: context.baseUrl,
|
|
929
|
+
token: await configuredApiToken(context)
|
|
930
|
+
});
|
|
931
|
+
return {
|
|
932
|
+
client,
|
|
933
|
+
project: await findProjectBySlug(client, projectSlug)
|
|
934
|
+
};
|
|
935
|
+
};
|
|
936
|
+
//#endregion
|
|
937
|
+
//#region src/commands/configs.ts
|
|
938
|
+
const LIFECYCLE_STATES = [
|
|
939
|
+
"new",
|
|
940
|
+
"in-use",
|
|
941
|
+
"settled",
|
|
942
|
+
"archive-ready",
|
|
943
|
+
"stale"
|
|
944
|
+
];
|
|
945
|
+
const tagIdNamed = async (client, project, name) => {
|
|
946
|
+
const tag = await client.findTagByName(project.id, name);
|
|
947
|
+
if (tag === void 0) throw new CliError(`No tag in ${project.slug} has the name "${name}".`);
|
|
948
|
+
return tag.id;
|
|
949
|
+
};
|
|
950
|
+
const tagIdsNamed = async (client, project, names) => {
|
|
951
|
+
const tagIds = [];
|
|
952
|
+
for (const name of names) tagIds.push(await tagIdNamed(client, project, name));
|
|
953
|
+
return tagIds;
|
|
954
|
+
};
|
|
955
|
+
const tagFilterOf = async (client, project, options) => {
|
|
956
|
+
if (options.untagged) return { form: "untagged" };
|
|
957
|
+
if (options.tag === void 0) return { form: "every-config" };
|
|
958
|
+
return {
|
|
959
|
+
form: "tagged",
|
|
960
|
+
tagIds: await tagIdsNamed(client, project, options.tag)
|
|
961
|
+
};
|
|
962
|
+
};
|
|
963
|
+
const configsInState = (listedConfigs, state) => state === void 0 ? listedConfigs : listedConfigs.filter((listedConfig) => listedConfig.config.state === state);
|
|
964
|
+
const summaryColumnsOf = (config) => [
|
|
965
|
+
config.key,
|
|
966
|
+
config.type,
|
|
967
|
+
config.role,
|
|
968
|
+
config.lifetime,
|
|
969
|
+
config.state
|
|
970
|
+
];
|
|
971
|
+
const configLines = (projectSlug, configs) => configs.length === 0 ? [`No configs found in ${projectSlug}.`] : alignedColumns(configs.map(summaryColumnsOf));
|
|
972
|
+
const listConfigs = async (context, _commandArguments, options) => {
|
|
973
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
974
|
+
const listedConfigs = configsInState(await client.listConfigs(project.id, await tagFilterOf(client, project, options)), options.state);
|
|
975
|
+
return {
|
|
976
|
+
json: listedConfigs.map((listedConfig) => listedConfig.receivedConfig),
|
|
977
|
+
text: [...configLines(project.slug, listedConfigs.map((listedConfig) => listedConfig.config)), ""].join("\n"),
|
|
978
|
+
exitCode: ExitCode.success
|
|
979
|
+
};
|
|
980
|
+
};
|
|
981
|
+
const referencesSegments = (config) => config.targets.some((targetingRules) => targetingRules.rules.some((rule) => rule.type === "conditional" && rule.conditions.some((condition) => condition.kind === "segment")));
|
|
982
|
+
const segmentNamesFor = async (client, project, config) => {
|
|
983
|
+
if (!referencesSegments(config)) return {};
|
|
984
|
+
const segments = await ifPermitted(client.listSegments(project.id));
|
|
985
|
+
return Object.fromEntries((segments?.parsedBody ?? []).map((segment) => [segment.id, segment]));
|
|
986
|
+
};
|
|
987
|
+
const getConfig = async (context, [configKey]) => {
|
|
988
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
989
|
+
const receivedConfig = await client.getConfig(project.id, configKey);
|
|
990
|
+
if (receivedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
991
|
+
const { parsedBody: config, responseBody } = receivedConfig;
|
|
992
|
+
return {
|
|
993
|
+
json: responseBody,
|
|
994
|
+
text: configText(config, configKey, await segmentNamesFor(client, project, config)),
|
|
995
|
+
exitCode: ExitCode.success
|
|
996
|
+
};
|
|
997
|
+
};
|
|
998
|
+
const createdConfigLines = (projectSlug, createdConfig, newConfig) => {
|
|
999
|
+
const settings = [
|
|
1000
|
+
createdConfig.type,
|
|
1001
|
+
createdConfig.role,
|
|
1002
|
+
createdConfig.lifetime,
|
|
1003
|
+
availabilityDescription(createdConfig)
|
|
1004
|
+
];
|
|
1005
|
+
return [`Created ${createdConfig.key} in ${projectSlug}: ${settings.join(", ")}.`, `Default targeting rule in every environment: ${String(newConfig.defaultValue)}`];
|
|
1006
|
+
};
|
|
1007
|
+
const keyTakenLines = (projectSlug, configKey, existingConfig) => [`${projectSlug} already has a config with the key ${configKey}, so no config was created:`, ...alignedColumns([summaryColumnsOf(existingConfig)])];
|
|
1008
|
+
const rejectsKey = (error) => error instanceof ApiValidationError && error.fieldErrors.some((fieldError) => fieldError.name === "key");
|
|
1009
|
+
const requestCreation = async (client, project, request) => {
|
|
1010
|
+
try {
|
|
1011
|
+
const { parsedBody: createdConfig, responseBody } = await client.createConfig(project.id, request);
|
|
1012
|
+
return {
|
|
1013
|
+
outcome: "created",
|
|
1014
|
+
createdConfig,
|
|
1015
|
+
responseBody
|
|
1016
|
+
};
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
if (!rejectsKey(error)) throw error;
|
|
1019
|
+
const existingConfig = await client.getConfig(project.id, request.key);
|
|
1020
|
+
if (existingConfig === void 0) throw error;
|
|
1021
|
+
return {
|
|
1022
|
+
outcome: "key-taken",
|
|
1023
|
+
existingConfig: existingConfig.parsedBody,
|
|
1024
|
+
responseBody: existingConfig.responseBody
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
const creationOutput = (project, newConfig, creation) => {
|
|
1029
|
+
switch (creation.outcome) {
|
|
1030
|
+
case "created": return {
|
|
1031
|
+
json: creation.responseBody,
|
|
1032
|
+
text: [...createdConfigLines(project.slug, creation.createdConfig, newConfig), ""].join("\n"),
|
|
1033
|
+
exitCode: ExitCode.success
|
|
1034
|
+
};
|
|
1035
|
+
case "key-taken": return {
|
|
1036
|
+
json: creation.responseBody,
|
|
1037
|
+
text: [...keyTakenLines(project.slug, newConfig.key, creation.existingConfig), ""].join("\n"),
|
|
1038
|
+
exitCode: ExitCode.problemsFound
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
const createConfig = async (context, _commandArguments, options) => {
|
|
1043
|
+
const newConfig = await newConfigOf(options, context.invocation.workingDirectory);
|
|
1044
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1045
|
+
return creationOutput(project, newConfig, await requestCreation(client, project, {
|
|
1046
|
+
...newConfig,
|
|
1047
|
+
tagIds: await tagIdsNamed(client, project, options.tag ?? [])
|
|
1048
|
+
}));
|
|
1049
|
+
};
|
|
1050
|
+
const sdkDescription = (evaluation) => [evaluation.sdkName ?? "unknown SDK", ...evaluation.sdkVersion === null ? [] : [evaluation.sdkVersion]].join(" ");
|
|
1051
|
+
const utcMinute = (time) => {
|
|
1052
|
+
const isoTime = new Date(time).toISOString();
|
|
1053
|
+
return `${isoTime.slice(0, 10)} ${isoTime.slice(11, 16)} UTC`;
|
|
1054
|
+
};
|
|
1055
|
+
const activityLines = (configKey, activity) => activity.latestEvaluations.length === 0 ? [`No SDK evaluations of ${configKey} found.`] : alignedColumns(activity.latestEvaluations.map((evaluation) => [
|
|
1056
|
+
evaluation.environment.slug,
|
|
1057
|
+
evaluation.source,
|
|
1058
|
+
sdkDescription(evaluation),
|
|
1059
|
+
utcMinute(evaluation.time)
|
|
1060
|
+
]));
|
|
1061
|
+
const showConfigActivity = async (context, [configKey]) => {
|
|
1062
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1063
|
+
const receivedActivity = await client.getConfigActivity(project.id, configKey);
|
|
1064
|
+
if (receivedActivity === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
1065
|
+
return {
|
|
1066
|
+
json: receivedActivity.responseBody,
|
|
1067
|
+
text: [...activityLines(configKey, receivedActivity.parsedBody), ""].join("\n"),
|
|
1068
|
+
exitCode: ExitCode.success
|
|
1069
|
+
};
|
|
1070
|
+
};
|
|
1071
|
+
//#endregion
|
|
1072
|
+
//#region src/commands/environments.ts
|
|
1073
|
+
const environmentColumns = (environment) => environment.live ? [
|
|
1074
|
+
environment.slug,
|
|
1075
|
+
environment.name,
|
|
1076
|
+
"live"
|
|
1077
|
+
] : [environment.slug, environment.name];
|
|
1078
|
+
const environmentLines = (projectSlug, environments) => environments.length === 0 ? [`No environments found in ${projectSlug}.`] : alignedColumns(environments.map(environmentColumns));
|
|
1079
|
+
const listEnvironments = async (context) => {
|
|
1080
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1081
|
+
const { parsedBody: environments, responseBody } = await client.listEnvironments(project.id);
|
|
1082
|
+
return {
|
|
1083
|
+
json: responseBody,
|
|
1084
|
+
text: [...environmentLines(project.slug, environments), ""].join("\n"),
|
|
1085
|
+
exitCode: ExitCode.success
|
|
1086
|
+
};
|
|
1087
|
+
};
|
|
1088
|
+
//#endregion
|
|
1089
|
+
//#region src/environment-selection.ts
|
|
1090
|
+
const environmentSlugsSentence = (environments) => environments.length === 0 ? "The project has no environments." : `Its environments are ${environments.map((environment) => environment.slug).join(", ")}.`;
|
|
1091
|
+
const findEnvironmentBySlug = async (client, project, slug) => {
|
|
1092
|
+
const { parsedBody: environments } = await client.listEnvironments(project.id);
|
|
1093
|
+
const environment = environments.find((listedEnvironment) => listedEnvironment.slug === slug);
|
|
1094
|
+
if (environment === void 0) throw new CliError(`No environment in ${project.slug} has the slug "${slug}". ${environmentSlugsSentence(environments)}`);
|
|
1095
|
+
return environment;
|
|
1096
|
+
};
|
|
1097
|
+
//#endregion
|
|
1098
|
+
//#region src/evaluate/context-form.ts
|
|
1099
|
+
const DEFAULT_CONTEXT_KIND = "user";
|
|
1100
|
+
const TYPED_TRAIT_MARKER = ":";
|
|
1101
|
+
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.`);
|
|
1102
|
+
const parseTraitJson = (trait, json) => {
|
|
1103
|
+
try {
|
|
1104
|
+
return JSON.parse(json);
|
|
1105
|
+
} catch {
|
|
1106
|
+
throw new CliError(`--context-trait ${trait} has a value after := that is not JSON. Write name=value for a string.`);
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
const traitEntryOf = (trait) => {
|
|
1110
|
+
const separatorIndex = trait.indexOf("=");
|
|
1111
|
+
if (separatorIndex === -1) throw traitUsageError(trait);
|
|
1112
|
+
const isTyped = trait[separatorIndex - 1] === TYPED_TRAIT_MARKER;
|
|
1113
|
+
const name = trait.slice(0, isTyped ? separatorIndex - 1 : separatorIndex);
|
|
1114
|
+
if (name === "") throw traitUsageError(trait);
|
|
1115
|
+
const value = trait.slice(separatorIndex + 1);
|
|
1116
|
+
return [name, isTyped ? parseTraitJson(trait, value) : value];
|
|
1117
|
+
};
|
|
1118
|
+
const contextJsonUsageError = () => new CliError("--context-json takes the context as a JSON object, as in {\"kind\":\"user\",\"identifier\":\"123\"}, or @<file>.");
|
|
1119
|
+
const contextOfJson = async (contextJson, workingDirectory) => {
|
|
1120
|
+
const context = parseJson(await atFileArgumentText("--context-json", contextJson, workingDirectory));
|
|
1121
|
+
if (!isJsonObject(context)) throw contextJsonUsageError();
|
|
1122
|
+
return context;
|
|
1123
|
+
};
|
|
1124
|
+
const inlineContextOf = (options) => ({
|
|
1125
|
+
kind: options.contextKind ?? DEFAULT_CONTEXT_KIND,
|
|
1126
|
+
identifier: options.contextId,
|
|
1127
|
+
name: options.contextName,
|
|
1128
|
+
traits: options.contextTrait === void 0 ? void 0 : Object.fromEntries(options.contextTrait.map(traitEntryOf))
|
|
1129
|
+
});
|
|
1130
|
+
const SAVED_CONTEXT_SEPARATOR = ":";
|
|
1131
|
+
const savedContextReferenceOf = (savedContext) => {
|
|
1132
|
+
const separatorIndex = savedContext.indexOf(SAVED_CONTEXT_SEPARATOR);
|
|
1133
|
+
const kind = savedContext.slice(0, separatorIndex);
|
|
1134
|
+
const identifier = savedContext.slice(separatorIndex + 1);
|
|
1135
|
+
if (separatorIndex === -1 || kind === "" || identifier === "") throw new CliError(`--saved-context takes <kind>:<identifier>, as in user:123, not ${savedContext}.`);
|
|
1136
|
+
return {
|
|
1137
|
+
kind,
|
|
1138
|
+
identifier
|
|
1139
|
+
};
|
|
1140
|
+
};
|
|
1141
|
+
const givenInlineOptionNames = (options) => [
|
|
1142
|
+
...options.contextKind === void 0 ? [] : ["--context-kind"],
|
|
1143
|
+
...options.contextId === void 0 ? [] : ["--context-id"],
|
|
1144
|
+
...options.contextName === void 0 ? [] : ["--context-name"],
|
|
1145
|
+
...options.contextTrait === void 0 ? [] : ["--context-trait"]
|
|
1146
|
+
];
|
|
1147
|
+
const givenContextDoors = (options) => {
|
|
1148
|
+
const { savedContext, contextJson } = options;
|
|
1149
|
+
const inlineOptionNames = givenInlineOptionNames(options);
|
|
1150
|
+
return [
|
|
1151
|
+
...savedContext === void 0 ? [] : [{
|
|
1152
|
+
optionNames: ["--saved-context"],
|
|
1153
|
+
contextForm: async () => ({
|
|
1154
|
+
form: "saved",
|
|
1155
|
+
reference: savedContextReferenceOf(savedContext)
|
|
1156
|
+
})
|
|
1157
|
+
}],
|
|
1158
|
+
...contextJson === void 0 ? [] : [{
|
|
1159
|
+
optionNames: ["--context-json"],
|
|
1160
|
+
contextForm: async (workingDirectory) => ({
|
|
1161
|
+
form: "inline",
|
|
1162
|
+
context: await contextOfJson(contextJson, workingDirectory)
|
|
1163
|
+
})
|
|
1164
|
+
}],
|
|
1165
|
+
...inlineOptionNames.length === 0 ? [] : [{
|
|
1166
|
+
optionNames: inlineOptionNames,
|
|
1167
|
+
contextForm: async () => ({
|
|
1168
|
+
form: "inline",
|
|
1169
|
+
context: inlineContextOf(options)
|
|
1170
|
+
})
|
|
1171
|
+
}]
|
|
1172
|
+
];
|
|
1173
|
+
};
|
|
1174
|
+
const contextFormOf = async (options, workingDirectory) => {
|
|
1175
|
+
const [door, ...otherDoors] = givenContextDoors(options);
|
|
1176
|
+
if (door === void 0) return { form: "empty" };
|
|
1177
|
+
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.`);
|
|
1178
|
+
return door.contextForm(workingDirectory);
|
|
1179
|
+
};
|
|
1180
|
+
//#endregion
|
|
1181
|
+
//#region src/evaluate/evaluation-text.ts
|
|
1182
|
+
const RESOLVED_VALUE_DESCRIPTIONS = {
|
|
1183
|
+
absent: () => "absent",
|
|
1184
|
+
"unknown-attribute": () => "unknown to the evaluator",
|
|
1185
|
+
scalar: (resolvedValue) => JSON.stringify(resolvedValue),
|
|
1186
|
+
array: (resolvedValue) => JSON.stringify(resolvedValue),
|
|
1187
|
+
object: (resolvedValue) => JSON.stringify(resolvedValue)
|
|
1188
|
+
};
|
|
1189
|
+
const contextDescription = (evaluation, contextForm) => {
|
|
1190
|
+
const evaluatedContext = evaluation.context;
|
|
1191
|
+
if (evaluatedContext === null) return "an empty context";
|
|
1192
|
+
if (evaluatedContext.identifier === null) return `a ${evaluatedContext.kind} context without an identifier`;
|
|
1193
|
+
const identity = `${evaluatedContext.kind} ${evaluatedContext.identifier}`;
|
|
1194
|
+
return contextForm.form === "saved" ? `saved context ${identity}` : identity;
|
|
1195
|
+
};
|
|
1196
|
+
const servedByDescription = (evaluation) => {
|
|
1197
|
+
const { servedBy } = evaluation;
|
|
1198
|
+
switch (servedBy.kind) {
|
|
1199
|
+
case "rule": return `served by rule ${evaluation.rules.findIndex((rule) => rule.ruleId === servedBy.ruleId) + 1}`;
|
|
1200
|
+
case "default": return "served by the default targeting rule";
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
const describeShareRange = (from, to) => `${formatPercentage(from)} to ${formatPercentage(to)}`;
|
|
1204
|
+
const bucketPlacement = (bucket) => {
|
|
1205
|
+
const assigned = `bucket ${bucket.assignedPercentage.toFixed(1)}`;
|
|
1206
|
+
const selectedShare = bucket.shares.find((share) => share.percentageId === bucket.selectedPercentageId);
|
|
1207
|
+
if (selectedShare !== void 0) return `${assigned} is in ${describeShareRange(selectedShare.from, selectedShare.to)}`;
|
|
1208
|
+
return `${assigned} is outside ${describeShareRange(0, bucket.shares.at(-1)?.to ?? 0)}`;
|
|
1209
|
+
};
|
|
1210
|
+
const describeBucket = (bucket) => bucket.defaultedToPredefinedBucket ? `no identifier, so ${bucketPlacement(bucket)}` : bucketPlacement(bucket);
|
|
1211
|
+
const withBucket = (outcome, bucket) => bucket === void 0 ? outcome : `${outcome}: ${describeBucket(bucket)}`;
|
|
1212
|
+
const conditionsOf = (rule) => rule?.type === "conditional" ? rule.conditions : [];
|
|
1213
|
+
const segmentMismatchEvidence = (explanation) => {
|
|
1214
|
+
if (!explanation.segmentFound) return "the segment was not found";
|
|
1215
|
+
return explanation.matchedGroupIndex === void 0 ? "no group matched" : `group ${explanation.matchedGroupIndex + 1} matched`;
|
|
1216
|
+
};
|
|
1217
|
+
const mismatchEvidence = (failingCondition) => {
|
|
1218
|
+
switch (failingCondition.kind) {
|
|
1219
|
+
case "attribute": return `it is ${RESOLVED_VALUE_DESCRIPTIONS[failingCondition.resolvedType](failingCondition.resolvedValue)}`;
|
|
1220
|
+
case "segment": return segmentMismatchEvidence(failingCondition);
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
const isFailingCondition = (condition) => condition.outcome === "not-matched";
|
|
1224
|
+
const failingConditionDescription = (explanation, conditions, segments) => {
|
|
1225
|
+
const failingCondition = explanation.conditions.find(isFailingCondition);
|
|
1226
|
+
const condition = conditions.find((ruleCondition) => ruleCondition.id === failingCondition?.conditionId);
|
|
1227
|
+
return failingCondition === void 0 || condition === void 0 ? void 0 : `${describeCondition(condition, segments)}, but ${mismatchEvidence(failingCondition)}`;
|
|
1228
|
+
};
|
|
1229
|
+
const describeNotMatched = (explanation, conditions, segments) => {
|
|
1230
|
+
const failingCondition = failingConditionDescription(explanation, conditions, segments);
|
|
1231
|
+
return failingCondition === void 0 ? withBucket("not matched", explanation.bucket) : `not matched: ${failingCondition}`;
|
|
1232
|
+
};
|
|
1233
|
+
const RULE_OUTCOME_DESCRIPTIONS = {
|
|
1234
|
+
matched: (explanation) => withBucket("matched", explanation.bucket),
|
|
1235
|
+
"not-matched": describeNotMatched,
|
|
1236
|
+
"not-evaluated": () => "not evaluated",
|
|
1237
|
+
errored: () => "errored"
|
|
1238
|
+
};
|
|
1239
|
+
const ruleLines = (evaluation) => {
|
|
1240
|
+
const rulesById = new Map(evaluation.targetingRules.rules.map((rule) => [rule.id, rule]));
|
|
1241
|
+
return alignedColumns(evaluation.rules.map((explanation, index) => {
|
|
1242
|
+
const rule = rulesById.get(explanation.ruleId);
|
|
1243
|
+
return [`${index + 1}. ${rule === void 0 ? explanation.ruleId : describeRule(rule, evaluation.segments)}`, RULE_OUTCOME_DESCRIPTIONS[explanation.outcome](explanation, conditionsOf(rule), evaluation.segments)];
|
|
1244
|
+
}));
|
|
1245
|
+
};
|
|
1246
|
+
const evaluationText = (evaluation, requested) => [
|
|
1247
|
+
...oldKeyLines(requested.configKey, evaluation.key),
|
|
1248
|
+
`${evaluation.key} in ${evaluation.environment.name} for ${contextDescription(evaluation, requested.contextForm)}`,
|
|
1249
|
+
"",
|
|
1250
|
+
`${evaluation.value ?? "no value"}, ${servedByDescription(evaluation)}`,
|
|
1251
|
+
"",
|
|
1252
|
+
...ruleLines(evaluation),
|
|
1253
|
+
`Default targeting rule: ${evaluation.targetingRules.defaultValue}`,
|
|
1254
|
+
""
|
|
1255
|
+
].join("\n");
|
|
1256
|
+
//#endregion
|
|
1257
|
+
//#region src/commands/evaluate.ts
|
|
1258
|
+
const contextFieldsOf = (contextForm) => {
|
|
1259
|
+
switch (contextForm.form) {
|
|
1260
|
+
case "inline": return { context: contextForm.context };
|
|
1261
|
+
case "saved": return { savedContext: contextForm.reference };
|
|
1262
|
+
case "empty": return {};
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
const NOT_FOUND_MESSAGES = {
|
|
1266
|
+
CONFIG_NOT_FOUND: ({ project, configKey }) => `No config in ${project.slug} has the key "${configKey}".`,
|
|
1267
|
+
ENVIRONMENT_NOT_FOUND: ({ project, environment }) => `No environment in ${project.slug} has the slug "${environment.slug}" any more.`,
|
|
1268
|
+
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
|
|
1269
|
+
};
|
|
1270
|
+
const requestEvaluation = async (client, requested) => {
|
|
1271
|
+
try {
|
|
1272
|
+
return await client.evaluateConfig(requested.project.id, requested.configKey, {
|
|
1273
|
+
environmentId: requested.environment.id,
|
|
1274
|
+
...contextFieldsOf(requested.contextForm)
|
|
1275
|
+
});
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
if (error instanceof EvaluationNotFoundError) throw new CliError(NOT_FOUND_MESSAGES[error.code](requested, error));
|
|
1278
|
+
throw error;
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
const evaluate = async (context, [configKey], options) => {
|
|
1282
|
+
const contextForm = await contextFormOf(options, context.invocation.workingDirectory);
|
|
1283
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
1284
|
+
const requested = {
|
|
1285
|
+
project,
|
|
1286
|
+
configKey,
|
|
1287
|
+
environment: await findEnvironmentBySlug(client, project, options.environment),
|
|
1288
|
+
contextForm
|
|
1289
|
+
};
|
|
1290
|
+
const { evaluation, responseBody } = await requestEvaluation(client, requested);
|
|
1291
|
+
return {
|
|
1292
|
+
json: responseBody,
|
|
1293
|
+
text: evaluationText(evaluation, requested),
|
|
1294
|
+
exitCode: ExitCode.success
|
|
1295
|
+
};
|
|
1296
|
+
};
|
|
1297
|
+
//#endregion
|
|
250
1298
|
//#region src/commands/init.ts
|
|
251
1299
|
const refuseExistingFile = async (file) => {
|
|
252
1300
|
if (await access(file).then(() => true, () => false)) throw new CliError(`${file} already exists. init never overwrites it; edit the file instead.`);
|
|
@@ -350,6 +1398,20 @@ const login = async (context) => {
|
|
|
350
1398
|
};
|
|
351
1399
|
};
|
|
352
1400
|
//#endregion
|
|
1401
|
+
//#region src/commands/projects.ts
|
|
1402
|
+
const projectLines = (projects) => projects.length === 0 ? ["No projects found."] : alignedColumns(projects.map((project) => [project.slug, project.name]));
|
|
1403
|
+
const listProjects = async (context) => {
|
|
1404
|
+
const { parsedBody: projects, responseBody } = await new AdminApiClient({
|
|
1405
|
+
baseUrl: context.baseUrl,
|
|
1406
|
+
token: await configuredApiToken(context)
|
|
1407
|
+
}).listProjects();
|
|
1408
|
+
return {
|
|
1409
|
+
json: responseBody,
|
|
1410
|
+
text: [...projectLines(projects), ""].join("\n"),
|
|
1411
|
+
exitCode: ExitCode.success
|
|
1412
|
+
};
|
|
1413
|
+
};
|
|
1414
|
+
//#endregion
|
|
353
1415
|
//#region src/plural.ts
|
|
354
1416
|
const plural = (count, singular, pluralForm) => count === 1 ? singular : pluralForm;
|
|
355
1417
|
//#endregion
|
|
@@ -1077,6 +2139,10 @@ const DART_RE_EXPORT_DIRECTIVES = ["export"];
|
|
|
1077
2139
|
const SWIFT_PACKAGE_MANIFEST = "Package.swift";
|
|
1078
2140
|
const SWIFT_PACKAGE_TARGETS_DIRECTORIES = ["Sources", "Tests"];
|
|
1079
2141
|
const XCODE_PROJECT_EXTENSION = ".xcodeproj";
|
|
2142
|
+
const JVM_SOURCE_EXTENSIONS = [".kt", ".java"];
|
|
2143
|
+
const CSHARP_PROJECT_EXTENSION = ".csproj";
|
|
2144
|
+
const CSHARP_SOURCE_EXTENSION = ".cs";
|
|
2145
|
+
const CSHARP_BUILD_OUTPUT_DIRECTORIES = ["bin", "obj"];
|
|
1080
2146
|
const stringAt = (code, index) => code.token(index)?.kind === "string" ? plainStringContent(code, {
|
|
1081
2147
|
first: index,
|
|
1082
2148
|
last: index
|
|
@@ -1245,6 +2311,63 @@ const goPackageNameIn = (code) => {
|
|
|
1245
2311
|
const name = code.token(1);
|
|
1246
2312
|
return code.isIdentifier(0, "package") && name?.kind === "identifier" ? name.text : void 0;
|
|
1247
2313
|
};
|
|
2314
|
+
const isCapitalized = (name) => name.charAt(0) >= "A" && name.charAt(0) <= "Z";
|
|
2315
|
+
const capitalizedNamesIn = (code) => new Set(code.tokens.flatMap((token) => token.kind === "identifier" && isCapitalized(token.text) ? [token.text] : []));
|
|
2316
|
+
const dottedNameAt = (code, start) => {
|
|
2317
|
+
const segments = [];
|
|
2318
|
+
let index = start;
|
|
2319
|
+
for (let token = code.token(index); token?.kind === "identifier"; token = code.token(index)) {
|
|
2320
|
+
segments.push(token.text);
|
|
2321
|
+
if (!code.isPunctuation(index + 1, ".")) break;
|
|
2322
|
+
index += 2;
|
|
2323
|
+
}
|
|
2324
|
+
return {
|
|
2325
|
+
segments,
|
|
2326
|
+
endsWithWildcard: code.isPunctuation(index - 1, ".") && code.isPunctuation(index, "*")
|
|
2327
|
+
};
|
|
2328
|
+
};
|
|
2329
|
+
const jvmPackagePathIn = (code) => {
|
|
2330
|
+
const packageIndex = code.tokens.findIndex((_token, index) => code.startsLine(index) && code.isIdentifier(index, "package"));
|
|
2331
|
+
return packageIndex === -1 ? [] : dottedNameAt(code, packageIndex + 1).segments;
|
|
2332
|
+
};
|
|
2333
|
+
const jvmImportsIn = (code) => code.tokens.flatMap((_token, index) => {
|
|
2334
|
+
if (!code.startsLine(index) || !code.isIdentifier(index, "import")) return [];
|
|
2335
|
+
const isStaticImport = code.isIdentifier(index + 1, "static");
|
|
2336
|
+
const { segments, endsWithWildcard } = dottedNameAt(code, isStaticImport ? index + 2 : index + 1);
|
|
2337
|
+
return [endsWithWildcard && !isStaticImport ? {
|
|
2338
|
+
kind: "wholePackage",
|
|
2339
|
+
packagePath: segments
|
|
2340
|
+
} : {
|
|
2341
|
+
kind: "classOrMember",
|
|
2342
|
+
path: segments
|
|
2343
|
+
}];
|
|
2344
|
+
});
|
|
2345
|
+
const jvmSourceRootOf = (directory, packagePath) => {
|
|
2346
|
+
const sourceRoot = ancestorDirectory(directory, packagePath.length);
|
|
2347
|
+
return join(sourceRoot, ...packagePath) === directory ? sourceRoot : void 0;
|
|
2348
|
+
};
|
|
2349
|
+
const jvmClassFileCandidates = (sourceRoot, importPath) => importPath.flatMap((_segment, index) => {
|
|
2350
|
+
const classPath = join(sourceRoot, ...importPath.slice(0, importPath.length - index));
|
|
2351
|
+
return JVM_SOURCE_EXTENSIONS.map((extension) => classPath + extension);
|
|
2352
|
+
});
|
|
2353
|
+
const csharpProjectRootDeclaredIn = async (directory) => {
|
|
2354
|
+
return (await readdir(directory, { withFileTypes: true }).catch(() => [])).some((entry) => entry.isFile() && extname(entry.name) === CSHARP_PROJECT_EXTENSION) ? directory : void 0;
|
|
2355
|
+
};
|
|
2356
|
+
const isSearchedForHolderClasses = (directoryName) => !CSHARP_BUILD_OUTPUT_DIRECTORIES.includes(directoryName) && !EXCLUDED_DIRECTORY_NAMES.has(directoryName);
|
|
2357
|
+
const csharpFilesUnder = async (directory) => {
|
|
2358
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
2359
|
+
const files = entries.filter((entry) => entry.isFile() && extname(entry.name) === CSHARP_SOURCE_EXTENSION).map((entry) => join(directory, entry.name));
|
|
2360
|
+
const filesOfSubdirectories = await Promise.all(entries.filter((entry) => entry.isDirectory() && isSearchedForHolderClasses(entry.name)).map((entry) => csharpFilesUnder(join(directory, entry.name))));
|
|
2361
|
+
return [...files, ...filesOfSubdirectories.flat()];
|
|
2362
|
+
};
|
|
2363
|
+
const filesOfCsharpClassName = (files) => {
|
|
2364
|
+
const filesOfClassName = /* @__PURE__ */ new Map();
|
|
2365
|
+
for (const file of files) {
|
|
2366
|
+
const className = basename(file, CSHARP_SOURCE_EXTENSION);
|
|
2367
|
+
filesOfClassName.set(className, [...filesOfClassName.get(className) ?? [], file]);
|
|
2368
|
+
}
|
|
2369
|
+
return filesOfClassName;
|
|
2370
|
+
};
|
|
1248
2371
|
const goPackageNameOfFile = async (file) => {
|
|
1249
2372
|
const contents = await readFileWithinSizeLimit(file);
|
|
1250
2373
|
return contents === void 0 ? void 0 : goPackageNameIn(new SourceCode(contents, "go"));
|
|
@@ -1253,14 +2376,17 @@ var LocalImports = class {
|
|
|
1253
2376
|
dartPackages;
|
|
1254
2377
|
goModules;
|
|
1255
2378
|
swiftProjects;
|
|
2379
|
+
csharpProjectRoots;
|
|
1256
2380
|
pathAliases;
|
|
1257
|
-
|
|
2381
|
+
filesOfDirectory = /* @__PURE__ */ new Map();
|
|
2382
|
+
csharpClassFilesOfProject = /* @__PURE__ */ new Map();
|
|
1258
2383
|
fileExistence = /* @__PURE__ */ new Map();
|
|
1259
2384
|
constructor(highestDirectory) {
|
|
1260
2385
|
this.highestDirectory = highestDirectory;
|
|
1261
2386
|
this.dartPackages = new NearestDeclarations(highestDirectory, dartPackageDeclaredIn);
|
|
1262
2387
|
this.goModules = new NearestDeclarations(highestDirectory, goModuleDeclaredIn);
|
|
1263
2388
|
this.swiftProjects = new NearestDeclarations(highestDirectory, swiftProjectDeclaredIn);
|
|
2389
|
+
this.csharpProjectRoots = new NearestDeclarations(highestDirectory, csharpProjectRootDeclaredIn);
|
|
1264
2390
|
this.pathAliases = new PathAliases(highestDirectory);
|
|
1265
2391
|
}
|
|
1266
2392
|
filesImportedBy(code, file) {
|
|
@@ -1287,6 +2413,18 @@ var LocalImports = class {
|
|
|
1287
2413
|
case "csharp": return Promise.resolve([]);
|
|
1288
2414
|
}
|
|
1289
2415
|
}
|
|
2416
|
+
holderClassFilesReferencedBy(code, file) {
|
|
2417
|
+
switch (code.language) {
|
|
2418
|
+
case "kotlin":
|
|
2419
|
+
case "java": return this.jvmHolderClassFilesReferencedBy(code, file);
|
|
2420
|
+
case "csharp": return this.csharpHolderClassFilesReferencedBy(code, file);
|
|
2421
|
+
case "javascript":
|
|
2422
|
+
case "python":
|
|
2423
|
+
case "dart":
|
|
2424
|
+
case "go":
|
|
2425
|
+
case "swift": return Promise.resolve([]);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
1290
2428
|
filesReExportedBy(code, file) {
|
|
1291
2429
|
switch (code.language) {
|
|
1292
2430
|
case "javascript": return this.javascriptFilesAt(javascriptReExportSpecifiers(code), file);
|
|
@@ -1380,11 +2518,53 @@ var LocalImports = class {
|
|
|
1380
2518
|
case "xcodeProject": return project.root;
|
|
1381
2519
|
}
|
|
1382
2520
|
}
|
|
1383
|
-
|
|
1384
|
-
const
|
|
2521
|
+
async jvmHolderClassFilesReferencedBy(code, file) {
|
|
2522
|
+
const directory = dirname(file);
|
|
2523
|
+
const classNames = capitalizedNamesIn(code);
|
|
2524
|
+
const sourceRoot = jvmSourceRootOf(directory, jvmPackagePathIn(code));
|
|
2525
|
+
return [...sourceRoot === void 0 ? [] : await this.jvmClassFilesImportedFrom(sourceRoot, jvmImportsIn(code), classNames), ...await this.jvmClassFilesIn(directory, classNames)];
|
|
2526
|
+
}
|
|
2527
|
+
async jvmClassFilesImportedFrom(sourceRoot, imports, classNames) {
|
|
2528
|
+
const files = [];
|
|
2529
|
+
for (const jvmImport of imports) switch (jvmImport.kind) {
|
|
2530
|
+
case "classOrMember": {
|
|
2531
|
+
const importedFile = await this.firstExistingFile(jvmClassFileCandidates(sourceRoot, jvmImport.path));
|
|
2532
|
+
if (importedFile !== void 0) files.push(importedFile);
|
|
2533
|
+
break;
|
|
2534
|
+
}
|
|
2535
|
+
case "wholePackage":
|
|
2536
|
+
files.push(...await this.jvmClassFilesIn(join(sourceRoot, ...jvmImport.packagePath), classNames));
|
|
2537
|
+
break;
|
|
2538
|
+
}
|
|
2539
|
+
return files;
|
|
2540
|
+
}
|
|
2541
|
+
async jvmClassFilesIn(directory, classNames) {
|
|
2542
|
+
return (await this.filesIn(directory)).filter((file) => {
|
|
2543
|
+
const extension = extname(file);
|
|
2544
|
+
return JVM_SOURCE_EXTENSIONS.includes(extension) && classNames.has(basename(file, extension));
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2547
|
+
async csharpHolderClassFilesReferencedBy(code, file) {
|
|
2548
|
+
const projectRoot = await this.csharpProjectRoots.declarationAbove(file);
|
|
2549
|
+
if (projectRoot === void 0) return [];
|
|
2550
|
+
const filesOfClassName = await this.csharpClassFilesIn(projectRoot);
|
|
2551
|
+
return [...capitalizedNamesIn(code)].flatMap((className) => filesOfClassName.get(className) ?? []);
|
|
2552
|
+
}
|
|
2553
|
+
csharpClassFilesIn(projectRoot) {
|
|
2554
|
+
const classFilesListedBefore = this.csharpClassFilesOfProject.get(projectRoot);
|
|
2555
|
+
if (classFilesListedBefore !== void 0) return classFilesListedBefore;
|
|
2556
|
+
const classFiles = csharpFilesUnder(projectRoot).then(filesOfCsharpClassName);
|
|
2557
|
+
this.csharpClassFilesOfProject.set(projectRoot, classFiles);
|
|
2558
|
+
return classFiles;
|
|
2559
|
+
}
|
|
2560
|
+
async goFilesIn(directory) {
|
|
2561
|
+
return (await this.filesIn(directory)).filter((file) => extname(file) === ".go");
|
|
2562
|
+
}
|
|
2563
|
+
filesIn(directory) {
|
|
2564
|
+
const filesListedBefore = this.filesOfDirectory.get(directory);
|
|
1385
2565
|
if (filesListedBefore !== void 0) return filesListedBefore;
|
|
1386
|
-
const files = readdir(directory, { withFileTypes: true }).then((entries) => entries.filter((entry) => entry.isFile()
|
|
1387
|
-
this.
|
|
2566
|
+
const files = readdir(directory, { withFileTypes: true }).then((entries) => entries.filter((entry) => entry.isFile()).map((entry) => join(directory, entry.name)), () => []);
|
|
2567
|
+
this.filesOfDirectory.set(directory, files);
|
|
1388
2568
|
return files;
|
|
1389
2569
|
}
|
|
1390
2570
|
};
|
|
@@ -2457,19 +3637,24 @@ const sdkFamiliesUsedIn = (highestDirectory) => {
|
|
|
2457
3637
|
}
|
|
2458
3638
|
}
|
|
2459
3639
|
};
|
|
2460
|
-
const
|
|
2461
|
-
|
|
2462
|
-
return (await Promise.all(importedPaths.map((importedPath) => familiesReachedThrough(importedPath, RE_EXPORT_STEPS_FOLLOWED)))).flat();
|
|
3640
|
+
const familiesReachedThroughEach = async (paths) => {
|
|
3641
|
+
return (await Promise.all(paths.map((path) => familiesReachedThrough(path, RE_EXPORT_STEPS_FOLLOWED)))).flat();
|
|
2463
3642
|
};
|
|
3643
|
+
const familiesMarkedThroughImports = async (file, code) => familiesReachedThroughEach(await localImports.filesImportedBy(code, file.path));
|
|
3644
|
+
const familiesMarkedInHolderClasses = async (file, code) => familiesReachedThroughEach(await localImports.holderClassFilesReferencedBy(code, file.path));
|
|
2464
3645
|
const familiesMarkedInSamePackageOrTarget = async (file, code) => {
|
|
2465
3646
|
const familiesFoundBefore = familiesMarkedInPackageOrTargetOfFile.get(file.path);
|
|
2466
3647
|
if (familiesFoundBefore !== void 0) return familiesFoundBefore;
|
|
2467
3648
|
const filesOfPackageOrTarget = await localImports.filesOfSamePackageOrTarget(code, file.path);
|
|
2468
|
-
const families =
|
|
3649
|
+
const families = familiesReachedThroughEach(filesOfPackageOrTarget);
|
|
2469
3650
|
for (const fileOfPackageOrTarget of filesOfPackageOrTarget) familiesMarkedInPackageOrTargetOfFile.set(fileOfPackageOrTarget, families);
|
|
2470
3651
|
return families;
|
|
2471
3652
|
};
|
|
2472
|
-
const familiesMarkedByOtherFiles = async (file, code) => [
|
|
3653
|
+
const familiesMarkedByOtherFiles = async (file, code) => [
|
|
3654
|
+
...await familiesMarkedThroughImports(file, code),
|
|
3655
|
+
...await familiesMarkedInSamePackageOrTarget(file, code),
|
|
3656
|
+
...await familiesMarkedInHolderClasses(file, code)
|
|
3657
|
+
];
|
|
2473
3658
|
return async (file, code) => {
|
|
2474
3659
|
const familiesForExtension = familiesOfExtension(file.extension);
|
|
2475
3660
|
const markedHere = familiesWithMarkerIn(file.contents, familiesForExtension);
|
|
@@ -2543,7 +3728,7 @@ const scanProject = async (context, path) => {
|
|
|
2543
3728
|
token: await configuredApiToken(context)
|
|
2544
3729
|
});
|
|
2545
3730
|
const project = await findProjectBySlug(client, projectSlug);
|
|
2546
|
-
const configs = await client.listConfigs(project.id);
|
|
3731
|
+
const configs = (await client.listConfigs(project.id, { form: "every-config" })).map((listedConfig) => listedConfig.config);
|
|
2547
3732
|
const projectKeys = new Set(configs.flatMap((config) => [config.key, ...config.deprecatedKeys.map((deprecatedKey) => deprecatedKey.key)]));
|
|
2548
3733
|
const codeReferenceScan = await scanCodeReferences({
|
|
2549
3734
|
root,
|
|
@@ -2642,6 +3827,140 @@ const scan = async (context, [path]) => {
|
|
|
2642
3827
|
};
|
|
2643
3828
|
};
|
|
2644
3829
|
//#endregion
|
|
3830
|
+
//#region src/segments/segment-text.ts
|
|
3831
|
+
const conditionGroupLines = (heading, groups) => [
|
|
3832
|
+
"",
|
|
3833
|
+
heading,
|
|
3834
|
+
...groups.map((group, index) => ` ${index + 1}. ${describeConditionGroup(group)}`)
|
|
3835
|
+
];
|
|
3836
|
+
const segmentText = (segment, environmentSlugs) => [
|
|
3837
|
+
segment.key,
|
|
3838
|
+
` Name: ${segment.name}`,
|
|
3839
|
+
...conditionGroupLines("Project definition", segment.groups),
|
|
3840
|
+
...Object.entries(segment.overrides).flatMap(([environmentId, groups]) => conditionGroupLines(`Override in ${environmentSlugs.get(environmentId) ?? environmentId}`, groups)),
|
|
3841
|
+
""
|
|
3842
|
+
].join("\n");
|
|
3843
|
+
//#endregion
|
|
3844
|
+
//#region src/commands/segments.ts
|
|
3845
|
+
const usageDescription = (segment) => `used by ${segment.usages.length} ${plural(segment.usages.length, "config", "configs")}`;
|
|
3846
|
+
const segmentLines = (projectSlug, segments) => segments.length === 0 ? [`No segments found in ${projectSlug}.`] : alignedColumns(segments.map((segment) => [
|
|
3847
|
+
segment.key,
|
|
3848
|
+
segment.name,
|
|
3849
|
+
usageDescription(segment)
|
|
3850
|
+
]));
|
|
3851
|
+
const listSegments = async (context) => {
|
|
3852
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
3853
|
+
const { parsedBody: segments, responseBody } = await client.listSegments(project.id);
|
|
3854
|
+
return {
|
|
3855
|
+
json: responseBody,
|
|
3856
|
+
text: [...segmentLines(project.slug, segments), ""].join("\n"),
|
|
3857
|
+
exitCode: ExitCode.success
|
|
3858
|
+
};
|
|
3859
|
+
};
|
|
3860
|
+
const environmentSlugsFor = async (client, project, segment) => {
|
|
3861
|
+
if (Object.keys(segment.overrides).length === 0) return /* @__PURE__ */ new Map();
|
|
3862
|
+
const environments = await ifPermitted(client.listEnvironments(project.id));
|
|
3863
|
+
return new Map((environments?.parsedBody ?? []).map((environment) => [environment.id, environment.slug]));
|
|
3864
|
+
};
|
|
3865
|
+
const getSegment = async (context, [segmentKey]) => {
|
|
3866
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
3867
|
+
const receivedSegment = await client.getSegment(project.id, segmentKey);
|
|
3868
|
+
if (receivedSegment === void 0) throw new CliError(`No segment in ${project.slug} has the key "${segmentKey}".`);
|
|
3869
|
+
const { parsedBody: segment, responseBody } = receivedSegment;
|
|
3870
|
+
return {
|
|
3871
|
+
json: responseBody,
|
|
3872
|
+
text: segmentText(segment, await environmentSlugsFor(client, project, segment)),
|
|
3873
|
+
exitCode: ExitCode.success
|
|
3874
|
+
};
|
|
3875
|
+
};
|
|
3876
|
+
//#endregion
|
|
3877
|
+
//#region src/targeting-rules/reference-text.ts
|
|
3878
|
+
const headedListLines = (heading, items) => [heading, ...items.map((item) => ` ${item}`)];
|
|
3879
|
+
const operatorsByTargetTypeLines = (operatorsByTargetType) => ["Operators per target type:", ...Object.entries(operatorsByTargetType).flatMap(([targetType, operators]) => [` ${targetType}:`, ...operators.map((operator) => ` ${operator}`)])];
|
|
3880
|
+
const targetingRulesReferenceText = (reference) => [
|
|
3881
|
+
...reference.notes.flatMap((note) => [note, ""]),
|
|
3882
|
+
...headedListLines("Condition kinds:", reference.conditionKinds),
|
|
3883
|
+
"",
|
|
3884
|
+
...headedListLines("Attributes a condition can test:", reference.conditionAttributes),
|
|
3885
|
+
"",
|
|
3886
|
+
...headedListLines("Context kind operators:", reference.contextKindOperators),
|
|
3887
|
+
"",
|
|
3888
|
+
...headedListLines("Segment operators:", reference.segmentOperators),
|
|
3889
|
+
"",
|
|
3890
|
+
...operatorsByTargetTypeLines(reference.operatorsByTargetType),
|
|
3891
|
+
""
|
|
3892
|
+
].join("\n");
|
|
3893
|
+
//#endregion
|
|
3894
|
+
//#region src/targeting-rules/targeting-rules-file.ts
|
|
3895
|
+
const STANDARD_INPUT_ARGUMENT = "-";
|
|
3896
|
+
const readRulesSource = async (rulesArgument, invocation) => {
|
|
3897
|
+
if (rulesArgument === STANDARD_INPUT_ARGUMENT) return {
|
|
3898
|
+
origin: "--rules reads stdin",
|
|
3899
|
+
contents: await text(invocation.stdin)
|
|
3900
|
+
};
|
|
3901
|
+
const { file, contents } = await readOptionFile("--rules", rulesArgument, invocation.workingDirectory);
|
|
3902
|
+
return {
|
|
3903
|
+
origin: `--rules names ${file}`,
|
|
3904
|
+
contents
|
|
3905
|
+
};
|
|
3906
|
+
};
|
|
3907
|
+
const readTargetingRulesFile = async (rulesArgument, invocation) => {
|
|
3908
|
+
const { origin, contents } = await readRulesSource(rulesArgument, invocation);
|
|
3909
|
+
const targetingRules = parseJson(contents);
|
|
3910
|
+
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.`);
|
|
3911
|
+
return targetingRules;
|
|
3912
|
+
};
|
|
3913
|
+
const withId = (entry) => entry.id === void 0 ? {
|
|
3914
|
+
id: randomUUID(),
|
|
3915
|
+
...entry
|
|
3916
|
+
} : entry;
|
|
3917
|
+
const conditionOrPercentageWithId = (conditionOrPercentage) => isJsonObject(conditionOrPercentage) ? withId(conditionOrPercentage) : conditionOrPercentage;
|
|
3918
|
+
const conditionsOrPercentagesWithIds = (rule, field) => {
|
|
3919
|
+
const conditionsOrPercentages = rule[field];
|
|
3920
|
+
return Array.isArray(conditionsOrPercentages) ? { [field]: conditionsOrPercentages.map(conditionOrPercentageWithId) } : {};
|
|
3921
|
+
};
|
|
3922
|
+
const ruleWithIds = (rule) => isJsonObject(rule) ? {
|
|
3923
|
+
...withId(rule),
|
|
3924
|
+
...conditionsOrPercentagesWithIds(rule, "conditions"),
|
|
3925
|
+
...conditionsOrPercentagesWithIds(rule, "percentages")
|
|
3926
|
+
} : rule;
|
|
3927
|
+
const withGeneratedIds = (targetingRules) => Array.isArray(targetingRules.rules) ? {
|
|
3928
|
+
...targetingRules,
|
|
3929
|
+
rules: targetingRules.rules.map(ruleWithIds)
|
|
3930
|
+
} : targetingRules;
|
|
3931
|
+
//#endregion
|
|
3932
|
+
//#region src/commands/targeting-rules.ts
|
|
3933
|
+
const replacedRulesLine = (configKey, environment, targetingRules) => {
|
|
3934
|
+
const ruleCount = Array.isArray(targetingRules.rules) ? targetingRules.rules.length : 0;
|
|
3935
|
+
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)}.`;
|
|
3936
|
+
};
|
|
3937
|
+
const setTargetingRules = async (context, [configKey], options) => {
|
|
3938
|
+
const targetingRules = withGeneratedIds(await readTargetingRulesFile(options.rules, context.invocation));
|
|
3939
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
3940
|
+
const environment = await findEnvironmentBySlug(client, project, options.environment);
|
|
3941
|
+
const request = {
|
|
3942
|
+
...targetingRules,
|
|
3943
|
+
environmentId: environment.id
|
|
3944
|
+
};
|
|
3945
|
+
if (await client.replaceTargetingRules(project.id, configKey, request) === "not-found") throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
3946
|
+
return {
|
|
3947
|
+
json: request,
|
|
3948
|
+
text: [replacedRulesLine(configKey, environment, targetingRules), ""].join("\n"),
|
|
3949
|
+
exitCode: ExitCode.success
|
|
3950
|
+
};
|
|
3951
|
+
};
|
|
3952
|
+
const showTargetingRulesReference = async (context) => {
|
|
3953
|
+
const { parsedBody: reference, responseBody } = await new AdminApiClient({
|
|
3954
|
+
baseUrl: context.baseUrl,
|
|
3955
|
+
token: await configuredApiToken(context)
|
|
3956
|
+
}).getTargetingRulesReference();
|
|
3957
|
+
return {
|
|
3958
|
+
json: responseBody,
|
|
3959
|
+
text: targetingRulesReferenceText(reference),
|
|
3960
|
+
exitCode: ExitCode.success
|
|
3961
|
+
};
|
|
3962
|
+
};
|
|
3963
|
+
//#endregion
|
|
2645
3964
|
//#region src/validate/findings.ts
|
|
2646
3965
|
const servedValueTypesOfConfigTypes = new Map([
|
|
2647
3966
|
["boolean", "boolean"],
|
|
@@ -2855,6 +4174,7 @@ const withoutTrailingSlashes = (url) => {
|
|
|
2855
4174
|
while (trimmedUrl.endsWith("/")) trimmedUrl = trimmedUrl.slice(0, -1);
|
|
2856
4175
|
return trimmedUrl;
|
|
2857
4176
|
};
|
|
4177
|
+
const repeatedOption = (value, values) => [...values ?? [], value];
|
|
2858
4178
|
const runCommand = async (invocation, command, actionArguments) => {
|
|
2859
4179
|
const parsedCommand = actionArguments.at(-1);
|
|
2860
4180
|
const options = parsedCommand.optsWithGlobals();
|
|
@@ -2863,7 +4183,7 @@ const runCommand = async (invocation, command, actionArguments) => {
|
|
|
2863
4183
|
baseUrl: withoutTrailingSlashes(options.baseUrl ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_BASE_URL") ?? DEFAULT_BASE_URL),
|
|
2864
4184
|
tokenOption: options.token,
|
|
2865
4185
|
projectSlug: options.project ?? nonEmptyVariable(invocation, "CONFIGDIRECTOR_PROJECT")
|
|
2866
|
-
}, parsedCommand.processedArgs);
|
|
4186
|
+
}, parsedCommand.processedArgs, parsedCommand.opts());
|
|
2867
4187
|
invocation.stdout.write(options.json ? `${JSON.stringify(output.json, null, 2)}\n` : output.text);
|
|
2868
4188
|
return output.exitCode;
|
|
2869
4189
|
};
|
|
@@ -2889,6 +4209,20 @@ const run = async (invocation) => {
|
|
|
2889
4209
|
program.command("init").description("write configdirector.json for a project in the current directory").action(commandAction(init));
|
|
2890
4210
|
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));
|
|
2891
4211
|
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));
|
|
4212
|
+
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));
|
|
4213
|
+
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));
|
|
4214
|
+
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));
|
|
4215
|
+
const configsCommand = program.command("configs").description("list, show and create a project's configs");
|
|
4216
|
+
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));
|
|
4217
|
+
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));
|
|
4218
|
+
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));
|
|
4219
|
+
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));
|
|
4220
|
+
const segmentsCommand = program.command("segments").description("list a project's segments and show one segment");
|
|
4221
|
+
segmentsCommand.command("list").description("list the project's segments with how many configs use each").action(commandAction(listSegments));
|
|
4222
|
+
segmentsCommand.command("get").argument("<key>", "the segment's key").description("show a segment's condition groups and its environment overrides").action(commandAction(getSegment));
|
|
4223
|
+
const targetingRulesCommand = program.command("targeting-rules").description("replace a config's targeting rules in one environment and show the format they take");
|
|
4224
|
+
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));
|
|
4225
|
+
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));
|
|
2892
4226
|
try {
|
|
2893
4227
|
await program.parseAsync(invocation.commandLineArguments, { from: "user" });
|
|
2894
4228
|
return exitCode;
|