@quonfig/cli 0.0.20 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import { select } from '@inquirer/prompts';
3
3
  import { BaseCommand } from '../index.js';
4
4
  import { decodeJWT, pollForToken, requestDeviceCode } from '../util/oauth-client.js';
5
5
  import { getApiUrl } from '../util/domain-urls.js';
6
- import { loadAuthConfig, saveAuthConfig, saveTokens } from '../util/token-storage.js';
6
+ import { getAuthConfigFilePath, getTokenFilePath, loadAuthConfig, saveAuthConfig, saveTokens, } from '../util/token-storage.js';
7
7
  export default class Login extends BaseCommand {
8
8
  static description = 'Log in to Quonfig via WorkOS device authorization';
9
9
  static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --profile myprofile'];
@@ -47,14 +47,22 @@ export default class Login extends BaseCommand {
47
47
  if (typeof jwtPayload.exp === 'number') {
48
48
  expiresAt = jwtPayload.exp * 1000;
49
49
  }
50
- // Save tokens
51
- await saveTokens({
52
- accessToken: tokenResponse.access_token,
53
- expiresAt,
54
- refreshToken: tokenResponse.refresh_token,
55
- userEmail,
56
- userId: user.id,
57
- });
50
+ // Save tokens — verifies the file actually persisted before returning.
51
+ this.verboseLog('Saving tokens to', getTokenFilePath());
52
+ let tokensPath;
53
+ try {
54
+ tokensPath = await saveTokens({
55
+ accessToken: tokenResponse.access_token,
56
+ expiresAt,
57
+ refreshToken: tokenResponse.refresh_token,
58
+ userEmail,
59
+ userId: user.id,
60
+ });
61
+ }
62
+ catch (error) {
63
+ return this.err(`Login failed: could not save tokens. ${error.message}`);
64
+ }
65
+ this.verboseLog('Tokens saved at', tokensPath);
58
66
  // Resolve org_id → workspace UUID via the API
59
67
  let workspaceId;
60
68
  let workspaceName;
@@ -113,18 +121,26 @@ export default class Login extends BaseCommand {
113
121
  // Save auth config
114
122
  const isFirstProfile = !existingConfig || Object.keys(existingConfig.profiles).length === 0;
115
123
  const shouldSetDefault = isFirstProfile || profileName === 'default' || !existingConfig?.defaultProfile;
116
- await saveAuthConfig({
117
- defaultProfile: shouldSetDefault ? profileName : existingConfig?.defaultProfile,
118
- profiles: {
119
- ...existingConfig?.profiles,
120
- [profileName]: {
121
- workspace: workspaceId || orgId || 'unknown',
122
- workspaceName: workspaceName || (orgId ? `org:${orgId}` : undefined),
123
- workspaceSlug,
124
- organizationName,
124
+ this.verboseLog('Saving auth config to', getAuthConfigFilePath());
125
+ let configPath;
126
+ try {
127
+ configPath = await saveAuthConfig({
128
+ defaultProfile: shouldSetDefault ? profileName : existingConfig?.defaultProfile,
129
+ profiles: {
130
+ ...existingConfig?.profiles,
131
+ [profileName]: {
132
+ workspace: workspaceId || orgId || 'unknown',
133
+ workspaceName: workspaceName || (orgId ? `org:${orgId}` : undefined),
134
+ workspaceSlug,
135
+ organizationName,
136
+ },
125
137
  },
126
- },
127
- });
138
+ });
139
+ }
140
+ catch (error) {
141
+ return this.err(`Login failed: could not save auth config. ${error.message}`);
142
+ }
143
+ this.verboseLog('Auth config saved at', configPath);
128
144
  this.log('\nSuccessfully logged in!');
129
145
  if (userEmail) {
130
146
  this.log(`Logged in as: ${userEmail}`);
@@ -1,17 +1,23 @@
1
1
  import { APICommand } from '../index.js';
2
- import { JsonObj } from '../result.js';
2
+ import type { JsonObj } from '../result.js';
3
3
  export default class Override extends APICommand {
4
4
  static args: {
5
5
  name: import("@oclif/core/lib/interfaces/parser.js").Arg<string | undefined, Record<string, unknown>>;
6
+ value: import("@oclif/core/lib/interfaces/parser.js").Arg<string | undefined, Record<string, unknown>>;
6
7
  };
7
8
  static description: string;
8
9
  static examples: string[];
9
10
  static flags: {
10
- environment: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
11
+ clear: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
12
+ env: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
11
13
  remove: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
12
- value: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
13
14
  };
14
15
  run(): Promise<JsonObj | void>;
15
- private removeOverride;
16
- private setOverride;
16
+ private callFindOrCreate;
17
+ private callRemove;
18
+ private fetchFlag;
19
+ private runClear;
20
+ private runList;
21
+ private runRemove;
22
+ private runSet;
17
23
  }
@@ -1,173 +1,269 @@
1
- import { Flags } from '@oclif/core';
1
+ import { Args, Flags } from '@oclif/core';
2
2
  import { APICommand } from '../index.js';
3
- import getEnvironment from '../ui/get-environment.js';
4
- import getString from '../ui/get-string.js';
5
- import autocomplete from '../util/autocomplete.js';
6
3
  import { checkmark } from '../util/color.js';
7
- import isInteractive from '../util/is-interactive.js';
8
- import nameArg from '../util/name-arg.js';
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 = { ...nameArg };
11
- static description = `Override the value of an item for your user/SDK key combo.
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
- To target users other than yourself, or to express rules like "user.email == X → value Y",
14
- you cannot use override — see:
15
- qfg config-schema # operator reference + worked multi-rule example
16
- qfg pull # clone the workspace so you can edit JSON directly`;
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 %> # will prompt for name and value',
19
- '<%= config.bin %> <%= command.id %> my.flag.name --value=true',
20
- '<%= config.bin %> <%= command.id %> my.flag.name --remove',
21
- '<%= config.bin %> <%= command.id %> my.double.config --value=3.14159',
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
- environment: Flags.string({ description: 'environment to override in' }),
25
- remove: Flags.boolean({ default: false, description: 'remove your override (if present)' }),
26
- value: Flags.string({ description: 'value to use for your override' }),
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
- if (flags.remove && flags.value) {
31
- this.err('remove and value flags are mutually exclusive');
32
- }
33
- // Fetch all configs from metadata endpoint
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
- if (!key) {
64
- return this.err('Key is required');
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
- const config = configs.find((c) => c.key === key);
67
- if (!config) {
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
- this.verboseLog('Selected config:', config);
71
- if (flags.remove) {
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 (!value) {
83
- return this.err('Value is required');
115
+ if (!args.name) {
116
+ return this.runList({ env, userEmail });
84
117
  }
85
- return this.setOverride(config, value, environment.id);
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
- // Handle 404 case - no override exists to remove
98
- if (request.status === 404) {
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.err(`Failed to remove override: ${request.status}`, { key, serverError: request.error });
124
+ return this.runSet({ env, key: args.name, rawValue: args.value, userEmail });
103
125
  }
104
- async setOverride(config, value, environmentId) {
105
- const { key, valueType, version } = config;
106
- // Map the valueType to the format expected by the API
107
- const typeMapping = {
108
- bool: 'bool',
109
- string: 'string',
110
- int: 'int',
111
- double: 'double',
112
- string_list: 'stringList',
113
- json: 'json',
114
- limit_definition: 'limitDefinition',
115
- duration: 'duration',
116
- int_range: 'intRange',
117
- };
118
- const type = typeMapping[valueType.toLowerCase()] || valueType;
119
- // Parse the value based on type and build variant object
120
- let variantValue = value;
121
- let variantType = type;
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
- case 'json': {
141
- try {
142
- variantValue = JSON.parse(value);
143
- }
144
- catch {
145
- return this.err(`Invalid JSON value: ${value}`);
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
- case 'string': {
150
- variantValue = value;
151
- break;
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
- // No default
154
- }
155
- const request = await this.apiClient.post('/internal/ops/v1/assign-variant', {
156
- configKey: key,
157
- currentVersionId: version,
158
- environmentId,
159
- variant: {
160
- type: variantType,
161
- value: variantValue,
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
  }
@@ -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 = configValue.duration;
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;