@quonfig/cli 0.0.72 → 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.
- package/README.md +97 -73
- package/dist/commands/cleanup/status.js +5 -1
- package/dist/commands/config-schema.js +8 -3
- package/dist/commands/info.js +9 -15
- package/dist/commands/log-level.js +7 -10
- package/dist/commands/set-default.d.ts +1 -0
- package/dist/commands/set-default.js +106 -71
- package/dist/commands/set-rollout.d.ts +1 -0
- package/dist/commands/set-rollout.js +65 -18
- package/dist/index.d.ts +30 -0
- package/dist/index.js +105 -8
- package/dist/init/templates.js +43 -0
- package/dist/util/rules.d.ts +98 -0
- package/dist/util/rules.js +131 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +18 -5
- package/package.json +1 -1
package/dist/commands/info.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
18
|
+
static description = `Set the fallback value for a flag or config in one environment.
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
+
valuePhrase = `be provided by \`${envVar}\``;
|
|
257
289
|
}
|
|
258
290
|
if (confidential && !secret.selected) {
|
|
259
291
|
configValue.confidential = true;
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
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:
|
|
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
|
-
|
|
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);
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,27 @@ import { Command } from '@oclif/core';
|
|
|
3
3
|
import { Client } from '@quonfig/node';
|
|
4
4
|
import type { ProjectEnvId } from '@quonfig/node';
|
|
5
5
|
import { JsonObj, Result } from './result.js';
|
|
6
|
+
/**
|
|
7
|
+
* The exit code oclif actually recorded on a thrown error.
|
|
8
|
+
*
|
|
9
|
+
* `Errors.CLIError` — what `this.error(msg, {exit: N})` throws — stores N on
|
|
10
|
+
* `oclif.exit` and has NO `exitCode` property at all. Reading `err.exitCode`
|
|
11
|
+
* therefore collapsed every non-default code (401 on the auth failures in
|
|
12
|
+
* `resolve-workspace.ts` / `get-client.ts`, for one) down to 1. Exported so the
|
|
13
|
+
* behaviour is unit-testable. See qfg-hzmb.
|
|
14
|
+
*/
|
|
15
|
+
export declare function errorExitCode(err: unknown): number;
|
|
16
|
+
/**
|
|
17
|
+
* The code to actually terminate the process with.
|
|
18
|
+
*
|
|
19
|
+
* A POSIX exit status is one byte. Several call sites pass an HTTP-flavoured
|
|
20
|
+
* code — `{exit: 401}` for the auth failures in `get-client.ts` and
|
|
21
|
+
* `resolve-workspace.ts` — and `process.exit(401)` truncates to 145, which a
|
|
22
|
+
* shell reads as "killed by signal 17". Anything outside 1-255 therefore
|
|
23
|
+
* terminates with the plain failure code; the caller's real intent is still
|
|
24
|
+
* reported verbatim as `error.exitCode` in the `--json` envelope.
|
|
25
|
+
*/
|
|
26
|
+
export declare function processExitCode(err: unknown): number;
|
|
6
27
|
export declare abstract class BaseCommand extends Command {
|
|
7
28
|
static baseFlags: {
|
|
8
29
|
interactive: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
@@ -20,6 +41,15 @@ export declare abstract class BaseCommand extends Command {
|
|
|
20
41
|
code?: string;
|
|
21
42
|
} & Error): Promise<void>;
|
|
22
43
|
init(): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* oclif's default is `{error: err}`, but `message`, `code` and `stack` are
|
|
46
|
+
* non-enumerable on an Error, so `JSON.stringify` flattens a thrown Error all
|
|
47
|
+
* the way down to `{}`. Re-attach the useful fields explicitly and keep the
|
|
48
|
+
* caller's payload alongside them. See qfg-hzmb.
|
|
49
|
+
*/
|
|
50
|
+
protected toErrorJson(err: unknown): {
|
|
51
|
+
error: JsonObj;
|
|
52
|
+
};
|
|
23
53
|
}
|
|
24
54
|
export declare abstract class APICommand extends BaseCommand {
|
|
25
55
|
static baseFlags: {
|