@quonfig/cli 0.0.19 → 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.
@@ -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;
@@ -14,7 +14,10 @@ export declare function decrypt(encryptedString: string, keyStringHex: string):
14
14
  * @param command - The APICommand instance for API calls and logging
15
15
  * @param value - The plaintext value to encrypt
16
16
  * @param secret - Secret configuration containing key name
17
- * @param environmentId - Environment ID to fetch encryption key for (empty string for default)
17
+ * @param environmentName - Environment slug to fetch encryption key for (empty string for default).
18
+ * Must be the slug stored in config files (e.g. "production"), not the DB UUID — the encryption
19
+ * key config's `environments[].id` holds slugs, and matching against UUIDs silently falls back
20
+ * to the default key and produces ciphertext that won't decrypt at runtime.
18
21
  * @returns Result<ConfigValue> with encrypted value or error
19
22
  */
20
- export declare function makeConfidentialValue(command: APICommand, value: string, secret: Secret, environmentId: string): Promise<Result<any>>;
23
+ export declare function makeConfidentialValue(command: APICommand, value: string, secret: Secret, environmentName: string): Promise<Result<any>>;
@@ -53,10 +53,13 @@ export function decrypt(encryptedString, keyStringHex) {
53
53
  * @param command - The APICommand instance for API calls and logging
54
54
  * @param value - The plaintext value to encrypt
55
55
  * @param secret - Secret configuration containing key name
56
- * @param environmentId - Environment ID to fetch encryption key for (empty string for default)
56
+ * @param environmentName - Environment slug to fetch encryption key for (empty string for default).
57
+ * Must be the slug stored in config files (e.g. "production"), not the DB UUID — the encryption
58
+ * key config's `environments[].id` holds slugs, and matching against UUIDs silently falls back
59
+ * to the default key and produces ciphertext that won't decrypt at runtime.
57
60
  * @returns Result<ConfigValue> with encrypted value or error
58
61
  */
59
- export async function makeConfidentialValue(command, value, secret, environmentId) {
62
+ export async function makeConfidentialValue(command, value, secret, environmentName) {
60
63
  // First check if the encryption key exists in metadata
61
64
  const metadataRequest = await command.apiClient.post('/api/v1/metadata/list', { workspaceId: command.workspaceId });
62
65
  if (!metadataRequest.ok) {
@@ -89,10 +92,12 @@ export async function makeConfidentialValue(command, value, secret, environmentI
89
92
  // Find the encryption key for this environment (or default)
90
93
  let secretKey;
91
94
  let envVar;
92
- // Check environment-specific config first
93
- if (keyConfig.environments && environmentId) {
95
+ // Check environment-specific config first. environments[].id is the env slug
96
+ // (e.g. "production") as stored in the git file — match by slug, not UUID.
97
+ if (keyConfig.environments && environmentName) {
98
+ const targetSlug = String(environmentName).toLowerCase();
94
99
  /* eslint-disable @typescript-eslint/no-explicit-any */
95
- const envConfig = keyConfig.environments.find((env) => String(env.id) === String(environmentId));
100
+ const envConfig = keyConfig.environments.find((env) => String(env.id).toLowerCase() === targetSlug);
96
101
  /* eslint-enable @typescript-eslint/no-explicit-any */
97
102
  const ruleValue = envConfig?.rules?.[0]?.value;
98
103
  // Check if it's a provided value (env var)
@@ -139,7 +144,7 @@ export async function makeConfidentialValue(command, value, secret, environmentI
139
144
  command.verboseLog(`Using literal value from ${secret.keyName} to encrypt secret`);
140
145
  }
141
146
  if (!secretKey) {
142
- return failure(`Failed to create secret: ${secret.keyName} does not have a value configured for environment ${environmentId || 'default'} or default env`, {
147
+ return failure(`Failed to create secret: ${secret.keyName} does not have a value configured for environment ${environmentName || 'default'} or default env`, {
143
148
  phase: 'finding-secret',
144
149
  });
145
150
  }
@@ -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
- const authConfig = await loadAuthConfig();
55
+ let authConfig = await loadAuthConfig();
56
56
  if (!authConfig) {
57
- command.error('Not logged in. Run `qfg login` first (or set QUONFIG_API_KEY for CI).', { exit: 401 });
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 saveTokens: (tokens: TokenData, options?: TokenStorageOptions) => Promise<void>;
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<void>;
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 {
@@ -69,7 +69,7 @@
69
69
  "required": true
70
70
  }
71
71
  },
72
- "description": "Create a new feature flag, config value, or other item.\n\nUse --type to specify the kind of item:\n boolean-flag On/off feature flag (sendToClientSdk defaults to true; best for gradual rollouts)\n string A string configuration value\n int An integer configuration value\n double A floating-point configuration value\n string-list A comma-separated list of strings\n json An arbitrary JSON blob\n boolean A plain boolean (not a feature flag)\n log_level A dynamic log level (value must be one of TRACE/DEBUG/INFO/WARN/ERROR/FATAL)\n\nThis sets the global default value. Override per-environment with:\n qfg set-default my.flag --environment production --value true\n\nFor a percentage rollout (gradual rollout / A/B test / canary deploy), use:\n qfg set-rollout my.flag --environment production --true-percent 20\n\nOr edit the JSON config file directly for complex targeting rules:\n qfg config-schema # full operator reference + examples\n qfg pull --dir ./config # clone workspace, then edit JSON and git push\n\nLog levels:\n qfg create log-level.my-app --type log_level --value WARN\n # Log-level keys must start with \"log-level.\".\n # For per-logger targeting, create ONE log-level config per service and add\n # rules on the \"quonfig-sdk-logging.key\" context property (e.g.\n # PROP_STARTS_WITH_ONE_OF MyPackage.) rather than one config per logger.",
72
+ "description": "Create a new feature flag, config value, or other item.\n\nUse --type to specify the kind of item:\n boolean-flag On/off feature flag (sendToClientSdk defaults to true; best for gradual rollouts)\n string A string configuration value\n int An integer configuration value\n double A floating-point configuration value\n string-list A comma-separated list of strings\n json An arbitrary JSON blob\n boolean A plain boolean (not a feature flag)\n duration An ISO 8601 duration string, e.g. PT30S, PT5M, PT1H30M (SDK returns milliseconds)\n log_level A dynamic log level (value must be one of TRACE/DEBUG/INFO/WARN/ERROR/FATAL)\n\nThis sets the global default value. Override per-environment with:\n qfg set-default my.flag --environment production --value true\n\nFor a percentage rollout (gradual rollout / A/B test / canary deploy), use:\n qfg set-rollout my.flag --environment production --true-percent 20\n\nOr edit the JSON config file directly for complex targeting rules:\n qfg config-schema # full operator reference + examples\n qfg pull --dir ./config # clone workspace, then edit JSON and git push\n\nLog levels:\n qfg create log-level.my-app --type log_level --value WARN\n # Log-level keys must start with \"log-level.\".\n # For per-logger targeting, create ONE log-level config per service and add\n # rules on the \"quonfig-sdk-logging.key\" context property (e.g.\n # PROP_STARTS_WITH_ONE_OF MyPackage.) rather than one config per logger.",
73
73
  "examples": [
74
74
  "<%= config.bin %> <%= command.id %> my.new.flag --type boolean-flag",
75
75
  "<%= config.bin %> <%= command.id %> my.new.flag --type boolean-flag --value=true",
@@ -77,6 +77,7 @@
77
77
  "<%= config.bin %> <%= command.id %> my.new.string --type string --value=\"hello world\" --secret",
78
78
  "<%= config.bin %> <%= command.id %> my.new.string --type string --env-var=MY_ENV_VAR_NAME",
79
79
  "<%= config.bin %> <%= command.id %> my.new.string --type json --value=\"{\\\"key\\\": \\\"value\\\"}\"",
80
+ "<%= config.bin %> <%= command.id %> my.timeout --type duration --value PT90S",
80
81
  "<%= config.bin %> <%= command.id %> log-level.my-app --type log_level --value WARN",
81
82
  "# After creating a flag, set a 20% rollout in production:",
82
83
  "<%= config.bin %> set-rollout my.new.flag --environment production --true-percent 20"
@@ -158,6 +159,7 @@
158
159
  "int",
159
160
  "string-list",
160
161
  "json",
162
+ "duration",
161
163
  "log_level"
162
164
  ],
163
165
  "type": "option"
@@ -295,7 +297,7 @@
295
297
  "type": "boolean"
296
298
  },
297
299
  "dir": {
298
- "description": "Path to local QUONFIG_DIR (defaults to QUONFIG_DIR env var)",
300
+ "description": "Path to local QUONFIG_DIR (defaults to QUONFIG_DIR env var). When omitted, fetches the workspace from the server using your active credentials.",
299
301
  "env": "QUONFIG_DIR",
300
302
  "name": "dir",
301
303
  "hasDynamicHelp": false,
@@ -317,6 +319,14 @@
317
319
  "hasDynamicHelp": false,
318
320
  "multiple": false,
319
321
  "type": "option"
322
+ },
323
+ "workspace": {
324
+ "char": "w",
325
+ "description": "Workspace slug or UUID for the remote-fetch path (defaults to QUONFIG_WORKSPACE env var or active profile). Only used when --dir is omitted.",
326
+ "name": "workspace",
327
+ "hasDynamicHelp": false,
328
+ "multiple": false,
329
+ "type": "option"
320
330
  }
321
331
  },
322
332
  "hasDynamicHelp": false,
@@ -1190,16 +1200,21 @@
1190
1200
  "aliases": [],
1191
1201
  "args": {
1192
1202
  "name": {
1193
- "description": "config/feature-flag/etc. name",
1203
+ "description": "flag/config key to override",
1194
1204
  "name": "name"
1205
+ },
1206
+ "value": {
1207
+ "description": "new value (type inferred: bool/int/double/json/string)",
1208
+ "name": "value"
1195
1209
  }
1196
1210
  },
1197
- "description": "Override the value of an item for your user/SDK key combo.\n\nTo target users other than yourself, or to express rules like \"user.email == X → value Y\",\nyou cannot use override — see:\n qfg config-schema # operator reference + worked multi-rule example\n qfg pull # clone the workspace so you can edit JSON directly",
1211
+ "description": "Override a flag value for your dev user.\n\nWrites a top-priority rule keyed on the dev-only quonfig-user.email property,\nso the override only fires for SDK clients that set quonfig-user.email in\ntheir context. Production SDKs typically don't set this property, which makes\noverrides effectively inert in production.\n\nExamples\n qfg override # list flags where you have an override\n qfg override my.flag true # set bool override\n qfg override my.flag 42 # set int override\n qfg override my.flag '{\"a\":1}' # set json override\n qfg override my.flag --remove # remove your override on my.flag\n qfg override --clear # remove ALL of your overrides in this env",
1198
1212
  "examples": [
1199
- "<%= config.bin %> <%= command.id %> # will prompt for name and value",
1200
- "<%= config.bin %> <%= command.id %> my.flag.name --value=true",
1201
- "<%= config.bin %> <%= command.id %> my.flag.name --remove",
1202
- "<%= config.bin %> <%= command.id %> my.double.config --value=3.14159"
1213
+ "<%= config.bin %> <%= command.id %>",
1214
+ "<%= config.bin %> <%= command.id %> my.flag true",
1215
+ "<%= config.bin %> <%= command.id %> my.flag --remove",
1216
+ "<%= config.bin %> <%= command.id %> --clear",
1217
+ "<%= config.bin %> <%= command.id %> my.flag true --env=staging"
1203
1218
  ],
1204
1219
  "flags": {
1205
1220
  "json": {
@@ -1252,25 +1267,24 @@
1252
1267
  "multiple": false,
1253
1268
  "type": "option"
1254
1269
  },
1255
- "environment": {
1256
- "description": "environment to override in",
1257
- "name": "environment",
1270
+ "clear": {
1271
+ "description": "remove ALL of your overrides in this env",
1272
+ "name": "clear",
1273
+ "allowNo": false,
1274
+ "type": "boolean"
1275
+ },
1276
+ "env": {
1277
+ "description": "environment to operate in (default: $QUONFIG_ENVIRONMENT)",
1278
+ "name": "env",
1258
1279
  "hasDynamicHelp": false,
1259
1280
  "multiple": false,
1260
1281
  "type": "option"
1261
1282
  },
1262
1283
  "remove": {
1263
- "description": "remove your override (if present)",
1284
+ "description": "remove your override on this key",
1264
1285
  "name": "remove",
1265
1286
  "allowNo": false,
1266
1287
  "type": "boolean"
1267
- },
1268
- "value": {
1269
- "description": "value to use for your override",
1270
- "name": "value",
1271
- "hasDynamicHelp": false,
1272
- "multiple": false,
1273
- "type": "option"
1274
1288
  }
1275
1289
  },
1276
1290
  "hasDynamicHelp": false,
@@ -2882,5 +2896,5 @@
2882
2896
  ]
2883
2897
  }
2884
2898
  },
2885
- "version": "0.0.19"
2899
+ "version": "0.0.21"
2886
2900
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "packageManager": "yarn@4.11.0+sha512.4e54aeace9141df2f0177c266b05ec50dc044638157dae128c471ba65994ac802122d7ab35bcd9e81641228b7dcf24867d28e750e0bcae8a05277d600008ad54",
3
3
  "name": "@quonfig/cli",
4
- "version": "0.0.19",
4
+ "version": "0.0.21",
5
5
  "bugs": {
6
6
  "url": "https://github.com/quonfig/cli/issues"
7
7
  },