@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,260 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { APICommand } from '../index.js';
|
|
3
|
+
import getConfirmation from '../ui/get-confirmation.js';
|
|
4
|
+
import getEnvironment from '../ui/get-environment.js';
|
|
5
|
+
import { checkmark } from '../util/color.js';
|
|
6
|
+
import isInteractive from '../util/is-interactive.js';
|
|
7
|
+
import nameArg from '../util/name-arg.js';
|
|
8
|
+
const WEIGHT_TOTAL = 100_000;
|
|
9
|
+
export default class SetRollout extends APICommand {
|
|
10
|
+
static args = { ...nameArg };
|
|
11
|
+
static description = `Configure a percentage rollout (gradual rollout / A/B test / canary deploy) for a flag.
|
|
12
|
+
|
|
13
|
+
The flag must already exist. Create it first if needed:
|
|
14
|
+
qfg create my.flag --type boolean-flag
|
|
15
|
+
|
|
16
|
+
Weights are specified as whole-number percentages (0–100). They must sum to 100.
|
|
17
|
+
|
|
18
|
+
For a boolean flag, use --true-percent to set the percentage of users that receive
|
|
19
|
+
true (the remainder automatically receive false):
|
|
20
|
+
qfg set-rollout my.flag --environment production --true-percent 20
|
|
21
|
+
→ 20% of users get true, 80% get false
|
|
22
|
+
|
|
23
|
+
For multi-value flags or custom splits, use --weights with a comma-separated list:
|
|
24
|
+
qfg set-rollout my.flag --environment production --weights "red:33,green:33,blue:34"
|
|
25
|
+
|
|
26
|
+
Hashing: users are bucketed by the value of hashByPropertyName (default: "user.key").
|
|
27
|
+
Use --hash-by to change which property drives the bucket assignment:
|
|
28
|
+
qfg set-rollout my.flag --environment production --true-percent 10 --hash-by user.id
|
|
29
|
+
|
|
30
|
+
For scripted / agent use, add --confirm to skip the interactive confirmation prompt.
|
|
31
|
+
|
|
32
|
+
To combine a rollout with segment targeting (e.g. beta ON, holdout OFF, everyone else 50/50),
|
|
33
|
+
you need to edit the JSON config file directly:
|
|
34
|
+
qfg config-schema # operator reference + worked multi-rule example
|
|
35
|
+
qfg pull --dir ./config # clone workspace to edit
|
|
36
|
+
|
|
37
|
+
To revert to a single value for everyone, use:
|
|
38
|
+
qfg set-default my.flag --environment production --value false
|
|
39
|
+
|
|
40
|
+
To inspect the current rollout:
|
|
41
|
+
qfg info my.flag`;
|
|
42
|
+
static examples = [
|
|
43
|
+
'<%= config.bin %> <%= command.id %> my.feature.flag --environment production --true-percent 20',
|
|
44
|
+
'<%= config.bin %> <%= command.id %> my.feature.flag --environment staging --true-percent 50',
|
|
45
|
+
'<%= config.bin %> <%= command.id %> my.feature.flag --environment production --true-percent 100 # full rollout',
|
|
46
|
+
'<%= config.bin %> <%= command.id %> my.feature.flag --environment production --true-percent 0 # kill switch (all false)',
|
|
47
|
+
'<%= config.bin %> <%= command.id %> my.variant.flag --environment production --weights "control:50,treatment:50"',
|
|
48
|
+
'<%= config.bin %> <%= command.id %> my.variant.flag --environment production --weights "a:33,b:33,c:34" --hash-by user.id',
|
|
49
|
+
];
|
|
50
|
+
static flags = {
|
|
51
|
+
environment: Flags.string({ description: 'environment to update (e.g. production, staging)' }),
|
|
52
|
+
'hash-by': Flags.string({
|
|
53
|
+
default: 'user.key',
|
|
54
|
+
description: 'user property used to assign buckets (default: user.key)',
|
|
55
|
+
}),
|
|
56
|
+
'true-percent': Flags.integer({
|
|
57
|
+
description: 'percentage of users that receive true (0–100). Remaining users receive false. Boolean flags only.',
|
|
58
|
+
max: 100,
|
|
59
|
+
min: 0,
|
|
60
|
+
}),
|
|
61
|
+
weights: Flags.string({
|
|
62
|
+
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
|
+
}),
|
|
64
|
+
confirm: Flags.boolean({ description: 'skip the interactive confirmation prompt (useful for scripts and agents)' }),
|
|
65
|
+
};
|
|
66
|
+
async run() {
|
|
67
|
+
const { args, flags } = await this.parse(SetRollout);
|
|
68
|
+
// Validate flag exclusivity
|
|
69
|
+
if (flags['true-percent'] !== undefined && flags.weights) {
|
|
70
|
+
return this.err('Use either --true-percent or --weights, not both.');
|
|
71
|
+
}
|
|
72
|
+
if (flags['true-percent'] === undefined && !flags.weights) {
|
|
73
|
+
return this.err('Either --true-percent or --weights is required.');
|
|
74
|
+
}
|
|
75
|
+
// Validate required args for non-interactive mode
|
|
76
|
+
if (!args.name && !isInteractive(flags)) {
|
|
77
|
+
return this.err("'name' argument is required when interactive mode isn't available.");
|
|
78
|
+
}
|
|
79
|
+
if (!flags.environment && !isInteractive(flags)) {
|
|
80
|
+
return this.err("'environment' is required when interactive mode isn't available.");
|
|
81
|
+
}
|
|
82
|
+
// Fetch config metadata to validate the key exists
|
|
83
|
+
const metadataRequest = await this.apiClient.post('/api/v1/metadata/list', { workspaceId: this.workspaceId });
|
|
84
|
+
if (!metadataRequest.ok) {
|
|
85
|
+
const errorMsg = metadataRequest.error?.error || `Failed to fetch configs: ${metadataRequest.status}`;
|
|
86
|
+
return this.err(errorMsg, { serverError: metadataRequest.error });
|
|
87
|
+
}
|
|
88
|
+
const metadataResponse = metadataRequest.json;
|
|
89
|
+
const configs = metadataResponse.configs;
|
|
90
|
+
let key = args.name;
|
|
91
|
+
if (!key && isInteractive(flags)) {
|
|
92
|
+
const { default: autocomplete } = await import('../util/autocomplete.js');
|
|
93
|
+
const selected = await autocomplete({
|
|
94
|
+
message: 'Which flag would you like to configure a rollout for?',
|
|
95
|
+
source: configs.map((c) => c.key),
|
|
96
|
+
});
|
|
97
|
+
if (selected)
|
|
98
|
+
key = selected;
|
|
99
|
+
}
|
|
100
|
+
if (!key) {
|
|
101
|
+
return this.err("'name' argument is required when interactive mode isn't available.");
|
|
102
|
+
}
|
|
103
|
+
const config = configs.find((c) => c.key === key);
|
|
104
|
+
if (!config) {
|
|
105
|
+
return this.err(`Could not find config named ${key}`);
|
|
106
|
+
}
|
|
107
|
+
// Get environment
|
|
108
|
+
const environment = await getEnvironment({
|
|
109
|
+
command: this,
|
|
110
|
+
flags,
|
|
111
|
+
message: 'Which environment would you like to configure the rollout for?',
|
|
112
|
+
providedEnvironment: flags.environment,
|
|
113
|
+
});
|
|
114
|
+
if (!environment)
|
|
115
|
+
return;
|
|
116
|
+
// Build weighted values
|
|
117
|
+
let weightedValues;
|
|
118
|
+
if (flags['true-percent'] !== undefined) {
|
|
119
|
+
const truePercent = flags['true-percent'];
|
|
120
|
+
const falsePercent = 100 - truePercent;
|
|
121
|
+
weightedValues = [
|
|
122
|
+
{ value: { type: 'bool', value: true }, weight: truePercent * (WEIGHT_TOTAL / 100) },
|
|
123
|
+
{ value: { type: 'bool', value: false }, weight: falsePercent * (WEIGHT_TOTAL / 100) },
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// Parse --weights "value1:N,value2:M,..."
|
|
128
|
+
const parsed = this.parseWeights(flags.weights, config.valueType);
|
|
129
|
+
if (!parsed.ok) {
|
|
130
|
+
return this.err(parsed.error);
|
|
131
|
+
}
|
|
132
|
+
weightedValues = parsed.weights;
|
|
133
|
+
}
|
|
134
|
+
const totalWeight = weightedValues.reduce((sum, wv) => sum + wv.weight, 0);
|
|
135
|
+
if (totalWeight !== WEIGHT_TOTAL) {
|
|
136
|
+
return this.err(`Weights must sum to 100. Got: ${weightedValues.map((wv) => `${wv.weight / 1000}%`).join(' + ')} = ${totalWeight / 1000}%`);
|
|
137
|
+
}
|
|
138
|
+
const rolloutValue = {
|
|
139
|
+
type: 'weighted_values',
|
|
140
|
+
value: {
|
|
141
|
+
hashByPropertyName: flags['hash-by'],
|
|
142
|
+
weightedValues,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
const rolloutDescription = weightedValues
|
|
146
|
+
.map((wv) => `${wv.weight / 1000}% → ${JSON.stringify(wv.value.value)}`)
|
|
147
|
+
.join(', ');
|
|
148
|
+
const message = `Confirm: set rollout for ${key} in ${environment.name} to [${rolloutDescription}]? yes/no`;
|
|
149
|
+
if (!(await getConfirmation({ flags, message }))) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Fetch the current full config to get commitSha and existing environments
|
|
153
|
+
const detailRequest = await this.apiClient.post('/api/v1/metadata/getByKey', {
|
|
154
|
+
workspaceId: this.workspaceId,
|
|
155
|
+
key,
|
|
156
|
+
});
|
|
157
|
+
if (!detailRequest.ok) {
|
|
158
|
+
return this.err(`Failed to fetch config details: ${detailRequest.status}`);
|
|
159
|
+
}
|
|
160
|
+
const currentConfig = detailRequest.json;
|
|
161
|
+
const newRule = { criteria: [], value: rolloutValue };
|
|
162
|
+
const envKey = environment.name;
|
|
163
|
+
const existingEnvs = currentConfig.environments ?? [];
|
|
164
|
+
const hasEnv = existingEnvs.some((e) => e.id === envKey);
|
|
165
|
+
const updatedEnvironments = hasEnv
|
|
166
|
+
? existingEnvs.map((e) => (e.id === envKey ? { ...e, rules: [newRule] } : e))
|
|
167
|
+
: [...existingEnvs, { id: envKey, rules: [newRule] }];
|
|
168
|
+
const updateFields = { environments: updatedEnvironments };
|
|
169
|
+
this.verboseLog('Update fields:', JSON.stringify(updateFields, null, 2));
|
|
170
|
+
let request;
|
|
171
|
+
if (config.type === 'feature_flag') {
|
|
172
|
+
request = await this.apiClient.post('/api/v1/flags/update', {
|
|
173
|
+
expectedCommitSha: currentConfig.commitSha,
|
|
174
|
+
flag: updateFields,
|
|
175
|
+
flagKey: key,
|
|
176
|
+
workspaceId: this.workspaceId,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
request = await this.apiClient.post('/api/v1/configs/update', {
|
|
181
|
+
config: updateFields,
|
|
182
|
+
configKey: key,
|
|
183
|
+
expectedCommitSha: currentConfig.commitSha,
|
|
184
|
+
workspaceId: this.workspaceId,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (request.ok) {
|
|
188
|
+
this.log(`${checkmark} Rollout set: ${key} in ${environment.name} → [${rolloutDescription}]`);
|
|
189
|
+
return {
|
|
190
|
+
environment: { id: environment.id, name: environment.name },
|
|
191
|
+
hashByPropertyName: flags['hash-by'],
|
|
192
|
+
key,
|
|
193
|
+
rollout: rolloutDescription,
|
|
194
|
+
success: true,
|
|
195
|
+
weightedValues,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
this.verboseLog(request.error);
|
|
199
|
+
return this.err(`Failed to set rollout: ${request.status} | ${JSON.stringify(request.error)}`, { key, serverError: request.error });
|
|
200
|
+
}
|
|
201
|
+
parseWeights(weightsStr, valueType) {
|
|
202
|
+
const pairs = weightsStr.split(',').map((s) => s.trim());
|
|
203
|
+
const weights = [];
|
|
204
|
+
let totalPercent = 0;
|
|
205
|
+
for (const pair of pairs) {
|
|
206
|
+
const colonIdx = pair.lastIndexOf(':');
|
|
207
|
+
if (colonIdx === -1) {
|
|
208
|
+
return {
|
|
209
|
+
error: `Invalid weight entry "${pair}". Expected format "value:percent" (e.g. "true:20").`,
|
|
210
|
+
ok: false,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const rawValue = pair.slice(0, colonIdx).trim();
|
|
214
|
+
const percentStr = pair.slice(colonIdx + 1).trim();
|
|
215
|
+
const percent = Number(percentStr);
|
|
216
|
+
if (Number.isNaN(percent) || percent < 0 || percent > 100) {
|
|
217
|
+
return { error: `Invalid percent "${percentStr}" in "${pair}". Must be 0–100.`, ok: false };
|
|
218
|
+
}
|
|
219
|
+
const typedValue = this.coerceValue(rawValue, valueType);
|
|
220
|
+
if (!typedValue.ok) {
|
|
221
|
+
return { error: typedValue.error, ok: false };
|
|
222
|
+
}
|
|
223
|
+
weights.push({ value: typedValue.typed, weight: percent * (WEIGHT_TOTAL / 100) });
|
|
224
|
+
totalPercent += percent;
|
|
225
|
+
}
|
|
226
|
+
if (Math.round(totalPercent) !== 100) {
|
|
227
|
+
return { error: `Weights must sum to 100. Got: ${totalPercent}%.`, ok: false };
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, weights };
|
|
230
|
+
}
|
|
231
|
+
coerceValue(raw, valueType) {
|
|
232
|
+
const type = valueType.toLowerCase();
|
|
233
|
+
switch (type) {
|
|
234
|
+
case 'bool': {
|
|
235
|
+
if (raw !== 'true' && raw !== 'false') {
|
|
236
|
+
return { error: `Invalid boolean value "${raw}". Must be "true" or "false".`, ok: false };
|
|
237
|
+
}
|
|
238
|
+
return { ok: true, typed: { type: 'bool', value: raw === 'true' } };
|
|
239
|
+
}
|
|
240
|
+
case 'int': {
|
|
241
|
+
const n = Number.parseInt(raw, 10);
|
|
242
|
+
if (Number.isNaN(n)) {
|
|
243
|
+
return { error: `Invalid int value "${raw}".`, ok: false };
|
|
244
|
+
}
|
|
245
|
+
return { ok: true, typed: { type: 'int', value: n } };
|
|
246
|
+
}
|
|
247
|
+
case 'double': {
|
|
248
|
+
const n = Number.parseFloat(raw);
|
|
249
|
+
if (Number.isNaN(n)) {
|
|
250
|
+
return { error: `Invalid double value "${raw}".`, ok: false };
|
|
251
|
+
}
|
|
252
|
+
return { ok: true, typed: { type: 'double', value: n } };
|
|
253
|
+
}
|
|
254
|
+
default: {
|
|
255
|
+
// string, json, etc — pass as-is
|
|
256
|
+
return { ok: true, typed: { type, value: raw } };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
package/dist/commands/whoami.js
CHANGED
|
@@ -36,20 +36,20 @@ export default class Whoami extends BaseCommand {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
// Display user information
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const workspaceDisplay =
|
|
42
|
-
? `${
|
|
39
|
+
const workspaceSlug = profile.workspaceSlug || profile.workspaceName;
|
|
40
|
+
const orgPrefix = profile.organizationName ? `${profile.organizationName} / ` : '';
|
|
41
|
+
const workspaceDisplay = workspaceSlug
|
|
42
|
+
? `${orgPrefix}${workspaceSlug} (${profile.workspace})`
|
|
43
43
|
: profile.workspace;
|
|
44
|
-
this.log(`
|
|
44
|
+
this.log(`Logged in as: ${userEmail || 'Unknown'}`);
|
|
45
|
+
this.log(`Workspace: ${workspaceDisplay}`);
|
|
45
46
|
return {
|
|
46
47
|
email: userEmail,
|
|
47
48
|
loggedIn: true,
|
|
48
|
-
|
|
49
|
-
profile: activeProfile,
|
|
49
|
+
organizationName: profile.organizationName,
|
|
50
50
|
userId: tokens.userId,
|
|
51
51
|
workspace: profile.workspace,
|
|
52
|
-
|
|
52
|
+
workspaceSlug,
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts';
|
|
2
|
+
import { BaseCommand } from '../../index.js';
|
|
3
|
+
import { getApiUrl } from '../../util/domain-urls.js';
|
|
4
|
+
import { getValidAccessToken } from '../../util/get-valid-token.js';
|
|
5
|
+
import { loadAuthConfig, saveAuthConfig } from '../../util/token-storage.js';
|
|
6
|
+
export default class WorkspaceSwitch extends BaseCommand {
|
|
7
|
+
static description = 'Switch to a different workspace';
|
|
8
|
+
static examples = ['<%= config.bin %> <%= command.id %>'];
|
|
9
|
+
async run() {
|
|
10
|
+
// Get a valid token — refresh silently if needed, error if not logged in
|
|
11
|
+
let accessToken;
|
|
12
|
+
try {
|
|
13
|
+
accessToken = await getValidAccessToken();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return this.err('Not logged in. Run `qfg login` first.');
|
|
17
|
+
}
|
|
18
|
+
const authConfig = await loadAuthConfig();
|
|
19
|
+
if (!authConfig) {
|
|
20
|
+
return this.err('Not logged in. Run `qfg login` first.');
|
|
21
|
+
}
|
|
22
|
+
// Fetch all workspaces the user has access to
|
|
23
|
+
const apiUrl = getApiUrl();
|
|
24
|
+
let candidates = [];
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch(`${apiUrl}/api/v1/userWorkspaces/list`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: {
|
|
29
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
30
|
+
'Content-Type': 'application/json',
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify({ json: {} }),
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
return this.err(`Failed to fetch workspaces (${res.status}). Try \`qfg login\` to re-authenticate.`);
|
|
36
|
+
}
|
|
37
|
+
const body = await res.json();
|
|
38
|
+
const all = (body.json ?? body);
|
|
39
|
+
candidates = Array.isArray(all) ? all : [];
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
return this.err(`Failed to fetch workspaces: ${String(error)}`);
|
|
43
|
+
}
|
|
44
|
+
if (candidates.length === 0) {
|
|
45
|
+
return this.err('No workspaces found for your account.');
|
|
46
|
+
}
|
|
47
|
+
// Pick workspace
|
|
48
|
+
let match;
|
|
49
|
+
if (candidates.length === 1) {
|
|
50
|
+
match = candidates[0];
|
|
51
|
+
this.log(`Only one workspace available: ${match.organizationName} / ${match.workspaceSlug}`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const chosen = await select({
|
|
55
|
+
choices: candidates.map((w) => ({
|
|
56
|
+
name: `${w.organizationName} / ${w.workspaceSlug}`,
|
|
57
|
+
value: w.workspaceId,
|
|
58
|
+
})),
|
|
59
|
+
message: 'Select workspace:',
|
|
60
|
+
});
|
|
61
|
+
const found = candidates.find((w) => w.workspaceId === chosen);
|
|
62
|
+
if (!found)
|
|
63
|
+
return this.err('No workspace selected.');
|
|
64
|
+
match = found;
|
|
65
|
+
}
|
|
66
|
+
// Save as the default profile
|
|
67
|
+
const defaultProfile = authConfig.defaultProfile || 'default';
|
|
68
|
+
await saveAuthConfig({
|
|
69
|
+
...authConfig,
|
|
70
|
+
profiles: {
|
|
71
|
+
...authConfig.profiles,
|
|
72
|
+
[defaultProfile]: {
|
|
73
|
+
workspace: match.workspaceId,
|
|
74
|
+
workspaceName: match.workspaceSlug,
|
|
75
|
+
workspaceSlug: match.workspaceSlug,
|
|
76
|
+
organizationName: match.organizationName,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
this.log(`\nSwitched to: ${match.organizationName} / ${match.workspaceSlug}`);
|
|
81
|
+
this.log(`\nTo use this workspace in a project, add to your .env:`);
|
|
82
|
+
this.log(` QUONFIG_WORKSPACE=${match.workspaceSlug}`);
|
|
83
|
+
return {
|
|
84
|
+
organizationName: match.organizationName,
|
|
85
|
+
success: true,
|
|
86
|
+
workspaceId: match.workspaceId,
|
|
87
|
+
workspaceSlug: match.workspaceSlug,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -1,38 +1,29 @@
|
|
|
1
1
|
import { BaseCommand } from '../index.js';
|
|
2
2
|
import { getActiveProfile, loadAuthConfig } from '../util/token-storage.js';
|
|
3
3
|
export default class Workspace extends BaseCommand {
|
|
4
|
-
static description = '
|
|
4
|
+
static description = 'Show the current workspace';
|
|
5
5
|
static examples = ['<%= config.bin %> <%= command.id %>'];
|
|
6
6
|
async run() {
|
|
7
7
|
const authConfig = await loadAuthConfig();
|
|
8
8
|
if (!authConfig || Object.keys(authConfig.profiles).length === 0) {
|
|
9
|
-
return this.err('Not logged in.
|
|
9
|
+
return this.err('Not logged in. Run `qfg login` first.');
|
|
10
10
|
}
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const profile = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
|
|
11
|
+
const activeProfileName = getActiveProfile();
|
|
12
|
+
const profile = authConfig.profiles[activeProfileName] || authConfig.profiles[authConfig.defaultProfile || 'default'];
|
|
14
13
|
if (!profile) {
|
|
15
|
-
return this.err('No
|
|
16
|
-
}
|
|
17
|
-
this.log(`Active profile: ${activeProfile}`);
|
|
18
|
-
this.log(`Workspace: ${profile.workspaceName || profile.workspace}`);
|
|
19
|
-
// Show all profiles if more than one
|
|
20
|
-
const profileNames = Object.keys(authConfig.profiles);
|
|
21
|
-
if (profileNames.length > 1) {
|
|
22
|
-
this.log('\nAll profiles:');
|
|
23
|
-
for (const name of profileNames) {
|
|
24
|
-
const p = authConfig.profiles[name];
|
|
25
|
-
const marker = name === activeProfile ? ' (active)' : '';
|
|
26
|
-
this.log(` ${name}: ${p.workspaceName || p.workspace}${marker}`);
|
|
27
|
-
}
|
|
28
|
-
this.log('\nTo switch, use: qfg login --profile <name>');
|
|
29
|
-
this.log('To change default: qfg profile');
|
|
14
|
+
return this.err('No workspace configured. Run `qfg login` first.');
|
|
30
15
|
}
|
|
16
|
+
const workspaceSlug = profile.workspaceSlug || profile.workspaceName || profile.workspace;
|
|
17
|
+
const orgLine = profile.organizationName ? `${profile.organizationName} / ` : '';
|
|
18
|
+
this.log(`Workspace: ${orgLine}${workspaceSlug}`);
|
|
19
|
+
this.log(`ID: ${profile.workspace}`);
|
|
20
|
+
this.log(`\nTo switch: qfg workspace switch`);
|
|
21
|
+
this.log(`To pin in a project, add to .env:`);
|
|
22
|
+
this.log(` QUONFIG_WORKSPACE=${workspaceSlug}`);
|
|
31
23
|
return {
|
|
32
|
-
|
|
33
|
-
profiles: Object.fromEntries(profileNames.map((name) => [name, authConfig.profiles[name]])),
|
|
24
|
+
organizationName: profile.organizationName,
|
|
34
25
|
workspace: profile.workspace,
|
|
35
|
-
|
|
26
|
+
workspaceSlug,
|
|
36
27
|
};
|
|
37
28
|
}
|
|
38
29
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export declare abstract class BaseCommand extends Command {
|
|
|
23
23
|
}
|
|
24
24
|
export declare abstract class APICommand extends BaseCommand {
|
|
25
25
|
static baseFlags: {
|
|
26
|
+
workspace: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
26
27
|
profile: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
27
28
|
interactive: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
28
29
|
'no-color': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
package/dist/index.js
CHANGED
|
@@ -84,10 +84,17 @@ export class BaseCommand extends Command {
|
|
|
84
84
|
export class APICommand extends BaseCommand {
|
|
85
85
|
static baseFlags = {
|
|
86
86
|
...globalFlags,
|
|
87
|
+
workspace: Flags.string({
|
|
88
|
+
char: 'w',
|
|
89
|
+
description: 'Workspace slug to use (overrides QUONFIG_WORKSPACE env var and saved default)',
|
|
90
|
+
helpGroup: 'GLOBAL',
|
|
91
|
+
required: false,
|
|
92
|
+
}),
|
|
87
93
|
profile: Flags.string({
|
|
88
94
|
char: 'p',
|
|
89
|
-
description: '
|
|
95
|
+
description: 'Deprecated: use --workspace instead',
|
|
90
96
|
helpGroup: 'GLOBAL',
|
|
97
|
+
hidden: true,
|
|
91
98
|
required: false,
|
|
92
99
|
}),
|
|
93
100
|
};
|
|
@@ -104,7 +111,7 @@ export class APICommand extends BaseCommand {
|
|
|
104
111
|
async init() {
|
|
105
112
|
await super.init();
|
|
106
113
|
const { flags } = await this.parse();
|
|
107
|
-
this.rawApiClient = await rawGetClient(this, undefined, flags.profile);
|
|
114
|
+
this.rawApiClient = await rawGetClient(this, undefined, flags.workspace ?? flags.profile);
|
|
108
115
|
// For JWT-based auth, we'll need to get environment info from the token
|
|
109
116
|
// For now, set a placeholder - this should be enhanced later
|
|
110
117
|
this.currentEnvironment = { id: 'unknown', projectId: 0 };
|
package/dist/util/get-client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Client } from '@quonfig/node';
|
|
2
2
|
import { getApiUrl } from '../util/domain-urls.js';
|
|
3
3
|
import jsonMaybe from '../util/json-maybe.js';
|
|
4
|
-
import { getActiveProfile, loadAuthConfig, loadTokens } from '../util/token-storage.js';
|
|
4
|
+
import { getActiveProfile, loadAuthConfig, loadTokens, resolveWorkspaceId } from '../util/token-storage.js';
|
|
5
5
|
import { getValidAccessToken } from '../util/get-valid-token.js';
|
|
6
6
|
import version from '../version.js';
|
|
7
7
|
let clientInstance;
|
|
@@ -39,16 +39,39 @@ const getClient = async (command, sdkKey, profile) => {
|
|
|
39
39
|
catch {
|
|
40
40
|
command.error('Session expired. Please run `qfg login` to re-authenticate.', { exit: 401 });
|
|
41
41
|
}
|
|
42
|
-
//
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
42
|
+
// Resolve workspace: --workspace/QUONFIG_WORKSPACE (slug) → QUONFIG_PROFILE (profile name) → default
|
|
43
|
+
const workspaceSlugOverride = profile ?? process.env.QUONFIG_WORKSPACE;
|
|
44
|
+
const profileNameOverride = process.env.QUONFIG_PROFILE;
|
|
45
|
+
if (workspaceSlugOverride) {
|
|
46
|
+
// Try slug resolution first, then fall back to treating it as a profile name
|
|
47
|
+
const resolved = resolveWorkspaceId(authConfig, workspaceSlugOverride);
|
|
48
|
+
if (resolved) {
|
|
49
|
+
workspaceId = resolved;
|
|
50
|
+
command.verboseLog('Workspace lookup', { source: 'slug', slug: workspaceSlugOverride, workspaceId });
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const profileData = authConfig.profiles[workspaceSlugOverride];
|
|
54
|
+
if (profileData) {
|
|
55
|
+
workspaceId = profileData.workspace;
|
|
56
|
+
command.verboseLog('Workspace lookup', { source: 'profile-name', profile: workspaceSlugOverride, workspaceId });
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
command.error(`Workspace '${workspaceSlugOverride}' not found. Run \`qfg workspace switch\` to select and save a workspace.`, { exit: 1 });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const activeProfile = getActiveProfile(profileNameOverride);
|
|
65
|
+
const profileData = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
|
|
66
|
+
command.verboseLog('Workspace lookup', {
|
|
67
|
+
source: 'default-profile',
|
|
68
|
+
activeProfile,
|
|
69
|
+
hasProfileData: Boolean(profileData),
|
|
70
|
+
workspaceId: profileData?.workspace,
|
|
71
|
+
});
|
|
72
|
+
if (profileData) {
|
|
73
|
+
workspaceId = profileData.workspace;
|
|
74
|
+
}
|
|
52
75
|
}
|
|
53
76
|
}
|
|
54
77
|
// Store workspaceId on the command for use in building URLs and cache it
|
|
@@ -15,9 +15,15 @@ export interface AuthConfig {
|
|
|
15
15
|
workspace: string;
|
|
16
16
|
workspaceName?: string;
|
|
17
17
|
workspaceSlug?: string;
|
|
18
|
+
organizationName?: string;
|
|
18
19
|
};
|
|
19
20
|
};
|
|
20
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Find a workspace ID by slug across all saved profiles.
|
|
24
|
+
* Used to resolve QUONFIG_WORKSPACE and --workspace flag values.
|
|
25
|
+
*/
|
|
26
|
+
export declare const resolveWorkspaceId: (config: AuthConfig, slug: string) => string | undefined;
|
|
21
27
|
export declare const saveTokens: (tokens: TokenData, options?: TokenStorageOptions) => Promise<void>;
|
|
22
28
|
export declare const loadTokens: (options?: TokenStorageOptions) => Promise<TokenData | null>;
|
|
23
29
|
export declare const saveAuthConfig: (config: AuthConfig, options?: TokenStorageOptions) => Promise<void>;
|
|
@@ -9,6 +9,18 @@ const getTokenFile = (options) => {
|
|
|
9
9
|
return path.join(getQuonfigDir(options), filename);
|
|
10
10
|
};
|
|
11
11
|
const getConfigFile = (options) => path.join(getQuonfigDir(options), 'config');
|
|
12
|
+
/**
|
|
13
|
+
* Find a workspace ID by slug across all saved profiles.
|
|
14
|
+
* Used to resolve QUONFIG_WORKSPACE and --workspace flag values.
|
|
15
|
+
*/
|
|
16
|
+
export const resolveWorkspaceId = (config, slug) => {
|
|
17
|
+
for (const profile of Object.values(config.profiles)) {
|
|
18
|
+
if (profile.workspaceSlug === slug || profile.workspaceName === slug) {
|
|
19
|
+
return profile.workspace;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
};
|
|
12
24
|
const ensureQuonfigDir = async (options) => {
|
|
13
25
|
const quonfigDir = getQuonfigDir(options);
|
|
14
26
|
try {
|
|
@@ -52,6 +64,9 @@ export const saveAuthConfig = async (config, options) => {
|
|
|
52
64
|
if (profileData.workspaceSlug) {
|
|
53
65
|
configContent += `workspace_slug = ${profileData.workspaceSlug}\n`;
|
|
54
66
|
}
|
|
67
|
+
if (profileData.organizationName) {
|
|
68
|
+
configContent += `organization_name = ${profileData.organizationName}\n`;
|
|
69
|
+
}
|
|
55
70
|
configContent += '\n';
|
|
56
71
|
}
|
|
57
72
|
const configTarget = getConfigFile(options);
|
|
@@ -78,12 +93,14 @@ export const loadAuthConfig = async (options) => {
|
|
|
78
93
|
const block = match[2];
|
|
79
94
|
const workspaceMatch = block.match(/workspace\s*=\s*([^\s#]+)(?:\s*#\s*(.+))?/);
|
|
80
95
|
const slugMatch = block.match(/workspace_slug\s*=\s*(\S+)/);
|
|
96
|
+
const orgMatch = block.match(/organization_name\s*=\s*(.+)/);
|
|
81
97
|
if (!workspaceMatch)
|
|
82
98
|
continue;
|
|
83
99
|
config.profiles[profileName] = {
|
|
84
100
|
workspace: workspaceMatch[1],
|
|
85
101
|
workspaceName: workspaceMatch[2]?.trim(),
|
|
86
102
|
workspaceSlug: slugMatch?.[1]?.trim(),
|
|
103
|
+
organizationName: orgMatch?.[1]?.trim(),
|
|
87
104
|
};
|
|
88
105
|
}
|
|
89
106
|
return Object.keys(config.profiles).length > 0 ? config : null;
|