@quonfig/cli 0.0.2 → 0.0.5
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 +306 -29
- package/bin/dev.js +5 -9
- package/bin/dev.mjs +6 -0
- package/dist/codegen/zod-introspection.js +13 -4
- 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 +27 -6
- package/dist/commands/mcp.js +1 -1
- package/dist/commands/pull.d.ts +15 -0
- package/dist/commands/pull.js +191 -0
- package/dist/commands/serve.js +3 -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/sync.d.ts +18 -0
- package/dist/commands/sync.js +142 -0
- package/dist/commands/whoami.js +4 -1
- package/dist/commands/workspace/bootstrap.d.ts +13 -0
- package/dist/commands/workspace/bootstrap.js +155 -0
- package/dist/init/init-workspace.js +4 -4
- package/dist/init/templates.js +1 -1
- package/dist/util/coerce.js +2 -1
- package/dist/util/get-client.js +6 -44
- package/dist/util/get-valid-token.d.ts +5 -0
- package/dist/util/get-valid-token.js +44 -0
- package/dist/util/git-ops.d.ts +32 -0
- package/dist/util/git-ops.js +164 -0
- package/dist/util/gitea-api.d.ts +11 -0
- package/dist/util/gitea-api.js +45 -0
- package/dist/util/gitea-token-storage.d.ts +8 -0
- package/dist/util/gitea-token-storage.js +47 -0
- package/dist/util/token-storage.d.ts +1 -0
- package/dist/util/token-storage.js +24 -10
- package/dist/verify/validate.d.ts +1 -0
- package/dist/verify/validate.js +136 -6
- package/oclif.manifest.json +404 -9
- package/package.json +2 -2
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { JsonObj } from '../result.js';
|
|
2
|
+
import { BaseCommand } from '../index.js';
|
|
3
|
+
export default class Sync extends BaseCommand {
|
|
4
|
+
static description: string;
|
|
5
|
+
static examples: string[];
|
|
6
|
+
static flags: {
|
|
7
|
+
dir: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
8
|
+
interval: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
9
|
+
watch: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
10
|
+
};
|
|
11
|
+
private workspaceId;
|
|
12
|
+
private resolvedDir;
|
|
13
|
+
run(): Promise<JsonObj | void>;
|
|
14
|
+
private runSync;
|
|
15
|
+
private ensureRemoteUrl;
|
|
16
|
+
private refreshToken;
|
|
17
|
+
private looksLike401;
|
|
18
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import { Flags } from '@oclif/core';
|
|
3
|
+
import { BaseCommand } from '../index.js';
|
|
4
|
+
import { getActiveProfile, loadAuthConfig } from '../util/token-storage.js';
|
|
5
|
+
import { loadGiteaToken, isGiteaTokenExpired, saveGiteaToken } from '../util/gitea-token-storage.js';
|
|
6
|
+
import { mintGiteaToken } from '../util/gitea-api.js';
|
|
7
|
+
import { isGitRepo, isWorkingTreeClean, gitFetch, canFastForward, hasDivergedFromRemote, gitMergeFfOnly, gitSetRemote, } from '../util/git-ops.js';
|
|
8
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
export default class Sync extends BaseCommand {
|
|
10
|
+
static description = 'Continuously poll for workspace config updates and apply them locally';
|
|
11
|
+
static examples = [
|
|
12
|
+
'<%= config.bin %> sync --watch',
|
|
13
|
+
'<%= config.bin %> sync --watch --interval 10',
|
|
14
|
+
'<%= config.bin %> sync --watch --dir ./our-config --interval 30',
|
|
15
|
+
];
|
|
16
|
+
static flags = {
|
|
17
|
+
dir: Flags.string({
|
|
18
|
+
description: 'Local directory to sync (defaults to QUONFIG_DIR env var)',
|
|
19
|
+
}),
|
|
20
|
+
interval: Flags.integer({
|
|
21
|
+
default: 60,
|
|
22
|
+
description: 'Poll interval in seconds',
|
|
23
|
+
}),
|
|
24
|
+
watch: Flags.boolean({
|
|
25
|
+
description: 'Run as a continuous polling daemon (required)',
|
|
26
|
+
required: true,
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
workspaceId;
|
|
30
|
+
resolvedDir;
|
|
31
|
+
async run() {
|
|
32
|
+
const { flags } = await this.parse(Sync);
|
|
33
|
+
// Resolve directory
|
|
34
|
+
const dir = flags.dir || process.env.QUONFIG_DIR;
|
|
35
|
+
if (!dir) {
|
|
36
|
+
return this.err('No directory specified. Use --dir <path> or set QUONFIG_DIR.');
|
|
37
|
+
}
|
|
38
|
+
this.resolvedDir = path.resolve(dir);
|
|
39
|
+
// Resolve workspace ID
|
|
40
|
+
const authConfig = await loadAuthConfig();
|
|
41
|
+
if (!authConfig) {
|
|
42
|
+
return this.err('Not logged in. Please run `qfg login` first.');
|
|
43
|
+
}
|
|
44
|
+
const activeProfile = getActiveProfile();
|
|
45
|
+
const profile = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
|
|
46
|
+
if (!profile) {
|
|
47
|
+
return this.err('No active profile found. Please run `qfg login` first.');
|
|
48
|
+
}
|
|
49
|
+
this.workspaceId = profile.workspace;
|
|
50
|
+
// Validate the directory is a git repo
|
|
51
|
+
const isRepo = await isGitRepo(this.resolvedDir);
|
|
52
|
+
if (!isRepo) {
|
|
53
|
+
return this.err(`${this.resolvedDir} is not a git repository. Run \`qfg pull --dir ${this.resolvedDir}\` first.`);
|
|
54
|
+
}
|
|
55
|
+
const intervalSec = flags.interval;
|
|
56
|
+
this.log(`Watching ${this.resolvedDir} — polling every ${intervalSec}s. Press Ctrl+C to stop.`);
|
|
57
|
+
// Ensure remote URL is up to date before starting
|
|
58
|
+
await this.ensureRemoteUrl();
|
|
59
|
+
// Run initial sync
|
|
60
|
+
await this.runSync();
|
|
61
|
+
// Poll loop
|
|
62
|
+
// eslint-disable-next-line no-constant-condition
|
|
63
|
+
while (true) {
|
|
64
|
+
await sleep(intervalSec * 1000);
|
|
65
|
+
await this.runSync();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async runSync() {
|
|
69
|
+
const timestamp = new Date().toISOString();
|
|
70
|
+
try {
|
|
71
|
+
// Ensure token is valid
|
|
72
|
+
await this.ensureRemoteUrl();
|
|
73
|
+
// Fetch
|
|
74
|
+
this.verboseLog('Sync', 'Fetching...');
|
|
75
|
+
await gitFetch(this.resolvedDir);
|
|
76
|
+
// Check if there's anything to do
|
|
77
|
+
const ff = await canFastForward(this.resolvedDir);
|
|
78
|
+
if (!ff) {
|
|
79
|
+
// Check if diverged
|
|
80
|
+
const diverged = await hasDivergedFromRemote(this.resolvedDir);
|
|
81
|
+
if (diverged) {
|
|
82
|
+
this.log(`[${timestamp}] Skipped: local commits diverge from remote. Resolve manually.`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.log(`[${timestamp}] Already up to date.`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Check working tree
|
|
89
|
+
const clean = await isWorkingTreeClean(this.resolvedDir);
|
|
90
|
+
if (!clean) {
|
|
91
|
+
this.log(`[${timestamp}] Skipped: local changes present. Commit or stash first.`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Merge
|
|
95
|
+
const newCommits = await gitMergeFfOnly(this.resolvedDir);
|
|
96
|
+
if (newCommits.length > 0) {
|
|
97
|
+
this.log(`[${timestamp}] Pulled ${newCommits.length} new commit(s):`);
|
|
98
|
+
for (const msg of newCommits) {
|
|
99
|
+
this.log(` - ${msg}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
this.log(`[${timestamp}] Updated.`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
108
|
+
if (this.looksLike401(err)) {
|
|
109
|
+
this.log(`[${timestamp}] Token expired, refreshing...`);
|
|
110
|
+
try {
|
|
111
|
+
await this.refreshToken();
|
|
112
|
+
await this.ensureRemoteUrl();
|
|
113
|
+
}
|
|
114
|
+
catch (refreshErr) {
|
|
115
|
+
this.log(`[${timestamp}] Warning: could not refresh token: ${String(refreshErr)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
this.log(`[${timestamp}] Warning: sync failed: ${msg}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async ensureRemoteUrl() {
|
|
124
|
+
const entry = await loadGiteaToken(this.workspaceId);
|
|
125
|
+
if (!entry || isGiteaTokenExpired(entry)) {
|
|
126
|
+
await this.refreshToken();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
await gitSetRemote(this.resolvedDir, entry.repoUrl);
|
|
130
|
+
}
|
|
131
|
+
async refreshToken() {
|
|
132
|
+
const data = await mintGiteaToken(this.workspaceId, 'read', 'pull');
|
|
133
|
+
const entry = { token: data.token, repoUrl: data.repoUrl, expiresAt: data.expiresAt };
|
|
134
|
+
await saveGiteaToken(this.workspaceId, entry);
|
|
135
|
+
await gitSetRemote(this.resolvedDir, entry.repoUrl);
|
|
136
|
+
this.verboseLog('Sync', 'Token refreshed.');
|
|
137
|
+
}
|
|
138
|
+
looksLike401(err) {
|
|
139
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
140
|
+
return msg.includes('401') || msg.toLowerCase().includes('authentication failed');
|
|
141
|
+
}
|
|
142
|
+
}
|
package/dist/commands/whoami.js
CHANGED
|
@@ -38,7 +38,10 @@ export default class Whoami extends BaseCommand {
|
|
|
38
38
|
// Display user information
|
|
39
39
|
this.log(`Logged in as: ${userEmail || 'Unknown'}`);
|
|
40
40
|
this.log(`Active profile: ${activeProfile}`);
|
|
41
|
-
|
|
41
|
+
const workspaceDisplay = profile.workspaceName
|
|
42
|
+
? `${profile.workspaceName} (${profile.workspace})`
|
|
43
|
+
: profile.workspace;
|
|
44
|
+
this.log(`Active workspace: ${workspaceDisplay}`);
|
|
42
45
|
return {
|
|
43
46
|
email: userEmail,
|
|
44
47
|
loggedIn: true,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { JsonObj } from '../../result.js';
|
|
2
|
+
import { BaseCommand } from '../../index.js';
|
|
3
|
+
export default class WorkspaceBootstrap extends BaseCommand {
|
|
4
|
+
static description: string;
|
|
5
|
+
static examples: string[];
|
|
6
|
+
static flags: {
|
|
7
|
+
dir: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
8
|
+
force: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
9
|
+
'skip-validate': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
10
|
+
};
|
|
11
|
+
run(): Promise<JsonObj | void>;
|
|
12
|
+
private remoteHasCommits;
|
|
13
|
+
}
|