@quonfig/cli 0.0.20 → 0.0.22

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.
@@ -0,0 +1,114 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { APICommand } from '../index.js';
3
+ import { confirmTypedSlug } from '../push/confirm.js';
4
+ import isInteractive from '../util/is-interactive.js';
5
+ import { checkmark } from '../util/color.js';
6
+ import { loadTokens } from '../util/token-storage.js';
7
+ const DELETE_ROUTES = {
8
+ config: { endpoint: '/api/v1/configs/delete', keyField: 'configKey' },
9
+ feature_flag: { endpoint: '/api/v1/flags/delete', keyField: 'flagKey' },
10
+ log_level: { endpoint: '/api/v1/logLevels/delete', keyField: 'logLevelKey' },
11
+ };
12
+ /**
13
+ * Parse the fresh SHA out of a 409 conflict message of the form
14
+ * "<path> was modified (expected <oldSha>, got <newSha>)"
15
+ */
16
+ function parseFreshShaFromConflict(message) {
17
+ if (!message)
18
+ return undefined;
19
+ const match = message.match(/got\s+([\w.-]+)\)/);
20
+ return match?.[1];
21
+ }
22
+ export default class Delete extends APICommand {
23
+ static args = {
24
+ name: Args.string({ description: 'flag/config/log-level key to delete', required: true }),
25
+ };
26
+ static description = `Delete a flag, config, or log-level from the workspace.
27
+
28
+ DESTRUCTIVE — removes the underlying JSON file from the workspace git repo
29
+ and commits with the actor's identity. The commit history preserves the
30
+ prior content; this is a hard-delete on the file but a soft-delete on
31
+ history.
32
+
33
+ Confirmation is required:
34
+ --yes skip the prompt (for scripts and CI)
35
+ interactive (default) you'll be asked to type the key name back
36
+
37
+ Examples
38
+ qfg delete my.flag --yes
39
+ qfg delete my.flag # interactive: type the key back to confirm
40
+ qfg delete my.config --yes`;
41
+ static examples = [
42
+ '<%= config.bin %> <%= command.id %> my.flag --yes',
43
+ '<%= config.bin %> <%= command.id %> my.config --yes',
44
+ ];
45
+ static flags = {
46
+ yes: Flags.boolean({ default: false, description: 'skip confirmation prompt' }),
47
+ };
48
+ async run() {
49
+ const { args, flags } = await this.parse(Delete);
50
+ const tokens = await loadTokens();
51
+ if (!tokens?.accessToken) {
52
+ return this.err('Not logged in. Run `qfg login` first.');
53
+ }
54
+ const key = args.name;
55
+ const metaRes = await this.apiClient.post('/api/v1/metadata/list', { workspaceId: this.workspaceId });
56
+ if (!metaRes.ok) {
57
+ return this.err(`Failed to look up ${key}: ${metaRes.status} ${JSON.stringify(metaRes.error)}`);
58
+ }
59
+ const items = metaRes.json?.configs ?? [];
60
+ const item = items.find((i) => i.key === key);
61
+ if (!item) {
62
+ return this.err(`${key} not found in this workspace.`);
63
+ }
64
+ const route = DELETE_ROUTES[item.type];
65
+ if (!route) {
66
+ return this.err(`Cannot delete ${key}: unsupported type "${item.type}". Supported: flag, config, log-level.`);
67
+ }
68
+ if (!flags.yes) {
69
+ if (!isInteractive(flags)) {
70
+ return this.err(`Refusing to delete ${key} without confirmation. Pass --yes or run interactively.`);
71
+ }
72
+ this.log(`About to delete ${item.type.replaceAll('_', ' ')}: ${key}`);
73
+ this.log('This rewrites the workspace repo and is destructive.');
74
+ const confirmed = await confirmTypedSlug(key, `Type "${key}" to confirm: `);
75
+ if (!confirmed) {
76
+ return this.err(`Aborted: ${key} was not typed back exactly. No changes made.`);
77
+ }
78
+ }
79
+ let expectedCommitSha = item.version;
80
+ let attempt = 0;
81
+ while (attempt < 2) {
82
+ attempt += 1;
83
+ const payload = {
84
+ workspaceId: this.workspaceId,
85
+ [route.keyField]: key,
86
+ ...(expectedCommitSha ? { expectedCommitSha } : {}),
87
+ };
88
+ this.verboseLog(`RPC ${route.endpoint}`, payload);
89
+ // eslint-disable-next-line no-await-in-loop
90
+ const res = await this.apiClient.post(route.endpoint, payload);
91
+ if (res.ok) {
92
+ const commitSha = res.json?.commitSha;
93
+ const shaSuffix = commitSha ? ` (commit ${commitSha})` : '';
94
+ return this.ok(`${checkmark} Deleted ${item.type.replaceAll('_', ' ')}: ${key}${shaSuffix}`, {
95
+ key,
96
+ type: item.type,
97
+ ...(commitSha ? { commitSha } : {}),
98
+ });
99
+ }
100
+ if (res.status === 404) {
101
+ return this.err(`${key} not found on the server (it may have been deleted by someone else).`);
102
+ }
103
+ if (res.status === 409 && attempt === 1) {
104
+ const fresh = parseFreshShaFromConflict(res.error?.message);
105
+ if (fresh) {
106
+ this.verboseLog(`Stale SHA — retrying delete with fresh SHA ${fresh}`);
107
+ expectedCommitSha = fresh;
108
+ continue;
109
+ }
110
+ }
111
+ return this.err(`Failed to delete ${key}: ${res.status} ${JSON.stringify(res.error)}`);
112
+ }
113
+ }
114
+ }
@@ -7,6 +7,7 @@ export default class Generate extends BaseCommand {
7
7
  dir: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
8
8
  'output-directory': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
9
  targets: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
10
+ workspace: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
10
11
  };
11
12
  static summary: string;
12
13
  run(): Promise<JsonObj | void>;
@@ -4,6 +4,7 @@ import * as path from 'node:path';
4
4
  import { z } from 'zod';
5
5
  import { NodeTypeScriptGenerator } from '../codegen/code-generators/node-typescript-generator.js';
6
6
  import { ReactTypeScriptGenerator } from '../codegen/code-generators/react-typescript-generator.js';
7
+ import { fetchWorkspaceSnapshot } from '../codegen/fetch-workspace-snapshot.js';
7
8
  import { LocalConfigReader } from '../codegen/local-config-reader.js';
8
9
  import { SupportedLanguage } from '../codegen/types.js';
9
10
  import { BaseCommand } from '../index.js';
@@ -109,7 +110,7 @@ Example quonfig.config.json:
109
110
  ];
110
111
  static flags = {
111
112
  dir: Flags.string({
112
- description: 'Path to local QUONFIG_DIR (defaults to QUONFIG_DIR env var)',
113
+ description: 'Path to local QUONFIG_DIR (defaults to QUONFIG_DIR env var). When omitted, fetches the workspace from the server using your active credentials.',
113
114
  env: 'QUONFIG_DIR',
114
115
  }),
115
116
  'output-directory': Flags.string({
@@ -120,16 +121,25 @@ Example quonfig.config.json:
120
121
  default: SupportedLanguage.React,
121
122
  description: `Determines for language/framework to generate code for (${Object.values(SupportedLanguage).join(', ')})`,
122
123
  }),
124
+ workspace: Flags.string({
125
+ char: 'w',
126
+ 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.',
127
+ }),
123
128
  };
124
129
  static summary = 'Generate type definitions for your Quonfig configuration';
125
130
  async run() {
126
131
  const { flags } = await this.parse(Generate);
127
132
  this.verboseLog('=== GENERATE COMMAND START ===');
133
+ let snapshot;
128
134
  try {
129
- // Resolve workspace directory
130
- const dir = flags.dir;
135
+ // When --dir/QUONFIG_DIR is set, read the local checkout (so users
136
+ // who edit flag JSON in place keep that workflow). Otherwise clone
137
+ // the workspace into a tmp dir for codegen-only callers.
138
+ let dir = flags.dir;
131
139
  if (!dir) {
132
- this.error('No directory specified. Run `qfg pull` first or pass --dir <path>.');
140
+ this.verboseLog('No --dir/QUONFIG_DIR set; fetching workspace snapshot from server...');
141
+ snapshot = await fetchWorkspaceSnapshot(this, { workspace: flags.workspace });
142
+ dir = snapshot.dir;
133
143
  }
134
144
  // Look for and read local quonfig.config.json file
135
145
  const localConfig = await this.readLocalConfig(flags['output-directory']);
@@ -142,6 +152,7 @@ Example quonfig.config.json:
142
152
  const configFile = await reader.read();
143
153
  this.verboseLog(`Config read complete. Found ${configFile.configs.length} configs.`);
144
154
  const fileCreationPromises = [];
155
+ let needsMustache = false;
145
156
  for (const target of targets) {
146
157
  // Resolve the language input
147
158
  const language = this.resolveLanguage(target);
@@ -154,6 +165,9 @@ Example quonfig.config.json:
154
165
  console.log(`Generating ${language} code for configs...`);
155
166
  const generatedCode = generator.generate();
156
167
  this.verboseLog(`Code generation complete. Size: ${generatedCode.length}`);
168
+ if (generatedCode.includes("from 'mustache'")) {
169
+ needsMustache = true;
170
+ }
157
171
  const fileManager = createFileManager({ outputDirectory: outputDir, verboseLog: this.verboseLog.bind(this) });
158
172
  fileCreationPromises.push(fileManager.writeFile({ data: generatedCode, filename: targetConfig.clientFileName }));
159
173
  if ([SupportedLanguage.Node, SupportedLanguage.React].includes(language)) {
@@ -163,11 +177,26 @@ Example quonfig.config.json:
163
177
  }
164
178
  }
165
179
  await Promise.all(fileCreationPromises);
180
+ if (needsMustache) {
181
+ console.log('\nNote: generated code imports `mustache` for templated config values.\n' +
182
+ ' Add it to your project: `npm install mustache @types/mustache`\n' +
183
+ ' (or pnpm/yarn equivalent).');
184
+ }
166
185
  }
167
186
  catch (error) {
168
187
  console.error('ERROR:', error);
169
188
  this.error(error);
170
189
  }
190
+ finally {
191
+ if (snapshot) {
192
+ try {
193
+ await snapshot.cleanup();
194
+ }
195
+ catch (cleanupError) {
196
+ this.verboseLog('Generate', `Snapshot cleanup failed: ${String(cleanupError)}`);
197
+ }
198
+ }
199
+ }
171
200
  this.verboseLog('=== GENERATE COMMAND END ===');
172
201
  return { success: true };
173
202
  }
@@ -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
  }