@quonfig/cli 0.0.20 → 0.0.22
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 +123 -59
- 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/delete.d.ts +13 -0
- package/dist/commands/delete.js +114 -0
- 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/quonfig.js +2 -1
- package/dist/util/coerce.js +21 -1
- package/dist/util/config-value-dto.js +3 -1
- package/dist/util/domain-urls.d.ts +1 -0
- package/dist/util/domain-urls.js +7 -0
- 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 +121 -20
- package/package.json +1 -1
|
@@ -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
|
}
|
package/dist/quonfig.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Quonfig } from '@quonfig/node';
|
|
2
|
+
import { getDeliveryUrl } from './util/domain-urls.js';
|
|
2
3
|
let quonfig;
|
|
3
4
|
const DEFAULT_CONTEXT_USER_ID_NAMESPACE = 'prefab-api-key';
|
|
4
5
|
const DEFAULT_CONTEXT_USER_ID = 'user-id';
|
|
@@ -14,7 +15,7 @@ export const initQuonfig = async (_ctx, flagsOrDatafile) => {
|
|
|
14
15
|
}
|
|
15
16
|
sdkKey = flagsOrDatafile['sdk-key'];
|
|
16
17
|
}
|
|
17
|
-
const apiUrl =
|
|
18
|
+
const apiUrl = getDeliveryUrl();
|
|
18
19
|
quonfig = new Quonfig({
|
|
19
20
|
sdkKey,
|
|
20
21
|
collectEvaluationSummaries: false,
|
package/dist/util/coerce.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import { ConfigValueType } from '@quonfig/node';
|
|
1
|
+
import { ConfigValueType, durationToMilliseconds } from '@quonfig/node';
|
|
2
2
|
const TRUE_VALUES = new Set(['true', '1', 't']);
|
|
3
3
|
const BOOLEAN_VALUES = new Set([...TRUE_VALUES, 'false', '0', 'f']);
|
|
4
|
+
// Strict ISO 8601 duration: at least one of D/H/M/S must be present (i.e. bare "P"
|
|
5
|
+
// or "PT" alone are rejected). Mirrors the lenient pattern in @quonfig/node but
|
|
6
|
+
// adds anchors and a non-empty check.
|
|
7
|
+
const ISO_DURATION_PATTERN = /^P(?:\d+(?:\.\d+)?D)?(?:T(?:\d+(?:\.\d+)?H)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?S)?)?$/;
|
|
8
|
+
const isValidIsoDuration = (value) => {
|
|
9
|
+
if (!ISO_DURATION_PATTERN.test(value))
|
|
10
|
+
return false;
|
|
11
|
+
// Reject "P", "PT" — pattern allows them but they have no components.
|
|
12
|
+
return value !== 'P' && value !== 'PT';
|
|
13
|
+
};
|
|
4
14
|
export const TYPE_MAPPING = {
|
|
5
15
|
bool: ConfigValueType.Bool,
|
|
6
16
|
boolean: ConfigValueType.Bool,
|
|
7
17
|
double: ConfigValueType.Double,
|
|
18
|
+
duration: ConfigValueType.Duration,
|
|
8
19
|
int: ConfigValueType.Int,
|
|
9
20
|
string: ConfigValueType.String,
|
|
10
21
|
'string-list': ConfigValueType.StringList,
|
|
@@ -50,6 +61,15 @@ export const coerceIntoType = (type, value) => {
|
|
|
50
61
|
throw new TypeError(`Invalid default value for JSON: ${value}`);
|
|
51
62
|
}
|
|
52
63
|
}
|
|
64
|
+
case 'duration': {
|
|
65
|
+
// durationToMilliseconds() returns 0 on no-match instead of throwing, so
|
|
66
|
+
// we anchor-check the format ourselves before delegating.
|
|
67
|
+
if (!isValidIsoDuration(value)) {
|
|
68
|
+
throw new TypeError(`Invalid default value for duration: ${value}. Expected ISO 8601 duration like PT30S, PT5M, PT1H30M.`);
|
|
69
|
+
}
|
|
70
|
+
const millis = durationToMilliseconds(value);
|
|
71
|
+
return [{ duration: { definition: value, millis } }, ConfigValueType.Duration];
|
|
72
|
+
}
|
|
53
73
|
default: {
|
|
54
74
|
return undefined;
|
|
55
75
|
}
|
|
@@ -57,7 +57,9 @@ export function mapConfigValueToDto(configValue, valueType) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
else if (configValue.duration !== undefined) {
|
|
60
|
-
value
|
|
60
|
+
// Storage / API expects {type: 'duration', value: '<ISO 8601 string>'} — not the
|
|
61
|
+
// internal {definition, millis} envelope used by the SDK's ConfigValue type.
|
|
62
|
+
value = configValue.duration.definition;
|
|
61
63
|
}
|
|
62
64
|
else if (configValue.intRange !== undefined) {
|
|
63
65
|
value = configValue.intRange;
|
package/dist/util/domain-urls.js
CHANGED
|
@@ -16,3 +16,10 @@ export const getAppUrl = (domain) => {
|
|
|
16
16
|
const actualDomain = domain || getDomain();
|
|
17
17
|
return `https://app.${actualDomain}`;
|
|
18
18
|
};
|
|
19
|
+
export const getDeliveryUrl = (domain) => {
|
|
20
|
+
if (process.env.QUONFIG_API_URL) {
|
|
21
|
+
return process.env.QUONFIG_API_URL;
|
|
22
|
+
}
|
|
23
|
+
const actualDomain = domain || getDomain();
|
|
24
|
+
return `https://primary.${actualDomain}`;
|
|
25
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getApiUrl } from './domain-urls.js';
|
|
2
2
|
import { getValidAccessToken } from './get-valid-token.js';
|
|
3
|
-
import { getActiveProfile, loadAuthConfig, resolveWorkspaceId as resolveOAuthWorkspaceId, } from './token-storage.js';
|
|
3
|
+
import { getActiveProfile, loadAuthConfig, loadTokens, resolveWorkspaceId as resolveOAuthWorkspaceId, saveAuthConfig, } from './token-storage.js';
|
|
4
4
|
const UUID_PATTERN = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
|
|
5
5
|
/**
|
|
6
6
|
* Resolve the target workspace UUID for commands that don't extend APICommand
|
|
@@ -52,9 +52,18 @@ export async function resolveWorkspaceUuid(command, flagOverride) {
|
|
|
52
52
|
return match.workspaceId;
|
|
53
53
|
}
|
|
54
54
|
// OAuth path.
|
|
55
|
-
|
|
55
|
+
let authConfig = await loadAuthConfig();
|
|
56
56
|
if (!authConfig) {
|
|
57
|
-
|
|
57
|
+
// Auth config can be missing while tokens.json still holds a valid
|
|
58
|
+
// refresh_token — e.g. after the domain-scoped config split (commit
|
|
59
|
+
// 0f8bee6) or a partial login that wrote tokens but never the config
|
|
60
|
+
// (qfg-2qj). getValidAccessToken already knows how to refresh, so try
|
|
61
|
+
// that path before declaring "Not logged in".
|
|
62
|
+
const tokens = await loadTokens();
|
|
63
|
+
if (!tokens?.refreshToken) {
|
|
64
|
+
command.error('Not logged in. Run `qfg login` first (or set QUONFIG_API_KEY for CI).', { exit: 401 });
|
|
65
|
+
}
|
|
66
|
+
authConfig = await recoverAuthConfigFromTokens(command);
|
|
58
67
|
}
|
|
59
68
|
if (override) {
|
|
60
69
|
const resolved = resolveOAuthWorkspaceId(authConfig, override);
|
|
@@ -73,3 +82,60 @@ export async function resolveWorkspaceUuid(command, flagOverride) {
|
|
|
73
82
|
}
|
|
74
83
|
return profileData.workspace;
|
|
75
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Best-effort rebuild of the on-disk auth config from a working refresh_token.
|
|
87
|
+
* Calls /api/v1/userWorkspaces/list to discover the user's workspaces, persists
|
|
88
|
+
* the first one as the default profile, and returns the recovered config.
|
|
89
|
+
*
|
|
90
|
+
* Bails to command.error on any failure (refresh, network, no workspaces) with
|
|
91
|
+
* a message that points the user at `qfg login`.
|
|
92
|
+
*/
|
|
93
|
+
async function recoverAuthConfigFromTokens(command) {
|
|
94
|
+
let jwt;
|
|
95
|
+
try {
|
|
96
|
+
jwt = await getValidAccessToken(command.verboseLog);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
100
|
+
command.error(`Tokens found but refresh failed: ${detail}. Run \`qfg login\` to repopulate.`, { exit: 401 });
|
|
101
|
+
}
|
|
102
|
+
let res;
|
|
103
|
+
try {
|
|
104
|
+
res = await fetch(`${getApiUrl()}/api/v1/userWorkspaces/list`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
|
107
|
+
body: JSON.stringify({ json: {} }),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
command.error(`Tokens found but failed to fetch workspaces: ${error instanceof Error ? error.message : String(error)}. Run \`qfg login\` to repopulate.`, { exit: 401 });
|
|
112
|
+
}
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
command.error(`Tokens found but workspace list returned HTTP ${res.status}. Run \`qfg login\` to repopulate.`, { exit: 401 });
|
|
115
|
+
}
|
|
116
|
+
const body = (await res.json());
|
|
117
|
+
const entries = (body.json ?? body);
|
|
118
|
+
const candidates = Array.isArray(entries) ? entries : [];
|
|
119
|
+
if (candidates.length === 0) {
|
|
120
|
+
command.error('Tokens found but no workspace profile on disk. Run `qfg login` to repopulate.', { exit: 401 });
|
|
121
|
+
}
|
|
122
|
+
const match = candidates[0];
|
|
123
|
+
const recovered = {
|
|
124
|
+
defaultProfile: 'default',
|
|
125
|
+
profiles: {
|
|
126
|
+
default: {
|
|
127
|
+
workspace: match.workspaceId,
|
|
128
|
+
workspaceName: match.workspaceSlug,
|
|
129
|
+
workspaceSlug: match.workspaceSlug,
|
|
130
|
+
organizationName: match.organizationName,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
await saveAuthConfig(recovered);
|
|
135
|
+
command.verboseLog('resolve-workspace: recovered auth config from tokens', {
|
|
136
|
+
workspaceId: match.workspaceId,
|
|
137
|
+
workspaceSlug: match.workspaceSlug,
|
|
138
|
+
candidateCount: candidates.length,
|
|
139
|
+
});
|
|
140
|
+
return recovered;
|
|
141
|
+
}
|
|
@@ -24,9 +24,11 @@ export interface AuthConfig {
|
|
|
24
24
|
* Used to resolve QUONFIG_WORKSPACE and --workspace flag values.
|
|
25
25
|
*/
|
|
26
26
|
export declare const resolveWorkspaceId: (config: AuthConfig, slug: string) => string | undefined;
|
|
27
|
-
export declare const
|
|
27
|
+
export declare const getTokenFilePath: (options?: TokenStorageOptions) => string;
|
|
28
|
+
export declare const getAuthConfigFilePath: (options?: TokenStorageOptions) => string;
|
|
29
|
+
export declare const saveTokens: (tokens: TokenData, options?: TokenStorageOptions) => Promise<string>;
|
|
28
30
|
export declare const loadTokens: (options?: TokenStorageOptions) => Promise<TokenData | null>;
|
|
29
|
-
export declare const saveAuthConfig: (config: AuthConfig, options?: TokenStorageOptions) => Promise<
|
|
31
|
+
export declare const saveAuthConfig: (config: AuthConfig, options?: TokenStorageOptions) => Promise<string>;
|
|
30
32
|
export declare const loadAuthConfig: (options?: TokenStorageOptions) => Promise<AuthConfig | null>;
|
|
31
33
|
export declare const getActiveProfile: (profileArg?: string) => string;
|
|
32
34
|
export declare const clearAuth: (options?: TokenStorageOptions) => Promise<void>;
|
|
@@ -34,12 +34,37 @@ const ensureQuonfigDir = async (options) => {
|
|
|
34
34
|
await fs.promises.mkdir(quonfigDir, { recursive: true });
|
|
35
35
|
}
|
|
36
36
|
};
|
|
37
|
+
export const getTokenFilePath = (options) => getTokenFile(options);
|
|
38
|
+
export const getAuthConfigFilePath = (options) => getConfigFile(options);
|
|
39
|
+
const verifyWritten = async (filePath, label) => {
|
|
40
|
+
let stat;
|
|
41
|
+
try {
|
|
42
|
+
stat = await fs.promises.stat(filePath);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
throw new Error(`Failed to persist ${label} to ${filePath}: file is missing after write (${error.message}). ` +
|
|
46
|
+
`This can happen if a concurrent process (e.g. npm install -g) clobbered the install. Re-run after it finishes.`);
|
|
47
|
+
}
|
|
48
|
+
if (!stat.isFile() || stat.size === 0) {
|
|
49
|
+
throw new Error(`Failed to persist ${label} to ${filePath}: file is empty or not a regular file after write.`);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
37
52
|
export const saveTokens = async (tokens, options) => {
|
|
38
53
|
await ensureQuonfigDir(options);
|
|
39
54
|
const targetFile = getTokenFile(options);
|
|
40
55
|
const tmpFile = `${targetFile}.tmp`;
|
|
41
56
|
await fs.promises.writeFile(tmpFile, JSON.stringify(tokens, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
42
57
|
await fs.promises.rename(tmpFile, targetFile);
|
|
58
|
+
await verifyWritten(targetFile, 'tokens');
|
|
59
|
+
// Round-trip parse to confirm the file on disk is the JSON we just wrote.
|
|
60
|
+
try {
|
|
61
|
+
const onDisk = await fs.promises.readFile(targetFile, 'utf8');
|
|
62
|
+
JSON.parse(onDisk);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new Error(`Failed to verify tokens at ${targetFile}: ${error.message}`);
|
|
66
|
+
}
|
|
67
|
+
return targetFile;
|
|
43
68
|
};
|
|
44
69
|
export const loadTokens = async (options) => {
|
|
45
70
|
try {
|
|
@@ -77,6 +102,8 @@ export const saveAuthConfig = async (config, options) => {
|
|
|
77
102
|
const configTmp = `${configTarget}.tmp`;
|
|
78
103
|
await fs.promises.writeFile(configTmp, configContent, { encoding: 'utf8', mode: 0o600 });
|
|
79
104
|
await fs.promises.rename(configTmp, configTarget);
|
|
105
|
+
await verifyWritten(configTarget, 'auth config');
|
|
106
|
+
return configTarget;
|
|
80
107
|
};
|
|
81
108
|
export const loadAuthConfig = async (options) => {
|
|
82
109
|
try {
|