vexp-cli 2.2.1 → 2.2.3
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/dist/agent-config.js +420 -43
- package/dist/cli.js +200 -31
- package/dist/hook-template.js +259 -26
- package/dist/workspace-repos.js +166 -0
- package/mcp/mcp-server.cjs +48 -43
- package/package.json +6 -6
package/dist/hook-template.js
CHANGED
|
@@ -58,6 +58,12 @@ esac
|
|
|
58
58
|
* Error aborts the tool call. Written to `.opencode/plugins/vexp-guard.js` by
|
|
59
59
|
* `vexp setup-agents` (opencode auto-loads that directory at startup).
|
|
60
60
|
*
|
|
61
|
+
* The SAME file is installed for Kilo Code at `.kilo/plugins/vexp-guard.js`
|
|
62
|
+
* (installKiloPlugin), because Kilo v7 is opencode vendored — its plugin loader
|
|
63
|
+
* lives at packages/opencode/src/config/plugin.ts inside Kilo-Org/kilocode, so
|
|
64
|
+
* the hook name, the `input.tool` ids and the grep/glob arg schemas are the same
|
|
65
|
+
* code and not merely a compatible API.
|
|
66
|
+
*
|
|
61
67
|
* It blocks the native `grep`/`glob` tools AND shelled-out tree search
|
|
62
68
|
* (grep/rg/find/... via bash) WHILE the vexp daemon is healthy, and fails OPEN
|
|
63
69
|
* when the daemon is down so search still works without an index — mirroring
|
|
@@ -66,16 +72,31 @@ esac
|
|
|
66
72
|
* The literal body is deliberately ASCII-only and free of backticks, `${...}`
|
|
67
73
|
* and backslash escapes so it can be embedded verbatim in a TS template literal
|
|
68
74
|
* (the VS Code extension keeps a byte-identical copy, enforced by the lockstep
|
|
69
|
-
* test in packages/vexp-cli/test/
|
|
75
|
+
* test in packages/vexp-cli/test/guard-lockstep.test.ts — which is where that
|
|
76
|
+
* guarantee actually lives; this comment used to point at hook-template.test.ts,
|
|
77
|
+
* which only ever compared the CLI constant with itself). Do NOT introduce any
|
|
70
78
|
* of those characters here without updating that test's un-escape logic.
|
|
71
79
|
*/
|
|
72
|
-
export const VEXP_OPENCODE_GUARD = `// vexp-guard - opencode plugin (generated by 'vexp setup-agents'
|
|
73
|
-
//
|
|
74
|
-
//
|
|
80
|
+
export const VEXP_OPENCODE_GUARD = `// vexp-guard - opencode / Kilo Code plugin (generated by 'vexp setup-agents'; it is
|
|
81
|
+
// rewritten on every setup, and a modified copy is saved to vexp-guard.js.vexp-bak
|
|
82
|
+
// first). Kilo v7 vendors opencode, so one plugin serves both unchanged.
|
|
83
|
+
//
|
|
84
|
+
// Blocks opencode's native grep/glob ONLY where vexp has a better answer: indexed
|
|
85
|
+
// source inside the workspace, while the daemon is healthy. Two deliberate limits:
|
|
86
|
+
//
|
|
87
|
+
// 1. It never touches bash. Shelling out is the escape hatch for everything vexp
|
|
88
|
+
// cannot index - runtime logs, build output, files outside the repo. vexp's
|
|
89
|
+
// Claude Code guard has always matched Grep|Glob|Regex only, and it works
|
|
90
|
+
// precisely because that valve stays open. Blocking shell search instead
|
|
91
|
+
// teaches the agent to evade (any prefix defeats it) and strands it when the
|
|
92
|
+
// answer genuinely is not in the index.
|
|
93
|
+
// 2. It only blocks targets vexp actually indexed. A search aimed at a log file,
|
|
94
|
+
// at dist/ or node_modules, or outside the workspace has no run_pipeline
|
|
95
|
+
// answer, so blocking it would be a dead end rather than a redirect.
|
|
96
|
+
//
|
|
75
97
|
// Fails OPEN when the daemon is down, so native search still works with no index.
|
|
76
|
-
// Mirrors vexp's Claude Code PreToolUse guard (.claude/hooks/vexp-guard.sh).
|
|
77
98
|
import { existsSync, readFileSync } from "node:fs";
|
|
78
|
-
import { join, dirname } from "node:path";
|
|
99
|
+
import { join, dirname, relative, isAbsolute, sep } from "node:path";
|
|
79
100
|
|
|
80
101
|
// Walk up from start to the first ancestor that owns a .vexp dir.
|
|
81
102
|
function findVexpDir(start) {
|
|
@@ -108,36 +129,248 @@ function daemonHealthy(vexpDir) {
|
|
|
108
129
|
}
|
|
109
130
|
|
|
110
131
|
const SEARCH_TOOLS = new Set(["grep", "glob"]);
|
|
111
|
-
const SHELL_SEARCH_BINS = ["rg", "grep", "egrep", "fgrep", "ag", "ack", "find", "fd", "fdfind"];
|
|
112
132
|
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
133
|
+
// Directory names vexp never indexes: build output and vendored dependencies.
|
|
134
|
+
const NON_INDEXED_DIRS = [
|
|
135
|
+
"node_modules", "dist", "build", "out", "coverage", "target", "vendor",
|
|
136
|
+
".vite", ".next", ".nuxt", ".output", ".turbo", ".cache", ".git", ".vexp"
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
// File kinds vexp never indexes. Logs are the case agents legitimately grep the
|
|
140
|
+
// most while debugging a running app. "-lock." is not redundant with ".lock":
|
|
141
|
+
// the two commonest JS lockfiles (package-lock.json, pnpm-lock.yaml) have no dot
|
|
142
|
+
// before "lock", so ".lock" alone let yarn.lock and Cargo.lock through while
|
|
143
|
+
// blocking them.
|
|
144
|
+
const NON_INDEXED_HINTS = [".log", ".lock", "-lock.", ".map", ".min.js"];
|
|
145
|
+
|
|
146
|
+
// Is this search target inside what vexp indexed? Only then is "call
|
|
147
|
+
// run_pipeline instead" real advice rather than a dead end. Anything that cannot
|
|
148
|
+
// be resolved confidently returns false (allow) - under-blocking just leaves the
|
|
149
|
+
// agent on grep, over-blocking strands it with no tool at all.
|
|
150
|
+
function isIndexedTarget(root, target) {
|
|
151
|
+
if (!target) return true; // no path given: a whole-workspace search is vexp's job
|
|
152
|
+
const s = String(target);
|
|
153
|
+
const low = s.toLowerCase();
|
|
154
|
+
for (const h of NON_INDEXED_HINTS) if (low.indexOf(h) !== -1) return false;
|
|
155
|
+
let rel;
|
|
156
|
+
try {
|
|
157
|
+
rel = relative(root, isAbsolute(s) ? s : join(root, s));
|
|
158
|
+
} catch (e) {
|
|
159
|
+
return false;
|
|
118
160
|
}
|
|
119
|
-
return false;
|
|
161
|
+
if (rel === "..") return false;
|
|
162
|
+
if (rel.indexOf(".." + sep) === 0) return false; // escapes the workspace
|
|
163
|
+
if (isAbsolute(rel)) return false; // different root or drive
|
|
164
|
+
const segs = rel.split(sep);
|
|
165
|
+
for (const g of segs) if (NON_INDEXED_DIRS.indexOf(g) !== -1) return false;
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Where a search is aimed. grep carries the regex in its pattern arg (never a
|
|
170
|
+
// path), so only path and the include filter describe its target; for glob the
|
|
171
|
+
// pattern IS the path.
|
|
172
|
+
function searchTargets(tool, args) {
|
|
173
|
+
if (!args) return [];
|
|
174
|
+
const out = [];
|
|
175
|
+
if (args.path) out.push(args.path);
|
|
176
|
+
if (tool === "glob" && args.pattern) out.push(args.pattern);
|
|
177
|
+
if (tool === "grep" && args.include) out.push(args.include);
|
|
178
|
+
return out;
|
|
120
179
|
}
|
|
121
180
|
|
|
122
181
|
export const VexpGuard = async ({ directory, worktree }) => {
|
|
123
|
-
const
|
|
182
|
+
const start = worktree || directory || ".";
|
|
124
183
|
let vexpDir = null;
|
|
125
184
|
return {
|
|
126
185
|
"tool.execute.before": async (input, output) => {
|
|
127
|
-
if (!vexpDir) vexpDir = findVexpDir(
|
|
128
|
-
if (!daemonHealthy(vexpDir)) return;
|
|
129
|
-
if (SEARCH_TOOLS.has(input.tool))
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
if (input.tool === "bash" && isShellSearch(output && output.args && output.args.command)) {
|
|
136
|
-
throw new Error(
|
|
137
|
-
"vexp daemon is running - call run_pipeline instead of shell search (grep/rg/find)."
|
|
138
|
-
);
|
|
186
|
+
if (!vexpDir) vexpDir = findVexpDir(start);
|
|
187
|
+
if (!daemonHealthy(vexpDir)) return; // no live index -> allow
|
|
188
|
+
if (!SEARCH_TOOLS.has(input.tool)) return; // bash and everything else: never blocked
|
|
189
|
+
const root = dirname(vexpDir);
|
|
190
|
+
const targets = searchTargets(input.tool, output && output.args);
|
|
191
|
+
for (const t of targets) {
|
|
192
|
+
if (!isIndexedTarget(root, t)) return; // vexp has no answer here -> allow
|
|
139
193
|
}
|
|
194
|
+
throw new Error(
|
|
195
|
+
"vexp daemon is running. AGENT INSTRUCTION: call run_pipeline instead of " +
|
|
196
|
+
input.tool + " - it returns pre-indexed, graph-ranked context in one call." +
|
|
197
|
+
" This is policy, not a transient failure: do NOT work around it with shell" +
|
|
198
|
+
" search or by writing a script."
|
|
199
|
+
);
|
|
140
200
|
},
|
|
141
201
|
};
|
|
142
202
|
};
|
|
143
203
|
`;
|
|
204
|
+
/**
|
|
205
|
+
* Cursor `preToolUse` hook. Same policy as the Claude Code hook and the
|
|
206
|
+
* opencode/Kilo plugin — block text search while the vexp daemon is healthy,
|
|
207
|
+
* never touch the shell, never block a target vexp did not index — expressed in
|
|
208
|
+
* the third dialect we now have to speak.
|
|
209
|
+
*
|
|
210
|
+
* Written in NODE, not bash, and that is deliberate. The Claude Code guard is a
|
|
211
|
+
* bash script and it silently enforced NOTHING on Windows for several releases
|
|
212
|
+
* (`[ -S "$SOCK" ]` can never match a named pipe), which is the single most
|
|
213
|
+
* expensive bug this file has produced. Cursor documents no PowerShell variant
|
|
214
|
+
* of `command` and does not say which shell runs it on Windows, so a bash guard
|
|
215
|
+
* here would be that bug again by construction. Anyone who installed vexp has
|
|
216
|
+
* node on PATH; a node script is the one interpreter we can count on everywhere.
|
|
217
|
+
*
|
|
218
|
+
* Denial is emitted on BOTH documented channels — the stdout verdict object and
|
|
219
|
+
* exit code 2 — because Cursor's docs describe both and we cannot test which one
|
|
220
|
+
* this build actually honours. Belt and braces on a schema we are trusting on
|
|
221
|
+
* documentation alone.
|
|
222
|
+
*
|
|
223
|
+
* Fails OPEN in every uncertain case: daemon down, unparseable input, an unknown
|
|
224
|
+
* payload shape. An over-eager guard strands the agent with no way to look at
|
|
225
|
+
* anything; an under-eager one costs tokens. Those are not symmetric.
|
|
226
|
+
*
|
|
227
|
+
* ASCII-only, no backticks and no ${...}: it is embedded verbatim in a TS
|
|
228
|
+
* template literal in two packages (lockstep test in test/guard-lockstep.test.ts).
|
|
229
|
+
*/
|
|
230
|
+
export const VEXP_CURSOR_GUARD = `#!/usr/bin/env node
|
|
231
|
+
// vexp-guard - Cursor preToolUse hook (generated by 'vexp setup-agents'; rewritten
|
|
232
|
+
// on every setup, and a modified copy is saved to vexp-guard.js.vexp-bak first).
|
|
233
|
+
//
|
|
234
|
+
// Blocks Cursor's Grep tool ONLY where vexp has a better answer: indexed source
|
|
235
|
+
// inside the workspace, while the daemon is healthy. Never blocks Shell - that
|
|
236
|
+
// valve is what keeps the agent able to reach logs, build output and anything
|
|
237
|
+
// outside the repo, and blocking it teaches evasion instead of redirection.
|
|
238
|
+
//
|
|
239
|
+
// NOTE: Cursor's native semantic codebase search is NOT exposed to hooks, so it
|
|
240
|
+
// cannot be redirected. This guard covers text search only.
|
|
241
|
+
const fs = require("node:fs");
|
|
242
|
+
const path = require("node:path");
|
|
243
|
+
|
|
244
|
+
function findVexpDir(start) {
|
|
245
|
+
let dir = start;
|
|
246
|
+
for (;;) {
|
|
247
|
+
if (fs.existsSync(path.join(dir, ".vexp"))) return path.join(dir, ".vexp");
|
|
248
|
+
const parent = path.dirname(dir);
|
|
249
|
+
if (parent === dir) return null;
|
|
250
|
+
dir = parent;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function pidAlive(pidFile) {
|
|
255
|
+
let pid = 0;
|
|
256
|
+
try { pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10); } catch (e) { return false; }
|
|
257
|
+
if (!pid) return false;
|
|
258
|
+
try { process.kill(pid, 0); return true; } catch (e) { return !!(e && e.code === "EPERM"); }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The daemon writes .vexp/healthy when up and removes it on graceful shutdown.
|
|
262
|
+
// Unix also requires a live socket AND a live pid: healthy + socket both linger
|
|
263
|
+
// after a kill -9, so the pid check is what makes fail-open actually work.
|
|
264
|
+
// Windows has no socket file, so the daemon drops .vexp/daemon.pipe instead.
|
|
265
|
+
function daemonHealthy(vexpDir) {
|
|
266
|
+
if (!vexpDir || !fs.existsSync(path.join(vexpDir, "healthy"))) return false;
|
|
267
|
+
if (process.platform === "win32") return fs.existsSync(path.join(vexpDir, "daemon.pipe"));
|
|
268
|
+
return fs.existsSync(path.join(vexpDir, "daemon.sock")) && pidAlive(path.join(vexpDir, "daemon.pid"));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const NON_INDEXED_DIRS = [
|
|
272
|
+
"node_modules", "dist", "build", "out", "coverage", "target", "vendor",
|
|
273
|
+
".vite", ".next", ".nuxt", ".output", ".turbo", ".cache", ".git", ".vexp"
|
|
274
|
+
];
|
|
275
|
+
const NON_INDEXED_HINTS = [".log", ".lock", "-lock.", ".map", ".min.js"];
|
|
276
|
+
|
|
277
|
+
// Is this search target inside what vexp indexed? Only then is "call
|
|
278
|
+
// run_pipeline instead" real advice rather than a dead end. Anything that cannot
|
|
279
|
+
// be resolved confidently returns false (allow).
|
|
280
|
+
function isIndexedTarget(root, target) {
|
|
281
|
+
if (!target) return true; // no path given: a whole-workspace search is vexp's job
|
|
282
|
+
const s = String(target);
|
|
283
|
+
const low = s.toLowerCase();
|
|
284
|
+
for (const h of NON_INDEXED_HINTS) if (low.indexOf(h) !== -1) return false;
|
|
285
|
+
let rel;
|
|
286
|
+
try {
|
|
287
|
+
rel = path.relative(root, path.isAbsolute(s) ? s : path.join(root, s));
|
|
288
|
+
} catch (e) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
if (rel === "..") return false;
|
|
292
|
+
if (rel.indexOf(".." + path.sep) === 0) return false; // escapes the workspace
|
|
293
|
+
if (path.isAbsolute(rel)) return false; // different root or drive
|
|
294
|
+
const segs = rel.split(path.sep);
|
|
295
|
+
for (const g of segs) if (NON_INDEXED_DIRS.indexOf(g) !== -1) return false;
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function allow() {
|
|
300
|
+
process.stdout.write(JSON.stringify({ permission: "allow" }));
|
|
301
|
+
process.exit(0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function deny(toolName) {
|
|
305
|
+
const reason =
|
|
306
|
+
"vexp daemon is running. AGENT INSTRUCTION: call run_pipeline instead of " +
|
|
307
|
+
toolName + " - it returns pre-indexed, graph-ranked context in one call." +
|
|
308
|
+
" This is policy, not a transient failure: do NOT work around it with shell" +
|
|
309
|
+
" search or by writing a script.";
|
|
310
|
+
// Both documented channels: the verdict object AND exit 2.
|
|
311
|
+
process.stdout.write(JSON.stringify({
|
|
312
|
+
permission: "deny",
|
|
313
|
+
agent_message: reason,
|
|
314
|
+
user_message: "vexp blocked a text search; the agent was told to use run_pipeline."
|
|
315
|
+
}));
|
|
316
|
+
process.stderr.write(reason);
|
|
317
|
+
process.exit(2);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Cursor documents tool_input for Shell only; the payload shape for a Grep call
|
|
321
|
+
// appears NOWHERE in its docs. Rather than guess key names - a guess that fires,
|
|
322
|
+
// reads undefined and then blocks everything or nothing - we do not read keys at
|
|
323
|
+
// all. We scan the VALUES of tool_input for anything path-shaped. That is immune
|
|
324
|
+
// to whatever the keys turn out to be called, and to them being renamed later.
|
|
325
|
+
//
|
|
326
|
+
// The search pattern itself is one of those values and may look like a path. It
|
|
327
|
+
// can only ever push us toward ALLOW (a pattern mentioning node_modules reads as
|
|
328
|
+
// a non-indexed target), never toward a wrongful block - which is the direction
|
|
329
|
+
// we want to be wrong in.
|
|
330
|
+
function candidateTargets(toolInput) {
|
|
331
|
+
if (!toolInput) return [];
|
|
332
|
+
// MCP hooks receive tool_input as a JSON *string*, so the type is not uniform.
|
|
333
|
+
let obj = toolInput;
|
|
334
|
+
if (typeof obj === "string") {
|
|
335
|
+
try { obj = JSON.parse(obj); } catch (e) { return []; }
|
|
336
|
+
}
|
|
337
|
+
if (typeof obj !== "object") return [];
|
|
338
|
+
// EVERY string value, with no "does this look like a path" pre-filter. An
|
|
339
|
+
// earlier version required a separator or a dot and therefore missed bare
|
|
340
|
+
// directory names - "dist", "build", "node_modules" - which are exactly the
|
|
341
|
+
// targets the carve-out exists to protect. Handing the search pattern itself
|
|
342
|
+
// to isIndexedTarget is harmless: a pattern that reads as non-indexed only
|
|
343
|
+
// ever produces an ALLOW, and a pattern that reads as indexed changes nothing.
|
|
344
|
+
const out = [];
|
|
345
|
+
for (const v of Object.values(obj)) {
|
|
346
|
+
if (typeof v === "string" && v) out.push(v);
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function main(input) {
|
|
352
|
+
const toolName = input && input.tool_name;
|
|
353
|
+
// Grep only. Shell is never blocked - it is the valve for everything vexp
|
|
354
|
+
// cannot index - and Read is never blocked because an agent must read to edit.
|
|
355
|
+
if (toolName !== "Grep") return allow();
|
|
356
|
+
|
|
357
|
+
const roots = (input && input.workspace_roots) || [];
|
|
358
|
+
const start = roots[0] || (input && input.cwd) || process.cwd();
|
|
359
|
+
const vexpDir = findVexpDir(start);
|
|
360
|
+
if (!daemonHealthy(vexpDir)) return allow(); // no live index -> allow
|
|
361
|
+
const root = path.dirname(vexpDir);
|
|
362
|
+
|
|
363
|
+
for (const t of candidateTargets(input.tool_input)) {
|
|
364
|
+
if (!isIndexedTarget(root, t)) return allow(); // vexp has no answer here
|
|
365
|
+
}
|
|
366
|
+
deny(toolName);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let raw = "";
|
|
370
|
+
process.stdin.on("data", (c) => { raw += c; });
|
|
371
|
+
process.stdin.on("end", () => {
|
|
372
|
+
let input;
|
|
373
|
+
try { input = JSON.parse(raw); } catch (e) { return allow(); }
|
|
374
|
+
try { main(input); } catch (e) { allow(); } // any surprise -> fail open
|
|
375
|
+
});
|
|
376
|
+
`;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
/** Expand a leading `~` — readline input arrives unexpanded, unlike a shell arg. */
|
|
5
|
+
export function expandHome(p) {
|
|
6
|
+
if (p === "~")
|
|
7
|
+
return os.homedir();
|
|
8
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) {
|
|
9
|
+
return path.join(os.homedir(), p.slice(2));
|
|
10
|
+
}
|
|
11
|
+
return p;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a `repos[].path` entry like vexp-core's `resolve_repo_path`:
|
|
15
|
+
* absolute paths are preserved, relative paths resolve against the workspace
|
|
16
|
+
* ROOT. Configs written before 2.2.1 stored paths relative to `.vexp/`, so
|
|
17
|
+
* when the root-relative answer is not a directory we fall back to the legacy
|
|
18
|
+
* base — same order as the Rust resolver, so list/add agree with the daemon.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveRepoEntryPath(workspaceRoot, raw) {
|
|
21
|
+
if (path.isAbsolute(raw))
|
|
22
|
+
return path.normalize(raw);
|
|
23
|
+
const fromRoot = path.resolve(workspaceRoot, raw);
|
|
24
|
+
if (fs.existsSync(fromRoot))
|
|
25
|
+
return fromRoot;
|
|
26
|
+
const fromLegacy = path.resolve(workspaceRoot, ".vexp", raw);
|
|
27
|
+
if (fs.existsSync(fromLegacy))
|
|
28
|
+
return fromLegacy;
|
|
29
|
+
return fromRoot;
|
|
30
|
+
}
|
|
31
|
+
function readWorkspaceConfig(configPath) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
34
|
+
if (!parsed || !Array.isArray(parsed.repos))
|
|
35
|
+
return null;
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* If `childRoot` is a secondary repo of a multi-repo workspace, return the
|
|
44
|
+
* parent workspace info. The link is `.vexp/parent_workspace.json` (written on
|
|
45
|
+
* add, both by the VS Code plugin and by `addRepoToWorkspace` below).
|
|
46
|
+
*
|
|
47
|
+
* Returns null — i.e. "treat this repo as standalone" — unless the parent
|
|
48
|
+
* config still exists AND still lists this repo: a stale parent_workspace.json
|
|
49
|
+
* left behind after the repo was disconnected must not hijack the CLI.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveParentWorkspace(childRoot) {
|
|
52
|
+
let configPath;
|
|
53
|
+
try {
|
|
54
|
+
const raw = JSON.parse(fs.readFileSync(path.join(childRoot, ".vexp", "parent_workspace.json"), "utf-8"));
|
|
55
|
+
if (typeof raw?.workspace_config !== "string")
|
|
56
|
+
return null;
|
|
57
|
+
configPath = raw.workspace_config;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (!fs.existsSync(configPath))
|
|
63
|
+
return null;
|
|
64
|
+
// workspace.json lives in <parentRoot>/.vexp/
|
|
65
|
+
const parentRoot = path.dirname(path.dirname(configPath));
|
|
66
|
+
if (path.resolve(parentRoot) === path.resolve(childRoot))
|
|
67
|
+
return null;
|
|
68
|
+
const config = readWorkspaceConfig(configPath);
|
|
69
|
+
if (!config)
|
|
70
|
+
return null;
|
|
71
|
+
const childResolved = path.resolve(childRoot);
|
|
72
|
+
const listed = config.repos.some((r) => {
|
|
73
|
+
if (typeof r?.path !== "string")
|
|
74
|
+
return false;
|
|
75
|
+
return path.resolve(resolveRepoEntryPath(parentRoot, r.path)) === childResolved;
|
|
76
|
+
});
|
|
77
|
+
if (!listed)
|
|
78
|
+
return null;
|
|
79
|
+
return {
|
|
80
|
+
parentRoot,
|
|
81
|
+
workspaceName: config.name ?? path.basename(parentRoot),
|
|
82
|
+
configPath,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Repos connected to the workspace at `workspaceRoot`. Empty array when there
|
|
87
|
+
* is no workspace.json (single-repo workspace).
|
|
88
|
+
*/
|
|
89
|
+
export function listWorkspaceRepos(workspaceRoot) {
|
|
90
|
+
const configPath = path.join(workspaceRoot, ".vexp", "workspace.json");
|
|
91
|
+
const config = readWorkspaceConfig(configPath);
|
|
92
|
+
if (!config)
|
|
93
|
+
return [];
|
|
94
|
+
const rootResolved = path.resolve(workspaceRoot);
|
|
95
|
+
return config.repos
|
|
96
|
+
.filter((r) => typeof r?.path === "string" && typeof r?.alias === "string")
|
|
97
|
+
.map((r) => {
|
|
98
|
+
const resolvedPath = resolveRepoEntryPath(workspaceRoot, r.path);
|
|
99
|
+
return {
|
|
100
|
+
alias: r.alias,
|
|
101
|
+
rawPath: r.path,
|
|
102
|
+
resolvedPath,
|
|
103
|
+
isPrimary: path.resolve(resolvedPath) === rootResolved,
|
|
104
|
+
exists: fs.existsSync(resolvedPath),
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Connect `newRepoPath` to the workspace at `workspaceRoot` — the exact write
|
|
110
|
+
* sequence of the VS Code `vexp.setupMultiRepo` command:
|
|
111
|
+
* 1. plan-limit guard BEFORE writing anything (maxRepos, 0 = unlimited)
|
|
112
|
+
* 2. read-or-create workspace.json (primary entry is `"."` — the 2.2.1+
|
|
113
|
+
* root-relative convention)
|
|
114
|
+
* 3. append `{ alias, path: <absolute> }` unless already present
|
|
115
|
+
* 4. write `.vexp/parent_workspace.json` in the child so opening the child
|
|
116
|
+
* resolves back to this workspace
|
|
117
|
+
*
|
|
118
|
+
* Indexing is NOT triggered here — the caller decides whether to restart the
|
|
119
|
+
* daemon (mirrors the plugin's "Restart / Later" prompt).
|
|
120
|
+
*/
|
|
121
|
+
export function addRepoToWorkspace(workspaceRoot, newRepoPath, maxRepos) {
|
|
122
|
+
const resolved = path.resolve(expandHome(newRepoPath.trim()));
|
|
123
|
+
let stat;
|
|
124
|
+
try {
|
|
125
|
+
stat = fs.statSync(resolved);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return { status: "invalid", reason: `Directory not found: ${resolved}` };
|
|
129
|
+
}
|
|
130
|
+
if (!stat.isDirectory()) {
|
|
131
|
+
return { status: "invalid", reason: `Not a directory: ${resolved}` };
|
|
132
|
+
}
|
|
133
|
+
if (path.resolve(workspaceRoot) === resolved) {
|
|
134
|
+
return { status: "invalid", reason: "That is the workspace root itself — it is already the primary repo." };
|
|
135
|
+
}
|
|
136
|
+
const configPath = path.join(workspaceRoot, ".vexp", "workspace.json");
|
|
137
|
+
const alias = path.basename(resolved);
|
|
138
|
+
// Plan-limit guard: same semantics as the plugin — count existing entries,
|
|
139
|
+
// block only genuinely-new repos, never after a partial write.
|
|
140
|
+
let config = readWorkspaceConfig(configPath);
|
|
141
|
+
const currentCount = config ? config.repos.length : 1; // primary always exists
|
|
142
|
+
const alreadyExists = config?.repos.some((r) => r.alias === alias ||
|
|
143
|
+
r.path === resolved ||
|
|
144
|
+
path.resolve(resolveRepoEntryPath(workspaceRoot, r.path)) === resolved) ?? false;
|
|
145
|
+
if (alreadyExists) {
|
|
146
|
+
return { status: "exists", alias };
|
|
147
|
+
}
|
|
148
|
+
if (maxRepos > 0 && currentCount >= maxRepos) {
|
|
149
|
+
return { status: "limit", maxRepos, current: currentCount };
|
|
150
|
+
}
|
|
151
|
+
if (!config) {
|
|
152
|
+
config = {
|
|
153
|
+
name: path.basename(workspaceRoot),
|
|
154
|
+
repos: [{ alias: path.basename(workspaceRoot), path: "." }],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
config.repos.push({ alias, path: resolved });
|
|
158
|
+
fs.mkdirSync(path.join(workspaceRoot, ".vexp"), { recursive: true });
|
|
159
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
160
|
+
// Bidirectional link in the child, so `vexp` run there targets this
|
|
161
|
+
// workspace's daemon instead of spawning a conflicting one.
|
|
162
|
+
const childVexpDir = path.join(resolved, ".vexp");
|
|
163
|
+
fs.mkdirSync(childVexpDir, { recursive: true });
|
|
164
|
+
fs.writeFileSync(path.join(childVexpDir, "parent_workspace.json"), JSON.stringify({ workspace_config: configPath }, null, 2));
|
|
165
|
+
return { status: "added", alias, configPath };
|
|
166
|
+
}
|