@quonfig/cli 0.0.4 → 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.
@@ -11,7 +11,26 @@ export default class Create extends APICommand {
11
11
  static args = {
12
12
  name: Args.string({ description: 'name for your new item (e.g. my.new.flag)', required: true }),
13
13
  };
14
- static description = 'Create a new item in Quonfig';
14
+ static description = `Create a new feature flag, config value, or other item.
15
+
16
+ Use --type to specify the kind of item:
17
+ boolean-flag On/off feature flag (sendToClientSdk defaults to true; best for gradual rollouts)
18
+ string A string configuration value
19
+ int An integer configuration value
20
+ double A floating-point configuration value
21
+ string-list A comma-separated list of strings
22
+ json An arbitrary JSON blob
23
+ boolean A plain boolean (not a feature flag)
24
+
25
+ This sets the global default value. Override per-environment with:
26
+ qfg set-default my.flag --environment production --value true
27
+
28
+ For a percentage rollout (gradual rollout / A/B test / canary deploy), use:
29
+ qfg set-rollout my.flag --environment production --true-percent 20
30
+
31
+ Or edit the JSON config file directly for complex targeting rules:
32
+ qfg config-schema # full operator reference + examples
33
+ qfg pull --dir ./config # clone workspace, then edit JSON and git push`;
15
34
  static examples = [
16
35
  '<%= config.bin %> <%= command.id %> my.new.flag --type boolean-flag',
17
36
  '<%= config.bin %> <%= command.id %> my.new.flag --type boolean-flag --value=true',
@@ -19,6 +38,8 @@ export default class Create extends APICommand {
19
38
  '<%= config.bin %> <%= command.id %> my.new.string --type string --value="hello world" --secret',
20
39
  '<%= config.bin %> <%= command.id %> my.new.string --type string --env-var=MY_ENV_VAR_NAME',
21
40
  '<%= config.bin %> <%= command.id %> my.new.string --type json --value="{\\"key\\": \\"value\\"}"',
41
+ '# After creating a flag, set a 20% rollout in production:',
42
+ '<%= config.bin %> set-rollout my.new.flag --environment production --true-percent 20',
22
43
  ];
23
44
  static flags = {
24
45
  confidential: Flags.boolean({ default: false, description: 'mark the value as confidential' }),
@@ -7,8 +7,23 @@ import isInteractive from '../util/is-interactive.js';
7
7
  import nameArg from '../util/name-arg.js';
8
8
  export default class Info extends APICommand {
9
9
  static args = { ...nameArg };
10
- static description = 'Show details about the provided config/feature-flag/etc.';
11
- static examples = ['<%= config.bin %> <%= command.id %> my.config.name'];
10
+ static description = `Show current values, rules, and evaluation stats for a flag or config.
11
+
12
+ Output shows the value for each environment:
13
+ - A simple value (true / false / "hello") means a single unconditional rule.
14
+ - "[see rules]" means the environment has targeting rules or a percentage rollout.
15
+ - "[inherit]" means the environment falls back to the Default value.
16
+
17
+ Percentage rollouts are displayed as "20.0% true, 80.0% false".
18
+ Evaluation counts for the past 24 hours are included by default.
19
+
20
+ Related commands:
21
+ qfg set-default my.flag --environment production --value true # set a scalar fallback
22
+ qfg set-rollout my.flag --environment production --true-percent 20 # set a % rollout`;
23
+ static examples = [
24
+ '<%= config.bin %> <%= command.id %> my.config.name',
25
+ '<%= config.bin %> <%= command.id %> my.config.name --exclude-evaluations # skip 24h stats',
26
+ ];
12
27
  static flags = {
13
28
  'exclude-evaluations': Flags.boolean({ default: false, description: 'Exclude evaluation data' }),
14
29
  };
@@ -7,7 +7,22 @@ import { loadGiteaToken, isGiteaTokenExpired, saveGiteaToken } from '../util/git
7
7
  import { mintAndStoreGiteaReadToken, mintGiteaToken } from '../util/gitea-api.js';
8
8
  import { isGitRepo, getRemoteUrl, isWorkingTreeClean, gitFetch, gitSetRemote, canFastForward, hasDivergedFromRemote, gitMergeFfOnly, gitClone, displayUrl, } from '../util/git-ops.js';
9
9
  export default class Pull extends BaseCommand {
10
- static description = 'Clone or update a local directory from the workspace Gitea repo';
10
+ static description = `Clone or update a local copy of your workspace config files.
11
+
12
+ Use this when you need to edit flag JSON directly — for complex targeting rules,
13
+ multi-rule configs, or anything beyond a single scalar value.
14
+
15
+ For the config file format, operator reference, and examples:
16
+ qfg config-schema # human-readable reference
17
+ qfg config-schema --json-schema # machine-readable JSON Schema
18
+
19
+ After editing files:
20
+ qfg verify <dir> # validate JSON before pushing
21
+ git -C <dir> add -A && git -C <dir> commit -m "feat: ..." && git -C <dir> push
22
+
23
+ CLI shortcuts (no JSON editing needed for simple cases):
24
+ qfg set-rollout my.flag --environment production --true-percent 20
25
+ qfg set-default my.flag --environment production --value true`;
11
26
  static examples = [
12
27
  '<%= config.bin %> pull --dir ./our-config',
13
28
  '<%= config.bin %> pull # uses QUONFIG_DIR env var',
@@ -14,12 +14,29 @@ import nameArg from '../util/name-arg.js';
14
14
  import secretFlags, { parsedSecretFlags } from '../util/secret-flags.js';
15
15
  export default class SetDefault extends APICommand {
16
16
  static args = { ...nameArg };
17
- static description = 'Set/update the default value for an environment (other rules still apply)';
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 # will prompt for value and env',
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, environmentId, key, secret, value, }) {
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, environmentId);
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
- const payload = {
248
- configKey: key,
249
- currentVersionId: config.version,
250
- environmentId: environmentId ? Number.parseInt(environmentId, 10) : 0,
251
- value: configValue,
252
- };
253
- this.verboseLog('Payload:', payload);
254
- const request = await this.apiClient.post('/internal/ops/v1/set-default', payload);
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: environmentId,
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
+ }