@quonfig/cli 0.0.4 → 0.0.6
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 +300 -120
- package/dist/commands/config-schema.d.ts +10 -0
- package/dist/commands/config-schema.js +165 -0
- package/dist/commands/create.js +22 -1
- package/dist/commands/info.js +17 -2
- package/dist/commands/login.js +19 -15
- package/dist/commands/profile.d.ts +1 -0
- package/dist/commands/profile.js +6 -42
- package/dist/commands/pull.js +16 -1
- package/dist/commands/set-default.js +78 -15
- package/dist/commands/set-rollout.d.ts +19 -0
- package/dist/commands/set-rollout.js +260 -0
- package/dist/commands/whoami.js +8 -8
- package/dist/commands/workspace/switch.d.ts +7 -0
- package/dist/commands/workspace/switch.js +90 -0
- package/dist/commands/workspace.js +14 -23
- package/dist/index.d.ts +1 -0
- package/dist/index.js +9 -2
- package/dist/util/get-client.js +34 -11
- package/dist/util/token-storage.d.ts +6 -0
- package/dist/util/token-storage.js +17 -0
- package/oclif.manifest.json +362 -22
- package/package.json +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { BaseCommand } from '../index.js';
|
|
2
|
+
import { JsonObj } from '../result.js';
|
|
3
|
+
export default class ConfigSchema extends BaseCommand {
|
|
4
|
+
static description: string;
|
|
5
|
+
static examples: string[];
|
|
6
|
+
static flags: {
|
|
7
|
+
'json-schema': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
8
|
+
};
|
|
9
|
+
run(): Promise<JsonObj | void>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { BaseCommand } from '../index.js';
|
|
3
|
+
import { storedConfigJsonSchema } from '../init/schema.js';
|
|
4
|
+
const REFERENCE = `
|
|
5
|
+
────────────────────────────────────────────────────────────
|
|
6
|
+
FILE LAYOUT
|
|
7
|
+
────────────────────────────────────────────────────────────
|
|
8
|
+
Config files live in your workspace (clone with: qfg pull --dir ./my-config):
|
|
9
|
+
|
|
10
|
+
feature-flags/ Boolean feature flags (on/off, targeting, percentage rollouts)
|
|
11
|
+
configs/ Typed config values (string, int, double, json, string-list)
|
|
12
|
+
segments/ User segments for targeting rules
|
|
13
|
+
log-levels/ Dynamic log-level configs
|
|
14
|
+
|
|
15
|
+
────────────────────────────────────────────────────────────
|
|
16
|
+
RULE EVALUATION
|
|
17
|
+
────────────────────────────────────────────────────────────
|
|
18
|
+
Rules are evaluated in ORDER — the FIRST matching rule wins.
|
|
19
|
+
Put specific rules (user lists, segments) BEFORE catch-all rules.
|
|
20
|
+
criteria: [] means "match everyone" (unconditional / catch-all)
|
|
21
|
+
criteria: [..] multiple entries in one rule are ANDed together
|
|
22
|
+
|
|
23
|
+
────────────────────────────────────────────────────────────
|
|
24
|
+
EXAMPLE: complex targeting + rollout in production
|
|
25
|
+
────────────────────────────────────────────────────────────
|
|
26
|
+
Four rules in order:
|
|
27
|
+
1. Always ON for users in an allowlist
|
|
28
|
+
2. Always ON for users in the "beta" segment
|
|
29
|
+
3. Always OFF for users in the "holdout" segment
|
|
30
|
+
4. 50/50 split for everyone else
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
"key": "my.feature.flag",
|
|
34
|
+
"type": "feature_flag",
|
|
35
|
+
"valueType": "bool",
|
|
36
|
+
"default": {
|
|
37
|
+
"rules": [{ "criteria": [], "value": { "type": "bool", "value": false } }]
|
|
38
|
+
},
|
|
39
|
+
"environments": [{
|
|
40
|
+
"id": "production",
|
|
41
|
+
"rules": [
|
|
42
|
+
{
|
|
43
|
+
"criteria": [{
|
|
44
|
+
"operator": "PROP_IS_ONE_OF",
|
|
45
|
+
"propertyName": "user.key",
|
|
46
|
+
"valueToMatch": { "type": "string_list", "value": ["user-123", "user-456"] }
|
|
47
|
+
}],
|
|
48
|
+
"value": { "type": "bool", "value": true }
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"criteria": [{
|
|
52
|
+
"operator": "IN_SEG",
|
|
53
|
+
"valueToMatch": { "type": "string", "value": "beta" }
|
|
54
|
+
}],
|
|
55
|
+
"value": { "type": "bool", "value": true }
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"criteria": [{
|
|
59
|
+
"operator": "IN_SEG",
|
|
60
|
+
"valueToMatch": { "type": "string", "value": "holdout" }
|
|
61
|
+
}],
|
|
62
|
+
"value": { "type": "bool", "value": false }
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"criteria": [],
|
|
66
|
+
"value": {
|
|
67
|
+
"type": "weighted_values",
|
|
68
|
+
"value": {
|
|
69
|
+
"hashByPropertyName": "user.key",
|
|
70
|
+
"weightedValues": [
|
|
71
|
+
{ "value": { "type": "bool", "value": true }, "weight": 50000 },
|
|
72
|
+
{ "value": { "type": "bool", "value": false }, "weight": 50000 }
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
────────────────────────────────────────────────────────────
|
|
82
|
+
CRITERION FIELDS
|
|
83
|
+
────────────────────────────────────────────────────────────
|
|
84
|
+
Each criterion object has:
|
|
85
|
+
operator (required) — see operator table below
|
|
86
|
+
propertyName (required for PROP_* operators) — user property, e.g. "user.key", "user.plan"
|
|
87
|
+
valueToMatch (required for all except ALWAYS_TRUE) — typed value to compare against
|
|
88
|
+
|
|
89
|
+
────────────────────────────────────────────────────────────
|
|
90
|
+
OPERATOR REFERENCE
|
|
91
|
+
────────────────────────────────────────────────────────────
|
|
92
|
+
Operator valueToMatch type Notes
|
|
93
|
+
────────────────────────────────────────────────────────
|
|
94
|
+
ALWAYS_TRUE (none) Unconditional match; use for catch-all rules
|
|
95
|
+
PROP_IS_ONE_OF string_list propertyName value is in the list
|
|
96
|
+
PROP_IS_NOT_ONE_OF string_list propertyName value is NOT in the list
|
|
97
|
+
PROP_STARTS_WITH_ONE_OF string_list string prefix match
|
|
98
|
+
PROP_DOES_NOT_START_WITH_ONE_OF string_list
|
|
99
|
+
PROP_ENDS_WITH_ONE_OF string_list string suffix match
|
|
100
|
+
PROP_DOES_NOT_END_WITH_ONE_OF string_list
|
|
101
|
+
PROP_CONTAINS_ONE_OF string_list substring match
|
|
102
|
+
PROP_DOES_NOT_CONTAIN_ONE_OF string_list
|
|
103
|
+
PROP_LESS_THAN int or double numeric comparison
|
|
104
|
+
PROP_LESS_THAN_OR_EQUAL int or double
|
|
105
|
+
PROP_GREATER_THAN int or double
|
|
106
|
+
PROP_GREATER_THAN_OR_EQUAL int or double
|
|
107
|
+
PROP_BEFORE string (ISO 8601) date/time comparison
|
|
108
|
+
PROP_AFTER string (ISO 8601)
|
|
109
|
+
PROP_MATCHES string regex match
|
|
110
|
+
PROP_DOES_NOT_MATCH string
|
|
111
|
+
IN_SEG string value = segment key (no propertyName needed)
|
|
112
|
+
NOT_IN_SEG string value = segment key (no propertyName needed)
|
|
113
|
+
IN_INT_RANGE int_range value = {min, max}
|
|
114
|
+
LOOKUP_KEY_IN string_list matches if SDK lookup key is in the list
|
|
115
|
+
LOOKUP_KEY_NOT_IN string_list
|
|
116
|
+
|
|
117
|
+
valueToMatch format: { "type": "<type>", "value": <value> }
|
|
118
|
+
string_list → { "type": "string_list", "value": ["a", "b"] }
|
|
119
|
+
string → { "type": "string", "value": "beta" }
|
|
120
|
+
int → { "type": "int", "value": 42 }
|
|
121
|
+
double → { "type": "double", "value": 3.14 }
|
|
122
|
+
|
|
123
|
+
────────────────────────────────────────────────────────────
|
|
124
|
+
PERCENTAGE ROLLOUT (weighted_values)
|
|
125
|
+
────────────────────────────────────────────────────────────
|
|
126
|
+
Weights are integers out of 100,000 (not percentages):
|
|
127
|
+
5%=5000 10%=10000 20%=20000 50%=50000 100%=100000
|
|
128
|
+
All weights in one rule MUST sum to exactly 100,000.
|
|
129
|
+
|
|
130
|
+
hashByPropertyName controls the bucket assignment property (default: "user.key").
|
|
131
|
+
|
|
132
|
+
────────────────────────────────────────────────────────────
|
|
133
|
+
RELATED COMMANDS
|
|
134
|
+
────────────────────────────────────────────────────────────
|
|
135
|
+
qfg create my.flag --type boolean-flag create a new flag
|
|
136
|
+
qfg set-default my.flag --environment prod ... set a scalar fallback value
|
|
137
|
+
qfg set-rollout my.flag --environment prod ... set a simple percentage rollout
|
|
138
|
+
qfg pull --dir ./config clone workspace to edit JSON directly
|
|
139
|
+
qfg verify ./config validate all JSON files before pushing
|
|
140
|
+
qfg info my.flag inspect current values and rules
|
|
141
|
+
qfg config-schema --json-schema print the machine-readable JSON Schema
|
|
142
|
+
`.trim();
|
|
143
|
+
export default class ConfigSchema extends BaseCommand {
|
|
144
|
+
static description = 'Print the config file format reference: rules engine, operators, targeting criteria, segments, and weighted rollouts. ' +
|
|
145
|
+
'Use --json-schema for the machine-readable JSON Schema document.';
|
|
146
|
+
static examples = [
|
|
147
|
+
'<%= config.bin %> <%= command.id %> # human-readable operator reference + examples',
|
|
148
|
+
'<%= config.bin %> <%= command.id %> --json-schema # full JSON Schema document (copy into your editor)',
|
|
149
|
+
];
|
|
150
|
+
static flags = {
|
|
151
|
+
'json-schema': Flags.boolean({
|
|
152
|
+
default: false,
|
|
153
|
+
description: 'Output the full JSON Schema document for config files instead of the human-readable reference',
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
async run() {
|
|
157
|
+
const { flags } = await this.parse(ConfigSchema);
|
|
158
|
+
if (flags['json-schema']) {
|
|
159
|
+
const schema = storedConfigJsonSchema();
|
|
160
|
+
this.log(JSON.stringify(schema, null, 2));
|
|
161
|
+
return schema;
|
|
162
|
+
}
|
|
163
|
+
this.log(REFERENCE);
|
|
164
|
+
}
|
|
165
|
+
}
|
package/dist/commands/create.js
CHANGED
|
@@ -11,7 +11,26 @@ export default class Create extends APICommand {
|
|
|
11
11
|
static args = {
|
|
12
12
|
name: Args.string({ description: 'name for your new item (e.g. my.new.flag)', required: true }),
|
|
13
13
|
};
|
|
14
|
-
static description =
|
|
14
|
+
static description = `Create a new feature flag, config value, or other item.
|
|
15
|
+
|
|
16
|
+
Use --type to specify the kind of item:
|
|
17
|
+
boolean-flag On/off feature flag (sendToClientSdk defaults to true; best for gradual rollouts)
|
|
18
|
+
string A string configuration value
|
|
19
|
+
int An integer configuration value
|
|
20
|
+
double A floating-point configuration value
|
|
21
|
+
string-list A comma-separated list of strings
|
|
22
|
+
json An arbitrary JSON blob
|
|
23
|
+
boolean A plain boolean (not a feature flag)
|
|
24
|
+
|
|
25
|
+
This sets the global default value. Override per-environment with:
|
|
26
|
+
qfg set-default my.flag --environment production --value true
|
|
27
|
+
|
|
28
|
+
For a percentage rollout (gradual rollout / A/B test / canary deploy), use:
|
|
29
|
+
qfg set-rollout my.flag --environment production --true-percent 20
|
|
30
|
+
|
|
31
|
+
Or edit the JSON config file directly for complex targeting rules:
|
|
32
|
+
qfg config-schema # full operator reference + examples
|
|
33
|
+
qfg pull --dir ./config # clone workspace, then edit JSON and git push`;
|
|
15
34
|
static examples = [
|
|
16
35
|
'<%= config.bin %> <%= command.id %> my.new.flag --type boolean-flag',
|
|
17
36
|
'<%= config.bin %> <%= command.id %> my.new.flag --type boolean-flag --value=true',
|
|
@@ -19,6 +38,8 @@ export default class Create extends APICommand {
|
|
|
19
38
|
'<%= config.bin %> <%= command.id %> my.new.string --type string --value="hello world" --secret',
|
|
20
39
|
'<%= config.bin %> <%= command.id %> my.new.string --type string --env-var=MY_ENV_VAR_NAME',
|
|
21
40
|
'<%= config.bin %> <%= command.id %> my.new.string --type json --value="{\\"key\\": \\"value\\"}"',
|
|
41
|
+
'# After creating a flag, set a 20% rollout in production:',
|
|
42
|
+
'<%= config.bin %> set-rollout my.new.flag --environment production --true-percent 20',
|
|
22
43
|
];
|
|
23
44
|
static flags = {
|
|
24
45
|
confidential: Flags.boolean({ default: false, description: 'mark the value as confidential' }),
|
package/dist/commands/info.js
CHANGED
|
@@ -7,8 +7,23 @@ import isInteractive from '../util/is-interactive.js';
|
|
|
7
7
|
import nameArg from '../util/name-arg.js';
|
|
8
8
|
export default class Info extends APICommand {
|
|
9
9
|
static args = { ...nameArg };
|
|
10
|
-
static description =
|
|
11
|
-
|
|
10
|
+
static description = `Show current values, rules, and evaluation stats for a flag or config.
|
|
11
|
+
|
|
12
|
+
Output shows the value for each environment:
|
|
13
|
+
- A simple value (true / false / "hello") means a single unconditional rule.
|
|
14
|
+
- "[see rules]" means the environment has targeting rules or a percentage rollout.
|
|
15
|
+
- "[inherit]" means the environment falls back to the Default value.
|
|
16
|
+
|
|
17
|
+
Percentage rollouts are displayed as "20.0% true, 80.0% false".
|
|
18
|
+
Evaluation counts for the past 24 hours are included by default.
|
|
19
|
+
|
|
20
|
+
Related commands:
|
|
21
|
+
qfg set-default my.flag --environment production --value true # set a scalar fallback
|
|
22
|
+
qfg set-rollout my.flag --environment production --true-percent 20 # set a % rollout`;
|
|
23
|
+
static examples = [
|
|
24
|
+
'<%= config.bin %> <%= command.id %> my.config.name',
|
|
25
|
+
'<%= config.bin %> <%= command.id %> my.config.name --exclude-evaluations # skip 24h stats',
|
|
26
|
+
];
|
|
12
27
|
static flags = {
|
|
13
28
|
'exclude-evaluations': Flags.boolean({ default: false, description: 'Exclude evaluation data' }),
|
|
14
29
|
};
|
package/dist/commands/login.js
CHANGED
|
@@ -10,7 +10,8 @@ export default class Login extends BaseCommand {
|
|
|
10
10
|
static flags = {
|
|
11
11
|
profile: Flags.string({
|
|
12
12
|
char: 'p',
|
|
13
|
-
description: 'Profile name to
|
|
13
|
+
description: 'Profile name to save credentials under (advanced; defaults to "default")',
|
|
14
|
+
hidden: true,
|
|
14
15
|
}),
|
|
15
16
|
};
|
|
16
17
|
async run() {
|
|
@@ -56,6 +57,8 @@ export default class Login extends BaseCommand {
|
|
|
56
57
|
let workspaceName;
|
|
57
58
|
let workspaceSlug;
|
|
58
59
|
let organizationName;
|
|
60
|
+
let organizationId;
|
|
61
|
+
let multipleWorkspacesAvailable = false;
|
|
59
62
|
try {
|
|
60
63
|
const apiUrl = getApiUrl();
|
|
61
64
|
this.verboseLog('Resolving workspace...', { apiUrl, orgId });
|
|
@@ -70,17 +73,15 @@ export default class Login extends BaseCommand {
|
|
|
70
73
|
if (res.ok) {
|
|
71
74
|
const body = await res.json();
|
|
72
75
|
const allWorkspaces = (body.json ?? body);
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
: [];
|
|
76
|
-
const candidates = orgWorkspaces.length > 0 ? orgWorkspaces : (Array.isArray(allWorkspaces) ? allWorkspaces : []);
|
|
76
|
+
const candidates = Array.isArray(allWorkspaces) ? allWorkspaces : [];
|
|
77
|
+
multipleWorkspacesAvailable = candidates.length > 1;
|
|
77
78
|
let match;
|
|
78
79
|
if (candidates.length === 1) {
|
|
79
80
|
match = candidates[0];
|
|
80
81
|
}
|
|
81
82
|
else if (candidates.length > 1) {
|
|
82
83
|
const chosen = await select({
|
|
83
|
-
choices: candidates.map((w) => ({ name: w.workspaceSlug
|
|
84
|
+
choices: candidates.map((w) => ({ name: `${w.organizationName} / ${w.workspaceSlug}`, value: w.workspaceId })),
|
|
84
85
|
message: 'Select workspace:',
|
|
85
86
|
});
|
|
86
87
|
match = candidates.find((w) => w.workspaceId === chosen);
|
|
@@ -90,6 +91,7 @@ export default class Login extends BaseCommand {
|
|
|
90
91
|
workspaceName = match.workspaceSlug;
|
|
91
92
|
workspaceSlug = match.workspaceSlug;
|
|
92
93
|
organizationName = match.organizationName;
|
|
94
|
+
organizationId = match.workosOrgId;
|
|
93
95
|
this.verboseLog('Resolved workspace', { workspaceId, workspaceName, workspaceSlug, organizationName });
|
|
94
96
|
}
|
|
95
97
|
}
|
|
@@ -113,33 +115,35 @@ export default class Login extends BaseCommand {
|
|
|
113
115
|
workspace: workspaceId || orgId || 'unknown',
|
|
114
116
|
workspaceName: workspaceName || (orgId ? `org:${orgId}` : undefined),
|
|
115
117
|
workspaceSlug,
|
|
118
|
+
organizationName,
|
|
116
119
|
},
|
|
117
120
|
},
|
|
118
121
|
});
|
|
119
|
-
this.log(`\nProfile '${profileName}' configured.`);
|
|
120
|
-
if (shouldSetDefault) {
|
|
121
|
-
this.log('Set as default profile.');
|
|
122
|
-
}
|
|
123
122
|
this.log('\nSuccessfully logged in!');
|
|
124
123
|
if (userEmail) {
|
|
125
124
|
this.log(`Logged in as: ${userEmail}`);
|
|
126
125
|
}
|
|
127
126
|
if (workspaceName) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
const orgPrefix = organizationName ? `${organizationName} / ` : '';
|
|
128
|
+
this.log(`Workspace: ${orgPrefix}${workspaceName}`);
|
|
129
|
+
if (workspaceId) {
|
|
130
|
+
this.log(` (${workspaceId})`);
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
else if (orgId) {
|
|
134
134
|
this.log(`Organization: ${orgId}`);
|
|
135
135
|
}
|
|
136
|
+
if (multipleWorkspacesAvailable) {
|
|
137
|
+
this.log(`\nTo switch workspaces: qfg workspace switch`);
|
|
138
|
+
}
|
|
136
139
|
return {
|
|
137
140
|
email: userEmail,
|
|
138
|
-
organizationId: orgId,
|
|
139
|
-
|
|
141
|
+
organizationId: organizationId || orgId,
|
|
142
|
+
organizationName,
|
|
140
143
|
success: true,
|
|
141
144
|
userId: user.id,
|
|
142
145
|
workspaceId,
|
|
146
|
+
workspaceSlug,
|
|
143
147
|
};
|
|
144
148
|
}
|
|
145
149
|
}
|
package/dist/commands/profile.js
CHANGED
|
@@ -1,47 +1,11 @@
|
|
|
1
|
-
import { select } from '@inquirer/prompts';
|
|
2
1
|
import { BaseCommand } from '../index.js';
|
|
3
|
-
import
|
|
2
|
+
import WorkspaceSwitch from './workspace/switch.js';
|
|
4
3
|
export default class Profile extends BaseCommand {
|
|
5
|
-
static description = '
|
|
6
|
-
static
|
|
4
|
+
static description = 'Deprecated. Use `qfg workspace switch` instead.';
|
|
5
|
+
static hidden = true;
|
|
6
|
+
static examples = ['<%= config.bin %> workspace switch'];
|
|
7
7
|
async run() {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return this.err('Not logged in. Please run `qfg login` first.');
|
|
11
|
-
}
|
|
12
|
-
const currentDefault = authConfig.defaultProfile || 'default';
|
|
13
|
-
// If only one profile, just show it
|
|
14
|
-
if (Object.keys(authConfig.profiles).length === 1) {
|
|
15
|
-
const profileName = Object.keys(authConfig.profiles)[0];
|
|
16
|
-
const profile = authConfig.profiles[profileName];
|
|
17
|
-
this.log(`Current default profile: ${profileName}`);
|
|
18
|
-
this.log(`Workspace: ${profile.workspaceName || profile.workspace}`);
|
|
19
|
-
this.log('\nYou only have one profile.');
|
|
20
|
-
return {
|
|
21
|
-
defaultProfile: profileName,
|
|
22
|
-
profiles: Object.keys(authConfig.profiles),
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
// Show current default
|
|
26
|
-
this.log(`Current default profile: ${currentDefault}\n`);
|
|
27
|
-
// Let user select a new default profile
|
|
28
|
-
const newDefault = await select({
|
|
29
|
-
choices: Object.entries(authConfig.profiles).map(([name, profile]) => ({
|
|
30
|
-
name: `${name} (${profile.workspaceName || profile.workspace})`,
|
|
31
|
-
value: name,
|
|
32
|
-
})),
|
|
33
|
-
message: 'Select default profile:',
|
|
34
|
-
});
|
|
35
|
-
// Update the default profile
|
|
36
|
-
await saveAuthConfig({
|
|
37
|
-
...authConfig,
|
|
38
|
-
defaultProfile: newDefault,
|
|
39
|
-
});
|
|
40
|
-
this.log(`\nDefault profile set to: ${newDefault}`);
|
|
41
|
-
return {
|
|
42
|
-
defaultProfile: newDefault,
|
|
43
|
-
previousDefault: currentDefault,
|
|
44
|
-
success: true,
|
|
45
|
-
};
|
|
8
|
+
this.log('Note: `qfg profile` is deprecated. Use `qfg workspace switch` instead.\n');
|
|
9
|
+
return WorkspaceSwitch.run([], this.config);
|
|
46
10
|
}
|
|
47
11
|
}
|
package/dist/commands/pull.js
CHANGED
|
@@ -7,7 +7,22 @@ import { loadGiteaToken, isGiteaTokenExpired, saveGiteaToken } from '../util/git
|
|
|
7
7
|
import { mintAndStoreGiteaReadToken, mintGiteaToken } from '../util/gitea-api.js';
|
|
8
8
|
import { isGitRepo, getRemoteUrl, isWorkingTreeClean, gitFetch, gitSetRemote, canFastForward, hasDivergedFromRemote, gitMergeFfOnly, gitClone, displayUrl, } from '../util/git-ops.js';
|
|
9
9
|
export default class Pull extends BaseCommand {
|
|
10
|
-
static description =
|
|
10
|
+
static description = `Clone or update a local copy of your workspace config files.
|
|
11
|
+
|
|
12
|
+
Use this when you need to edit flag JSON directly — for complex targeting rules,
|
|
13
|
+
multi-rule configs, or anything beyond a single scalar value.
|
|
14
|
+
|
|
15
|
+
For the config file format, operator reference, and examples:
|
|
16
|
+
qfg config-schema # human-readable reference
|
|
17
|
+
qfg config-schema --json-schema # machine-readable JSON Schema
|
|
18
|
+
|
|
19
|
+
After editing files:
|
|
20
|
+
qfg verify <dir> # validate JSON before pushing
|
|
21
|
+
git -C <dir> add -A && git -C <dir> commit -m "feat: ..." && git -C <dir> push
|
|
22
|
+
|
|
23
|
+
CLI shortcuts (no JSON editing needed for simple cases):
|
|
24
|
+
qfg set-rollout my.flag --environment production --true-percent 20
|
|
25
|
+
qfg set-default my.flag --environment production --value true`;
|
|
11
26
|
static examples = [
|
|
12
27
|
'<%= config.bin %> pull --dir ./our-config',
|
|
13
28
|
'<%= config.bin %> pull # uses QUONFIG_DIR env var',
|
|
@@ -14,12 +14,29 @@ import nameArg from '../util/name-arg.js';
|
|
|
14
14
|
import secretFlags, { parsedSecretFlags } from '../util/secret-flags.js';
|
|
15
15
|
export default class SetDefault extends APICommand {
|
|
16
16
|
static args = { ...nameArg };
|
|
17
|
-
static description =
|
|
17
|
+
static description = `Set the unconditional fallback value for a flag or config in a specific environment.
|
|
18
|
+
|
|
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.
|
|
22
|
+
|
|
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
|
+
|
|
27
|
+
To set a percentage rollout (gradual rollout / A/B test / canary deploy) instead:
|
|
28
|
+
qfg set-rollout my.flag --environment production --true-percent 20
|
|
29
|
+
|
|
30
|
+
To see all current values and rules for a flag:
|
|
31
|
+
qfg info my.flag`;
|
|
18
32
|
static examples = [
|
|
19
|
-
'<%= config.bin %> <%= command.id %> my.flag.name
|
|
33
|
+
'<%= config.bin %> <%= command.id %> my.flag.name # prompts for value and env',
|
|
20
34
|
'<%= 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',
|
|
21
36
|
'<%= config.bin %> <%= command.id %> my.flag.name --value=true --secret',
|
|
22
37
|
'<%= config.bin %> <%= command.id %> my.config.name --env-var=MY_ENV_VAR_NAME --environment=production',
|
|
38
|
+
'# For a percentage rollout, use set-rollout instead:',
|
|
39
|
+
'<%= config.bin %> set-rollout my.flag.name --environment production --true-percent 20',
|
|
23
40
|
];
|
|
24
41
|
static flags = {
|
|
25
42
|
confidential: Flags.boolean({ default: false, description: 'mark the value as confidential' }),
|
|
@@ -123,7 +140,6 @@ export default class SetDefault extends APICommand {
|
|
|
123
140
|
config,
|
|
124
141
|
envVar: flags['env-var'],
|
|
125
142
|
environment,
|
|
126
|
-
environmentId: environment.id,
|
|
127
143
|
key,
|
|
128
144
|
secret,
|
|
129
145
|
});
|
|
@@ -147,13 +163,12 @@ export default class SetDefault extends APICommand {
|
|
|
147
163
|
confidential,
|
|
148
164
|
config,
|
|
149
165
|
environment,
|
|
150
|
-
environmentId: environment.id,
|
|
151
166
|
key,
|
|
152
167
|
secret,
|
|
153
168
|
value,
|
|
154
169
|
});
|
|
155
170
|
}
|
|
156
|
-
async submitChange({ confidential, config, envVar, environment,
|
|
171
|
+
async submitChange({ confidential, config, envVar, environment, key, secret, value, }) {
|
|
157
172
|
/* eslint-disable camelcase */
|
|
158
173
|
const typeMapping = {
|
|
159
174
|
bool: 'bool',
|
|
@@ -174,7 +189,7 @@ export default class SetDefault extends APICommand {
|
|
|
174
189
|
successMessage = `Successfully changed default to \`${value}\``;
|
|
175
190
|
if (secret.selected) {
|
|
176
191
|
// Handle encrypted values using shared utility
|
|
177
|
-
const encryptedValueResult = await makeConfidentialValue(this, value, secret,
|
|
192
|
+
const encryptedValueResult = await makeConfidentialValue(this, value, secret, environment.id);
|
|
178
193
|
if (!encryptedValueResult.ok) {
|
|
179
194
|
return this.err(encryptedValueResult.message || 'Failed to encrypt value');
|
|
180
195
|
}
|
|
@@ -244,19 +259,67 @@ export default class SetDefault extends APICommand {
|
|
|
244
259
|
configValue.confidential = true;
|
|
245
260
|
successMessage += ' (confidential)';
|
|
246
261
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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 };
|
|
290
|
+
}
|
|
291
|
+
this.verboseLog('Update fields:', JSON.stringify(updateFields, null, 2));
|
|
292
|
+
// Route to the correct update endpoint based on config type.
|
|
293
|
+
let request;
|
|
294
|
+
if (config.type === 'feature_flag') {
|
|
295
|
+
request = await this.apiClient.post('/api/v1/flags/update', {
|
|
296
|
+
workspaceId: this.workspaceId,
|
|
297
|
+
flagKey: key,
|
|
298
|
+
flag: updateFields,
|
|
299
|
+
expectedCommitSha: currentConfig.commitSha,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
else if (config.type === 'log_level') {
|
|
303
|
+
request = await this.apiClient.post('/api/v1/logLevels/update', {
|
|
304
|
+
workspaceId: this.workspaceId,
|
|
305
|
+
logLevelKey: key,
|
|
306
|
+
logLevel: updateFields,
|
|
307
|
+
expectedCommitSha: currentConfig.commitSha,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
request = await this.apiClient.post('/api/v1/configs/update', {
|
|
312
|
+
workspaceId: this.workspaceId,
|
|
313
|
+
configKey: key,
|
|
314
|
+
config: updateFields,
|
|
315
|
+
expectedCommitSha: currentConfig.commitSha,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
255
318
|
if (request.ok) {
|
|
256
319
|
this.log(`${checkmark} ${successMessage}`);
|
|
257
320
|
return {
|
|
258
321
|
environment: {
|
|
259
|
-
id:
|
|
322
|
+
id: environment.id,
|
|
260
323
|
name: environment.name,
|
|
261
324
|
},
|
|
262
325
|
key,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { APICommand } from '../index.js';
|
|
2
|
+
import { JsonObj } from '../result.js';
|
|
3
|
+
export default class SetRollout extends APICommand {
|
|
4
|
+
static args: {
|
|
5
|
+
name: import("@oclif/core/lib/interfaces/parser.js").Arg<string | undefined, Record<string, unknown>>;
|
|
6
|
+
};
|
|
7
|
+
static description: string;
|
|
8
|
+
static examples: string[];
|
|
9
|
+
static flags: {
|
|
10
|
+
environment: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
11
|
+
'hash-by': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
12
|
+
'true-percent': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
13
|
+
weights: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
14
|
+
confirm: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
15
|
+
};
|
|
16
|
+
run(): Promise<JsonObj | void>;
|
|
17
|
+
private parseWeights;
|
|
18
|
+
private coerceValue;
|
|
19
|
+
}
|