@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.
Files changed (53) hide show
  1. package/README.md +229 -77
  2. package/dist/agent/skill-bundle.generated.d.ts +1 -1
  3. package/dist/agent/skill-bundle.generated.js +4 -4
  4. package/dist/codegen/code-generators/base-typescript-generator.d.ts +1 -1
  5. package/dist/codegen/schema-extractor.d.ts +1 -1
  6. package/dist/commands/config-schema.js +9 -2
  7. package/dist/commands/create.d.ts +3 -1
  8. package/dist/commands/create.js +93 -20
  9. package/dist/commands/environments.d.ts +8 -0
  10. package/dist/commands/environments.js +32 -0
  11. package/dist/commands/get.d.ts +1 -0
  12. package/dist/commands/get.js +40 -3
  13. package/dist/commands/info.d.ts +1 -0
  14. package/dist/commands/info.js +69 -32
  15. package/dist/commands/set-default.d.ts +2 -0
  16. package/dist/commands/set-default.js +98 -35
  17. package/dist/commands/set-rollout.js +2 -2
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.js +35 -3
  20. package/dist/interactive-prompt.d.ts +18 -0
  21. package/dist/interactive-prompt.js +11 -2
  22. package/dist/migrate/quonfig-target/operators.js +4 -2
  23. package/dist/migrate/quonfig-target/ruleset.d.ts +48 -0
  24. package/dist/migrate/quonfig-target/ruleset.js +132 -8
  25. package/dist/migrate/sources/flagsmith/translate.js +11 -0
  26. package/dist/migrate/sources/launchdarkly/translate.js +19 -16
  27. package/dist/push/git-pack.d.ts +12 -0
  28. package/dist/push/git-pack.js +27 -3
  29. package/dist/push/run-push.d.ts +2 -1
  30. package/dist/push/run-push.js +1 -2
  31. package/dist/ui/get-confirmation.d.ts +10 -1
  32. package/dist/ui/get-confirmation.js +13 -4
  33. package/dist/ui/get-string.d.ts +18 -1
  34. package/dist/ui/get-string.js +20 -5
  35. package/dist/ui/get-value.d.ts +3 -1
  36. package/dist/ui/get-value.js +7 -7
  37. package/dist/util/clone-and-stack-push.js +8 -3
  38. package/dist/util/encryption.js +11 -20
  39. package/dist/util/git-ops.d.ts +4 -2
  40. package/dist/util/git-ops.js +4 -2
  41. package/dist/util/redact.d.ts +56 -0
  42. package/dist/util/redact.js +98 -0
  43. package/dist/util/value-source.d.ts +30 -0
  44. package/dist/util/value-source.js +81 -0
  45. package/dist/verify/regex-dialect.d.ts +39 -0
  46. package/dist/verify/regex-dialect.js +278 -0
  47. package/dist/verify/standalone.d.ts +3 -1
  48. package/dist/verify/standalone.js +4 -19
  49. package/dist/verify/validate.js +20 -0
  50. package/dist/version.d.ts +1 -1
  51. package/dist/version.js +1 -1
  52. package/oclif.manifest.json +218 -97
  53. package/package.json +18 -16
@@ -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 encrypted values
282
- if (value.decryptWith) {
283
- return '[encrypted]';
284
- }
285
- // Handle confidential values
286
- if (value.confidential) {
287
- return '[confidential]';
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
- // Anything that is not a catch-all targets someone. Both live catch-all
340
- // spellings (`criteria: []` and all-ALWAYS_TRUE) are unconditional — one
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
- // A rule value is a "simple scalar" if it carries a literal value that can be
346
- // rendered inline next to "[override]". Rollouts ({type: 'weighted_values',
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
- displayValue = `[override] \`${overrideValue}\``;
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 custom config
430
- displayValue = '[see rules]';
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
- const envUrl = envConfig.id ? `${url}?environment=${envConfig.id}` : `${url}?environment=undefined`;
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 valueToShow = fallbackRule ? fallbackRule.value.value : envConfig.rules[0].value.value;
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
- const overrideValue = overrideRule.value.value;
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
- '<%= config.bin %> <%= command.id %> my.flag.name --value=true --secret',
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({ default: false, description: 'mark the value as confidential' }),
53
- 'env-var': Flags.string({ description: 'environment variable to use as default value' }),
54
- environment: Flags.string({ description: 'environment to change' }),
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
- if (flags.confidential && secret.selected) {
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
- // Check if existing config has encrypted values
123
- if (!secret.selected && currentConfig.default?.rules) {
124
- for (const rule of currentConfig.default.rules) {
125
- if (rule.value?.decryptWith) {
126
- this.verboseLog('Auto-detected encryption from existing config');
127
- secret = {
128
- keyName: secret.keyName,
129
- selected: true,
130
- };
131
- break;
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}? yes/no`
161
- : `Confirm: set the fallback for ${key} in ${environment.name} to ${valuePhrase}?${keptPhrase} yes/no`;
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 = flags.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
- message: 'Default value',
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 secretMaybe = secret.selected ? ' (encrypted)' : '';
193
- if (!(await getConfirmation({ flags, message: confirmMessage(`\`${value}\`${secretMaybe}`) }))) {
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
- modifiers += ' (encrypted)';
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(`'${value}' is not a valid value for ${key}`);
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: ${value}`);
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: ${value}`);
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: ${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: ${value}. ${DURATION_FORMAT_HINT}`);
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
- modifiers += ' (confidential)';
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 ${value || envVar} a valid value?`
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}? yes/no`
186
- : `Confirm: set rollout for ${key} in ${environment.name} to [${rolloutDescription}]?${keptPhrase} yes/no`;
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
- this.logToStderr(`[${category}] ${typeof message === 'string' ? message : JSON.stringify(message)}`);
116
+ const scrubbed = scrubForLog(message);
117
+ this.logToStderr(`[${category}] ${typeof scrubbed === 'string' ? scrubbed : JSON.stringify(scrubbed)}`);
114
118
  }
115
119
  else {
116
- this.logToStderr(typeof category === 'string' ? category : JSON.stringify(category));
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 (arg.required || implicitFlags.includes(key)) {
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` simply
13
- * selects the negated operator — semantics-preserving, always converted.
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' },