@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.
- package/README.md +97 -73
- package/dist/commands/cleanup/status.js +5 -1
- package/dist/commands/config-schema.js +8 -3
- package/dist/commands/get.js +6 -4
- 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/commands/workspace.d.ts +10 -1
- package/dist/commands/workspace.js +72 -13
- package/dist/index.d.ts +30 -0
- package/dist/index.js +105 -8
- package/dist/init/templates.js +43 -0
- package/dist/push/run-push.d.ts +21 -0
- package/dist/push/run-push.js +26 -3
- 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/get.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
112
|
+
this.logToStderr(`Successfully decrypted config '${key}'`);
|
|
111
113
|
}
|
|
112
114
|
catch (error) {
|
|
113
115
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
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);
|
|
@@ -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 —
|
|
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
|