@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.
- package/README.md +123 -59
- package/dist/codegen/fetch-workspace-snapshot.d.ts +18 -0
- package/dist/codegen/fetch-workspace-snapshot.js +52 -0
- package/dist/codegen/local-config-reader.js +6 -1
- package/dist/commands/create.js +3 -1
- package/dist/commands/delete.d.ts +13 -0
- package/dist/commands/delete.js +114 -0
- package/dist/commands/generate.d.ts +1 -0
- package/dist/commands/generate.js +33 -4
- package/dist/commands/login.js +36 -20
- package/dist/commands/override.d.ts +11 -5
- package/dist/commands/override.js +244 -148
- package/dist/quonfig.js +2 -1
- package/dist/util/coerce.js +21 -1
- package/dist/util/config-value-dto.js +3 -1
- package/dist/util/domain-urls.d.ts +1 -0
- package/dist/util/domain-urls.js +7 -0
- package/dist/util/resolve-workspace.js +69 -3
- package/dist/util/token-storage.d.ts +4 -2
- package/dist/util/token-storage.js +27 -0
- package/oclif.manifest.json +121 -20
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
130
|
-
|
|
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.
|
|
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
|
}
|
package/dist/commands/login.js
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
16
|
-
private
|
|
16
|
+
private callFindOrCreate;
|
|
17
|
+
private callRemove;
|
|
18
|
+
private fetchFlag;
|
|
19
|
+
private runClear;
|
|
20
|
+
private runList;
|
|
21
|
+
private runRemove;
|
|
22
|
+
private runSet;
|
|
17
23
|
}
|