@quonfig/cli 0.4.0 → 0.5.0
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/README.md +157 -75
- package/dist/agent/skill-bundle.generated.d.ts +1 -1
- package/dist/agent/skill-bundle.generated.js +4 -4
- package/dist/codegen/code-generators/base-typescript-generator.d.ts +1 -1
- package/dist/codegen/schema-extractor.d.ts +1 -1
- package/dist/commands/config-schema.js +9 -2
- package/dist/commands/create.d.ts +3 -1
- package/dist/commands/create.js +93 -20
- package/dist/commands/get.d.ts +1 -0
- package/dist/commands/get.js +40 -3
- package/dist/commands/info.js +10 -1
- package/dist/commands/set-default.d.ts +2 -0
- package/dist/commands/set-default.js +70 -18
- package/dist/index.d.ts +6 -0
- package/dist/index.js +35 -3
- package/dist/interactive-prompt.d.ts +18 -0
- package/dist/interactive-prompt.js +11 -2
- package/dist/migrate/quonfig-target/operators.js +4 -2
- package/dist/migrate/quonfig-target/ruleset.d.ts +48 -0
- package/dist/migrate/quonfig-target/ruleset.js +132 -8
- package/dist/migrate/sources/flagsmith/translate.js +11 -0
- package/dist/migrate/sources/launchdarkly/translate.js +19 -16
- package/dist/push/git-pack.d.ts +12 -0
- package/dist/push/git-pack.js +27 -3
- package/dist/push/run-push.d.ts +2 -1
- package/dist/push/run-push.js +1 -2
- package/dist/ui/get-string.d.ts +18 -1
- package/dist/ui/get-string.js +20 -5
- package/dist/ui/get-value.d.ts +3 -1
- package/dist/ui/get-value.js +7 -7
- package/dist/util/clone-and-stack-push.js +8 -3
- package/dist/util/encryption.js +11 -20
- package/dist/util/git-ops.d.ts +4 -2
- package/dist/util/git-ops.js +4 -2
- package/dist/util/redact.d.ts +56 -0
- package/dist/util/redact.js +98 -0
- package/dist/util/value-source.d.ts +30 -0
- package/dist/util/value-source.js +81 -0
- package/dist/verify/regex-dialect.d.ts +39 -0
- package/dist/verify/regex-dialect.js +278 -0
- package/dist/verify/standalone.d.ts +3 -1
- package/dist/verify/standalone.js +4 -19
- package/dist/verify/validate.js +20 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +236 -192
- package/package.json +18 -16
|
@@ -12,7 +12,9 @@ import { makeConfidentialValue } from '../util/encryption.js';
|
|
|
12
12
|
import isInteractive from '../util/is-interactive.js';
|
|
13
13
|
import nameArg from '../util/name-arg.js';
|
|
14
14
|
import { catchAllRule, countTargetingRules, keptTargetingNote, replacedTargetingNote, seedScopeRules, seededFromDefaultNote, upsertEnvRules, upsertFallbackRule, } from '../util/rules.js';
|
|
15
|
+
import { redactedDisplay, redactedSummary } from '../util/redact.js';
|
|
15
16
|
import secretFlags, { parsedSecretFlags } from '../util/secret-flags.js';
|
|
17
|
+
import { resolveValueSource, valueSourceFlags } from '../util/value-source.js';
|
|
16
18
|
export default class SetDefault extends APICommand {
|
|
17
19
|
static aliases = ['toggle'];
|
|
18
20
|
static args = { ...nameArg };
|
|
@@ -31,6 +33,16 @@ rules (they stay in git history).
|
|
|
31
33
|
To set a percentage rollout (gradual rollout / A/B test / canary deploy) instead:
|
|
32
34
|
qfg set-rollout my.flag --environment production --true-percent 20
|
|
33
35
|
|
|
36
|
+
Secrets (--secret, or any config whose value is already encrypted) are
|
|
37
|
+
STRING-ONLY. To store a JSON secret, make it a string secret holding the JSON
|
|
38
|
+
and JSON.parse it in your app. The CLI never prints a secret's plaintext; it
|
|
39
|
+
shows a redacted fingerprint like [encrypted *****3fa9c, 51 chars, ends …a1b2].
|
|
40
|
+
Rotate a secret without the value entering argv / shell history:
|
|
41
|
+
pbpaste | qfg set-default stripe.api-key --environment production --secret --value-stdin --confirm
|
|
42
|
+
qfg set-default stripe.api-key --environment production --secret --value-from-env STRIPE_API_KEY --confirm
|
|
43
|
+
(--env-var is different: it stores NO value and tells the SDK to read one from
|
|
44
|
+
that env var at runtime.)
|
|
45
|
+
|
|
34
46
|
To see all current values and rules for a flag:
|
|
35
47
|
qfg info my.flag
|
|
36
48
|
|
|
@@ -41,7 +53,11 @@ To target specific organizations or users, write rules on their KEY
|
|
|
41
53
|
'<%= config.bin %> <%= command.id %> my.flag.name # prompts for value and env',
|
|
42
54
|
'<%= config.bin %> <%= command.id %> my.flag.name --value=true --environment=staging',
|
|
43
55
|
'<%= config.bin %> <%= command.id %> my.flag.name --value=false --environment=production --replace-targeting # kill-switch: off for everyone',
|
|
44
|
-
'
|
|
56
|
+
'# Rotate a secret without the value entering argv:',
|
|
57
|
+
'pbpaste | <%= config.bin %> <%= command.id %> my.secret --environment=production --secret --value-stdin --confirm',
|
|
58
|
+
'<%= config.bin %> <%= command.id %> my.secret --environment=production --secret --value-from-env=MY_SECRET --confirm',
|
|
59
|
+
'# Write the [Default] rules (inherited by every environment without its own):',
|
|
60
|
+
'<%= config.bin %> <%= command.id %> my.secret --environment="[Default]" --secret --value-from-env=MY_SECRET --confirm',
|
|
45
61
|
'<%= config.bin %> <%= command.id %> my.config.name --env-var=MY_ENV_VAR_NAME --environment=production',
|
|
46
62
|
'# For a percentage rollout, use set-rollout instead:',
|
|
47
63
|
'<%= config.bin %> set-rollout my.flag.name --environment production --true-percent 20',
|
|
@@ -50,15 +66,20 @@ To target specific organizations or users, write rules on their KEY
|
|
|
50
66
|
];
|
|
51
67
|
static flags = {
|
|
52
68
|
confidential: Flags.boolean({ default: false, description: 'mark the value as confidential' }),
|
|
53
|
-
'env-var': Flags.string({
|
|
54
|
-
|
|
69
|
+
'env-var': Flags.string({
|
|
70
|
+
description: 'store no value: the SDK reads it from this env var at RUNTIME. To read a value from a local env var NOW, use --value-from-env',
|
|
71
|
+
}),
|
|
72
|
+
environment: Flags.string({
|
|
73
|
+
description: 'environment to change, e.g. production. Use "[Default]" for the default rules that every environment without its own rules inherits',
|
|
74
|
+
}),
|
|
55
75
|
'replace-targeting': Flags.boolean({
|
|
56
76
|
default: false,
|
|
57
77
|
description: "set the value for EVERYONE: delete this environment's targeting rules instead of keeping them (they stay in git history)",
|
|
58
78
|
}),
|
|
59
|
-
value: Flags.string({ description: 'new default value' }),
|
|
79
|
+
value: Flags.string({ description: 'new default value (for secrets prefer --value-stdin / --value-from-env)' }),
|
|
80
|
+
...valueSourceFlags,
|
|
60
81
|
...confirmFlag,
|
|
61
|
-
...secretFlags('encrypt the value of this item'),
|
|
82
|
+
...secretFlags('encrypt the value of this item (string-only; the CLI never prints the plaintext)'),
|
|
62
83
|
};
|
|
63
84
|
async run() {
|
|
64
85
|
const { args, flags } = await this.parse(SetDefault);
|
|
@@ -69,6 +90,11 @@ To target specific organizations or users, write rules on their KEY
|
|
|
69
90
|
if (flags['env-var'] && secret.selected) {
|
|
70
91
|
return this.err('cannot specify both --env-var and --secret');
|
|
71
92
|
}
|
|
93
|
+
// qfg-38lp: --value / --value-stdin / --value-from-env all resolve here.
|
|
94
|
+
const valueSource = await resolveValueSource(flags);
|
|
95
|
+
if (!valueSource.ok) {
|
|
96
|
+
return this.err(valueSource.error ? valueSource.message : 'Value is required');
|
|
97
|
+
}
|
|
72
98
|
if (flags.confidential && secret.selected) {
|
|
73
99
|
console.warn("Note: --confidential is implied when using --secret, so you don't need to specify both.");
|
|
74
100
|
}
|
|
@@ -107,6 +133,13 @@ To target specific organizations or users, write rules on their KEY
|
|
|
107
133
|
return this.err(`Could not find config named ${key}`);
|
|
108
134
|
}
|
|
109
135
|
this.verboseLog('Selected config:', config);
|
|
136
|
+
// qfg-38lp: secrets are string-only end to end. Say so here instead of
|
|
137
|
+
// letting the server answer with a bare 422.
|
|
138
|
+
if (secret.selected && config.valueType.toLowerCase() !== 'string') {
|
|
139
|
+
return this.err(`secrets are string-only; ${key} is type ${config.valueType}. ` +
|
|
140
|
+
'Store JSON (or any structured value) as a string secret instead — e.g. ' +
|
|
141
|
+
'`qfg create <new-key> --type string --secret --value-stdin < value.json` — and JSON.parse it in your app.', { key, phase: 'validation', valueType: config.valueType });
|
|
142
|
+
}
|
|
110
143
|
// Fetch the stored document ONCE. It drives encryption auto-detection,
|
|
111
144
|
// the targeting-rule count in the confirmation prompt, the rules the write
|
|
112
145
|
// is applied to, and the commitSha for optimistic concurrency.
|
|
@@ -179,18 +212,23 @@ To target specific organizations or users, write rules on their KEY
|
|
|
179
212
|
targetingRuleCount,
|
|
180
213
|
});
|
|
181
214
|
}
|
|
182
|
-
let value =
|
|
215
|
+
let value = valueSource.value;
|
|
216
|
+
// A secret / confidential value is masked while typed and never shown.
|
|
217
|
+
const hideValue = secret.selected || confidential;
|
|
183
218
|
if (!value && isInteractive(flags)) {
|
|
184
219
|
value = await getString({
|
|
185
220
|
allowBlank: true,
|
|
186
|
-
|
|
221
|
+
mask: hideValue,
|
|
222
|
+
message: hideValue ? 'Secret value (input is masked)' : 'Default value',
|
|
187
223
|
});
|
|
188
224
|
}
|
|
189
225
|
if (value === undefined) {
|
|
190
226
|
return this.err('Value is required');
|
|
191
227
|
}
|
|
192
|
-
const
|
|
193
|
-
|
|
228
|
+
const promptPhrase = hideValue
|
|
229
|
+
? redactedDisplay(redactedSummary({ encrypted: secret.selected, plaintext: value }))
|
|
230
|
+
: `\`${value}\``;
|
|
231
|
+
if (!(await getConfirmation({ flags, message: confirmMessage(promptPhrase) }))) {
|
|
194
232
|
return;
|
|
195
233
|
}
|
|
196
234
|
return this.submitChange({
|
|
@@ -225,8 +263,17 @@ To target specific organizations or users, write rules on their KEY
|
|
|
225
263
|
// "be provided by `MY_ENV_VAR`" — plus any (encrypted)/(confidential) note.
|
|
226
264
|
let valuePhrase;
|
|
227
265
|
let modifiers = '';
|
|
266
|
+
// Set whenever the value must not be printed (secret or confidential), qfg-38lp.
|
|
267
|
+
let redacted;
|
|
268
|
+
// How the value may appear in an error message.
|
|
269
|
+
let shownValue = value;
|
|
228
270
|
if (envVar === undefined) {
|
|
229
271
|
valuePhrase = `\`${value}\``;
|
|
272
|
+
if (confidential && !secret.selected) {
|
|
273
|
+
redacted = redactedSummary({ plaintext: value });
|
|
274
|
+
valuePhrase = redactedDisplay(redacted);
|
|
275
|
+
shownValue = valuePhrase;
|
|
276
|
+
}
|
|
230
277
|
if (secret.selected) {
|
|
231
278
|
// Handle encrypted values using shared utility
|
|
232
279
|
const encryptedValueResult = await makeConfidentialValue(this, value, secret, environment.name);
|
|
@@ -234,7 +281,9 @@ To target specific organizations or users, write rules on their KEY
|
|
|
234
281
|
return this.err(encryptedValueResult.message || 'Failed to encrypt value');
|
|
235
282
|
}
|
|
236
283
|
configValue = mapConfigValueToDto(encryptedValueResult.value, ConfigValueType.String);
|
|
237
|
-
|
|
284
|
+
redacted = redactedSummary({ ciphertext: String(encryptedValueResult.value.value), plaintext: value });
|
|
285
|
+
valuePhrase = redactedDisplay(redacted);
|
|
286
|
+
shownValue = valuePhrase;
|
|
238
287
|
}
|
|
239
288
|
else {
|
|
240
289
|
// Parse the value based on type and build value object
|
|
@@ -249,7 +298,7 @@ To target specific organizations or users, write rules on their KEY
|
|
|
249
298
|
case 'bool': {
|
|
250
299
|
const lowerValue = value.toLowerCase();
|
|
251
300
|
if (lowerValue !== 'true' && lowerValue !== 'false') {
|
|
252
|
-
return this.err(`'${
|
|
301
|
+
return this.err(`'${shownValue}' is not a valid value for ${key}`);
|
|
253
302
|
}
|
|
254
303
|
variantValue = lowerValue === 'true';
|
|
255
304
|
break;
|
|
@@ -258,14 +307,14 @@ To target specific organizations or users, write rules on their KEY
|
|
|
258
307
|
// Shared strict grammar with qfg create (qfg-2agi.23).
|
|
259
308
|
variantValue = parseIntValue(value);
|
|
260
309
|
if (variantValue === undefined) {
|
|
261
|
-
return this.err(`Invalid default value for int: ${
|
|
310
|
+
return this.err(`Invalid default value for int: ${shownValue}`);
|
|
262
311
|
}
|
|
263
312
|
break;
|
|
264
313
|
}
|
|
265
314
|
case 'double': {
|
|
266
315
|
variantValue = parseDoubleValue(value);
|
|
267
316
|
if (variantValue === undefined) {
|
|
268
|
-
return this.err(`Invalid default value for double: ${
|
|
317
|
+
return this.err(`Invalid default value for double: ${shownValue}`);
|
|
269
318
|
}
|
|
270
319
|
break;
|
|
271
320
|
}
|
|
@@ -274,7 +323,7 @@ To target specific organizations or users, write rules on their KEY
|
|
|
274
323
|
variantValue = JSON.parse(value);
|
|
275
324
|
}
|
|
276
325
|
catch {
|
|
277
|
-
return this.err(`Invalid JSON value: ${
|
|
326
|
+
return this.err(`Invalid JSON value: ${shownValue}`);
|
|
278
327
|
}
|
|
279
328
|
break;
|
|
280
329
|
}
|
|
@@ -285,7 +334,7 @@ To target specific organizations or users, write rules on their KEY
|
|
|
285
334
|
case 'duration': {
|
|
286
335
|
// Stored verbatim as the ISO 8601 string; no conversions (qfg-2agi.3).
|
|
287
336
|
if (!isValidIsoDuration(value)) {
|
|
288
|
-
return this.err(`Invalid value for duration: ${
|
|
337
|
+
return this.err(`Invalid value for duration: ${shownValue}. ${DURATION_FORMAT_HINT}`);
|
|
289
338
|
}
|
|
290
339
|
break;
|
|
291
340
|
}
|
|
@@ -305,7 +354,9 @@ To target specific organizations or users, write rules on their KEY
|
|
|
305
354
|
}
|
|
306
355
|
if (confidential && !secret.selected) {
|
|
307
356
|
configValue.confidential = true;
|
|
308
|
-
|
|
357
|
+
// the redacted display already says "confidential"
|
|
358
|
+
if (!redacted)
|
|
359
|
+
modifiers += ' (confidential)';
|
|
309
360
|
}
|
|
310
361
|
// Surgical by default (qfg-qjdm): the fallback rule's value is replaced
|
|
311
362
|
// in place and every targeting rule around it is kept. --replace-targeting
|
|
@@ -374,13 +425,14 @@ To target specific organizations or users, write rules on their KEY
|
|
|
374
425
|
},
|
|
375
426
|
key,
|
|
376
427
|
success: true,
|
|
377
|
-
value,
|
|
428
|
+
// qfg-38lp: a secret/confidential value is reported redacted, never verbatim.
|
|
429
|
+
value: redacted ?? value,
|
|
378
430
|
...counts,
|
|
379
431
|
};
|
|
380
432
|
}
|
|
381
433
|
this.verboseLog(request.error);
|
|
382
434
|
const errMsg = request.status === 400
|
|
383
|
-
? `Failed to change default: ${request.status} -- is ${
|
|
435
|
+
? `Failed to change default: ${request.status} -- is ${shownValue || envVar} a valid value?`
|
|
384
436
|
: `Failed to change default: ${request.status}`;
|
|
385
437
|
return this.err(errMsg, { key, serverError: request.error });
|
|
386
438
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -50,6 +50,12 @@ export declare abstract class BaseCommand extends Command {
|
|
|
50
50
|
protected toErrorJson(err: unknown): {
|
|
51
51
|
error: JsonObj;
|
|
52
52
|
};
|
|
53
|
+
/**
|
|
54
|
+
* qfg-38lp: a value typed as a stray positional (`qfg set-default KEY <secret>`)
|
|
55
|
+
* comes back from oclif as `Unexpected argument: <secret>`. On commands that
|
|
56
|
+
* take a value, hide the offending tokens instead of echoing them.
|
|
57
|
+
*/
|
|
58
|
+
private redactValueParseError;
|
|
53
59
|
}
|
|
54
60
|
export declare abstract class APICommand extends BaseCommand {
|
|
55
61
|
static baseFlags: {
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { run } from '@oclif/core';
|
|
2
2
|
import { Command, Errors, Flags } from '@oclif/core';
|
|
3
3
|
import rawGetClient, { unwrapRequest } from './util/get-client.js';
|
|
4
|
+
import { scrubForLog } from './util/redact.js';
|
|
4
5
|
const globalFlags = {
|
|
5
6
|
interactive: Flags.boolean({
|
|
6
7
|
allowNo: true,
|
|
@@ -109,11 +110,15 @@ export class BaseCommand extends Command {
|
|
|
109
110
|
verboseLog = (category, message) => {
|
|
110
111
|
if (!this.isVerbose)
|
|
111
112
|
return;
|
|
113
|
+
// qfg-38lp: verbose output (request bodies, API responses, stored config
|
|
114
|
+
// documents) must never carry a confidential plaintext.
|
|
112
115
|
if (message) {
|
|
113
|
-
|
|
116
|
+
const scrubbed = scrubForLog(message);
|
|
117
|
+
this.logToStderr(`[${category}] ${typeof scrubbed === 'string' ? scrubbed : JSON.stringify(scrubbed)}`);
|
|
114
118
|
}
|
|
115
119
|
else {
|
|
116
|
-
|
|
120
|
+
const scrubbed = scrubForLog(category);
|
|
121
|
+
this.logToStderr(typeof scrubbed === 'string' ? scrubbed : JSON.stringify(scrubbed));
|
|
117
122
|
}
|
|
118
123
|
};
|
|
119
124
|
async catch(err) {
|
|
@@ -127,6 +132,7 @@ export class BaseCommand extends Command {
|
|
|
127
132
|
if (err instanceof Errors.ExitError || err.code === 'EEXIT') {
|
|
128
133
|
throw err;
|
|
129
134
|
}
|
|
135
|
+
this.redactValueParseError(err);
|
|
130
136
|
// qfg-hzmb: `this.log()` is a documented no-op whenever jsonEnabled(), so
|
|
131
137
|
// this override used to print NOTHING for a failing `--json` run — empty
|
|
132
138
|
// stdout was indistinguishable from an empty success. Emit the structured
|
|
@@ -162,9 +168,12 @@ export class BaseCommand extends Command {
|
|
|
162
168
|
// An Error's own *enumerable* properties are the structured payload handed
|
|
163
169
|
// to `err()` — keep those. `oclif` is internal bookkeeping (surfaced as
|
|
164
170
|
// `exitCode`), and `stack` is dropped on purpose (oclif.stacktrace=false).
|
|
171
|
+
// `parse` is oclif's parse-error context: hundreds of KB of config dump
|
|
172
|
+
// that includes the raw argv, so a `--value=<secret>` landed on stdout
|
|
173
|
+
// (qfg-38lp). The message already names the bad flag; drop the dump.
|
|
165
174
|
const payload = {};
|
|
166
175
|
for (const [key, value] of Object.entries(err)) {
|
|
167
|
-
if (key !== 'oclif' && key !== 'stack')
|
|
176
|
+
if (key !== 'oclif' && key !== 'stack' && key !== 'parse')
|
|
168
177
|
payload[key] = value;
|
|
169
178
|
}
|
|
170
179
|
const { code } = err;
|
|
@@ -177,6 +186,29 @@ export class BaseCommand extends Command {
|
|
|
177
186
|
},
|
|
178
187
|
};
|
|
179
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* qfg-38lp: a value typed as a stray positional (`qfg set-default KEY <secret>`)
|
|
191
|
+
* comes back from oclif as `Unexpected argument: <secret>`. On commands that
|
|
192
|
+
* take a value, hide the offending tokens instead of echoing them.
|
|
193
|
+
*/
|
|
194
|
+
redactValueParseError(err) {
|
|
195
|
+
const flags = this.ctor.flags ?? {};
|
|
196
|
+
if (!('value' in flags))
|
|
197
|
+
return;
|
|
198
|
+
if (err.message.startsWith('Unexpected argument')) {
|
|
199
|
+
const count = Array.isArray(err.args) ? err.args.length : 1;
|
|
200
|
+
err.message =
|
|
201
|
+
`Unexpected argument${count === 1 ? '' : 's'} (${count} hidden, in case ${count === 1 ? 'it is' : 'they are'} a value). ` +
|
|
202
|
+
'Pass a value with --value, --value-stdin or --value-from-env=VAR, not as a positional argument.';
|
|
203
|
+
delete err.args;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (err.message.startsWith('Nonexistent flag')) {
|
|
207
|
+
err.message = err.message.replaceAll(/=\S+/g, '=<hidden>');
|
|
208
|
+
if (Array.isArray(err.flags))
|
|
209
|
+
err.flags = err.flags.map((f) => String(f).replace(/=.*$/, '=<hidden>'));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
180
212
|
}
|
|
181
213
|
export class APICommand extends BaseCommand {
|
|
182
214
|
static baseFlags = {
|
|
@@ -1,2 +1,20 @@
|
|
|
1
1
|
import { Config } from '@oclif/core/lib/interfaces/index.js';
|
|
2
|
+
import Create from './commands/create.js';
|
|
3
|
+
import GenerateNewHexKey from './commands/generate-new-hex-key.js';
|
|
4
|
+
import Get from './commands/get.js';
|
|
5
|
+
import Info from './commands/info.js';
|
|
6
|
+
import List from './commands/list.js';
|
|
7
|
+
import Override from './commands/override.js';
|
|
8
|
+
import SetDefault from './commands/set-default.js';
|
|
9
|
+
type SuggestedCommand = {
|
|
10
|
+
command: typeof Create | typeof GenerateNewHexKey | typeof Get | typeof Info | typeof List | typeof Override | typeof SetDefault;
|
|
11
|
+
description: string;
|
|
12
|
+
displayCommandName: string;
|
|
13
|
+
id: string;
|
|
14
|
+
implicitFlags: string[];
|
|
15
|
+
};
|
|
16
|
+
declare const commands: SuggestedCommand[];
|
|
17
|
+
type AvailableCommand = (typeof commands)[number];
|
|
18
|
+
export declare const getFlags: (flags: AvailableCommand["command"]["flags"], commandId: string, implicitFlags: string[], command: SuggestedCommand["command"]) => Promise<string[]>;
|
|
2
19
|
export declare const interactivePrompt: (config: Config) => Promise<void>;
|
|
20
|
+
export {};
|
|
@@ -94,14 +94,23 @@ const getArgs = async (args, commandId, command) => {
|
|
|
94
94
|
// Filter out undefined values (OAuth commands handle their own name prompting)
|
|
95
95
|
return values.filter((v) => v !== undefined);
|
|
96
96
|
};
|
|
97
|
-
const getFlags = async (flags, commandId, implicitFlags, command) => {
|
|
97
|
+
export const getFlags = async (flags, commandId, implicitFlags, command) => {
|
|
98
98
|
if (!flags && implicitFlags.length === 0) {
|
|
99
99
|
return [];
|
|
100
100
|
}
|
|
101
101
|
const inputs = [];
|
|
102
102
|
const promiseInputs = [];
|
|
103
|
+
// qfg-38lp: secrets are string-only, so "create --secret" never asks for a type.
|
|
104
|
+
const forceStringType = commandId === 'create' && implicitFlags.includes('secret');
|
|
103
105
|
for (const [key, arg] of Object.entries(flags)) {
|
|
104
|
-
if (
|
|
106
|
+
if (forceStringType && key === 'type') {
|
|
107
|
+
inputs.push('--type=string');
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// `create --type` is optional on the command line (--secret implies string)
|
|
111
|
+
// but the interactive create still asks for it.
|
|
112
|
+
const promptForType = commandId === 'create' && key === 'type';
|
|
113
|
+
if (arg.required || promptForType || implicitFlags.includes(key)) {
|
|
105
114
|
if (arg.type === 'boolean') {
|
|
106
115
|
inputs.push(`--${key}`);
|
|
107
116
|
}
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
* provider extends the table rather than reinventing the dispatch.
|
|
10
10
|
*/
|
|
11
11
|
/**
|
|
12
|
-
* Operators that have a direct negated form in Quonfig. `negate: true`
|
|
13
|
-
*
|
|
12
|
+
* Operators that have a direct negated form in Quonfig. `negate: true` selects
|
|
13
|
+
* the negated operator and always converts. LD never matches a context missing
|
|
14
|
+
* the attribute, so `clauseToCriteria` (ruleset.ts) ANDs an IS_PRESENT guard in
|
|
15
|
+
* front of each negated property operator to keep that (qfg-goi1.5.10).
|
|
14
16
|
*/
|
|
15
17
|
const NEGATABLE = {
|
|
16
18
|
contains: { negated: 'PROP_DOES_NOT_CONTAIN_ONE_OF', plain: 'PROP_CONTAINS_ONE_OF' },
|
|
@@ -91,6 +91,27 @@ export interface RulesetContext {
|
|
|
91
91
|
* becomes a dotted path (`address.city`).
|
|
92
92
|
*/
|
|
93
93
|
export declare function normalizeAttribute(contextKind: string | undefined, attribute: string): string;
|
|
94
|
+
/**
|
|
95
|
+
* Join several regex patterns into one alternation `(?:a)|(?:b)` (qfg-ktkr.6).
|
|
96
|
+
* Each alternative is wrapped in a non-capturing group so anchors and inner
|
|
97
|
+
* `|` stay scoped to their own pattern. The join adds no quantified group, so
|
|
98
|
+
* it stays inside the v1 safety rule as long as every pattern is in v1 and
|
|
99
|
+
* the length and unbounded-quantifier caps hold; returns `null` otherwise
|
|
100
|
+
* (qfg-ktkr.2: the full checkRegexV1, not just the caps).
|
|
101
|
+
*/
|
|
102
|
+
export declare function joinRegexAlternation(patterns: string[]): null | string;
|
|
103
|
+
/**
|
|
104
|
+
* Expand a provider rule's clauses into the OR-alternatives of clause lists
|
|
105
|
+
* that preserve LaunchDarkly semantics (qfg-ktkr.6). LD ORs a clause's values;
|
|
106
|
+
* Quonfig has no within-rule OR, so a multi-value `matches` clause whose
|
|
107
|
+
* patterns cannot be joined within the v1 caps is:
|
|
108
|
+
* - negated (matches none): split into single-pattern negated clauses ANDed
|
|
109
|
+
* in the same rule;
|
|
110
|
+
* - positive (matches any): fanned out into one alternative per pattern.
|
|
111
|
+
* Alternatives serve the same value, so first-match-wins over them is the OR.
|
|
112
|
+
* Returns `null` (rule dropped + reported) past MAX_SPLIT_RULES alternatives.
|
|
113
|
+
*/
|
|
114
|
+
export declare function expandClauses(clauses: SourceClause[], ctx: RulesetContext): null | SourceClause[][];
|
|
94
115
|
/**
|
|
95
116
|
* Map one provider clause to a Quonfig criterion, or `null` when the clause's
|
|
96
117
|
* operator has no v1 mapping (D3 — negated comparisons, applicationVersion…).
|
|
@@ -98,12 +119,39 @@ export declare function normalizeAttribute(contextKind: string | undefined, attr
|
|
|
98
119
|
* partial AND would silently broaden targeting.
|
|
99
120
|
*/
|
|
100
121
|
export declare function clauseToCriterion(clause: SourceClause, ctx: RulesetContext): QuonfigCriterion | null;
|
|
122
|
+
/**
|
|
123
|
+
* Map one provider clause to the Quonfig criteria it ANDs into a rule, or
|
|
124
|
+
* `null` when the clause cannot be mapped (see `clauseToCriterion`).
|
|
125
|
+
*
|
|
126
|
+
* A negated property clause (`in`, `contains`, `startsWith`, `endsWith`,
|
|
127
|
+
* `matches` with `negate: true`) gets an `IS_PRESENT` guard on the same
|
|
128
|
+
* property ANDed in front of it (qfg-goi1.5.10, operator-semantics Q6b).
|
|
129
|
+
* LaunchDarkly never matches a context that lacks the clause attribute, negated
|
|
130
|
+
* or not; Quonfig's negated operators do match a missing attribute (Q6a, kept),
|
|
131
|
+
* so without the guard a migrated rule would broaden to every context missing
|
|
132
|
+
* the attribute. Negated `segmentMatch` (NOT_IN_SEG) has no property and is not
|
|
133
|
+
* guarded. This only shapes NEW migrations; already-migrated workspaces are
|
|
134
|
+
* never rewritten.
|
|
135
|
+
*/
|
|
136
|
+
export declare function clauseToCriteria(clause: SourceClause, ctx: RulesetContext): QuonfigCriterion[] | null;
|
|
101
137
|
/**
|
|
102
138
|
* Build a Quonfig rule from a provider rule's clauses (AND semantics) plus its
|
|
103
139
|
* served value. Returns `null` when any clause cannot be mapped — the rule is
|
|
104
140
|
* dropped wholesale and reported, rather than silently broadened.
|
|
105
141
|
*/
|
|
106
142
|
export declare function buildRule(clauses: SourceClause[], value: QuonfigRuleValue, ctx: RulesetContext): QuonfigRule | null;
|
|
143
|
+
/**
|
|
144
|
+
* AND a clause list into criteria, or `null` when any clause is unmappable.
|
|
145
|
+
* An IS_PRESENT guard already in the list is not repeated (a split negated
|
|
146
|
+
* multi-pattern clause guards its property once).
|
|
147
|
+
*/
|
|
148
|
+
export declare function clausesToCriteria(clauses: SourceClause[], ctx: RulesetContext): null | QuonfigCriterion[];
|
|
149
|
+
/**
|
|
150
|
+
* Build the Quonfig rules for one provider rule: `buildRule` over each
|
|
151
|
+
* alternative from `expandClauses` (qfg-ktkr.6). Usually one rule; empty when
|
|
152
|
+
* the rule is dropped (and reported).
|
|
153
|
+
*/
|
|
154
|
+
export declare function buildRules(clauses: SourceClause[], value: QuonfigRuleValue, ctx: RulesetContext): QuonfigRule[];
|
|
107
155
|
/**
|
|
108
156
|
* Convert a provider rollout to a Quonfig `weighted_values`. The experiment
|
|
109
157
|
* framing (`kind: "experiment"` + `seed`) is dropped + reported (plan §5.4),
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* verbs do the LaunchDarkly collapse; a second provider with a different
|
|
9
9
|
* topology composes them differently.
|
|
10
10
|
*/
|
|
11
|
+
import { checkRegexV1 } from '../../verify/regex-dialect.js';
|
|
11
12
|
import { resolveKey } from '../key-rewriter.js';
|
|
12
13
|
import { mapLaunchDarklyOperator } from './operators.js';
|
|
13
14
|
import { toQuonfigValue } from './values.js';
|
|
@@ -41,6 +42,57 @@ export function normalizeAttribute(contextKind, attribute) {
|
|
|
41
42
|
const dotted = attribute.replace(/^\//, '').replaceAll('/', '.');
|
|
42
43
|
return `${kind}.${dotted}`;
|
|
43
44
|
}
|
|
45
|
+
/** Upper bound on rules one LD rule may fan out into when patterns are split. */
|
|
46
|
+
const MAX_SPLIT_RULES = 16;
|
|
47
|
+
/**
|
|
48
|
+
* Join several regex patterns into one alternation `(?:a)|(?:b)` (qfg-ktkr.6).
|
|
49
|
+
* Each alternative is wrapped in a non-capturing group so anchors and inner
|
|
50
|
+
* `|` stay scoped to their own pattern. The join adds no quantified group, so
|
|
51
|
+
* it stays inside the v1 safety rule as long as every pattern is in v1 and
|
|
52
|
+
* the length and unbounded-quantifier caps hold; returns `null` otherwise
|
|
53
|
+
* (qfg-ktkr.2: the full checkRegexV1, not just the caps).
|
|
54
|
+
*/
|
|
55
|
+
export function joinRegexAlternation(patterns) {
|
|
56
|
+
const joined = patterns.map((p) => `(?:${p})`).join('|');
|
|
57
|
+
return checkRegexV1(joined) === null ? joined : null;
|
|
58
|
+
}
|
|
59
|
+
/** A multi-value `matches` clause whose patterns cannot be joined within v1. */
|
|
60
|
+
function isUnjoinableMatches(clause) {
|
|
61
|
+
return clause.op === 'matches' && clause.values.length > 1 && joinRegexAlternation(clause.values.map(String)) === null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Expand a provider rule's clauses into the OR-alternatives of clause lists
|
|
65
|
+
* that preserve LaunchDarkly semantics (qfg-ktkr.6). LD ORs a clause's values;
|
|
66
|
+
* Quonfig has no within-rule OR, so a multi-value `matches` clause whose
|
|
67
|
+
* patterns cannot be joined within the v1 caps is:
|
|
68
|
+
* - negated (matches none): split into single-pattern negated clauses ANDed
|
|
69
|
+
* in the same rule;
|
|
70
|
+
* - positive (matches any): fanned out into one alternative per pattern.
|
|
71
|
+
* Alternatives serve the same value, so first-match-wins over them is the OR.
|
|
72
|
+
* Returns `null` (rule dropped + reported) past MAX_SPLIT_RULES alternatives.
|
|
73
|
+
*/
|
|
74
|
+
export function expandClauses(clauses, ctx) {
|
|
75
|
+
let alternatives = [[]];
|
|
76
|
+
for (const clause of clauses) {
|
|
77
|
+
if (!isUnjoinableMatches(clause)) {
|
|
78
|
+
for (const alt of alternatives)
|
|
79
|
+
alt.push(clause);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const singles = clause.values.map((v) => ({ ...clause, values: [v] }));
|
|
83
|
+
if (clause.negate) {
|
|
84
|
+
for (const alt of alternatives)
|
|
85
|
+
alt.push(...singles);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
alternatives = alternatives.flatMap((alt) => singles.map((single) => [...alt, single]));
|
|
89
|
+
if (alternatives.length > MAX_SPLIT_RULES) {
|
|
90
|
+
ctx.report.add('skipped-rule', ctx.sourceKey, `rule dropped: its regex clauses expand to more than ${MAX_SPLIT_RULES} rules (patterns too long or too complex to join within Quonfig regex v1)`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return alternatives;
|
|
95
|
+
}
|
|
44
96
|
/** Build a `valueToMatch` typed value for a non-presence, non-segment clause. */
|
|
45
97
|
function valueToMatchFor(operator, clause, ctx) {
|
|
46
98
|
if (STRING_LIST_OPERATORS.has(operator)) {
|
|
@@ -60,6 +112,11 @@ function valueToMatchFor(operator, clause, ctx) {
|
|
|
60
112
|
}
|
|
61
113
|
if (operator === 'PROP_MATCHES' || operator === 'PROP_DOES_NOT_MATCH') {
|
|
62
114
|
if (clause.values.length > 1) {
|
|
115
|
+
// LD ORs the patterns (qfg-ktkr.6). `expandClauses` splits any set too
|
|
116
|
+
// big to join, so this only falls back for a direct caller.
|
|
117
|
+
const joined = joinRegexAlternation(clause.values.map(String));
|
|
118
|
+
if (joined !== null)
|
|
119
|
+
return { type: 'string', value: joined };
|
|
63
120
|
ctx.report.add('skipped-rule', ctx.sourceKey, `regex clause listed ${clause.values.length} patterns; Quonfig PROP_MATCHES takes one — only the first kept`);
|
|
64
121
|
}
|
|
65
122
|
return { type: 'string', value: String(clause.values[0] ?? '') };
|
|
@@ -97,23 +154,92 @@ export function clauseToCriterion(clause, ctx) {
|
|
|
97
154
|
? { operator }
|
|
98
155
|
: { operator, propertyName: normalizeAttribute(clause.contextKind, clause.attribute) };
|
|
99
156
|
criterion.valueToMatch = valueToMatchFor(operator, clause, ctx);
|
|
157
|
+
// qfg-ktkr.2: a pattern outside Quonfig regex v1 would be refused by
|
|
158
|
+
// `qfg verify` and the Git hook, so drop the rule and report why instead of
|
|
159
|
+
// writing a file nobody can push.
|
|
160
|
+
if ((operator === 'PROP_MATCHES' || operator === 'PROP_DOES_NOT_MATCH') && criterion.valueToMatch) {
|
|
161
|
+
const pattern = String(criterion.valueToMatch.value);
|
|
162
|
+
const violation = checkRegexV1(pattern);
|
|
163
|
+
if (violation) {
|
|
164
|
+
ctx.report.add('skipped-rule', ctx.sourceKey, `regex clause on "${clause.attribute}" skipped: pattern "${pattern}" is outside Quonfig regex v1 (${violation.reason}). ${violation.hint}`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
100
168
|
return criterion;
|
|
101
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Map one provider clause to the Quonfig criteria it ANDs into a rule, or
|
|
172
|
+
* `null` when the clause cannot be mapped (see `clauseToCriterion`).
|
|
173
|
+
*
|
|
174
|
+
* A negated property clause (`in`, `contains`, `startsWith`, `endsWith`,
|
|
175
|
+
* `matches` with `negate: true`) gets an `IS_PRESENT` guard on the same
|
|
176
|
+
* property ANDed in front of it (qfg-goi1.5.10, operator-semantics Q6b).
|
|
177
|
+
* LaunchDarkly never matches a context that lacks the clause attribute, negated
|
|
178
|
+
* or not; Quonfig's negated operators do match a missing attribute (Q6a, kept),
|
|
179
|
+
* so without the guard a migrated rule would broaden to every context missing
|
|
180
|
+
* the attribute. Negated `segmentMatch` (NOT_IN_SEG) has no property and is not
|
|
181
|
+
* guarded. This only shapes NEW migrations; already-migrated workspaces are
|
|
182
|
+
* never rewritten.
|
|
183
|
+
*/
|
|
184
|
+
export function clauseToCriteria(clause, ctx) {
|
|
185
|
+
const criterion = clauseToCriterion(clause, ctx);
|
|
186
|
+
if (criterion === null)
|
|
187
|
+
return null;
|
|
188
|
+
if (clause.negate && criterion.propertyName !== undefined) {
|
|
189
|
+
return [{ operator: 'IS_PRESENT', propertyName: criterion.propertyName }, criterion];
|
|
190
|
+
}
|
|
191
|
+
return [criterion];
|
|
192
|
+
}
|
|
102
193
|
/**
|
|
103
194
|
* Build a Quonfig rule from a provider rule's clauses (AND semantics) plus its
|
|
104
195
|
* served value. Returns `null` when any clause cannot be mapped — the rule is
|
|
105
196
|
* dropped wholesale and reported, rather than silently broadened.
|
|
106
197
|
*/
|
|
107
198
|
export function buildRule(clauses, value, ctx) {
|
|
199
|
+
const criteria = clausesToCriteria(clauses, ctx);
|
|
200
|
+
if (criteria === null)
|
|
201
|
+
return null;
|
|
202
|
+
// A rule with no clauses matches everyone — represent that as ALWAYS_TRUE.
|
|
203
|
+
return { criteria: criteria.length > 0 ? criteria : [ALWAYS_TRUE], value };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* AND a clause list into criteria, or `null` when any clause is unmappable.
|
|
207
|
+
* An IS_PRESENT guard already in the list is not repeated (a split negated
|
|
208
|
+
* multi-pattern clause guards its property once).
|
|
209
|
+
*/
|
|
210
|
+
export function clausesToCriteria(clauses, ctx) {
|
|
108
211
|
const criteria = [];
|
|
109
212
|
for (const clause of clauses) {
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
213
|
+
const mapped = clauseToCriteria(clause, ctx);
|
|
214
|
+
if (mapped === null)
|
|
112
215
|
return null;
|
|
113
|
-
|
|
216
|
+
for (const c of mapped) {
|
|
217
|
+
const duplicateGuard = c.operator === 'IS_PRESENT' &&
|
|
218
|
+
criteria.some((e) => e.operator === 'IS_PRESENT' && e.propertyName === c.propertyName);
|
|
219
|
+
if (!duplicateGuard)
|
|
220
|
+
criteria.push(c);
|
|
221
|
+
}
|
|
114
222
|
}
|
|
115
|
-
|
|
116
|
-
|
|
223
|
+
return criteria;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Build the Quonfig rules for one provider rule: `buildRule` over each
|
|
227
|
+
* alternative from `expandClauses` (qfg-ktkr.6). Usually one rule; empty when
|
|
228
|
+
* the rule is dropped (and reported).
|
|
229
|
+
*/
|
|
230
|
+
export function buildRules(clauses, value, ctx) {
|
|
231
|
+
const alternatives = expandClauses(clauses, ctx);
|
|
232
|
+
if (alternatives === null)
|
|
233
|
+
return [];
|
|
234
|
+
const rules = [];
|
|
235
|
+
for (const alt of alternatives) {
|
|
236
|
+
const rule = buildRule(alt, value, ctx);
|
|
237
|
+
// An unmappable clause fails every alternative alike — report it once.
|
|
238
|
+
if (rule === null)
|
|
239
|
+
return [];
|
|
240
|
+
rules.push(rule);
|
|
241
|
+
}
|
|
242
|
+
return rules;
|
|
117
243
|
}
|
|
118
244
|
/** Resolve a variation index to its typed Quonfig value. */
|
|
119
245
|
function variationValue(index, ctx) {
|
|
@@ -202,9 +328,7 @@ export function collapseEnvironment(env, ctx) {
|
|
|
202
328
|
}
|
|
203
329
|
for (const rule of env.rules ?? []) {
|
|
204
330
|
const value = rule.rollout === undefined ? variationValue(rule.variation ?? 0, ctx) : rolloutToWeightedValues(rule.rollout, ctx);
|
|
205
|
-
|
|
206
|
-
if (built !== null)
|
|
207
|
-
rules.push(built);
|
|
331
|
+
rules.push(...buildRules(rule.clauses, value, ctx));
|
|
208
332
|
}
|
|
209
333
|
if (env.fallthrough) {
|
|
210
334
|
rules.push({ criteria: [ALWAYS_TRUE], value: servedValue(env.fallthrough, ctx) });
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* mutable state. Report accumulation flows through the `ConversionReport`
|
|
16
16
|
* argument supplied by the caller (the `flagsmith.ts` source).
|
|
17
17
|
*/
|
|
18
|
+
import { checkRegexV1 } from '../../../verify/regex-dialect.js';
|
|
18
19
|
import { resolveKey } from '../../key-rewriter.js';
|
|
19
20
|
import { inferValueType, toQuonfigValue } from '../../quonfig-target/values.js';
|
|
20
21
|
import { normalizeImportedWeights } from '../../quonfig-target/weights.js';
|
|
@@ -270,6 +271,16 @@ export function mapCondition(condition) {
|
|
|
270
271
|
};
|
|
271
272
|
}
|
|
272
273
|
case 'REGEX': {
|
|
274
|
+
// qfg-ktkr.2: a pattern outside Quonfig regex v1 would be refused by
|
|
275
|
+
// `qfg verify` and the Git hook; drop the rule with the reason instead.
|
|
276
|
+
const pattern = String(value ?? '');
|
|
277
|
+
const violation = checkRegexV1(pattern);
|
|
278
|
+
if (violation) {
|
|
279
|
+
return {
|
|
280
|
+
drop: true,
|
|
281
|
+
reason: `pattern "${pattern}" is outside Quonfig regex v1 (${violation.reason}). ${violation.hint}`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
273
284
|
return {
|
|
274
285
|
criterion: {
|
|
275
286
|
operator: 'PROP_MATCHES',
|