@quonfig/cli 0.0.2 → 0.0.4
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 +119 -22
- package/bin/dev.js +5 -9
- package/bin/dev.mjs +6 -0
- package/dist/codegen/zod-introspection.js +13 -4
- package/dist/commands/login.js +27 -6
- package/dist/commands/mcp.js +1 -1
- package/dist/commands/pull.d.ts +15 -0
- package/dist/commands/pull.js +176 -0
- package/dist/commands/serve.js +3 -1
- package/dist/commands/sync.d.ts +18 -0
- package/dist/commands/sync.js +142 -0
- package/dist/commands/whoami.js +4 -1
- package/dist/commands/workspace/bootstrap.d.ts +13 -0
- package/dist/commands/workspace/bootstrap.js +155 -0
- package/dist/init/init-workspace.js +4 -4
- package/dist/init/templates.js +1 -1
- package/dist/util/coerce.js +2 -1
- package/dist/util/get-client.js +6 -44
- package/dist/util/get-valid-token.d.ts +5 -0
- package/dist/util/get-valid-token.js +44 -0
- package/dist/util/git-ops.d.ts +32 -0
- package/dist/util/git-ops.js +164 -0
- package/dist/util/gitea-api.d.ts +11 -0
- package/dist/util/gitea-api.js +45 -0
- package/dist/util/gitea-token-storage.d.ts +8 -0
- package/dist/util/gitea-token-storage.js +47 -0
- package/dist/util/token-storage.d.ts +1 -0
- package/dist/util/token-storage.js +24 -10
- package/dist/verify/validate.d.ts +1 -0
- package/dist/verify/validate.js +136 -6
- package/oclif.manifest.json +223 -2
- package/package.json +2 -2
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
2
|
+
import * as util from 'node:util';
|
|
3
|
+
const execFile = util.promisify(execFileCb);
|
|
4
|
+
/**
|
|
5
|
+
* Redact a token from a URL string so it is safe to display to users.
|
|
6
|
+
*/
|
|
7
|
+
export const redactToken = (url) => url.replace(/:([^@/]+)@/, ':***@');
|
|
8
|
+
/**
|
|
9
|
+
* Wrap execFile errors to strip tokens from the message.
|
|
10
|
+
*/
|
|
11
|
+
const safeExec = async (file, args, options) => {
|
|
12
|
+
try {
|
|
13
|
+
return await execFile(file, args, { cwd: options?.cwd });
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
const e = err;
|
|
17
|
+
const message = redactToken(e.message || String(err));
|
|
18
|
+
const stderr = redactToken(e.stderr ?? '');
|
|
19
|
+
const safeErr = new Error(message);
|
|
20
|
+
safeErr.stderr = stderr;
|
|
21
|
+
throw safeErr;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
export const gitClone = async (repoUrl, dir) => {
|
|
25
|
+
await safeExec('git', ['clone', repoUrl, dir]);
|
|
26
|
+
};
|
|
27
|
+
export const isGitRepo = async (dir) => {
|
|
28
|
+
try {
|
|
29
|
+
await execFile('git', ['-C', dir, 'rev-parse', '--git-dir']);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
export const getRemoteUrl = async (dir) => {
|
|
37
|
+
try {
|
|
38
|
+
const { stdout } = await execFile('git', ['-C', dir, 'remote', 'get-url', 'origin']);
|
|
39
|
+
return stdout.trim() || null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
export const isWorkingTreeClean = async (dir) => {
|
|
46
|
+
const { stdout } = await execFile('git', ['-C', dir, 'status', '--porcelain']);
|
|
47
|
+
return stdout.trim() === '';
|
|
48
|
+
};
|
|
49
|
+
export const gitFetch = async (dir) => {
|
|
50
|
+
await safeExec('git', ['-C', dir, 'fetch', 'origin']);
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Returns true if origin/main has commits that can be fast-forwarded into the local branch.
|
|
54
|
+
*/
|
|
55
|
+
export const canFastForward = async (dir) => {
|
|
56
|
+
try {
|
|
57
|
+
// Get the local HEAD and origin/main SHAs
|
|
58
|
+
const { stdout: localSha } = await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
|
|
59
|
+
const { stdout: remoteSha } = await execFile('git', ['-C', dir, 'rev-parse', 'origin/main']);
|
|
60
|
+
const local = localSha.trim();
|
|
61
|
+
const remote = remoteSha.trim();
|
|
62
|
+
if (local === remote)
|
|
63
|
+
return false; // already up to date
|
|
64
|
+
// Check if local is an ancestor of remote (i.e. ff is possible)
|
|
65
|
+
try {
|
|
66
|
+
await execFile('git', ['-C', dir, 'merge-base', '--is-ancestor', local, remote]);
|
|
67
|
+
return true; // exit code 0 means local is an ancestor of remote
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false; // local has diverged
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Returns true if local has commits not reachable from origin/main (diverged).
|
|
79
|
+
*/
|
|
80
|
+
export const hasDivergedFromRemote = async (dir) => {
|
|
81
|
+
try {
|
|
82
|
+
const { stdout: localSha } = await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
|
|
83
|
+
const { stdout: remoteSha } = await execFile('git', ['-C', dir, 'rev-parse', 'origin/main']);
|
|
84
|
+
const local = localSha.trim();
|
|
85
|
+
const remote = remoteSha.trim();
|
|
86
|
+
if (local === remote)
|
|
87
|
+
return false;
|
|
88
|
+
// If remote is an ancestor of local, local is ahead (diverged for our purposes)
|
|
89
|
+
try {
|
|
90
|
+
await execFile('git', ['-C', dir, 'merge-base', '--is-ancestor', remote, local]);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Check if truly diverged (neither is ancestor of the other)
|
|
95
|
+
try {
|
|
96
|
+
await execFile('git', ['-C', dir, 'merge-base', '--is-ancestor', local, remote]);
|
|
97
|
+
return false; // ff possible, not diverged
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return true; // truly diverged
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Performs a fast-forward-only merge of origin/main. Returns list of new commit subjects.
|
|
110
|
+
*/
|
|
111
|
+
export const gitMergeFfOnly = async (dir) => {
|
|
112
|
+
// Get commits that will be merged (before merge)
|
|
113
|
+
let newCommits = [];
|
|
114
|
+
try {
|
|
115
|
+
const { stdout: localSha } = await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
|
|
116
|
+
const { stdout: log } = await execFile('git', [
|
|
117
|
+
'-C',
|
|
118
|
+
dir,
|
|
119
|
+
'log',
|
|
120
|
+
'--pretty=format:%s',
|
|
121
|
+
`${localSha.trim()}..origin/main`,
|
|
122
|
+
]);
|
|
123
|
+
newCommits = log
|
|
124
|
+
.split('\n')
|
|
125
|
+
.map((s) => s.trim())
|
|
126
|
+
.filter(Boolean);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Non-fatal — we'll still attempt the merge
|
|
130
|
+
}
|
|
131
|
+
await safeExec('git', ['-C', dir, 'merge', '--ff-only', 'origin/main']);
|
|
132
|
+
return newCommits;
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Adds or updates the origin remote for a repo.
|
|
136
|
+
*/
|
|
137
|
+
export const gitSetRemote = async (dir, url) => {
|
|
138
|
+
const existing = await getRemoteUrl(dir);
|
|
139
|
+
if (existing !== null) {
|
|
140
|
+
await safeExec('git', ['-C', dir, 'remote', 'set-url', 'origin', url]);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
await safeExec('git', ['-C', dir, 'remote', 'add', 'origin', url]);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
export const gitPushForceLease = async (dir) => {
|
|
147
|
+
await safeExec('git', ['-C', dir, 'push', 'origin', 'main', '--force-with-lease']);
|
|
148
|
+
};
|
|
149
|
+
export const gitPushForce = async (dir) => {
|
|
150
|
+
await safeExec('git', ['-C', dir, 'push', 'origin', 'main', '--force']);
|
|
151
|
+
};
|
|
152
|
+
export const hasAtLeastOneCommit = async (dir) => {
|
|
153
|
+
try {
|
|
154
|
+
await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Get the URL stripped of credentials for display purposes.
|
|
163
|
+
*/
|
|
164
|
+
export const displayUrl = (url) => redactToken(url);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { GiteaTokenEntry } from './gitea-token-storage.js';
|
|
2
|
+
export interface GiteaTokenResponse {
|
|
3
|
+
token: string;
|
|
4
|
+
repoUrl: string;
|
|
5
|
+
expiresAt: string | null;
|
|
6
|
+
}
|
|
7
|
+
export declare const mintGiteaToken: (workspaceId: string, scope: "read" | "write", purpose: "pull" | "bootstrap") => Promise<GiteaTokenResponse>;
|
|
8
|
+
/**
|
|
9
|
+
* Mint a Gitea read token, store it, and return the entry.
|
|
10
|
+
*/
|
|
11
|
+
export declare const mintAndStoreGiteaReadToken: (workspaceId: string) => Promise<GiteaTokenEntry>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getApiUrl } from './domain-urls.js';
|
|
2
|
+
import { getValidAccessToken } from './get-valid-token.js';
|
|
3
|
+
import { saveGiteaToken } from './gitea-token-storage.js';
|
|
4
|
+
export const mintGiteaToken = async (workspaceId, scope, purpose) => {
|
|
5
|
+
const accessToken = await getValidAccessToken();
|
|
6
|
+
const apiUrl = getApiUrl();
|
|
7
|
+
const res = await fetch(`${apiUrl}/api/v1/gitea/token`, {
|
|
8
|
+
method: 'POST',
|
|
9
|
+
headers: {
|
|
10
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
11
|
+
'Content-Type': 'application/json',
|
|
12
|
+
},
|
|
13
|
+
body: JSON.stringify({ json: { scope, workspaceId, purpose } }),
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
const text = await res.text().catch(() => '');
|
|
17
|
+
let message = `HTTP ${res.status}`;
|
|
18
|
+
try {
|
|
19
|
+
const body = JSON.parse(text);
|
|
20
|
+
// oRPC error shape
|
|
21
|
+
if (body?.json?.message)
|
|
22
|
+
message = body.json.message;
|
|
23
|
+
else if (body?.message)
|
|
24
|
+
message = body.message;
|
|
25
|
+
}
|
|
26
|
+
catch { /* use raw text */ }
|
|
27
|
+
throw new Error(message || text);
|
|
28
|
+
}
|
|
29
|
+
const body = await res.json();
|
|
30
|
+
const data = body.json ?? body;
|
|
31
|
+
return data;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Mint a Gitea read token, store it, and return the entry.
|
|
35
|
+
*/
|
|
36
|
+
export const mintAndStoreGiteaReadToken = async (workspaceId) => {
|
|
37
|
+
const data = await mintGiteaToken(workspaceId, 'read', 'pull');
|
|
38
|
+
const entry = {
|
|
39
|
+
token: data.token,
|
|
40
|
+
repoUrl: data.repoUrl,
|
|
41
|
+
expiresAt: data.expiresAt,
|
|
42
|
+
};
|
|
43
|
+
await saveGiteaToken(workspaceId, entry);
|
|
44
|
+
return entry;
|
|
45
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface GiteaTokenEntry {
|
|
2
|
+
token: string;
|
|
3
|
+
repoUrl: string;
|
|
4
|
+
expiresAt: string | null;
|
|
5
|
+
}
|
|
6
|
+
export declare const loadGiteaToken: (workspaceId: string) => Promise<GiteaTokenEntry | null>;
|
|
7
|
+
export declare const saveGiteaToken: (workspaceId: string, entry: GiteaTokenEntry) => Promise<void>;
|
|
8
|
+
export declare const isGiteaTokenExpired: (entry: GiteaTokenEntry) => boolean;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { getDomain } from './domain-urls.js';
|
|
5
|
+
const BUFFER_MS = 5 * 60 * 1000; // 5 minutes
|
|
6
|
+
const getQuonfigDir = () => path.join(os.homedir(), '.quonfig');
|
|
7
|
+
const getGiteaTokenFile = () => {
|
|
8
|
+
const domain = getDomain();
|
|
9
|
+
const filename = domain !== 'quonfig.com' ? `gitea-tokens-${domain.replaceAll('.', '-')}.json` : 'gitea-tokens.json';
|
|
10
|
+
return path.join(getQuonfigDir(), filename);
|
|
11
|
+
};
|
|
12
|
+
const ensureQuonfigDir = async () => {
|
|
13
|
+
const dir = getQuonfigDir();
|
|
14
|
+
try {
|
|
15
|
+
await fs.promises.access(dir, fs.constants.F_OK);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const loadStore = async () => {
|
|
22
|
+
try {
|
|
23
|
+
const data = await fs.promises.readFile(getGiteaTokenFile(), 'utf8');
|
|
24
|
+
return JSON.parse(data);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
export const loadGiteaToken = async (workspaceId) => {
|
|
31
|
+
const store = await loadStore();
|
|
32
|
+
return store[workspaceId] ?? null;
|
|
33
|
+
};
|
|
34
|
+
export const saveGiteaToken = async (workspaceId, entry) => {
|
|
35
|
+
await ensureQuonfigDir();
|
|
36
|
+
const store = await loadStore();
|
|
37
|
+
store[workspaceId] = entry;
|
|
38
|
+
const targetFile = getGiteaTokenFile();
|
|
39
|
+
const tmpFile = `${targetFile}.tmp`;
|
|
40
|
+
await fs.promises.writeFile(tmpFile, JSON.stringify(store, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
41
|
+
await fs.promises.rename(tmpFile, targetFile);
|
|
42
|
+
};
|
|
43
|
+
export const isGiteaTokenExpired = (entry) => {
|
|
44
|
+
if (!entry.expiresAt)
|
|
45
|
+
return false;
|
|
46
|
+
return new Date(entry.expiresAt).getTime() - BUFFER_MS < Date.now();
|
|
47
|
+
};
|
|
@@ -20,7 +20,10 @@ const ensureQuonfigDir = async (options) => {
|
|
|
20
20
|
};
|
|
21
21
|
export const saveTokens = async (tokens, options) => {
|
|
22
22
|
await ensureQuonfigDir(options);
|
|
23
|
-
|
|
23
|
+
const targetFile = getTokenFile(options);
|
|
24
|
+
const tmpFile = `${targetFile}.tmp`;
|
|
25
|
+
await fs.promises.writeFile(tmpFile, JSON.stringify(tokens, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
26
|
+
await fs.promises.rename(tmpFile, targetFile);
|
|
24
27
|
};
|
|
25
28
|
export const loadTokens = async (options) => {
|
|
26
29
|
try {
|
|
@@ -45,9 +48,16 @@ export const saveAuthConfig = async (config, options) => {
|
|
|
45
48
|
if (profileData.workspaceName) {
|
|
46
49
|
configContent += ` # ${profileData.workspaceName}`;
|
|
47
50
|
}
|
|
48
|
-
configContent += '\n
|
|
51
|
+
configContent += '\n';
|
|
52
|
+
if (profileData.workspaceSlug) {
|
|
53
|
+
configContent += `workspace_slug = ${profileData.workspaceSlug}\n`;
|
|
54
|
+
}
|
|
55
|
+
configContent += '\n';
|
|
49
56
|
}
|
|
50
|
-
|
|
57
|
+
const configTarget = getConfigFile(options);
|
|
58
|
+
const configTmp = `${configTarget}.tmp`;
|
|
59
|
+
await fs.promises.writeFile(configTmp, configContent, { encoding: 'utf8', mode: 0o600 });
|
|
60
|
+
await fs.promises.rename(configTmp, configTarget);
|
|
51
61
|
};
|
|
52
62
|
export const loadAuthConfig = async (options) => {
|
|
53
63
|
try {
|
|
@@ -60,16 +70,20 @@ export const loadAuthConfig = async (options) => {
|
|
|
60
70
|
if (defaultMatch && defaultMatch[1]) {
|
|
61
71
|
config.defaultProfile = defaultMatch[1].trim();
|
|
62
72
|
}
|
|
63
|
-
// Parse profiles
|
|
64
|
-
const
|
|
73
|
+
// Parse profiles — each block starts with [profile name] and contains key=value lines
|
|
74
|
+
const profileBlockRegex = /\[profile\s+(\w+)]\s*\n((?:[^\[]*\n?)*)/g;
|
|
65
75
|
let match;
|
|
66
|
-
while ((match =
|
|
76
|
+
while ((match = profileBlockRegex.exec(data)) !== null) {
|
|
67
77
|
const profileName = match[1];
|
|
68
|
-
const
|
|
69
|
-
const
|
|
78
|
+
const block = match[2];
|
|
79
|
+
const workspaceMatch = block.match(/workspace\s*=\s*([^\s#]+)(?:\s*#\s*(.+))?/);
|
|
80
|
+
const slugMatch = block.match(/workspace_slug\s*=\s*(\S+)/);
|
|
81
|
+
if (!workspaceMatch)
|
|
82
|
+
continue;
|
|
70
83
|
config.profiles[profileName] = {
|
|
71
|
-
workspace,
|
|
72
|
-
workspaceName,
|
|
84
|
+
workspace: workspaceMatch[1],
|
|
85
|
+
workspaceName: workspaceMatch[2]?.trim(),
|
|
86
|
+
workspaceSlug: slugMatch?.[1]?.trim(),
|
|
73
87
|
};
|
|
74
88
|
}
|
|
75
89
|
return Object.keys(config.profiles).length > 0 ? config : null;
|
package/dist/verify/validate.js
CHANGED
|
@@ -202,6 +202,7 @@ export function validateWorkspace(workspaceDir) {
|
|
|
202
202
|
rules: 0,
|
|
203
203
|
segmentRefsChecked: 0,
|
|
204
204
|
schemaRefsChecked: 0,
|
|
205
|
+
envRefsChecked: 0,
|
|
205
206
|
uniqueKeysVerified: 0,
|
|
206
207
|
};
|
|
207
208
|
// Collect all configs for cross-reference checks
|
|
@@ -209,6 +210,7 @@ export function validateWorkspace(workspaceDir) {
|
|
|
209
210
|
const allSchemaFiles = [];
|
|
210
211
|
const segmentKeys = new Set();
|
|
211
212
|
const schemaKeys = new Set();
|
|
213
|
+
const declaredEnvIds = new Set();
|
|
212
214
|
// Check for unexpected top-level entries
|
|
213
215
|
let entries;
|
|
214
216
|
try {
|
|
@@ -238,6 +240,50 @@ export function validateWorkspace(workspaceDir) {
|
|
|
238
240
|
});
|
|
239
241
|
}
|
|
240
242
|
}
|
|
243
|
+
// Validate quonfig.json at workspace root
|
|
244
|
+
const quonfigPath = path.join(workspaceDir, "quonfig.json");
|
|
245
|
+
if (!fs.existsSync(quonfigPath)) {
|
|
246
|
+
issues.push({
|
|
247
|
+
file: "quonfig.json",
|
|
248
|
+
message: `quonfig.json is missing from workspace root`,
|
|
249
|
+
severity: "error",
|
|
250
|
+
suggestion: `Create quonfig.json with format: {"environments": ["production", "staging"]}`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
let quonfigRaw;
|
|
255
|
+
try {
|
|
256
|
+
quonfigRaw = fs.readFileSync(quonfigPath, "utf-8");
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
quonfigRaw = "";
|
|
260
|
+
issues.push({ file: "quonfig.json", message: `Cannot read quonfig.json: ${err.message}`, severity: "error" });
|
|
261
|
+
}
|
|
262
|
+
if (quonfigRaw) {
|
|
263
|
+
let quonfigParsed;
|
|
264
|
+
try {
|
|
265
|
+
quonfigParsed = JSON.parse(quonfigRaw);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
quonfigParsed = null;
|
|
269
|
+
issues.push({ file: "quonfig.json", message: `Invalid JSON: ${err.message}`, severity: "error" });
|
|
270
|
+
}
|
|
271
|
+
const quonfigResult = z.object({ environments: z.array(z.string()) }).safeParse(quonfigParsed);
|
|
272
|
+
if (!quonfigResult.success) {
|
|
273
|
+
issues.push({
|
|
274
|
+
file: "quonfig.json",
|
|
275
|
+
message: `quonfig.json must have an "environments" array of strings`,
|
|
276
|
+
severity: "error",
|
|
277
|
+
suggestion: `Format: {"environments": ["production", "staging"]}`,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
for (const envId of quonfigResult.data.environments) {
|
|
282
|
+
declaredEnvIds.add(envId);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
241
287
|
// First pass: parse all files and collect keys
|
|
242
288
|
for (const dir of KNOWN_DIRS) {
|
|
243
289
|
const dirPath = path.join(workspaceDir, dir);
|
|
@@ -351,9 +397,9 @@ export function validateWorkspace(workspaceDir) {
|
|
|
351
397
|
// Validate environment IDs are slugs, not UUIDs
|
|
352
398
|
validateEnvironmentIds(config.environments, relPath, issues);
|
|
353
399
|
// Validate rules
|
|
354
|
-
validateRules(config.default.rules, relPath, "default", config.valueType, issues);
|
|
400
|
+
validateRules(config.default.rules, relPath, "default", config.valueType, issues, config.variants);
|
|
355
401
|
for (const env of config.environments) {
|
|
356
|
-
validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues);
|
|
402
|
+
validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues, config.variants);
|
|
357
403
|
}
|
|
358
404
|
// Count by type
|
|
359
405
|
switch (config.type) {
|
|
@@ -435,6 +481,20 @@ export function validateWorkspace(workspaceDir) {
|
|
|
435
481
|
});
|
|
436
482
|
}
|
|
437
483
|
}
|
|
484
|
+
// Check environment IDs referenced in config exist in quonfig.json
|
|
485
|
+
if (declaredEnvIds.size > 0) {
|
|
486
|
+
for (const env of config.environments) {
|
|
487
|
+
stats.envRefsChecked++;
|
|
488
|
+
if (!declaredEnvIds.has(env.id)) {
|
|
489
|
+
issues.push({
|
|
490
|
+
file: relPath,
|
|
491
|
+
message: `References environment "${env.id}" which is not declared in quonfig.json`,
|
|
492
|
+
severity: "error",
|
|
493
|
+
suggestion: `Add "${env.id}" to the environments array in quonfig.json, or remove this environment override`,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
438
498
|
}
|
|
439
499
|
}
|
|
440
500
|
// Check for duplicate keys across directories
|
|
@@ -479,7 +539,7 @@ function emptyStats() {
|
|
|
479
539
|
return {
|
|
480
540
|
configs: 0, featureFlags: 0, segments: 0, logLevels: 0, schemas: 0,
|
|
481
541
|
environmentOverrides: 0, rules: 0, segmentRefsChecked: 0,
|
|
482
|
-
schemaRefsChecked: 0, uniqueKeysVerified: 0,
|
|
542
|
+
schemaRefsChecked: 0, envRefsChecked: 0, uniqueKeysVerified: 0,
|
|
483
543
|
};
|
|
484
544
|
}
|
|
485
545
|
// ── Validate from in-memory file map (for git hook) ─────────────────────
|
|
@@ -495,6 +555,35 @@ export function validateFileMap(files) {
|
|
|
495
555
|
const allSchemaFiles = [];
|
|
496
556
|
const allConfigs = [];
|
|
497
557
|
const parsedConfigs = [];
|
|
558
|
+
const declaredEnvIds = new Set();
|
|
559
|
+
// Validate quonfig.json if it's included in the file map (it may not be in every commit)
|
|
560
|
+
const quonfigContent = files.get("quonfig.json");
|
|
561
|
+
if (quonfigContent !== undefined) {
|
|
562
|
+
let quonfigParsed;
|
|
563
|
+
try {
|
|
564
|
+
quonfigParsed = JSON.parse(quonfigContent);
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
quonfigParsed = null;
|
|
568
|
+
issues.push({ file: "quonfig.json", message: `Invalid JSON: ${err.message}`, severity: "error" });
|
|
569
|
+
}
|
|
570
|
+
if (quonfigParsed !== null) {
|
|
571
|
+
const quonfigResult = z.object({ environments: z.array(z.string()) }).safeParse(quonfigParsed);
|
|
572
|
+
if (!quonfigResult.success) {
|
|
573
|
+
issues.push({
|
|
574
|
+
file: "quonfig.json",
|
|
575
|
+
message: `quonfig.json must have an "environments" array of strings`,
|
|
576
|
+
severity: "error",
|
|
577
|
+
suggestion: `Format: {"environments": ["production", "staging"]}`,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
for (const envId of quonfigResult.data.environments) {
|
|
582
|
+
declaredEnvIds.add(envId);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
498
587
|
for (const [relPath, content] of files) {
|
|
499
588
|
const parts = relPath.split("/");
|
|
500
589
|
if (parts.length < 2)
|
|
@@ -576,9 +665,9 @@ export function validateFileMap(files) {
|
|
|
576
665
|
issues.push({ file: relPath, message: `Log level must have valueType "log_level", got "${config.valueType}"`, severity: "error" });
|
|
577
666
|
}
|
|
578
667
|
validateEnvironmentIds(config.environments, relPath, issues);
|
|
579
|
-
validateRules(config.default.rules, relPath, "default", config.valueType, issues);
|
|
668
|
+
validateRules(config.default.rules, relPath, "default", config.valueType, issues, config.variants);
|
|
580
669
|
for (const env of config.environments) {
|
|
581
|
-
validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues);
|
|
670
|
+
validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues, config.variants);
|
|
582
671
|
}
|
|
583
672
|
allConfigs.push({ key: config.key, file: relPath });
|
|
584
673
|
parsedConfigs.push({ relPath, config, dir });
|
|
@@ -604,6 +693,19 @@ export function validateFileMap(files) {
|
|
|
604
693
|
suggestion: `Create schemas/${config.schemaKey}.json or schemas-protected/${config.schemaKey}.json, or remove schemaKey`,
|
|
605
694
|
});
|
|
606
695
|
}
|
|
696
|
+
// Check environment IDs referenced in config exist in quonfig.json
|
|
697
|
+
if (declaredEnvIds.size > 0) {
|
|
698
|
+
for (const env of config.environments) {
|
|
699
|
+
if (!declaredEnvIds.has(env.id)) {
|
|
700
|
+
issues.push({
|
|
701
|
+
file: relPath,
|
|
702
|
+
message: `References environment "${env.id}" which is not declared in quonfig.json`,
|
|
703
|
+
severity: "error",
|
|
704
|
+
suggestion: `Add "${env.id}" to the environments array in quonfig.json, or remove this environment override`,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
607
709
|
}
|
|
608
710
|
// Duplicate keys
|
|
609
711
|
const keyToFiles = new Map();
|
|
@@ -674,10 +776,12 @@ function validateKey(key, file, issues) {
|
|
|
674
776
|
issues.push({ file, message: `Key contains slash or backslash`, severity: "error" });
|
|
675
777
|
}
|
|
676
778
|
}
|
|
677
|
-
function validateRules(rules, file, context, expectedValueType, issues) {
|
|
779
|
+
function validateRules(rules, file, context, expectedValueType, issues, variants = []) {
|
|
678
780
|
if (rules.length === 0) {
|
|
679
781
|
issues.push({ file, message: `${context}: no rules defined`, severity: "warning" });
|
|
680
782
|
}
|
|
783
|
+
// Pre-compute serialized variant values for matching
|
|
784
|
+
const variantValues = variants.map(v => JSON.stringify(v.value));
|
|
681
785
|
for (let i = 0; i < rules.length; i++) {
|
|
682
786
|
const rule = rules[i];
|
|
683
787
|
const ruleCtx = `${context}.rules[${i}]`;
|
|
@@ -692,6 +796,18 @@ function validateRules(rules, file, context, expectedValueType, issues) {
|
|
|
692
796
|
severity: "error",
|
|
693
797
|
});
|
|
694
798
|
}
|
|
799
|
+
// Check that value matches one of the defined variants
|
|
800
|
+
if (variants.length > 0) {
|
|
801
|
+
const serialized = JSON.stringify(rule.value);
|
|
802
|
+
if (!variantValues.includes(serialized)) {
|
|
803
|
+
issues.push({
|
|
804
|
+
file,
|
|
805
|
+
message: `${ruleCtx}: value does not match any defined variant`,
|
|
806
|
+
severity: "error",
|
|
807
|
+
suggestion: `Rule value must be one of the defined variants`,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
695
811
|
}
|
|
696
812
|
else if (rule.value.type === "weighted_values") {
|
|
697
813
|
// Validate weighted values
|
|
@@ -707,6 +823,18 @@ function validateRules(rules, file, context, expectedValueType, issues) {
|
|
|
707
823
|
severity: "error",
|
|
708
824
|
});
|
|
709
825
|
}
|
|
826
|
+
// Check that each weighted value matches one of the defined variants
|
|
827
|
+
if (variants.length > 0) {
|
|
828
|
+
const serialized = JSON.stringify(wv.weightedValues[j].value);
|
|
829
|
+
if (!variantValues.includes(serialized)) {
|
|
830
|
+
issues.push({
|
|
831
|
+
file,
|
|
832
|
+
message: `${ruleCtx}.weightedValues[${j}]: value does not match any defined variant`,
|
|
833
|
+
severity: "error",
|
|
834
|
+
suggestion: `Weighted value must be one of the defined variants`,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
710
838
|
}
|
|
711
839
|
}
|
|
712
840
|
// Check criteria
|
|
@@ -799,6 +927,8 @@ export function formatResult(result) {
|
|
|
799
927
|
checks.push(`${s.segmentRefsChecked} segment refs`);
|
|
800
928
|
if (s.schemaRefsChecked > 0)
|
|
801
929
|
checks.push(`${s.schemaRefsChecked} schema refs`);
|
|
930
|
+
if (s.envRefsChecked > 0)
|
|
931
|
+
checks.push(`${s.envRefsChecked} env refs`);
|
|
802
932
|
lines.push(` Checks: ${checks.join(", ")}`);
|
|
803
933
|
// Group issues by file
|
|
804
934
|
const byFile = new Map();
|