@quonfig/cli 0.0.71 → 0.0.73

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.
@@ -72,7 +72,9 @@ export default class Get extends APICommand {
72
72
  const providedByDep = config.dependencies?.find((dep) => dep.dependencyType === 'providedBy');
73
73
  if (providedByDep) {
74
74
  const envVarName = providedByDep.source;
75
- this.log(`This config is provided by env var '${envVarName}'`);
75
+ // qfg-zvef: diagnostics go to stderr so stdout stays a bare value —
76
+ // `qfg get` is commonly used in `$(...)` substitutions.
77
+ this.logToStderr(`This config is provided by env var '${envVarName}'`);
76
78
  const envValue = process.env[envVarName];
77
79
  if (envValue === undefined) {
78
80
  return this.err(`Environment variable '${envVarName}' is not set. Cannot resolve config '${key}'.`, {
@@ -82,7 +84,7 @@ export default class Get extends APICommand {
82
84
  });
83
85
  }
84
86
  value = envValue;
85
- this.log(`Successfully resolved config '${key}' from env var`);
87
+ this.logToStderr(`Successfully resolved config '${key}' from env var`);
86
88
  }
87
89
  const decryptWithDep = config.dependencies?.find((dep) => dep.dependencyType === 'decryptWith');
88
90
  if (decryptWithDep && decryptWithDep.config) {
@@ -90,7 +92,7 @@ export default class Get extends APICommand {
90
92
  const keyProvidedByDep = encryptionKeyConfig.dependencies?.find((dep) => dep.dependencyType === 'providedBy');
91
93
  if (keyProvidedByDep) {
92
94
  const envVarName = keyProvidedByDep.source;
93
- this.log(`This config is encrypted by key '${encryptionKeyConfig.key}' that should be found in env var '${envVarName}'`);
95
+ this.logToStderr(`This config is encrypted by key '${encryptionKeyConfig.key}' that should be found in env var '${envVarName}'`);
94
96
  const encryptionKey = process.env[envVarName];
95
97
  if (encryptionKey === undefined) {
96
98
  return this.err(`Environment variable '${envVarName}' is not set. Cannot decrypt config '${key}'.`, {
@@ -107,7 +109,7 @@ export default class Get extends APICommand {
107
109
  }
108
110
  try {
109
111
  value = decrypt(value, encryptionKey);
110
- this.log(`Successfully decrypted config '${key}'`);
112
+ this.logToStderr(`Successfully decrypted config '${key}'`);
111
113
  }
112
114
  catch (error) {
113
115
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -5,6 +5,7 @@ import autocomplete from '../util/autocomplete.js';
5
5
  import { getAppUrl } from '../util/domain-urls.js';
6
6
  import isInteractive from '../util/is-interactive.js';
7
7
  import nameArg from '../util/name-arg.js';
8
+ import { isCatchAllRule } from '../util/rules.js';
8
9
  export default class Info extends APICommand {
9
10
  static aliases = ['flag:show', 'flag:info'];
10
11
  static args = { ...nameArg };
@@ -335,14 +336,11 @@ Related commands:
335
336
  }
336
337
  return this.extractSimpleValue(value);
337
338
  }
338
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
339
+ // Anything that is not a catch-all targets someone. Both live catch-all
340
+ // spellings (`criteria: []` and all-ALWAYS_TRUE) are unconditional — one
341
+ // shared predicate, qfg-9kr8.
339
342
  hasNonTrivialCriteria(rule) {
340
- if (!rule.criteria || rule.criteria.length === 0)
341
- return false;
342
- // ALWAYS_TRUE is not a real criteria - treat it as unconditional
343
- if (rule.criteria.length === 1 && rule.criteria[0].operator === 'ALWAYS_TRUE')
344
- return false;
345
- return true;
343
+ return !isCatchAllRule(rule);
346
344
  }
347
345
  // A rule value is a "simple scalar" if it carries a literal value that can be
348
346
  // rendered inline next to "[override]". Rollouts ({type: 'weighted_values',
@@ -404,10 +402,8 @@ Related commands:
404
402
  }
405
403
  else if (envConfig.name === 'Default') {
406
404
  // For Default environment, show the unconditional fallback value
407
- // Find the ALWAYS_TRUE rule (usually the last rule)
408
- const fallbackRule = envConfig.rules.find(
409
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
410
- (r) => r.criteria?.length === 1 && r.criteria[0].operator === 'ALWAYS_TRUE');
405
+ // Find the unconditional rule (usually the last rule)
406
+ const fallbackRule = envConfig.rules.find((r) => isCatchAllRule(r));
411
407
  displayValue = fallbackRule
412
408
  ? this.formatValue(fallbackRule.value, false)
413
409
  : this.formatValue(envConfig.rules[0].value, false);
@@ -430,7 +426,7 @@ Related commands:
430
426
  }
431
427
  }
432
428
  else {
433
- // Only has unconditional rules (ALWAYS_TRUE) - show [see rules] to indicate env has custom config
429
+ // Only has unconditional rules - show [see rules] to indicate env has custom config
434
430
  displayValue = '[see rules]';
435
431
  }
436
432
  }
@@ -443,9 +439,7 @@ Related commands:
443
439
  if (envConfig.rules && envConfig.rules.length > 0) {
444
440
  if (envConfig.name === 'Default') {
445
441
  // For Default, show the unconditional fallback value
446
- const fallbackRule = envConfig.rules.find(
447
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
448
- (r) => r.criteria?.length === 1 && r.criteria[0].operator === 'ALWAYS_TRUE');
442
+ const fallbackRule = envConfig.rules.find((r) => isCatchAllRule(r));
449
443
  const valueToShow = fallbackRule ? fallbackRule.value.value : envConfig.rules[0].value.value;
450
444
  json[envConfig.name].value = valueToShow;
451
445
  }
@@ -2,6 +2,7 @@ import { Args, Flags } from '@oclif/core';
2
2
  import { APICommand } from '../index.js';
3
3
  import { checkmark } from '../util/color.js';
4
4
  import { LOG_LEVELS, LOG_LEVEL_KEY_PREFIX, isLogLevel } from '../util/log-levels.js';
5
+ import { seedScopeRules, upsertEnvRules } from '../util/rules.js';
5
6
  const SDK_LOGGER_CONTEXT = 'quonfig-sdk-logging.key';
6
7
  /**
7
8
  * Convenience command for log-level configs.
@@ -122,10 +123,14 @@ For more complex rules (regex, multi-criterion, exact match) edit the JSON:
122
123
  // Merge semantics: if an existing rule has the same exact targeting
123
124
  // criterion (same propertyName, same valueToMatch set), replace its value.
124
125
  // Otherwise insert the new rule before the catch-all so it takes priority.
125
- const existingRules = environment ? findEnvRules(current, environment) : (current.default?.rules ?? []);
126
+ // An environment with no block of its own INHERITS default.rules at
127
+ // evaluation time, so the merge starts from a clone of those rules rather
128
+ // than from an empty list — otherwise writing one logger's level silently
129
+ // dropped every inherited rule for that environment (qfg-qjdm).
130
+ const { rules: existingRules } = seedScopeRules(current, environment);
126
131
  const mergedRules = mergeTargetingRule(existingRules, newRule);
127
132
  const logLevelPatch = environment
128
- ? { environments: replaceEnvRules(current.environments ?? [], environment, mergedRules) }
133
+ ? { environments: upsertEnvRules(current.environments ?? [], environment, mergedRules) }
129
134
  : { default: { rules: mergedRules } };
130
135
  const updateRequest = await this.apiClient.post('/api/v1/logLevels/update', {
131
136
  workspaceId: this.workspaceId,
@@ -146,14 +151,6 @@ For more complex rules (regex, multi-criterion, exact match) edit the JSON:
146
151
  });
147
152
  }
148
153
  }
149
- function findEnvRules(config, environment) {
150
- const env = (config.environments ?? []).find((e) => e.id === environment);
151
- return env?.rules ?? [];
152
- }
153
- function replaceEnvRules(envs, environment, rules) {
154
- const hasEnv = envs.some((e) => e.id === environment);
155
- return hasEnv ? envs.map((e) => (e.id === environment ? { ...e, rules } : e)) : [...envs, { id: environment, rules }];
156
- }
157
154
  function mergeTargetingRule(existing, incoming) {
158
155
  const incomingTargets = new Set(stringListTargets(incoming.criteria[0].valueToMatch));
159
156
  const incomingPropertyName = incoming.criteria[0].propertyName;
@@ -14,6 +14,7 @@ export default class SetDefault extends APICommand {
14
14
  confidential: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
15
15
  'env-var': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
16
16
  environment: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
17
+ 'replace-targeting': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
17
18
  value: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
18
19
  };
19
20
  run(): Promise<JsonObj | void>;
@@ -10,19 +10,22 @@ import { mapConfigValueToDto } from '../util/config-value-dto.js';
10
10
  import { makeConfidentialValue } from '../util/encryption.js';
11
11
  import isInteractive from '../util/is-interactive.js';
12
12
  import nameArg from '../util/name-arg.js';
13
+ import { catchAllRule, countTargetingRules, keptTargetingNote, replacedTargetingNote, seedScopeRules, seededFromDefaultNote, upsertEnvRules, upsertFallbackRule, } from '../util/rules.js';
13
14
  import secretFlags, { parsedSecretFlags } from '../util/secret-flags.js';
14
15
  export default class SetDefault extends APICommand {
15
16
  static aliases = ['toggle'];
16
17
  static args = { ...nameArg };
17
- static description = `Set the unconditional fallback value for a flag or config in a specific environment.
18
+ static description = `Set the fallback value for a flag or config in one environment.
18
19
 
19
- This updates the catch-all rule — the value users receive when NO targeting rule matches.
20
- Any targeting rules or percentage rollouts you have configured are NOT affected; they
21
- continue to fire before this fallback is evaluated.
20
+ The fallback is the unconditional rule at the end of the environment's rule
21
+ list: what users receive when no targeting rule matches. Targeting rules and
22
+ percentage rollouts above it are kept, and the command tells you how many.
23
+ If the environment has no rules of its own yet, they are copied from the
24
+ flag's default rules first, so inherited targeting is kept too.
22
25
 
23
- "Other rules still apply" means: if you have rules targeting specific users, segments,
24
- or properties, those rules still take priority. This command only changes what everyone
25
- else sees.
26
+ To turn a flag on or off for EVERYONE, including users matched by targeting
27
+ rules, add --replace-targeting. This deletes the environment's targeting
28
+ rules (they stay in git history).
26
29
 
27
30
  To set a percentage rollout (gradual rollout / A/B test / canary deploy) instead:
28
31
  qfg set-rollout my.flag --environment production --true-percent 20
@@ -32,7 +35,7 @@ To see all current values and rules for a flag:
32
35
  static examples = [
33
36
  '<%= config.bin %> <%= command.id %> my.flag.name # prompts for value and env',
34
37
  '<%= config.bin %> <%= command.id %> my.flag.name --value=true --environment=staging',
35
- '<%= config.bin %> <%= command.id %> my.flag.name --value=false --environment=production # kill-switch: turn off for everyone',
38
+ '<%= config.bin %> <%= command.id %> my.flag.name --value=false --environment=production --replace-targeting # kill-switch: off for everyone',
36
39
  '<%= config.bin %> <%= command.id %> my.flag.name --value=true --secret',
37
40
  '<%= config.bin %> <%= command.id %> my.config.name --env-var=MY_ENV_VAR_NAME --environment=production',
38
41
  '# For a percentage rollout, use set-rollout instead:',
@@ -44,6 +47,10 @@ To see all current values and rules for a flag:
44
47
  confidential: Flags.boolean({ default: false, description: 'mark the value as confidential' }),
45
48
  'env-var': Flags.string({ description: 'environment variable to use as default value' }),
46
49
  environment: Flags.string({ description: 'environment to change' }),
50
+ 'replace-targeting': Flags.boolean({
51
+ default: false,
52
+ description: "set the value for EVERYONE: delete this environment's targeting rules instead of keeping them (they stay in git history)",
53
+ }),
47
54
  value: Flags.string({ description: 'new default value' }),
48
55
  ...confirmFlag,
49
56
  ...secretFlags('encrypt the value of this item'),
@@ -95,28 +102,28 @@ To see all current values and rules for a flag:
95
102
  return this.err(`Could not find config named ${key}`);
96
103
  }
97
104
  this.verboseLog('Selected config:', config);
105
+ // Fetch the stored document ONCE. It drives encryption auto-detection,
106
+ // the targeting-rule count in the confirmation prompt, the rules the write
107
+ // is applied to, and the commitSha for optimistic concurrency.
108
+ const detailRequest = await this.apiClient.post('/api/v1/metadata/getByKey', {
109
+ workspaceId: this.workspaceId,
110
+ key,
111
+ });
112
+ if (!detailRequest.ok) {
113
+ return this.err(`Failed to fetch config details: ${detailRequest.status}`);
114
+ }
115
+ const currentConfig = detailRequest.json;
116
+ this.verboseLog('Config details:', currentConfig);
98
117
  // Check if existing config has encrypted values
99
- if (!secret.selected) {
100
- const configDetailsRequest = await this.apiClient.post('/api/v1/metadata/getByKey', {
101
- workspaceId: this.workspaceId,
102
- key,
103
- });
104
- if (configDetailsRequest.ok) {
105
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
- const configDetails = configDetailsRequest.json;
107
- this.verboseLog('Config details:', configDetails);
108
- // Check if any existing rules have encrypted values
109
- if (configDetails.default?.rules) {
110
- for (const rule of configDetails.default.rules) {
111
- if (rule.value?.decryptWith) {
112
- this.verboseLog('Auto-detected encryption from existing config');
113
- secret = {
114
- keyName: secret.keyName,
115
- selected: true,
116
- };
117
- break;
118
- }
119
- }
118
+ if (!secret.selected && currentConfig.default?.rules) {
119
+ for (const rule of currentConfig.default.rules) {
120
+ if (rule.value?.decryptWith) {
121
+ this.verboseLog('Auto-detected encryption from existing config');
122
+ secret = {
123
+ keyName: secret.keyName,
124
+ selected: true,
125
+ };
126
+ break;
120
127
  }
121
128
  }
122
129
  }
@@ -131,22 +138,40 @@ To see all current values and rules for a flag:
131
138
  if (!environment) {
132
139
  return;
133
140
  }
141
+ // The scope this write targets: one environment's rule list, or the
142
+ // document's `default` block when the user picked [Default].
143
+ // environment.name is the slug stored in git files (e.g. "production");
144
+ // environment.id is the DB UUID and is never written to git files.
145
+ const envName = environment.id === '' ? undefined : environment.name;
146
+ const replaceTargeting = flags['replace-targeting'];
147
+ const scope = seedScopeRules(currentConfig, envName);
148
+ // A --replace-targeting write does NOT seed from default: there is nothing
149
+ // in that environment to replace, and the default rules it inherits are
150
+ // not being deleted, so reporting them as "replaced" would be a lie.
151
+ const targetingRuleCount = replaceTargeting && scope.seeded ? 0 : countTargetingRules(scope.rules);
152
+ const keptPhrase = targetingRuleCount > 0 ? ` (${targetingRuleCount} targeting rule${targetingRuleCount === 1 ? '' : 's'} kept)` : '';
153
+ const deletePhrase = targetingRuleCount > 0 ? `, deleting ${targetingRuleCount} targeting rule(s)` : '';
154
+ const confirmMessage = (valuePhrase) => replaceTargeting
155
+ ? `Confirm: set ${key} in ${environment.name} to ${valuePhrase} for EVERYONE${deletePhrase}? yes/no`
156
+ : `Confirm: set the fallback for ${key} in ${environment.name} to ${valuePhrase}?${keptPhrase} yes/no`;
134
157
  const { confidential } = flags;
135
158
  // Get the value
136
159
  if (flags['env-var']) {
137
- if (!(await getConfirmation({
138
- flags,
139
- message: `Confirm: change the default for ${key} in ${environment.name} to be provided by \`${flags['env-var']}\`? yes/no`,
140
- }))) {
160
+ if (!(await getConfirmation({ flags, message: confirmMessage(`be provided by \`${flags['env-var']}\``) }))) {
141
161
  return;
142
162
  }
143
163
  return this.submitChange({
144
164
  confidential,
145
165
  config,
166
+ currentConfig,
167
+ envName,
146
168
  envVar: flags['env-var'],
147
169
  environment,
148
170
  key,
171
+ replaceTargeting,
172
+ scope,
149
173
  secret,
174
+ targetingRuleCount,
150
175
  });
151
176
  }
152
177
  let value = flags.value;
@@ -160,20 +185,24 @@ To see all current values and rules for a flag:
160
185
  return this.err('Value is required');
161
186
  }
162
187
  const secretMaybe = secret.selected ? ' (encrypted)' : '';
163
- const message = `Confirm: change the default for ${key} in ${environment.name} to \`${value}\`${secretMaybe}? yes/no`;
164
- if (!(await getConfirmation({ flags, message }))) {
188
+ if (!(await getConfirmation({ flags, message: confirmMessage(`\`${value}\`${secretMaybe}`) }))) {
165
189
  return;
166
190
  }
167
191
  return this.submitChange({
168
192
  confidential,
169
193
  config,
194
+ currentConfig,
195
+ envName,
170
196
  environment,
171
197
  key,
198
+ replaceTargeting,
199
+ scope,
172
200
  secret,
201
+ targetingRuleCount,
173
202
  value,
174
203
  });
175
204
  }
176
- async submitChange({ confidential, config, envVar, environment, key, secret, value, }) {
205
+ async submitChange({ confidential, config, currentConfig, envName, envVar, environment, key, replaceTargeting, scope, secret, targetingRuleCount, value, }) {
177
206
  const typeMapping = {
178
207
  bool: 'bool',
179
208
  string: 'string',
@@ -187,9 +216,12 @@ To see all current values and rules for a flag:
187
216
  };
188
217
  const type = typeMapping[config.valueType.toLowerCase()] || config.valueType;
189
218
  let configValue;
190
- let successMessage;
219
+ // How the new value is named in the prompt and the report — `\`true\``, or
220
+ // "be provided by `MY_ENV_VAR`" — plus any (encrypted)/(confidential) note.
221
+ let valuePhrase;
222
+ let modifiers = '';
191
223
  if (envVar === undefined) {
192
- successMessage = `Successfully changed default to \`${value}\``;
224
+ valuePhrase = `\`${value}\``;
193
225
  if (secret.selected) {
194
226
  // Handle encrypted values using shared utility
195
227
  const encryptedValueResult = await makeConfidentialValue(this, value, secret, environment.name);
@@ -197,7 +229,7 @@ To see all current values and rules for a flag:
197
229
  return this.err(encryptedValueResult.message || 'Failed to encrypt value');
198
230
  }
199
231
  configValue = mapConfigValueToDto(encryptedValueResult.value, ConfigValueType.String);
200
- successMessage += ' (encrypted)';
232
+ modifiers += ' (encrypted)';
201
233
  }
202
234
  else {
203
235
  // Parse the value based on type and build value object
@@ -253,41 +285,22 @@ To see all current values and rules for a flag:
253
285
  // ValueSchema discriminates on `type`; provided values are
254
286
  // {type: 'provided', value: {source, lookup}}. See qfg-84df.
255
287
  configValue = mapConfigValueToDto({ provided: { lookup: envVar, source: ProvidedSource.EnvVar } }, ConfigValueType.String);
256
- successMessage = `Successfully changed default to be provided by \`${envVar}\``;
288
+ valuePhrase = `be provided by \`${envVar}\``;
257
289
  }
258
290
  if (confidential && !secret.selected) {
259
291
  configValue.confidential = true;
260
- successMessage += ' (confidential)';
261
- }
262
- // Fetch the current full config to get the existing environments array and commitSha.
263
- const detailRequest = await this.apiClient.post('/api/v1/metadata/getByKey', {
264
- workspaceId: this.workspaceId,
265
- key,
266
- });
267
- if (!detailRequest.ok) {
268
- return this.err(`Failed to fetch config details: ${detailRequest.status}`);
269
- }
270
- const currentConfig = detailRequest.json;
271
- // Build a single catch-all rule (empty criteria = matches everything).
272
- const newRule = { criteria: [], value: configValue };
273
- // For [Default] (environment.id === ''), update default.rules.
274
- // For a specific environment, update the environments array.
275
- let updateFields;
276
- if (environment.id === '') {
277
- // [Default] — no environment-specific block, update the catch-all default rules.
278
- updateFields = { default: { rules: [newRule] } };
279
- }
280
- else {
281
- // environment.name is the slug stored in git files (e.g. "production").
282
- // environment.id is the DB UUID — never written to git files.
283
- const envKey = environment.name;
284
- const existingEnvs = currentConfig.environments ?? [];
285
- const hasEnv = existingEnvs.some((e) => e.id === envKey);
286
- const updatedEnvironments = hasEnv
287
- ? existingEnvs.map((e) => (e.id === envKey ? { ...e, rules: [newRule] } : e))
288
- : [...existingEnvs, { id: envKey, rules: [newRule] }];
289
- updateFields = { environments: updatedEnvironments };
292
+ modifiers += ' (confidential)';
290
293
  }
294
+ // Surgical by default (qfg-qjdm): the fallback rule's value is replaced
295
+ // in place and every targeting rule around it is kept. --replace-targeting
296
+ // is the explicit opt-in to collapsing the scope to a single unconditional
297
+ // rule — the behavior this command used to have without saying so.
298
+ const newRules = replaceTargeting ? [catchAllRule(configValue)] : upsertFallbackRule(scope.rules, configValue);
299
+ // For [Default] (envName undefined), update default.rules.
300
+ // For a specific environment, upsert the environments array.
301
+ const updateFields = envName
302
+ ? { environments: upsertEnvRules(currentConfig.environments ?? [], envName, newRules) }
303
+ : { default: { rules: newRules } };
291
304
  this.verboseLog('Update fields:', JSON.stringify(updateFields, null, 2));
292
305
  // Route to the correct update endpoint based on config type.
293
306
  let request;
@@ -316,7 +329,28 @@ To see all current values and rules for a flag:
316
329
  });
317
330
  }
318
331
  if (request.ok) {
319
- this.log(`${checkmark} ${successMessage}`);
332
+ // Always say what happened to the targeting rules — the whole point of
333
+ // qfg-97z9 was that this command changed them silently.
334
+ const scopeLabel = envName ?? 'the default';
335
+ const sentences = replaceTargeting
336
+ ? [
337
+ `Set ${scopeLabel} to ${valuePhrase}${modifiers} for everyone.`,
338
+ replacedTargetingNote(targetingRuleCount, currentConfig.commitSha),
339
+ ]
340
+ : [
341
+ `Set ${scopeLabel} fallback to ${valuePhrase}${modifiers}.`,
342
+ scope.seeded && envName ? seededFromDefaultNote(envName) : '',
343
+ keptTargetingNote(targetingRuleCount),
344
+ ];
345
+ this.log(`${checkmark} ${sentences.filter(Boolean).join(' ')}`);
346
+ const counts = replaceTargeting
347
+ ? {
348
+ previousCommitSha: currentConfig.commitSha,
349
+ ...(targetingRuleCount > 0 ? { replacedTargetingRuleCount: targetingRuleCount } : {}),
350
+ }
351
+ : targetingRuleCount > 0
352
+ ? { keptTargetingRuleCount: targetingRuleCount }
353
+ : {};
320
354
  return {
321
355
  environment: {
322
356
  id: environment.id,
@@ -325,6 +359,7 @@ To see all current values and rules for a flag:
325
359
  key,
326
360
  success: true,
327
361
  value,
362
+ ...counts,
328
363
  };
329
364
  }
330
365
  this.verboseLog(request.error);
@@ -10,6 +10,7 @@ export default class SetRollout extends APICommand {
10
10
  environment: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
11
11
  'hash-by': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
12
12
  'true-percent': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
13
+ 'replace-targeting': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
13
14
  weights: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
14
15
  confirm: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
15
16
  };
@@ -5,6 +5,7 @@ import getEnvironment from '../ui/get-environment.js';
5
5
  import { checkmark } from '../util/color.js';
6
6
  import isInteractive from '../util/is-interactive.js';
7
7
  import nameArg from '../util/name-arg.js';
8
+ import { catchAllRule, countTargetingRules, keptTargetingNote, replacedTargetingNote, seedScopeRules, seededFromDefaultNote, upsertEnvRules, upsertFallbackRule, } from '../util/rules.js';
8
9
  const WEIGHT_TOTAL = 100_000;
9
10
  export default class SetRollout extends APICommand {
10
11
  static args = { ...nameArg };
@@ -27,6 +28,16 @@ Hashing: users are bucketed by the value of hashByPropertyName (default: "user.k
27
28
  Use --hash-by to change which property drives the bucket assignment:
28
29
  qfg set-rollout my.flag --environment production --true-percent 10 --hash-by user.id
29
30
 
31
+ The rollout becomes the environment's fallback: the unconditional rule at the end
32
+ of its rule list, what users receive when no targeting rule matches. Targeting
33
+ rules above it are kept, and the command tells you how many. If the environment
34
+ has no rules of its own yet, they are copied from the flag's default rules first,
35
+ so inherited targeting is kept too.
36
+
37
+ To roll out to EVERYONE, including users matched by targeting rules, add
38
+ --replace-targeting. This deletes the environment's targeting rules (they stay in
39
+ git history).
40
+
30
41
  For scripted / agent use, add --confirm to skip the interactive confirmation prompt.
31
42
 
32
43
  To combine a rollout with segment targeting (e.g. beta ON, holdout OFF, everyone else 50/50),
@@ -35,7 +46,7 @@ you need to edit the JSON config file directly:
35
46
  qfg pull --dir ./config # clone workspace to edit
36
47
 
37
48
  To revert to a single value for everyone, use:
38
- qfg set-default my.flag --environment production --value false
49
+ qfg set-default my.flag --environment production --value false --replace-targeting
39
50
 
40
51
  To inspect the current rollout:
41
52
  qfg info my.flag`;
@@ -43,7 +54,8 @@ To inspect the current rollout:
43
54
  '<%= config.bin %> <%= command.id %> my.feature.flag --environment production --true-percent 20',
44
55
  '<%= config.bin %> <%= command.id %> my.feature.flag --environment staging --true-percent 50',
45
56
  '<%= config.bin %> <%= command.id %> my.feature.flag --environment production --true-percent 100 # full rollout',
46
- '<%= config.bin %> <%= command.id %> my.feature.flag --environment production --true-percent 0 # kill switch (all false)',
57
+ '# For a kill switch — false for EVERYONE, including targeted users — use set-default:',
58
+ '<%= config.bin %> set-default my.feature.flag --value=false --environment=production --replace-targeting',
47
59
  '<%= config.bin %> <%= command.id %> my.variant.flag --environment production --weights "control:50,treatment:50"',
48
60
  '<%= config.bin %> <%= command.id %> my.variant.flag --environment production --weights "a:33,b:33,c:34" --hash-by user.id',
49
61
  ];
@@ -58,6 +70,10 @@ To inspect the current rollout:
58
70
  max: 100,
59
71
  min: 0,
60
72
  }),
73
+ 'replace-targeting': Flags.boolean({
74
+ default: false,
75
+ description: "roll out to EVERYONE: delete this environment's targeting rules instead of keeping them (they stay in git history)",
76
+ }),
61
77
  weights: Flags.string({
62
78
  description: 'comma-separated "value:percent" pairs that must sum to 100 (e.g. "true:20,false:80" or "red:33,green:33,blue:34")',
63
79
  }),
@@ -145,11 +161,8 @@ To inspect the current rollout:
145
161
  const rolloutDescription = weightedValues
146
162
  .map((wv) => `${wv.weight / 1000}% → ${JSON.stringify(wv.value.value)}`)
147
163
  .join(', ');
148
- const message = `Confirm: set rollout for ${key} in ${environment.name} to [${rolloutDescription}]? yes/no`;
149
- if (!(await getConfirmation({ flags, message }))) {
150
- return;
151
- }
152
- // Fetch the current full config to get commitSha and existing environments
164
+ // Fetch the current full config BEFORE confirming: the prompt has to say
165
+ // how many targeting rules the write keeps (or deletes).
153
166
  const detailRequest = await this.apiClient.post('/api/v1/metadata/getByKey', {
154
167
  workspaceId: this.workspaceId,
155
168
  key,
@@ -158,20 +171,34 @@ To inspect the current rollout:
158
171
  return this.err(`Failed to fetch config details: ${detailRequest.status}`);
159
172
  }
160
173
  const currentConfig = detailRequest.json;
161
- const newRule = { criteria: [], value: rolloutValue };
162
- const envKey = environment.name;
163
- const existingEnvs = currentConfig.environments ?? [];
164
- const hasEnv = existingEnvs.some((e) => e.id === envKey);
165
- const updatedEnvironments = hasEnv
166
- ? existingEnvs.map((e) => (e.id === envKey ? { ...e, rules: [newRule] } : e))
167
- : [...existingEnvs, { id: envKey, rules: [newRule] }];
174
+ // The scope this write targets: one environment's rule list, or the
175
+ // document's `default` block when the user picked [Default].
176
+ const envName = environment.id === '' ? undefined : environment.name;
177
+ const replaceTargeting = flags['replace-targeting'];
178
+ const scope = seedScopeRules(currentConfig, envName);
179
+ // A --replace-targeting write does NOT seed from default: there is nothing
180
+ // in that environment to replace.
181
+ const targetingRuleCount = replaceTargeting && scope.seeded ? 0 : countTargetingRules(scope.rules);
182
+ const keptPhrase = targetingRuleCount > 0 ? ` (${targetingRuleCount} targeting rule${targetingRuleCount === 1 ? '' : 's'} kept)` : '';
183
+ const deletePhrase = targetingRuleCount > 0 ? `, deleting ${targetingRuleCount} targeting rule(s)` : '';
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`;
187
+ if (!(await getConfirmation({ flags, message }))) {
188
+ return;
189
+ }
190
+ // Surgical by default (qfg-qjdm): the rollout replaces the FALLBACK rule's
191
+ // value and every targeting rule around it is kept. A newly appended
192
+ // catch-all uses the ALWAYS_TRUE spelling (qfg-gv54); an existing fallback
193
+ // keeps whichever spelling it already had.
194
+ const newRules = replaceTargeting ? [catchAllRule(rolloutValue)] : upsertFallbackRule(scope.rules, rolloutValue);
168
195
  // Auto-create variants when the config has none. The UI cannot render a
169
196
  // rollout without named variants, and the server/gitea verify now rejects
170
197
  // it. Bool configs are exempt — the UI supplies implicit true/false variants.
171
198
  const existingVariants = currentConfig.variants ?? [];
172
- const updateFields = {
173
- environments: updatedEnvironments,
174
- };
199
+ const updateFields = envName
200
+ ? { environments: upsertEnvRules(currentConfig.environments ?? [], envName, newRules) }
201
+ : { default: { rules: newRules } };
175
202
  if (existingVariants.length === 0 && config.valueType !== 'bool') {
176
203
  const synthesized = synthesizeVariants(weightedValues);
177
204
  if (synthesized.length > 0) {
@@ -198,7 +225,26 @@ To inspect the current rollout:
198
225
  });
199
226
  }
200
227
  if (request.ok) {
201
- this.log(`${checkmark} Rollout set: ${key} in ${environment.name} → [${rolloutDescription}]`);
228
+ const scopeLabel = envName ?? 'the default';
229
+ const sentences = replaceTargeting
230
+ ? [
231
+ `Rollout set: ${key} in ${scopeLabel} → [${rolloutDescription}] for everyone.`,
232
+ replacedTargetingNote(targetingRuleCount, currentConfig.commitSha),
233
+ ]
234
+ : [
235
+ `Rollout set: ${key} in ${scopeLabel} → [${rolloutDescription}].`,
236
+ scope.seeded && envName ? seededFromDefaultNote(envName) : '',
237
+ keptTargetingNote(targetingRuleCount),
238
+ ];
239
+ this.log(`${checkmark} ${sentences.filter(Boolean).join(' ')}`);
240
+ const counts = replaceTargeting
241
+ ? {
242
+ previousCommitSha: currentConfig.commitSha,
243
+ ...(targetingRuleCount > 0 ? { replacedTargetingRuleCount: targetingRuleCount } : {}),
244
+ }
245
+ : targetingRuleCount > 0
246
+ ? { keptTargetingRuleCount: targetingRuleCount }
247
+ : {};
202
248
  return {
203
249
  environment: { id: environment.id, name: environment.name },
204
250
  hashByPropertyName: flags['hash-by'],
@@ -206,6 +252,7 @@ To inspect the current rollout:
206
252
  rollout: rolloutDescription,
207
253
  success: true,
208
254
  weightedValues,
255
+ ...counts,
209
256
  };
210
257
  }
211
258
  this.verboseLog(request.error);
@@ -13,9 +13,18 @@ export default class Workspace extends BaseCommand {
13
13
  private activePin;
14
14
  /**
15
15
  * Fan out across every cached org's token and merge the results. One org's
16
- * stale token doesn't block the others — we verbose-log and continue.
16
+ * stale token doesn't block the others — that isolation is deliberate for
17
+ * multi-org accounts. What is NOT ok (qfg-t15h) is losing the fact that an
18
+ * org failed: each failure is returned so the caller can decide between a
19
+ * hard session-expired error (nothing listed) and a per-org warning
20
+ * alongside the orgs that did answer.
17
21
  */
18
22
  private fetchAllOrgsWorkspaces;
23
+ /**
24
+ * qfg-t15h: returns a tagged result rather than a bare array, so a 401 or a
25
+ * dead connection is distinguishable from an org that genuinely has no
26
+ * workspaces yet.
27
+ */
19
28
  private fetchWorkspaces;
20
29
  /**
21
30
  * Pin the default-profile workspaceId to org/ws form by looking it up in