@quonfig/cli 0.5.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.
@@ -279,14 +279,17 @@ Related commands:
279
279
  formatValue(value, includeQuotes = true) {
280
280
  if (!value)
281
281
  return 'null';
282
- // Handle encrypted values
283
- if (value.decryptWith) {
284
- return '[encrypted]';
285
- }
286
- // Handle confidential values
287
- if (value.confidential) {
288
- return '[confidential]';
289
- }
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;
290
293
  // Handle weighted values (A/B testing)
291
294
  if (Array.isArray(value.weightedValues)) {
292
295
  const weights = value.weightedValues
@@ -298,16 +301,6 @@ Related commands:
298
301
  });
299
302
  return weights.join(', ');
300
303
  }
301
- // Handle provided values (ENV_VAR)
302
- if (value.provided) {
303
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
304
- let str = `${value.provided.lookup}`;
305
- if (value.confidential) {
306
- str = `\`${str}\``;
307
- }
308
- str += ' via ENV';
309
- return str;
310
- }
311
304
  // Handle simple values
312
305
  // For config display, extract raw value without quotes
313
306
  if (!includeQuotes) {
@@ -337,23 +330,23 @@ Related commands:
337
330
  }
338
331
  return this.extractSimpleValue(value);
339
332
  }
340
- // Anything that is not a catch-all targets someone. Both live catch-all
341
- // 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).
342
336
  // shared predicate, qfg-9kr8.
343
337
  hasNonTrivialCriteria(rule) {
344
338
  return !isCatchAllRule(rule);
345
339
  }
346
- // A rule value is a "simple scalar" if it carries a literal value that can be
347
- // rendered inline next to "[override]". Rollouts ({type: 'weighted_values',
348
- // ...}, or anything else where `value.value` is itself an object) cannot —
349
- // String(obj) yields "[object Object]" (qfg-5j9i), and a percent split is
350
- // 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
351
342
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
352
343
  isSimpleScalarRuleValue(value) {
353
344
  if (!value || typeof value !== 'object')
354
345
  return false;
355
346
  if (value.type === 'weighted_values' || Array.isArray(value.weightedValues))
356
347
  return false;
348
+ if (value.type === 'provided' || value.provided)
349
+ return true;
357
350
  // Old format: {type: 'string'|'bool'|'int'|'double'|'stringList'|'json', value: X}
358
351
  if (typeof value.type === 'string' && value.value !== undefined) {
359
352
  // A `json` payload may legitimately be an object — render it as JSON, not [object Object].
@@ -369,6 +362,11 @@ Related commands:
369
362
  value.stringList !== undefined ||
370
363
  value.json !== undefined);
371
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.
372
370
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
373
371
  parseConfig(config, environments, url) {
374
372
  const contents = [];
@@ -419,7 +417,11 @@ Related commands:
419
417
  const overrideRule = envConfig.rules.find((r) => this.hasNonTrivialCriteria(r));
420
418
  if (overrideRule && this.isSimpleScalarRuleValue(overrideRule.value)) {
421
419
  const overrideValue = this.formatValue(overrideRule.value, false);
422
- 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}\``;
423
425
  }
424
426
  else {
425
427
  // Rollouts / weighted_values / anything we can't render inline → defer to the URL (qfg-5j9i).
@@ -427,12 +429,21 @@ Related commands:
427
429
  }
428
430
  }
429
431
  else {
430
- // Only has unconditional rules - show [see rules] to indicate env has custom config
431
- 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]';
432
442
  }
433
443
  }
434
444
  contents.push(`- ${envConfig.name}: ${displayValue}`);
435
- 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;
436
447
  json[envConfig.name] = {
437
448
  url: envUrl,
438
449
  };
@@ -462,8 +473,11 @@ Related commands:
462
473
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
463
474
  const overrideRule = envConfig.rules.find((r) => this.hasNonTrivialCriteria(r));
464
475
  if (overrideRule) {
465
- // Extract just the value without formatting
466
- 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;
467
481
  json[envConfig.name].override = overrideValue;
468
482
  }
469
483
  }
@@ -473,6 +487,20 @@ Related commands:
473
487
  this.log(contents.join('\n').trim());
474
488
  return json;
475
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
+ }
476
504
  transformEvaluationStatsForJson(statsPerEnv, environments, startTime, endTime) {
477
505
  const envResults = [];
478
506
  let grandTotal = 0;
@@ -65,7 +65,10 @@ To target specific organizations or users, write rules on their KEY
65
65
  '<%= config.bin %> pull && <%= config.bin %> config-schema',
66
66
  ];
67
67
  static flags = {
68
- confidential: Flags.boolean({ default: false, description: 'mark the value as confidential' }),
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
+ }),
69
72
  'env-var': Flags.string({
70
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',
71
74
  }),
@@ -95,7 +98,7 @@ To target specific organizations or users, write rules on their KEY
95
98
  if (!valueSource.ok) {
96
99
  return this.err(valueSource.error ? valueSource.message : 'Value is required');
97
100
  }
98
- if (flags.confidential && secret.selected) {
101
+ if (flags.confidential === true && secret.selected) {
99
102
  console.warn("Note: --confidential is implied when using --secret, so you don't need to specify both.");
100
103
  }
101
104
  // Validate required arguments before making API calls
@@ -152,18 +155,27 @@ To target specific organizations or users, write rules on their KEY
152
155
  }
153
156
  const currentConfig = detailRequest.json;
154
157
  this.verboseLog('Config details:', currentConfig);
155
- // Check if existing config has encrypted values
156
- if (!secret.selected && currentConfig.default?.rules) {
157
- for (const rule of currentConfig.default.rules) {
158
- if (rule.value?.decryptWith) {
159
- this.verboseLog('Auto-detected encryption from existing config');
160
- secret = {
161
- keyName: secret.keyName,
162
- selected: true,
163
- };
164
- break;
165
- }
166
- }
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.');
167
179
  }
168
180
  // Get the environment
169
181
  const environment = await getEnvironment({
@@ -190,9 +202,8 @@ To target specific organizations or users, write rules on their KEY
190
202
  const keptPhrase = targetingRuleCount > 0 ? ` (${targetingRuleCount} targeting rule${targetingRuleCount === 1 ? '' : 's'} kept)` : '';
191
203
  const deletePhrase = targetingRuleCount > 0 ? `, deleting ${targetingRuleCount} targeting rule(s)` : '';
192
204
  const confirmMessage = (valuePhrase) => replaceTargeting
193
- ? `Confirm: set ${key} in ${environment.name} to ${valuePhrase} for EVERYONE${deletePhrase}? yes/no`
194
- : `Confirm: set the fallback for ${key} in ${environment.name} to ${valuePhrase}?${keptPhrase} yes/no`;
195
- 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}`;
196
207
  // Get the value
197
208
  if (flags['env-var']) {
198
209
  if (!(await getConfirmation({ flags, message: confirmMessage(`be provided by \`${flags['env-var']}\``) }))) {
@@ -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
  }
@@ -1,7 +1,16 @@
1
+ import type { PromptContext } from './get-string.js';
1
2
  export declare const confirmFlag: {
2
3
  confirm: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
3
4
  };
4
- declare const getConfirmation: ({ flags, message, }: {
5
+ /**
6
+ * Ask a yes/no question; Enter alone answers no.
7
+ *
8
+ * Uses @inquirer/prompts' `confirm`, which reads in raw mode. oclif's
9
+ * `ux.confirm` read a cooked line, so under a pty the terminal echoed a stray
10
+ * `^D` before the success line (qfg-r3a47).
11
+ */
12
+ declare const getConfirmation: ({ context, flags, message, }: {
13
+ context?: PromptContext;
5
14
  flags: {
6
15
  confirm?: boolean;
7
16
  interactive?: boolean;
@@ -1,12 +1,21 @@
1
- import { Flags, ux } from '@oclif/core';
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Flags } from '@oclif/core';
2
3
  import isInteractive from '../util/is-interactive.js';
3
4
  export const confirmFlag = { confirm: Flags.boolean({ description: 'confirm without prompt' }) };
4
- const getConfirmation = async ({ flags, message, }) => {
5
+ /**
6
+ * Ask a yes/no question; Enter alone answers no.
7
+ *
8
+ * Uses @inquirer/prompts' `confirm`, which reads in raw mode. oclif's
9
+ * `ux.confirm` read a cooked line, so under a pty the terminal echoed a stray
10
+ * `^D` before the success line (qfg-r3a47).
11
+ */
12
+ const getConfirmation = async ({ context, flags, message, }) => {
5
13
  if (flags.confirm)
6
14
  return true;
7
- if (!isInteractive(flags)) {
15
+ // A caller-supplied context (tests) brings its own streams instead of the TTY.
16
+ if (!context && !isInteractive(flags)) {
8
17
  throw new Error('Cannot prompt for confirmation in a non-interactive context. Pass --confirm to skip the confirmation prompt.');
9
18
  }
10
- return ux.confirm(message);
19
+ return confirm({ default: false, message }, context);
11
20
  };
12
21
  export default getConfirmation;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.5.0";
1
+ declare const _default: "0.6.0";
2
2
  export default _default;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED from package.json by scripts/generate-version.mjs — do not edit.
2
- export default '0.5.0';
2
+ export default '0.6.0';