@quonfig/cli 0.0.19 → 0.0.21
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 +76 -59
- package/dist/codegen/code-generators/node-typescript-generator.js +2 -2
- package/dist/codegen/code-generators/react-typescript-generator.js +2 -2
- package/dist/codegen/fetch-workspace-snapshot.d.ts +18 -0
- package/dist/codegen/fetch-workspace-snapshot.js +52 -0
- package/dist/codegen/local-config-reader.js +6 -1
- package/dist/commands/create.js +3 -1
- package/dist/commands/generate.d.ts +1 -0
- package/dist/commands/generate.js +33 -4
- package/dist/commands/login.js +36 -20
- package/dist/commands/override.d.ts +11 -5
- package/dist/commands/override.js +244 -148
- package/dist/commands/set-default.js +1 -1
- package/dist/util/coerce.js +21 -1
- package/dist/util/config-value-dto.js +3 -1
- package/dist/util/encryption.d.ts +5 -2
- package/dist/util/encryption.js +11 -6
- package/dist/util/resolve-workspace.js +69 -3
- package/dist/util/token-storage.d.ts +4 -2
- package/dist/util/token-storage.js +27 -0
- package/oclif.manifest.json +34 -20
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import * as path from 'node:path';
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { NodeTypeScriptGenerator } from '../codegen/code-generators/node-typescript-generator.js';
|
|
6
6
|
import { ReactTypeScriptGenerator } from '../codegen/code-generators/react-typescript-generator.js';
|
|
7
|
+
import { fetchWorkspaceSnapshot } from '../codegen/fetch-workspace-snapshot.js';
|
|
7
8
|
import { LocalConfigReader } from '../codegen/local-config-reader.js';
|
|
8
9
|
import { SupportedLanguage } from '../codegen/types.js';
|
|
9
10
|
import { BaseCommand } from '../index.js';
|
|
@@ -109,7 +110,7 @@ Example quonfig.config.json:
|
|
|
109
110
|
];
|
|
110
111
|
static flags = {
|
|
111
112
|
dir: Flags.string({
|
|
112
|
-
description: 'Path to local QUONFIG_DIR (defaults to QUONFIG_DIR env var)',
|
|
113
|
+
description: 'Path to local QUONFIG_DIR (defaults to QUONFIG_DIR env var). When omitted, fetches the workspace from the server using your active credentials.',
|
|
113
114
|
env: 'QUONFIG_DIR',
|
|
114
115
|
}),
|
|
115
116
|
'output-directory': Flags.string({
|
|
@@ -120,16 +121,25 @@ Example quonfig.config.json:
|
|
|
120
121
|
default: SupportedLanguage.React,
|
|
121
122
|
description: `Determines for language/framework to generate code for (${Object.values(SupportedLanguage).join(', ')})`,
|
|
122
123
|
}),
|
|
124
|
+
workspace: Flags.string({
|
|
125
|
+
char: 'w',
|
|
126
|
+
description: 'Workspace slug or UUID for the remote-fetch path (defaults to QUONFIG_WORKSPACE env var or active profile). Only used when --dir is omitted.',
|
|
127
|
+
}),
|
|
123
128
|
};
|
|
124
129
|
static summary = 'Generate type definitions for your Quonfig configuration';
|
|
125
130
|
async run() {
|
|
126
131
|
const { flags } = await this.parse(Generate);
|
|
127
132
|
this.verboseLog('=== GENERATE COMMAND START ===');
|
|
133
|
+
let snapshot;
|
|
128
134
|
try {
|
|
129
|
-
//
|
|
130
|
-
|
|
135
|
+
// When --dir/QUONFIG_DIR is set, read the local checkout (so users
|
|
136
|
+
// who edit flag JSON in place keep that workflow). Otherwise clone
|
|
137
|
+
// the workspace into a tmp dir for codegen-only callers.
|
|
138
|
+
let dir = flags.dir;
|
|
131
139
|
if (!dir) {
|
|
132
|
-
this.
|
|
140
|
+
this.verboseLog('No --dir/QUONFIG_DIR set; fetching workspace snapshot from server...');
|
|
141
|
+
snapshot = await fetchWorkspaceSnapshot(this, { workspace: flags.workspace });
|
|
142
|
+
dir = snapshot.dir;
|
|
133
143
|
}
|
|
134
144
|
// Look for and read local quonfig.config.json file
|
|
135
145
|
const localConfig = await this.readLocalConfig(flags['output-directory']);
|
|
@@ -142,6 +152,7 @@ Example quonfig.config.json:
|
|
|
142
152
|
const configFile = await reader.read();
|
|
143
153
|
this.verboseLog(`Config read complete. Found ${configFile.configs.length} configs.`);
|
|
144
154
|
const fileCreationPromises = [];
|
|
155
|
+
let needsMustache = false;
|
|
145
156
|
for (const target of targets) {
|
|
146
157
|
// Resolve the language input
|
|
147
158
|
const language = this.resolveLanguage(target);
|
|
@@ -154,6 +165,9 @@ Example quonfig.config.json:
|
|
|
154
165
|
console.log(`Generating ${language} code for configs...`);
|
|
155
166
|
const generatedCode = generator.generate();
|
|
156
167
|
this.verboseLog(`Code generation complete. Size: ${generatedCode.length}`);
|
|
168
|
+
if (generatedCode.includes("from 'mustache'")) {
|
|
169
|
+
needsMustache = true;
|
|
170
|
+
}
|
|
157
171
|
const fileManager = createFileManager({ outputDirectory: outputDir, verboseLog: this.verboseLog.bind(this) });
|
|
158
172
|
fileCreationPromises.push(fileManager.writeFile({ data: generatedCode, filename: targetConfig.clientFileName }));
|
|
159
173
|
if ([SupportedLanguage.Node, SupportedLanguage.React].includes(language)) {
|
|
@@ -163,11 +177,26 @@ Example quonfig.config.json:
|
|
|
163
177
|
}
|
|
164
178
|
}
|
|
165
179
|
await Promise.all(fileCreationPromises);
|
|
180
|
+
if (needsMustache) {
|
|
181
|
+
console.log('\nNote: generated code imports `mustache` for templated config values.\n' +
|
|
182
|
+
' Add it to your project: `npm install mustache @types/mustache`\n' +
|
|
183
|
+
' (or pnpm/yarn equivalent).');
|
|
184
|
+
}
|
|
166
185
|
}
|
|
167
186
|
catch (error) {
|
|
168
187
|
console.error('ERROR:', error);
|
|
169
188
|
this.error(error);
|
|
170
189
|
}
|
|
190
|
+
finally {
|
|
191
|
+
if (snapshot) {
|
|
192
|
+
try {
|
|
193
|
+
await snapshot.cleanup();
|
|
194
|
+
}
|
|
195
|
+
catch (cleanupError) {
|
|
196
|
+
this.verboseLog('Generate', `Snapshot cleanup failed: ${String(cleanupError)}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
171
200
|
this.verboseLog('=== GENERATE COMMAND END ===');
|
|
172
201
|
return { success: true };
|
|
173
202
|
}
|
package/dist/commands/login.js
CHANGED
|
@@ -3,7 +3,7 @@ import { select } from '@inquirer/prompts';
|
|
|
3
3
|
import { BaseCommand } from '../index.js';
|
|
4
4
|
import { decodeJWT, pollForToken, requestDeviceCode } from '../util/oauth-client.js';
|
|
5
5
|
import { getApiUrl } from '../util/domain-urls.js';
|
|
6
|
-
import { loadAuthConfig, saveAuthConfig, saveTokens } from '../util/token-storage.js';
|
|
6
|
+
import { getAuthConfigFilePath, getTokenFilePath, loadAuthConfig, saveAuthConfig, saveTokens, } from '../util/token-storage.js';
|
|
7
7
|
export default class Login extends BaseCommand {
|
|
8
8
|
static description = 'Log in to Quonfig via WorkOS device authorization';
|
|
9
9
|
static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --profile myprofile'];
|
|
@@ -47,14 +47,22 @@ export default class Login extends BaseCommand {
|
|
|
47
47
|
if (typeof jwtPayload.exp === 'number') {
|
|
48
48
|
expiresAt = jwtPayload.exp * 1000;
|
|
49
49
|
}
|
|
50
|
-
// Save tokens
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
// Save tokens — verifies the file actually persisted before returning.
|
|
51
|
+
this.verboseLog('Saving tokens to', getTokenFilePath());
|
|
52
|
+
let tokensPath;
|
|
53
|
+
try {
|
|
54
|
+
tokensPath = await saveTokens({
|
|
55
|
+
accessToken: tokenResponse.access_token,
|
|
56
|
+
expiresAt,
|
|
57
|
+
refreshToken: tokenResponse.refresh_token,
|
|
58
|
+
userEmail,
|
|
59
|
+
userId: user.id,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
return this.err(`Login failed: could not save tokens. ${error.message}`);
|
|
64
|
+
}
|
|
65
|
+
this.verboseLog('Tokens saved at', tokensPath);
|
|
58
66
|
// Resolve org_id → workspace UUID via the API
|
|
59
67
|
let workspaceId;
|
|
60
68
|
let workspaceName;
|
|
@@ -113,18 +121,26 @@ export default class Login extends BaseCommand {
|
|
|
113
121
|
// Save auth config
|
|
114
122
|
const isFirstProfile = !existingConfig || Object.keys(existingConfig.profiles).length === 0;
|
|
115
123
|
const shouldSetDefault = isFirstProfile || profileName === 'default' || !existingConfig?.defaultProfile;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
this.verboseLog('Saving auth config to', getAuthConfigFilePath());
|
|
125
|
+
let configPath;
|
|
126
|
+
try {
|
|
127
|
+
configPath = await saveAuthConfig({
|
|
128
|
+
defaultProfile: shouldSetDefault ? profileName : existingConfig?.defaultProfile,
|
|
129
|
+
profiles: {
|
|
130
|
+
...existingConfig?.profiles,
|
|
131
|
+
[profileName]: {
|
|
132
|
+
workspace: workspaceId || orgId || 'unknown',
|
|
133
|
+
workspaceName: workspaceName || (orgId ? `org:${orgId}` : undefined),
|
|
134
|
+
workspaceSlug,
|
|
135
|
+
organizationName,
|
|
136
|
+
},
|
|
125
137
|
},
|
|
126
|
-
}
|
|
127
|
-
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
return this.err(`Login failed: could not save auth config. ${error.message}`);
|
|
142
|
+
}
|
|
143
|
+
this.verboseLog('Auth config saved at', configPath);
|
|
128
144
|
this.log('\nSuccessfully logged in!');
|
|
129
145
|
if (userEmail) {
|
|
130
146
|
this.log(`Logged in as: ${userEmail}`);
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { APICommand } from '../index.js';
|
|
2
|
-
import { JsonObj } from '../result.js';
|
|
2
|
+
import type { JsonObj } from '../result.js';
|
|
3
3
|
export default class Override extends APICommand {
|
|
4
4
|
static args: {
|
|
5
5
|
name: import("@oclif/core/lib/interfaces/parser.js").Arg<string | undefined, Record<string, unknown>>;
|
|
6
|
+
value: import("@oclif/core/lib/interfaces/parser.js").Arg<string | undefined, Record<string, unknown>>;
|
|
6
7
|
};
|
|
7
8
|
static description: string;
|
|
8
9
|
static examples: string[];
|
|
9
10
|
static flags: {
|
|
10
|
-
|
|
11
|
+
clear: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
12
|
+
env: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
11
13
|
remove: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
12
|
-
value: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
13
14
|
};
|
|
14
15
|
run(): Promise<JsonObj | void>;
|
|
15
|
-
private
|
|
16
|
-
private
|
|
16
|
+
private callFindOrCreate;
|
|
17
|
+
private callRemove;
|
|
18
|
+
private fetchFlag;
|
|
19
|
+
private runClear;
|
|
20
|
+
private runList;
|
|
21
|
+
private runRemove;
|
|
22
|
+
private runSet;
|
|
17
23
|
}
|
|
@@ -1,173 +1,269 @@
|
|
|
1
|
-
import { Flags } from '@oclif/core';
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { APICommand } from '../index.js';
|
|
3
|
-
import getEnvironment from '../ui/get-environment.js';
|
|
4
|
-
import getString from '../ui/get-string.js';
|
|
5
|
-
import autocomplete from '../util/autocomplete.js';
|
|
6
3
|
import { checkmark } from '../util/color.js';
|
|
7
|
-
import
|
|
8
|
-
|
|
4
|
+
import { loadTokens } from '../util/token-storage.js';
|
|
5
|
+
const OVERRIDE_PROP = 'quonfig-user.email';
|
|
6
|
+
const OVERRIDE_OP = 'PROP_IS_ONE_OF';
|
|
7
|
+
/**
|
|
8
|
+
* Parse the fresh SHA out of a 409 conflict message of the form
|
|
9
|
+
* "<path> was modified (expected <oldSha>, got <newSha>)"
|
|
10
|
+
* Returns undefined if the message doesn't match.
|
|
11
|
+
*/
|
|
12
|
+
function parseFreshShaFromConflict(message) {
|
|
13
|
+
if (!message)
|
|
14
|
+
return undefined;
|
|
15
|
+
const match = message.match(/got\s+([\w.-]+)\)/);
|
|
16
|
+
return match?.[1];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Coerce a raw CLI value string into the typed Value the server expects.
|
|
20
|
+
* Order matters: bool → int → double → json → string.
|
|
21
|
+
*/
|
|
22
|
+
function inferValue(raw) {
|
|
23
|
+
if (raw === 'true' || raw === 'false') {
|
|
24
|
+
return { type: 'bool', value: raw === 'true' };
|
|
25
|
+
}
|
|
26
|
+
if (/^-?\d+$/.test(raw)) {
|
|
27
|
+
return { type: 'int', value: Number.parseInt(raw, 10) };
|
|
28
|
+
}
|
|
29
|
+
if (/^-?\d+\.\d+$/.test(raw)) {
|
|
30
|
+
return { type: 'double', value: Number.parseFloat(raw) };
|
|
31
|
+
}
|
|
32
|
+
const trimmed = raw.trim();
|
|
33
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
34
|
+
try {
|
|
35
|
+
return { type: 'json', value: JSON.parse(trimmed) };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Fall through to string — looked like JSON but didn't parse.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { type: 'string', value: raw };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Find the existing override rule for a user in a specific env. Returns the
|
|
45
|
+
* value the user is currently overridden to, or undefined.
|
|
46
|
+
*/
|
|
47
|
+
function findExistingOverride(flag, env, userEmail) {
|
|
48
|
+
const envEntry = flag.environments?.find((e) => e.id === env);
|
|
49
|
+
for (const rule of envEntry?.rules ?? []) {
|
|
50
|
+
if (!rule.criteria || rule.criteria.length !== 1)
|
|
51
|
+
continue;
|
|
52
|
+
const c = rule.criteria[0];
|
|
53
|
+
if (c.propertyName !== OVERRIDE_PROP || c.operator !== OVERRIDE_OP)
|
|
54
|
+
continue;
|
|
55
|
+
const emails = c.valueToMatch?.value ?? [];
|
|
56
|
+
if (emails.includes(userEmail) && rule.value) {
|
|
57
|
+
return rule.value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
function valuesEqual(a, b) {
|
|
63
|
+
if (a.type !== b.type)
|
|
64
|
+
return false;
|
|
65
|
+
return JSON.stringify(a.value) === JSON.stringify(b.value);
|
|
66
|
+
}
|
|
9
67
|
export default class Override extends APICommand {
|
|
10
|
-
static args = {
|
|
11
|
-
|
|
68
|
+
static args = {
|
|
69
|
+
name: Args.string({ description: 'flag/config key to override' }),
|
|
70
|
+
value: Args.string({ description: 'new value (type inferred: bool/int/double/json/string)' }),
|
|
71
|
+
};
|
|
72
|
+
static description = `Override a flag value for your dev user.
|
|
12
73
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
74
|
+
Writes a top-priority rule keyed on the dev-only quonfig-user.email property,
|
|
75
|
+
so the override only fires for SDK clients that set quonfig-user.email in
|
|
76
|
+
their context. Production SDKs typically don't set this property, which makes
|
|
77
|
+
overrides effectively inert in production.
|
|
78
|
+
|
|
79
|
+
Examples
|
|
80
|
+
qfg override # list flags where you have an override
|
|
81
|
+
qfg override my.flag true # set bool override
|
|
82
|
+
qfg override my.flag 42 # set int override
|
|
83
|
+
qfg override my.flag '{"a":1}' # set json override
|
|
84
|
+
qfg override my.flag --remove # remove your override on my.flag
|
|
85
|
+
qfg override --clear # remove ALL of your overrides in this env`;
|
|
17
86
|
static examples = [
|
|
18
|
-
'<%= config.bin %> <%= command.id %>
|
|
19
|
-
'<%= config.bin %> <%= command.id %> my.flag
|
|
20
|
-
'<%= config.bin %> <%= command.id %> my.flag
|
|
21
|
-
'<%= config.bin %> <%= command.id %>
|
|
87
|
+
'<%= config.bin %> <%= command.id %>',
|
|
88
|
+
'<%= config.bin %> <%= command.id %> my.flag true',
|
|
89
|
+
'<%= config.bin %> <%= command.id %> my.flag --remove',
|
|
90
|
+
'<%= config.bin %> <%= command.id %> --clear',
|
|
91
|
+
'<%= config.bin %> <%= command.id %> my.flag true --env=staging',
|
|
22
92
|
];
|
|
23
93
|
static flags = {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
94
|
+
clear: Flags.boolean({ default: false, description: 'remove ALL of your overrides in this env' }),
|
|
95
|
+
env: Flags.string({ description: 'environment to operate in (default: $QUONFIG_ENVIRONMENT)' }),
|
|
96
|
+
remove: Flags.boolean({ default: false, description: 'remove your override on this key' }),
|
|
27
97
|
};
|
|
28
98
|
async run() {
|
|
29
99
|
const { args, flags } = await this.parse(Override);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const metadataRequest = await this.apiClient.post('/api/v1/metadata/list', { workspaceId: this.workspaceId });
|
|
35
|
-
if (!metadataRequest.ok) {
|
|
36
|
-
const errorMsg = metadataRequest.error?.error || `Failed to fetch configs: ${metadataRequest.status}`;
|
|
37
|
-
return this.err(errorMsg, { serverError: metadataRequest.error });
|
|
38
|
-
}
|
|
39
|
-
const metadataResponse = metadataRequest.json;
|
|
40
|
-
const configs = metadataResponse.configs;
|
|
41
|
-
// Get the environment
|
|
42
|
-
const environment = await getEnvironment({
|
|
43
|
-
command: this,
|
|
44
|
-
flags,
|
|
45
|
-
message: 'Which environment would you like to override in?',
|
|
46
|
-
providedEnvironment: flags.environment,
|
|
47
|
-
});
|
|
48
|
-
if (!environment) {
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
// Get the key - from args or prompt
|
|
52
|
-
let key = args.name;
|
|
53
|
-
if (!key && isInteractive(flags)) {
|
|
54
|
-
const configKeys = configs.map((c) => c.key);
|
|
55
|
-
const selectedKey = await autocomplete({
|
|
56
|
-
message: 'Which item would you like to override?',
|
|
57
|
-
source: configKeys,
|
|
58
|
-
});
|
|
59
|
-
if (selectedKey) {
|
|
60
|
-
key = selectedKey;
|
|
61
|
-
}
|
|
100
|
+
const tokens = await loadTokens();
|
|
101
|
+
const userEmail = tokens?.userEmail;
|
|
102
|
+
if (!userEmail) {
|
|
103
|
+
return this.err('Not logged in. Run `qfg login` first.');
|
|
62
104
|
}
|
|
63
|
-
|
|
64
|
-
|
|
105
|
+
const env = flags.env || process.env.QUONFIG_ENVIRONMENT;
|
|
106
|
+
if (!env) {
|
|
107
|
+
return this.err('No environment specified. Pass --env=<env> or set QUONFIG_ENVIRONMENT.');
|
|
65
108
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return this.err(`Could not find config named ${key}`);
|
|
109
|
+
if (env === 'production') {
|
|
110
|
+
this.warn('Overrides on production are inert for SDK clients that do not set quonfig-user.email (most production SDKs do not). Continuing anyway.');
|
|
69
111
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return this.removeOverride(key, environment.id, config.version);
|
|
73
|
-
}
|
|
74
|
-
// Get the value
|
|
75
|
-
let value = flags.value;
|
|
76
|
-
if (!value && isInteractive(flags)) {
|
|
77
|
-
value = await getString({
|
|
78
|
-
allowBlank: false,
|
|
79
|
-
message: 'Override value',
|
|
80
|
-
});
|
|
112
|
+
if (flags.clear) {
|
|
113
|
+
return this.runClear({ env, userEmail });
|
|
81
114
|
}
|
|
82
|
-
if (!
|
|
83
|
-
return this.
|
|
115
|
+
if (!args.name) {
|
|
116
|
+
return this.runList({ env, userEmail });
|
|
84
117
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
async removeOverride(key, environmentId, currentVersionId) {
|
|
88
|
-
const request = await this.apiClient.post('/internal/ops/v1/remove-variant', {
|
|
89
|
-
configKey: key,
|
|
90
|
-
currentVersionId,
|
|
91
|
-
environmentId,
|
|
92
|
-
});
|
|
93
|
-
if (request.ok) {
|
|
94
|
-
this.log(`${checkmark} Override removed`);
|
|
95
|
-
return;
|
|
118
|
+
if (flags.remove) {
|
|
119
|
+
return this.runRemove({ env, key: args.name, userEmail });
|
|
96
120
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
this.log(`No override found for ${key}`);
|
|
100
|
-
return;
|
|
121
|
+
if (args.value === undefined) {
|
|
122
|
+
return this.err('Missing value. Usage: qfg override <key> <value> [--env=<env>]');
|
|
101
123
|
}
|
|
102
|
-
this.
|
|
124
|
+
return this.runSet({ env, key: args.name, rawValue: args.value, userEmail });
|
|
103
125
|
}
|
|
104
|
-
async
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
switch (type) {
|
|
123
|
-
case 'stringList': {
|
|
124
|
-
variantValue = value.split(',');
|
|
125
|
-
variantType = 'string_list';
|
|
126
|
-
break;
|
|
127
|
-
}
|
|
128
|
-
case 'bool': {
|
|
129
|
-
variantValue = value.toLowerCase() === 'true';
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
132
|
-
case 'int': {
|
|
133
|
-
variantValue = Number.parseInt(value, 10);
|
|
134
|
-
break;
|
|
135
|
-
}
|
|
136
|
-
case 'double': {
|
|
137
|
-
variantValue = Number.parseFloat(value);
|
|
138
|
-
break;
|
|
126
|
+
async callFindOrCreate(opts) {
|
|
127
|
+
let sha = opts.currentSha;
|
|
128
|
+
let attempt = 0;
|
|
129
|
+
// One stale-SHA retry: parse the fresh SHA from the 409 message and try again.
|
|
130
|
+
while (attempt < 2) {
|
|
131
|
+
attempt += 1;
|
|
132
|
+
// eslint-disable-next-line no-await-in-loop
|
|
133
|
+
const res = await this.apiClient.post('/api/v1/flags/findOrCreateOverride', {
|
|
134
|
+
currentSha: sha,
|
|
135
|
+
env: opts.env,
|
|
136
|
+
flagKey: opts.key,
|
|
137
|
+
userEmail: opts.userEmail,
|
|
138
|
+
value: opts.value,
|
|
139
|
+
workspaceId: this.workspaceId,
|
|
140
|
+
});
|
|
141
|
+
if (res.ok) {
|
|
142
|
+
const commitSha = res.json?.commitSha;
|
|
143
|
+
return this.ok(`${checkmark} Set override ${opts.key}=${JSON.stringify(opts.value.value)} (${opts.value.type}) for ${opts.userEmail} in env=${opts.env} (commit ${commitSha}).`, { commitSha, env: opts.env, key: opts.key, value: opts.value });
|
|
139
144
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
if (res.status === 409 && attempt === 1) {
|
|
146
|
+
const fresh = parseFreshShaFromConflict(res.error?.message);
|
|
147
|
+
if (fresh) {
|
|
148
|
+
this.verboseLog(`Stale SHA — retrying once with fresh SHA ${fresh}`);
|
|
149
|
+
sha = fresh;
|
|
150
|
+
continue;
|
|
146
151
|
}
|
|
147
|
-
break;
|
|
148
152
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
return this.err(`Failed to set override on ${opts.key}: ${res.status} ${JSON.stringify(res.error)}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async callRemove(opts) {
|
|
157
|
+
let sha = opts.flag.commitSha ?? '';
|
|
158
|
+
let attempt = 0;
|
|
159
|
+
while (attempt < 2) {
|
|
160
|
+
attempt += 1;
|
|
161
|
+
// eslint-disable-next-line no-await-in-loop
|
|
162
|
+
const res = await this.apiClient.post('/api/v1/flags/removeOverride', {
|
|
163
|
+
currentSha: sha,
|
|
164
|
+
env: opts.env,
|
|
165
|
+
flagKey: opts.key,
|
|
166
|
+
userEmail: opts.userEmail,
|
|
167
|
+
workspaceId: this.workspaceId,
|
|
168
|
+
});
|
|
169
|
+
if (res.ok)
|
|
170
|
+
return true;
|
|
171
|
+
if (res.status === 409 && attempt === 1) {
|
|
172
|
+
const fresh = parseFreshShaFromConflict(res.error?.message);
|
|
173
|
+
if (fresh) {
|
|
174
|
+
this.verboseLog(`Stale SHA — retrying remove with fresh SHA ${fresh}`);
|
|
175
|
+
sha = fresh;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
152
178
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
179
|
+
this.err(`Failed to remove override on ${opts.key}: ${res.status} ${JSON.stringify(res.error)}`);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
async fetchFlag(key) {
|
|
185
|
+
const res = await this.apiClient.post('/api/v1/flags/getByKey', {
|
|
186
|
+
flagKey: key,
|
|
187
|
+
workspaceId: this.workspaceId,
|
|
188
|
+
});
|
|
189
|
+
if (!res.ok) {
|
|
190
|
+
if (res.status === 404)
|
|
191
|
+
return undefined;
|
|
192
|
+
this.err(`Failed to fetch flag ${key}: ${res.status} ${JSON.stringify(res.error)}`);
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
return res.json;
|
|
196
|
+
}
|
|
197
|
+
async runClear(opts) {
|
|
198
|
+
const res = await this.apiClient.post('/api/v1/flags/list', { workspaceId: this.workspaceId });
|
|
199
|
+
if (!res.ok) {
|
|
200
|
+
return this.err(`Failed to list flags: ${res.status} ${JSON.stringify(res.error)}`);
|
|
201
|
+
}
|
|
202
|
+
const flags = res.json ?? [];
|
|
203
|
+
const targets = flags.filter((f) => findExistingOverride(f, opts.env, opts.userEmail) !== undefined);
|
|
204
|
+
if (targets.length === 0) {
|
|
205
|
+
return this.ok(`No overrides to clear in env=${opts.env}.`);
|
|
206
|
+
}
|
|
207
|
+
const cleared = [];
|
|
208
|
+
for (const f of targets) {
|
|
209
|
+
// eslint-disable-next-line no-await-in-loop
|
|
210
|
+
const removed = await this.callRemove({
|
|
211
|
+
env: opts.env,
|
|
212
|
+
flag: f,
|
|
213
|
+
key: f.key,
|
|
214
|
+
userEmail: opts.userEmail,
|
|
215
|
+
});
|
|
216
|
+
if (removed)
|
|
217
|
+
cleared.push(f.key);
|
|
218
|
+
}
|
|
219
|
+
return this.ok(`${checkmark} Cleared ${cleared.length} override(s) in env=${opts.env}: ${cleared.join(', ')}`);
|
|
220
|
+
}
|
|
221
|
+
async runList(opts) {
|
|
222
|
+
const res = await this.apiClient.post('/api/v1/flags/list', { workspaceId: this.workspaceId });
|
|
223
|
+
if (!res.ok) {
|
|
224
|
+
return this.err(`Failed to list flags: ${res.status} ${JSON.stringify(res.error)}`);
|
|
225
|
+
}
|
|
226
|
+
const flags = res.json ?? [];
|
|
227
|
+
const overridden = flags.filter((f) => findExistingOverride(f, opts.env, opts.userEmail) !== undefined);
|
|
228
|
+
if (overridden.length === 0) {
|
|
229
|
+
return this.ok(`You have no overrides in env=${opts.env}.`);
|
|
230
|
+
}
|
|
231
|
+
this.log(`Your overrides in env=${opts.env}:`);
|
|
232
|
+
for (const f of overridden) {
|
|
233
|
+
const v = findExistingOverride(f, opts.env, opts.userEmail);
|
|
234
|
+
this.log(` ${f.key} = ${JSON.stringify(v?.value)} (${v?.type})`);
|
|
235
|
+
}
|
|
236
|
+
return { env: opts.env, overrides: overridden.map((f) => f.key) };
|
|
237
|
+
}
|
|
238
|
+
async runRemove(opts) {
|
|
239
|
+
const flag = await this.fetchFlag(opts.key);
|
|
240
|
+
if (!flag) {
|
|
241
|
+
return this.err(`Flag ${opts.key} not found.`);
|
|
242
|
+
}
|
|
243
|
+
const existing = findExistingOverride(flag, opts.env, opts.userEmail);
|
|
244
|
+
if (!existing) {
|
|
245
|
+
return this.ok(`You have no override on ${opts.key} in env=${opts.env}.`);
|
|
246
|
+
}
|
|
247
|
+
const removed = await this.callRemove({ env: opts.env, flag, key: opts.key, userEmail: opts.userEmail });
|
|
248
|
+
return removed ? this.ok(`${checkmark} Removed override on ${opts.key} in env=${opts.env}.`) : undefined;
|
|
249
|
+
}
|
|
250
|
+
async runSet(opts) {
|
|
251
|
+
const value = inferValue(opts.rawValue);
|
|
252
|
+
const flag = await this.fetchFlag(opts.key);
|
|
253
|
+
if (!flag) {
|
|
254
|
+
return this.err(`Flag ${opts.key} not found.`);
|
|
255
|
+
}
|
|
256
|
+
const existing = findExistingOverride(flag, opts.env, opts.userEmail);
|
|
257
|
+
if (existing && valuesEqual(existing, value)) {
|
|
258
|
+
return this.ok(`${opts.key} is already set to ${JSON.stringify(value.value)} (${value.type}) for ${opts.userEmail} in env=${opts.env}.`);
|
|
259
|
+
}
|
|
260
|
+
const sha = flag.commitSha ?? '';
|
|
261
|
+
return this.callFindOrCreate({
|
|
262
|
+
currentSha: sha,
|
|
263
|
+
env: opts.env,
|
|
264
|
+
key: opts.key,
|
|
265
|
+
userEmail: opts.userEmail,
|
|
266
|
+
value,
|
|
163
267
|
});
|
|
164
|
-
if (request.ok) {
|
|
165
|
-
this.log(`${checkmark} Override set`);
|
|
166
|
-
return { key, success: true };
|
|
167
|
-
}
|
|
168
|
-
const errMsg = request.status === 400
|
|
169
|
-
? `Failed to override value: ${request.status} -- is ${value} a valid ${type}?`
|
|
170
|
-
: `Failed to override value: ${request.status}`;
|
|
171
|
-
this.err(errMsg, { key, serverError: request.error });
|
|
172
268
|
}
|
|
173
269
|
}
|
|
@@ -192,7 +192,7 @@ To see all current values and rules for a flag:
|
|
|
192
192
|
successMessage = `Successfully changed default to \`${value}\``;
|
|
193
193
|
if (secret.selected) {
|
|
194
194
|
// Handle encrypted values using shared utility
|
|
195
|
-
const encryptedValueResult = await makeConfidentialValue(this, value, secret, environment.
|
|
195
|
+
const encryptedValueResult = await makeConfidentialValue(this, value, secret, environment.name);
|
|
196
196
|
if (!encryptedValueResult.ok) {
|
|
197
197
|
return this.err(encryptedValueResult.message || 'Failed to encrypt value');
|
|
198
198
|
}
|