@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.
@@ -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.20"
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.20",
4
+ "version": "0.0.21",
5
5
  "bugs": {
6
6
  "url": "https://github.com/quonfig/cli/issues"
7
7
  },