@quonfig/cli 0.4.0 → 0.6.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 +229 -77
- 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/environments.d.ts +8 -0
- package/dist/commands/environments.js +32 -0
- package/dist/commands/get.d.ts +1 -0
- package/dist/commands/get.js +40 -3
- package/dist/commands/info.d.ts +1 -0
- package/dist/commands/info.js +69 -32
- package/dist/commands/set-default.d.ts +2 -0
- package/dist/commands/set-default.js +98 -35
- package/dist/commands/set-rollout.js +2 -2
- 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-confirmation.d.ts +10 -1
- package/dist/ui/get-confirmation.js +13 -4
- 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 +218 -97
- package/package.json +18 -16
package/dist/commands/info.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
2
|
import { getEnvironments } from '../api/get-environments.js';
|
|
3
3
|
import { APICommand } from '../index.js';
|
|
4
|
+
import { redactedDisplay, redactedSummary } from '../util/redact.js';
|
|
4
5
|
import autocomplete from '../util/autocomplete.js';
|
|
5
6
|
import { getAppUrl } from '../util/domain-urls.js';
|
|
6
7
|
import isInteractive from '../util/is-interactive.js';
|
|
@@ -278,14 +279,17 @@ Related commands:
|
|
|
278
279
|
formatValue(value, includeQuotes = true) {
|
|
279
280
|
if (!value)
|
|
280
281
|
return 'null';
|
|
281
|
-
// Handle
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
282
|
+
// Handle provided values (ENV_VAR). The stored value is the env var NAME,
|
|
283
|
+
// not a secret, so it is shown even when the value is confidential.
|
|
284
|
+
// Shape: {type: 'provided', value: {source, lookup}} (qfg-r3a47).
|
|
285
|
+
const provided = (value.type === 'provided' ? value.value : value.provided);
|
|
286
|
+
if (provided && typeof provided === 'object') {
|
|
287
|
+
return `\`${provided.lookup}\` via ENV`;
|
|
288
|
+
}
|
|
289
|
+
// Secrets and confidential values show the redacted fingerprint (qfg-r3a47).
|
|
290
|
+
const redacted = this.redactedValue(value);
|
|
291
|
+
if (redacted)
|
|
292
|
+
return redacted;
|
|
289
293
|
// Handle weighted values (A/B testing)
|
|
290
294
|
if (Array.isArray(value.weightedValues)) {
|
|
291
295
|
const weights = value.weightedValues
|
|
@@ -297,16 +301,6 @@ Related commands:
|
|
|
297
301
|
});
|
|
298
302
|
return weights.join(', ');
|
|
299
303
|
}
|
|
300
|
-
// Handle provided values (ENV_VAR)
|
|
301
|
-
if (value.provided) {
|
|
302
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
303
|
-
let str = `${value.provided.lookup}`;
|
|
304
|
-
if (value.confidential) {
|
|
305
|
-
str = `\`${str}\``;
|
|
306
|
-
}
|
|
307
|
-
str += ' via ENV';
|
|
308
|
-
return str;
|
|
309
|
-
}
|
|
310
304
|
// Handle simple values
|
|
311
305
|
// For config display, extract raw value without quotes
|
|
312
306
|
if (!includeQuotes) {
|
|
@@ -336,23 +330,23 @@ Related commands:
|
|
|
336
330
|
}
|
|
337
331
|
return this.extractSimpleValue(value);
|
|
338
332
|
}
|
|
339
|
-
//
|
|
340
|
-
//
|
|
333
|
+
// The redacted display for an encrypted or confidential value, or undefined
|
|
334
|
+
// for a value that may be shown. An encrypted value hashes its ciphertext,
|
|
335
|
+
// a confidential one its plaintext, matching the SDK (src/util/redact.ts).
|
|
341
336
|
// shared predicate, qfg-9kr8.
|
|
342
337
|
hasNonTrivialCriteria(rule) {
|
|
343
338
|
return !isCatchAllRule(rule);
|
|
344
339
|
}
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
// ...}, or anything else where `value.value` is itself an object) cannot —
|
|
348
|
-
// String(obj) yields "[object Object]" (qfg-5j9i), and a percent split is
|
|
349
|
-
// never a one-token override anyway.
|
|
340
|
+
// Anything that is not a catch-all targets someone. Both live catch-all
|
|
341
|
+
// spellings (`criteria: []` and all-ALWAYS_TRUE) are unconditional — one
|
|
350
342
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
351
343
|
isSimpleScalarRuleValue(value) {
|
|
352
344
|
if (!value || typeof value !== 'object')
|
|
353
345
|
return false;
|
|
354
346
|
if (value.type === 'weighted_values' || Array.isArray(value.weightedValues))
|
|
355
347
|
return false;
|
|
348
|
+
if (value.type === 'provided' || value.provided)
|
|
349
|
+
return true;
|
|
356
350
|
// Old format: {type: 'string'|'bool'|'int'|'double'|'stringList'|'json', value: X}
|
|
357
351
|
if (typeof value.type === 'string' && value.value !== undefined) {
|
|
358
352
|
// A `json` payload may legitimately be an object — render it as JSON, not [object Object].
|
|
@@ -368,6 +362,11 @@ Related commands:
|
|
|
368
362
|
value.stringList !== undefined ||
|
|
369
363
|
value.json !== undefined);
|
|
370
364
|
}
|
|
365
|
+
// A rule value is a "simple scalar" if it carries a literal value that can be
|
|
366
|
+
// rendered inline next to "[override]". Rollouts ({type: 'weighted_values',
|
|
367
|
+
// ...}, or anything else where `value.value` is itself an object) cannot —
|
|
368
|
+
// String(obj) yields "[object Object]" (qfg-5j9i), and a percent split is
|
|
369
|
+
// never a one-token override anyway.
|
|
371
370
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
372
371
|
parseConfig(config, environments, url) {
|
|
373
372
|
const contents = [];
|
|
@@ -418,7 +417,11 @@ Related commands:
|
|
|
418
417
|
const overrideRule = envConfig.rules.find((r) => this.hasNonTrivialCriteria(r));
|
|
419
418
|
if (overrideRule && this.isSimpleScalarRuleValue(overrideRule.value)) {
|
|
420
419
|
const overrideValue = this.formatValue(overrideRule.value, false);
|
|
421
|
-
|
|
420
|
+
// A redacted display or `VAR` via ENV is already delimited.
|
|
421
|
+
displayValue =
|
|
422
|
+
this.redactedValue(overrideRule.value) || overrideValue.startsWith('`')
|
|
423
|
+
? `[override] ${overrideValue}`
|
|
424
|
+
: `[override] \`${overrideValue}\``;
|
|
422
425
|
}
|
|
423
426
|
else {
|
|
424
427
|
// Rollouts / weighted_values / anything we can't render inline → defer to the URL (qfg-5j9i).
|
|
@@ -426,12 +429,21 @@ Related commands:
|
|
|
426
429
|
}
|
|
427
430
|
}
|
|
428
431
|
else {
|
|
429
|
-
// Only has unconditional rules - show [see rules] to indicate env has
|
|
430
|
-
|
|
432
|
+
// Only has unconditional rules - show [see rules] to indicate env has
|
|
433
|
+
// custom config. A secret / provided fallback shows its redacted
|
|
434
|
+
// fingerprint or env var instead, so a rotation can be confirmed per
|
|
435
|
+
// environment without revealing it (qfg-r3a47).
|
|
436
|
+
const fallbackRule = envConfig.rules.find((r) => isCatchAllRule(r));
|
|
437
|
+
const fallbackValue = fallbackRule?.value;
|
|
438
|
+
displayValue =
|
|
439
|
+
fallbackValue && (this.redactedValue(fallbackValue) || fallbackValue.type === 'provided')
|
|
440
|
+
? this.formatValue(fallbackValue, false)
|
|
441
|
+
: '[see rules]';
|
|
431
442
|
}
|
|
432
443
|
}
|
|
433
444
|
contents.push(`- ${envConfig.name}: ${displayValue}`);
|
|
434
|
-
|
|
445
|
+
// [Default] has no environment id; its URL is the flag page itself (qfg-r3a47).
|
|
446
|
+
const envUrl = envConfig.id ? `${url}?environment=${envConfig.id}` : url;
|
|
435
447
|
json[envConfig.name] = {
|
|
436
448
|
url: envUrl,
|
|
437
449
|
};
|
|
@@ -440,7 +452,15 @@ Related commands:
|
|
|
440
452
|
if (envConfig.name === 'Default') {
|
|
441
453
|
// For Default, show the unconditional fallback value
|
|
442
454
|
const fallbackRule = envConfig.rules.find((r) => isCatchAllRule(r));
|
|
443
|
-
const
|
|
455
|
+
const shownRule = fallbackRule ?? envConfig.rules[0];
|
|
456
|
+
// qfg-38lp: a confidential (unencrypted) value is redacted, as in text mode.
|
|
457
|
+
const valueToShow = shownRule.value?.confidential && !shownRule.value?.decryptWith
|
|
458
|
+
? redactedDisplay(redactedSummary({
|
|
459
|
+
plaintext: typeof shownRule.value.value === 'string'
|
|
460
|
+
? shownRule.value.value
|
|
461
|
+
: JSON.stringify(shownRule.value.value),
|
|
462
|
+
}))
|
|
463
|
+
: shownRule.value.value;
|
|
444
464
|
json[envConfig.name].value = valueToShow;
|
|
445
465
|
}
|
|
446
466
|
else {
|
|
@@ -453,8 +473,11 @@ Related commands:
|
|
|
453
473
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
454
474
|
const overrideRule = envConfig.rules.find((r) => this.hasNonTrivialCriteria(r));
|
|
455
475
|
if (overrideRule) {
|
|
456
|
-
// Extract just the value without formatting
|
|
457
|
-
|
|
476
|
+
// Extract just the value without formatting; a confidential
|
|
477
|
+
// override is redacted, never the plaintext (qfg-r3a47).
|
|
478
|
+
const overrideValue = overrideRule.value?.confidential && !overrideRule.value?.decryptWith
|
|
479
|
+
? this.redactedValue(overrideRule.value)
|
|
480
|
+
: overrideRule.value.value;
|
|
458
481
|
json[envConfig.name].override = overrideValue;
|
|
459
482
|
}
|
|
460
483
|
}
|
|
@@ -464,6 +487,20 @@ Related commands:
|
|
|
464
487
|
this.log(contents.join('\n').trim());
|
|
465
488
|
return json;
|
|
466
489
|
}
|
|
490
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
491
|
+
redactedValue(value) {
|
|
492
|
+
if (!value || typeof value !== 'object')
|
|
493
|
+
return undefined;
|
|
494
|
+
if (value.type === 'provided' || value.provided)
|
|
495
|
+
return undefined;
|
|
496
|
+
const raw = value.value ?? value.string;
|
|
497
|
+
const text = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
498
|
+
if (value.decryptWith)
|
|
499
|
+
return redactedDisplay(redactedSummary({ ciphertext: text }));
|
|
500
|
+
if (value.confidential)
|
|
501
|
+
return redactedDisplay(redactedSummary({ plaintext: text }));
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
467
504
|
transformEvaluationStatsForJson(statsPerEnv, environments, startTime, endTime) {
|
|
468
505
|
const envResults = [];
|
|
469
506
|
let grandTotal = 0;
|
|
@@ -11,6 +11,8 @@ export default class SetDefault extends APICommand {
|
|
|
11
11
|
secret: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
12
12
|
'secret-key-name': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
13
13
|
confirm: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
14
|
+
'value-from-env': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
15
|
+
'value-stdin': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
14
16
|
confidential: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
15
17
|
'env-var': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
16
18
|
environment: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
@@ -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',
|
|
@@ -49,16 +65,24 @@ To target specific organizations or users, write rules on their KEY
|
|
|
49
65
|
'<%= config.bin %> pull && <%= config.bin %> config-schema',
|
|
50
66
|
];
|
|
51
67
|
static flags = {
|
|
52
|
-
confidential: Flags.boolean({
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
confidential: Flags.boolean({
|
|
69
|
+
allowNo: true,
|
|
70
|
+
description: 'mark the value as confidential. Auto-detected when the key already holds a confidential value; pass --no-confidential to store it in the clear',
|
|
71
|
+
}),
|
|
72
|
+
'env-var': Flags.string({
|
|
73
|
+
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',
|
|
74
|
+
}),
|
|
75
|
+
environment: Flags.string({
|
|
76
|
+
description: 'environment to change, e.g. production. Use "[Default]" for the default rules that every environment without its own rules inherits',
|
|
77
|
+
}),
|
|
55
78
|
'replace-targeting': Flags.boolean({
|
|
56
79
|
default: false,
|
|
57
80
|
description: "set the value for EVERYONE: delete this environment's targeting rules instead of keeping them (they stay in git history)",
|
|
58
81
|
}),
|
|
59
|
-
value: Flags.string({ description: 'new default value' }),
|
|
82
|
+
value: Flags.string({ description: 'new default value (for secrets prefer --value-stdin / --value-from-env)' }),
|
|
83
|
+
...valueSourceFlags,
|
|
60
84
|
...confirmFlag,
|
|
61
|
-
...secretFlags('encrypt the value of this item'),
|
|
85
|
+
...secretFlags('encrypt the value of this item (string-only; the CLI never prints the plaintext)'),
|
|
62
86
|
};
|
|
63
87
|
async run() {
|
|
64
88
|
const { args, flags } = await this.parse(SetDefault);
|
|
@@ -69,7 +93,12 @@ To target specific organizations or users, write rules on their KEY
|
|
|
69
93
|
if (flags['env-var'] && secret.selected) {
|
|
70
94
|
return this.err('cannot specify both --env-var and --secret');
|
|
71
95
|
}
|
|
72
|
-
|
|
96
|
+
// qfg-38lp: --value / --value-stdin / --value-from-env all resolve here.
|
|
97
|
+
const valueSource = await resolveValueSource(flags);
|
|
98
|
+
if (!valueSource.ok) {
|
|
99
|
+
return this.err(valueSource.error ? valueSource.message : 'Value is required');
|
|
100
|
+
}
|
|
101
|
+
if (flags.confidential === true && secret.selected) {
|
|
73
102
|
console.warn("Note: --confidential is implied when using --secret, so you don't need to specify both.");
|
|
74
103
|
}
|
|
75
104
|
// Validate required arguments before making API calls
|
|
@@ -107,6 +136,13 @@ To target specific organizations or users, write rules on their KEY
|
|
|
107
136
|
return this.err(`Could not find config named ${key}`);
|
|
108
137
|
}
|
|
109
138
|
this.verboseLog('Selected config:', config);
|
|
139
|
+
// qfg-38lp: secrets are string-only end to end. Say so here instead of
|
|
140
|
+
// letting the server answer with a bare 422.
|
|
141
|
+
if (secret.selected && config.valueType.toLowerCase() !== 'string') {
|
|
142
|
+
return this.err(`secrets are string-only; ${key} is type ${config.valueType}. ` +
|
|
143
|
+
'Store JSON (or any structured value) as a string secret instead — e.g. ' +
|
|
144
|
+
'`qfg create <new-key> --type string --secret --value-stdin < value.json` — and JSON.parse it in your app.', { key, phase: 'validation', valueType: config.valueType });
|
|
145
|
+
}
|
|
110
146
|
// Fetch the stored document ONCE. It drives encryption auto-detection,
|
|
111
147
|
// the targeting-rule count in the confirmation prompt, the rules the write
|
|
112
148
|
// is applied to, and the commitSha for optimistic concurrency.
|
|
@@ -119,18 +155,27 @@ To target specific organizations or users, write rules on their KEY
|
|
|
119
155
|
}
|
|
120
156
|
const currentConfig = detailRequest.json;
|
|
121
157
|
this.verboseLog('Config details:', currentConfig);
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
158
|
+
// Auto-detect from EVERY stored rule value, default and per-environment
|
|
159
|
+
// (qfg-r3a47): an update to an encrypted or confidential key must not
|
|
160
|
+
// silently store the new value in the clear.
|
|
161
|
+
const storedValues = [
|
|
162
|
+
...(currentConfig.default?.rules ?? []),
|
|
163
|
+
...(currentConfig.environments ?? []).flatMap((env) => env.rules ?? []),
|
|
164
|
+
].map((rule) => rule.value);
|
|
165
|
+
if (!secret.selected && storedValues.some((value) => value?.decryptWith)) {
|
|
166
|
+
this.verboseLog('Auto-detected encryption from existing config');
|
|
167
|
+
secret = {
|
|
168
|
+
keyName: secret.keyName,
|
|
169
|
+
selected: true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
let confidential = flags.confidential === true;
|
|
173
|
+
if (flags.confidential === undefined &&
|
|
174
|
+
!secret.selected &&
|
|
175
|
+
storedValues.some((value) => value?.confidential && value.type !== 'provided')) {
|
|
176
|
+
confidential = true;
|
|
177
|
+
this.logToStderr(`Note: ${key} already holds a confidential value, so the new value is stored confidential too. ` +
|
|
178
|
+
'Pass --no-confidential to store it in the clear.');
|
|
134
179
|
}
|
|
135
180
|
// Get the environment
|
|
136
181
|
const environment = await getEnvironment({
|
|
@@ -157,9 +202,8 @@ To target specific organizations or users, write rules on their KEY
|
|
|
157
202
|
const keptPhrase = targetingRuleCount > 0 ? ` (${targetingRuleCount} targeting rule${targetingRuleCount === 1 ? '' : 's'} kept)` : '';
|
|
158
203
|
const deletePhrase = targetingRuleCount > 0 ? `, deleting ${targetingRuleCount} targeting rule(s)` : '';
|
|
159
204
|
const confirmMessage = (valuePhrase) => replaceTargeting
|
|
160
|
-
? `Confirm: set ${key} in ${environment.name} to ${valuePhrase} for EVERYONE${deletePhrase}
|
|
161
|
-
: `Confirm: set the fallback for ${key} in ${environment.name} to ${valuePhrase}?${keptPhrase}
|
|
162
|
-
const { confidential } = flags;
|
|
205
|
+
? `Confirm: set ${key} in ${environment.name} to ${valuePhrase} for EVERYONE${deletePhrase}?`
|
|
206
|
+
: `Confirm: set the fallback for ${key} in ${environment.name} to ${valuePhrase}?${keptPhrase}`;
|
|
163
207
|
// Get the value
|
|
164
208
|
if (flags['env-var']) {
|
|
165
209
|
if (!(await getConfirmation({ flags, message: confirmMessage(`be provided by \`${flags['env-var']}\``) }))) {
|
|
@@ -179,18 +223,23 @@ To target specific organizations or users, write rules on their KEY
|
|
|
179
223
|
targetingRuleCount,
|
|
180
224
|
});
|
|
181
225
|
}
|
|
182
|
-
let value =
|
|
226
|
+
let value = valueSource.value;
|
|
227
|
+
// A secret / confidential value is masked while typed and never shown.
|
|
228
|
+
const hideValue = secret.selected || confidential;
|
|
183
229
|
if (!value && isInteractive(flags)) {
|
|
184
230
|
value = await getString({
|
|
185
231
|
allowBlank: true,
|
|
186
|
-
|
|
232
|
+
mask: hideValue,
|
|
233
|
+
message: hideValue ? 'Secret value (input is masked)' : 'Default value',
|
|
187
234
|
});
|
|
188
235
|
}
|
|
189
236
|
if (value === undefined) {
|
|
190
237
|
return this.err('Value is required');
|
|
191
238
|
}
|
|
192
|
-
const
|
|
193
|
-
|
|
239
|
+
const promptPhrase = hideValue
|
|
240
|
+
? redactedDisplay(redactedSummary({ encrypted: secret.selected, plaintext: value }))
|
|
241
|
+
: `\`${value}\``;
|
|
242
|
+
if (!(await getConfirmation({ flags, message: confirmMessage(promptPhrase) }))) {
|
|
194
243
|
return;
|
|
195
244
|
}
|
|
196
245
|
return this.submitChange({
|
|
@@ -225,8 +274,17 @@ To target specific organizations or users, write rules on their KEY
|
|
|
225
274
|
// "be provided by `MY_ENV_VAR`" — plus any (encrypted)/(confidential) note.
|
|
226
275
|
let valuePhrase;
|
|
227
276
|
let modifiers = '';
|
|
277
|
+
// Set whenever the value must not be printed (secret or confidential), qfg-38lp.
|
|
278
|
+
let redacted;
|
|
279
|
+
// How the value may appear in an error message.
|
|
280
|
+
let shownValue = value;
|
|
228
281
|
if (envVar === undefined) {
|
|
229
282
|
valuePhrase = `\`${value}\``;
|
|
283
|
+
if (confidential && !secret.selected) {
|
|
284
|
+
redacted = redactedSummary({ plaintext: value });
|
|
285
|
+
valuePhrase = redactedDisplay(redacted);
|
|
286
|
+
shownValue = valuePhrase;
|
|
287
|
+
}
|
|
230
288
|
if (secret.selected) {
|
|
231
289
|
// Handle encrypted values using shared utility
|
|
232
290
|
const encryptedValueResult = await makeConfidentialValue(this, value, secret, environment.name);
|
|
@@ -234,7 +292,9 @@ To target specific organizations or users, write rules on their KEY
|
|
|
234
292
|
return this.err(encryptedValueResult.message || 'Failed to encrypt value');
|
|
235
293
|
}
|
|
236
294
|
configValue = mapConfigValueToDto(encryptedValueResult.value, ConfigValueType.String);
|
|
237
|
-
|
|
295
|
+
redacted = redactedSummary({ ciphertext: String(encryptedValueResult.value.value), plaintext: value });
|
|
296
|
+
valuePhrase = redactedDisplay(redacted);
|
|
297
|
+
shownValue = valuePhrase;
|
|
238
298
|
}
|
|
239
299
|
else {
|
|
240
300
|
// Parse the value based on type and build value object
|
|
@@ -249,7 +309,7 @@ To target specific organizations or users, write rules on their KEY
|
|
|
249
309
|
case 'bool': {
|
|
250
310
|
const lowerValue = value.toLowerCase();
|
|
251
311
|
if (lowerValue !== 'true' && lowerValue !== 'false') {
|
|
252
|
-
return this.err(`'${
|
|
312
|
+
return this.err(`'${shownValue}' is not a valid value for ${key}`);
|
|
253
313
|
}
|
|
254
314
|
variantValue = lowerValue === 'true';
|
|
255
315
|
break;
|
|
@@ -258,14 +318,14 @@ To target specific organizations or users, write rules on their KEY
|
|
|
258
318
|
// Shared strict grammar with qfg create (qfg-2agi.23).
|
|
259
319
|
variantValue = parseIntValue(value);
|
|
260
320
|
if (variantValue === undefined) {
|
|
261
|
-
return this.err(`Invalid default value for int: ${
|
|
321
|
+
return this.err(`Invalid default value for int: ${shownValue}`);
|
|
262
322
|
}
|
|
263
323
|
break;
|
|
264
324
|
}
|
|
265
325
|
case 'double': {
|
|
266
326
|
variantValue = parseDoubleValue(value);
|
|
267
327
|
if (variantValue === undefined) {
|
|
268
|
-
return this.err(`Invalid default value for double: ${
|
|
328
|
+
return this.err(`Invalid default value for double: ${shownValue}`);
|
|
269
329
|
}
|
|
270
330
|
break;
|
|
271
331
|
}
|
|
@@ -274,7 +334,7 @@ To target specific organizations or users, write rules on their KEY
|
|
|
274
334
|
variantValue = JSON.parse(value);
|
|
275
335
|
}
|
|
276
336
|
catch {
|
|
277
|
-
return this.err(`Invalid JSON value: ${
|
|
337
|
+
return this.err(`Invalid JSON value: ${shownValue}`);
|
|
278
338
|
}
|
|
279
339
|
break;
|
|
280
340
|
}
|
|
@@ -285,7 +345,7 @@ To target specific organizations or users, write rules on their KEY
|
|
|
285
345
|
case 'duration': {
|
|
286
346
|
// Stored verbatim as the ISO 8601 string; no conversions (qfg-2agi.3).
|
|
287
347
|
if (!isValidIsoDuration(value)) {
|
|
288
|
-
return this.err(`Invalid value for duration: ${
|
|
348
|
+
return this.err(`Invalid value for duration: ${shownValue}. ${DURATION_FORMAT_HINT}`);
|
|
289
349
|
}
|
|
290
350
|
break;
|
|
291
351
|
}
|
|
@@ -305,7 +365,9 @@ To target specific organizations or users, write rules on their KEY
|
|
|
305
365
|
}
|
|
306
366
|
if (confidential && !secret.selected) {
|
|
307
367
|
configValue.confidential = true;
|
|
308
|
-
|
|
368
|
+
// the redacted display already says "confidential"
|
|
369
|
+
if (!redacted)
|
|
370
|
+
modifiers += ' (confidential)';
|
|
309
371
|
}
|
|
310
372
|
// Surgical by default (qfg-qjdm): the fallback rule's value is replaced
|
|
311
373
|
// in place and every targeting rule around it is kept. --replace-targeting
|
|
@@ -374,13 +436,14 @@ To target specific organizations or users, write rules on their KEY
|
|
|
374
436
|
},
|
|
375
437
|
key,
|
|
376
438
|
success: true,
|
|
377
|
-
value,
|
|
439
|
+
// qfg-38lp: a secret/confidential value is reported redacted, never verbatim.
|
|
440
|
+
value: redacted ?? value,
|
|
378
441
|
...counts,
|
|
379
442
|
};
|
|
380
443
|
}
|
|
381
444
|
this.verboseLog(request.error);
|
|
382
445
|
const errMsg = request.status === 400
|
|
383
|
-
? `Failed to change default: ${request.status} -- is ${
|
|
446
|
+
? `Failed to change default: ${request.status} -- is ${shownValue || envVar} a valid value?`
|
|
384
447
|
: `Failed to change default: ${request.status}`;
|
|
385
448
|
return this.err(errMsg, { key, serverError: request.error });
|
|
386
449
|
}
|
|
@@ -182,8 +182,8 @@ To inspect the current rollout:
|
|
|
182
182
|
const keptPhrase = targetingRuleCount > 0 ? ` (${targetingRuleCount} targeting rule${targetingRuleCount === 1 ? '' : 's'} kept)` : '';
|
|
183
183
|
const deletePhrase = targetingRuleCount > 0 ? `, deleting ${targetingRuleCount} targeting rule(s)` : '';
|
|
184
184
|
const message = replaceTargeting
|
|
185
|
-
? `Confirm: set rollout for ${key} in ${environment.name} to [${rolloutDescription}] for EVERYONE${deletePhrase}
|
|
186
|
-
: `Confirm: set rollout for ${key} in ${environment.name} to [${rolloutDescription}]?${keptPhrase}
|
|
185
|
+
? `Confirm: set rollout for ${key} in ${environment.name} to [${rolloutDescription}] for EVERYONE${deletePhrase}?`
|
|
186
|
+
: `Confirm: set rollout for ${key} in ${environment.name} to [${rolloutDescription}]?${keptPhrase}`;
|
|
187
187
|
if (!(await getConfirmation({ flags, message }))) {
|
|
188
188
|
return;
|
|
189
189
|
}
|
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' },
|