vexp-cli 2.0.11 → 2.0.13
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 +154 -75
- package/dist/autostart.js +375 -0
- package/dist/binary.js +63 -0
- package/dist/cli.js +258 -105
- package/dist/mcp-supervisor.js +235 -0
- package/dist/serve.js +160 -0
- package/dist/trace.js +80 -0
- package/mcp/mcp-server.cjs +38 -38
- package/package.json +6 -6
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as net from "net";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { spawn } from "child_process";
|
|
6
|
+
import { randomUUID } from "crypto";
|
|
7
|
+
import { getMcpServerPath } from "./binary.js";
|
|
8
|
+
const DEFAULT_PORT = 7821;
|
|
9
|
+
function pidFilePath() {
|
|
10
|
+
const home = os.homedir();
|
|
11
|
+
return path.join(home, ".vexp", "mcp.pid");
|
|
12
|
+
}
|
|
13
|
+
function tokenFilePath() {
|
|
14
|
+
const home = os.homedir();
|
|
15
|
+
return path.join(home, ".vexp", "mcp.token");
|
|
16
|
+
}
|
|
17
|
+
function readPidRecord() {
|
|
18
|
+
try {
|
|
19
|
+
const raw = fs.readFileSync(pidFilePath(), "utf-8");
|
|
20
|
+
const rec = JSON.parse(raw);
|
|
21
|
+
if (typeof rec.pid === "number" && typeof rec.port === "number") {
|
|
22
|
+
return {
|
|
23
|
+
pid: rec.pid,
|
|
24
|
+
port: rec.port,
|
|
25
|
+
startedAt: rec.startedAt ?? 0,
|
|
26
|
+
owner: rec.owner ?? "unknown",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* missing or malformed */
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function writePidRecord(rec) {
|
|
36
|
+
const p = pidFilePath();
|
|
37
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
38
|
+
fs.writeFileSync(p, JSON.stringify(rec, null, 2), { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
function removePidFile() {
|
|
41
|
+
try {
|
|
42
|
+
fs.unlinkSync(pidFilePath());
|
|
43
|
+
}
|
|
44
|
+
catch { /* already gone */ }
|
|
45
|
+
}
|
|
46
|
+
function isPidAlive(pid) {
|
|
47
|
+
try {
|
|
48
|
+
process.kill(pid, 0);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function isPortInUse(port, timeoutMs = 300) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const sock = net.createConnection({ port, host: "127.0.0.1" });
|
|
58
|
+
const timer = setTimeout(() => { sock.destroy(); resolve(false); }, timeoutMs);
|
|
59
|
+
sock.on("connect", () => { clearTimeout(timer); sock.destroy(); resolve(true); });
|
|
60
|
+
sock.on("error", () => { clearTimeout(timer); resolve(false); });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export function resolveOrGenerateMcpToken() {
|
|
64
|
+
const p = tokenFilePath();
|
|
65
|
+
try {
|
|
66
|
+
const existing = fs.readFileSync(p, "utf-8").trim();
|
|
67
|
+
if (existing)
|
|
68
|
+
return existing;
|
|
69
|
+
}
|
|
70
|
+
catch { /* missing */ }
|
|
71
|
+
const token = randomUUID();
|
|
72
|
+
try {
|
|
73
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
74
|
+
fs.writeFileSync(p, token, { mode: 0o600 });
|
|
75
|
+
}
|
|
76
|
+
catch { /* non-fatal */ }
|
|
77
|
+
return token;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Ensure exactly one vexp-mcp HTTP server is running, tracked via
|
|
81
|
+
* `~/.vexp/mcp.pid`. Shared between CLI, VS Code plugin, and `vexp serve`
|
|
82
|
+
* so that stop/start cycles, multiple VS Code windows, and the login
|
|
83
|
+
* supervisor never duplicate the process.
|
|
84
|
+
*
|
|
85
|
+
* Returns the current (alive) PID whether it was reused or freshly
|
|
86
|
+
* spawned. Sets `started=true` only on fresh spawn.
|
|
87
|
+
*
|
|
88
|
+
* The MCP server is deliberately workspace-agnostic: clients disambiguate
|
|
89
|
+
* via URL path /ws/<workspaceHash>/mcp. No VEXP_WORKSPACE env is passed.
|
|
90
|
+
*/
|
|
91
|
+
export async function ensureMcpHttpServer(opts = {}) {
|
|
92
|
+
const port = opts.port ?? DEFAULT_PORT;
|
|
93
|
+
const owner = opts.owner ?? "unknown";
|
|
94
|
+
const token = opts.token ?? resolveOrGenerateMcpToken();
|
|
95
|
+
const mcpPath = getMcpServerPath();
|
|
96
|
+
if (!mcpPath)
|
|
97
|
+
return null;
|
|
98
|
+
// Fast path: already alive and tracked.
|
|
99
|
+
{
|
|
100
|
+
const existing = readPidRecord();
|
|
101
|
+
if (existing && existing.port === port && isPidAlive(existing.pid) && (await isPortInUse(port))) {
|
|
102
|
+
return { pid: existing.pid, port, started: false };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Slow path: we may need to spawn. Serialize across concurrent callers
|
|
106
|
+
// in the SAME process (CLI REPL race) AND across processes (plugin +
|
|
107
|
+
// CLI running in parallel). Atomic mkdir on ~/.vexp/mcp.lock gives both.
|
|
108
|
+
const lockDir = path.join(os.homedir(), ".vexp", "mcp.lock");
|
|
109
|
+
fs.mkdirSync(path.dirname(lockDir), { recursive: true });
|
|
110
|
+
let iHoldLock = false;
|
|
111
|
+
const deadline = Date.now() + 3000;
|
|
112
|
+
while (Date.now() < deadline) {
|
|
113
|
+
try {
|
|
114
|
+
fs.mkdirSync(lockDir); // atomic; fails EEXIST if someone else is spawning
|
|
115
|
+
iHoldLock = true;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Someone else is spawning. Re-probe — if they finish quickly we can
|
|
120
|
+
// just reuse their result without ever acquiring the lock.
|
|
121
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
122
|
+
const rec = readPidRecord();
|
|
123
|
+
if (rec && rec.port === port && isPidAlive(rec.pid) && (await isPortInUse(port))) {
|
|
124
|
+
return { pid: rec.pid, port, started: false };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!iHoldLock) {
|
|
129
|
+
// Lock dir is stuck (likely from a crashed process that didn't clean
|
|
130
|
+
// up). Force-break and retry once.
|
|
131
|
+
try {
|
|
132
|
+
fs.rmdirSync(lockDir);
|
|
133
|
+
}
|
|
134
|
+
catch { /* not ours to break */ }
|
|
135
|
+
try {
|
|
136
|
+
fs.mkdirSync(lockDir);
|
|
137
|
+
iHoldLock = true;
|
|
138
|
+
}
|
|
139
|
+
catch { /* give up */ }
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
// Double-check under the lock — another holder may have just spawned.
|
|
143
|
+
const existing = readPidRecord();
|
|
144
|
+
if (existing && existing.port === port && isPidAlive(existing.pid) && (await isPortInUse(port))) {
|
|
145
|
+
return { pid: existing.pid, port, started: false };
|
|
146
|
+
}
|
|
147
|
+
if (existing)
|
|
148
|
+
removePidFile();
|
|
149
|
+
// Non-tracked process already bound to :port — don't clobber.
|
|
150
|
+
if (await isPortInUse(port)) {
|
|
151
|
+
return { pid: -1, port, started: false };
|
|
152
|
+
}
|
|
153
|
+
const child = spawn("node", [mcpPath, "--http", `--port=${port}`], {
|
|
154
|
+
detached: true,
|
|
155
|
+
stdio: "ignore",
|
|
156
|
+
env: {
|
|
157
|
+
...process.env,
|
|
158
|
+
VEXP_HTTP: "1",
|
|
159
|
+
VEXP_PORT: String(port),
|
|
160
|
+
VEXP_MCP_TOKEN: token,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
child.unref();
|
|
164
|
+
const pid = child.pid;
|
|
165
|
+
if (!pid)
|
|
166
|
+
return null;
|
|
167
|
+
writePidRecord({ pid, port, startedAt: Date.now(), owner });
|
|
168
|
+
// Wait for the child to actually bind the port BEFORE releasing the
|
|
169
|
+
// lock. Without this, a second caller arriving right after release
|
|
170
|
+
// sees pidfile+live-pid but port-not-yet-bound → falsely concludes
|
|
171
|
+
// the spawn failed and starts another one. Cap at 3s; if it takes
|
|
172
|
+
// longer, caller logic will re-probe or surface the error downstream.
|
|
173
|
+
const bindDeadline = Date.now() + 3000;
|
|
174
|
+
while (Date.now() < bindDeadline) {
|
|
175
|
+
if (await isPortInUse(port))
|
|
176
|
+
break;
|
|
177
|
+
if (!isPidAlive(pid))
|
|
178
|
+
break; // child died — stop waiting
|
|
179
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
180
|
+
}
|
|
181
|
+
return { pid, port, started: true };
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
if (iHoldLock) {
|
|
185
|
+
try {
|
|
186
|
+
fs.rmdirSync(lockDir);
|
|
187
|
+
}
|
|
188
|
+
catch { /* best effort */ }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Stop the vexp-mcp HTTP server.
|
|
194
|
+
*
|
|
195
|
+
* If `expectedOwner` is set, only stop if the running process was started
|
|
196
|
+
* by that owner — prevents a plugin instance from killing a `vexp serve`
|
|
197
|
+
* supervisor's MCP process. Pass no owner to force-stop regardless.
|
|
198
|
+
*/
|
|
199
|
+
export async function stopMcpHttpServer(expectedOwner) {
|
|
200
|
+
const rec = readPidRecord();
|
|
201
|
+
if (!rec)
|
|
202
|
+
return false;
|
|
203
|
+
if (expectedOwner && rec.owner !== expectedOwner && rec.owner !== "unknown") {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
if (!isPidAlive(rec.pid)) {
|
|
207
|
+
removePidFile();
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
process.kill(rec.pid, "SIGTERM");
|
|
212
|
+
}
|
|
213
|
+
catch { /* already dead */ }
|
|
214
|
+
const deadline = Date.now() + 2000;
|
|
215
|
+
while (Date.now() < deadline) {
|
|
216
|
+
if (!isPidAlive(rec.pid))
|
|
217
|
+
break;
|
|
218
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
219
|
+
}
|
|
220
|
+
if (isPidAlive(rec.pid)) {
|
|
221
|
+
try {
|
|
222
|
+
process.kill(rec.pid, "SIGKILL");
|
|
223
|
+
}
|
|
224
|
+
catch { /* gone */ }
|
|
225
|
+
}
|
|
226
|
+
removePidFile();
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
export function mcpHttpStatus() {
|
|
230
|
+
const rec = readPidRecord();
|
|
231
|
+
if (rec && isPidAlive(rec.pid)) {
|
|
232
|
+
return { running: true, pid: rec.pid, port: rec.port, owner: rec.owner };
|
|
233
|
+
}
|
|
234
|
+
return { running: false };
|
|
235
|
+
}
|
package/dist/serve.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as net from "net";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { spawn } from "child_process";
|
|
6
|
+
import { getBinaryPath, binaryEnv } from "./binary.js";
|
|
7
|
+
import { ensureMcpHttpServer } from "./mcp-supervisor.js";
|
|
8
|
+
const HEALTH_INTERVAL_MS = 60_000;
|
|
9
|
+
const DEFAULT_MCP_PORT = 7821;
|
|
10
|
+
function registryPath() {
|
|
11
|
+
return path.join(os.homedir(), ".vexp", "daemons.json");
|
|
12
|
+
}
|
|
13
|
+
function autostartLogPath() {
|
|
14
|
+
return path.join(os.homedir(), ".vexp", "autostart.log");
|
|
15
|
+
}
|
|
16
|
+
function readRegistry() {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(registryPath(), "utf-8"));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function writeRegistry(reg) {
|
|
25
|
+
const p = registryPath();
|
|
26
|
+
try {
|
|
27
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
28
|
+
fs.writeFileSync(p, JSON.stringify(reg, null, 2), "utf-8");
|
|
29
|
+
}
|
|
30
|
+
catch { /* non-fatal */ }
|
|
31
|
+
}
|
|
32
|
+
function isSocketAlive(socketPath, timeoutMs = 300) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
if (process.platform !== "win32" && !fs.existsSync(socketPath)) {
|
|
35
|
+
resolve(false);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const sock = net.createConnection({ path: socketPath });
|
|
39
|
+
const timer = setTimeout(() => { sock.destroy(); resolve(false); }, timeoutMs);
|
|
40
|
+
sock.on("connect", () => { clearTimeout(timer); sock.destroy(); resolve(true); });
|
|
41
|
+
sock.on("error", () => { clearTimeout(timer); resolve(false); });
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function appendLog(line) {
|
|
45
|
+
const stamped = `[${new Date().toISOString()}] ${line}\n`;
|
|
46
|
+
try {
|
|
47
|
+
fs.mkdirSync(path.dirname(autostartLogPath()), { recursive: true });
|
|
48
|
+
fs.appendFileSync(autostartLogPath(), stamped);
|
|
49
|
+
}
|
|
50
|
+
catch { /* non-fatal */ }
|
|
51
|
+
process.stderr.write(stamped);
|
|
52
|
+
}
|
|
53
|
+
function rotateLogIfLarge() {
|
|
54
|
+
const p = autostartLogPath();
|
|
55
|
+
try {
|
|
56
|
+
const st = fs.statSync(p);
|
|
57
|
+
if (st.size > 2 * 1024 * 1024) {
|
|
58
|
+
fs.renameSync(p, p + ".old");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch { /* missing */ }
|
|
62
|
+
}
|
|
63
|
+
async function resurrectDaemon(workspaceRoot, socketPath) {
|
|
64
|
+
const manifest = path.join(workspaceRoot, ".vexp", "manifest.json");
|
|
65
|
+
if (!fs.existsSync(manifest))
|
|
66
|
+
return false;
|
|
67
|
+
if (await isSocketAlive(socketPath))
|
|
68
|
+
return true;
|
|
69
|
+
let binary;
|
|
70
|
+
try {
|
|
71
|
+
binary = getBinaryPath();
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
appendLog(`daemon ${workspaceRoot}: no vexp-core binary (${err instanceof Error ? err.message : err})`);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const child = spawn(binary, ["daemon", "--workspace", workspaceRoot, "--socket", socketPath], {
|
|
79
|
+
detached: true,
|
|
80
|
+
stdio: "ignore",
|
|
81
|
+
env: binaryEnv(binary),
|
|
82
|
+
});
|
|
83
|
+
child.unref();
|
|
84
|
+
appendLog(`daemon spawned: ${workspaceRoot} (pid=${child.pid})`);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
appendLog(`daemon spawn failed for ${workspaceRoot}: ${err instanceof Error ? err.message : err}`);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function resurrectAll() {
|
|
93
|
+
const reg = readRegistry();
|
|
94
|
+
const live = {};
|
|
95
|
+
for (const [ws, sock] of Object.entries(reg)) {
|
|
96
|
+
const manifest = path.join(ws, ".vexp", "manifest.json");
|
|
97
|
+
if (!fs.existsSync(manifest)) {
|
|
98
|
+
appendLog(`registry prune: ${ws} (no manifest)`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const ok = await resurrectDaemon(ws, sock);
|
|
102
|
+
if (ok)
|
|
103
|
+
live[ws] = sock;
|
|
104
|
+
}
|
|
105
|
+
writeRegistry(live);
|
|
106
|
+
}
|
|
107
|
+
async function startMcp() {
|
|
108
|
+
const port = parseInt(process.env.VEXP_PORT ?? String(DEFAULT_MCP_PORT), 10);
|
|
109
|
+
const result = await ensureMcpHttpServer({ port, owner: "serve" });
|
|
110
|
+
if (!result) {
|
|
111
|
+
appendLog(`MCP server: could not resolve mcp-server.cjs path`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (result.pid === -1) {
|
|
115
|
+
appendLog(`MCP server: port ${port} already bound by an unknown process — leaving as-is`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
appendLog(result.started
|
|
119
|
+
? `MCP server started (pid=${result.pid}, port=${result.port})`
|
|
120
|
+
: `MCP server already running (pid=${result.pid}, port=${result.port})`);
|
|
121
|
+
}
|
|
122
|
+
async function healthLoop() {
|
|
123
|
+
while (true) {
|
|
124
|
+
await new Promise((r) => setTimeout(r, HEALTH_INTERVAL_MS));
|
|
125
|
+
rotateLogIfLarge();
|
|
126
|
+
try {
|
|
127
|
+
await resurrectAll();
|
|
128
|
+
await startMcp();
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
appendLog(`health loop error: ${err instanceof Error ? err.message : err}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Entry point for `vexp serve`. Long-running supervisor invoked by OS
|
|
137
|
+
* login autostart (LaunchAgent / systemd --user / Startup folder).
|
|
138
|
+
*
|
|
139
|
+
* Responsibilities:
|
|
140
|
+
* 1. Resurrect every daemon listed in ~/.vexp/daemons.json whose
|
|
141
|
+
* workspace still has a .vexp/manifest.json (incremental reindex
|
|
142
|
+
* happens inside the Rust daemon on startup).
|
|
143
|
+
* 2. Keep exactly one vexp-mcp HTTP server alive on :7821 via the
|
|
144
|
+
* shared mcp.pid lock (mcp-supervisor).
|
|
145
|
+
* 3. Periodically re-check and re-spawn anything that died.
|
|
146
|
+
*/
|
|
147
|
+
export async function runServe() {
|
|
148
|
+
rotateLogIfLarge();
|
|
149
|
+
appendLog("vexp serve starting");
|
|
150
|
+
const shutdown = () => {
|
|
151
|
+
appendLog("vexp serve received shutdown signal");
|
|
152
|
+
process.exit(0);
|
|
153
|
+
};
|
|
154
|
+
process.on("SIGTERM", shutdown);
|
|
155
|
+
process.on("SIGINT", shutdown);
|
|
156
|
+
await resurrectAll();
|
|
157
|
+
await startMcp();
|
|
158
|
+
appendLog("vexp serve ready — entering health loop");
|
|
159
|
+
await healthLoop();
|
|
160
|
+
}
|
package/dist/trace.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
/** True when the CLI was launched with -i / --trace, or VEXP_TRACE=1. */
|
|
4
|
+
export function isTraceEnabled() {
|
|
5
|
+
if (process.env.VEXP_TRACE === "1")
|
|
6
|
+
return true;
|
|
7
|
+
const argv = process.argv.slice(2);
|
|
8
|
+
return argv.includes("-i") || argv.includes("--trace");
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Scrub potentially-sensitive data from a string before logging:
|
|
12
|
+
* - IPv4/IPv6 loopback/private addresses → <ip>
|
|
13
|
+
* - UUIDs (tokens) → <token>
|
|
14
|
+
* - Bearer values → <token>
|
|
15
|
+
* - User home path → ~
|
|
16
|
+
*/
|
|
17
|
+
export function redact(s) {
|
|
18
|
+
const home = os.homedir();
|
|
19
|
+
let out = s;
|
|
20
|
+
out = out.replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, "<ip>");
|
|
21
|
+
out = out.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<token>");
|
|
22
|
+
out = out.replace(/Bearer\s+[A-Za-z0-9._-]+/g, "Bearer <token>");
|
|
23
|
+
if (home && home.length > 1) {
|
|
24
|
+
const safe = home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
25
|
+
out = out.replace(new RegExp(safe, "g"), "~");
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
function clock() {
|
|
30
|
+
const d = new Date();
|
|
31
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
32
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
33
|
+
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
34
|
+
return `${hh}:${mm}:${ss}`;
|
|
35
|
+
}
|
|
36
|
+
export class Tracer {
|
|
37
|
+
enabled;
|
|
38
|
+
depth = 0;
|
|
39
|
+
constructor(enabled = isTraceEnabled()) {
|
|
40
|
+
this.enabled = enabled;
|
|
41
|
+
}
|
|
42
|
+
indent() {
|
|
43
|
+
return " ".repeat(this.depth);
|
|
44
|
+
}
|
|
45
|
+
emit(sym, color, msg, extra) {
|
|
46
|
+
if (!this.enabled)
|
|
47
|
+
return;
|
|
48
|
+
const prefix = chalk.dim(`[${clock()}]`) + " " + this.indent() + color(sym);
|
|
49
|
+
const line = extra
|
|
50
|
+
? `${prefix} ${redact(msg)} ${chalk.dim(extra)}`
|
|
51
|
+
: `${prefix} ${redact(msg)}`;
|
|
52
|
+
process.stderr.write(line + "\n");
|
|
53
|
+
}
|
|
54
|
+
info(msg) { this.emit("·", chalk.dim, msg); }
|
|
55
|
+
/**
|
|
56
|
+
* Execute `fn` bracketed by trace markers. Logs ✓ on success, ✗ on
|
|
57
|
+
* failure; rethrows so the caller's control flow is unchanged.
|
|
58
|
+
*/
|
|
59
|
+
async step(name, fn) {
|
|
60
|
+
if (!this.enabled)
|
|
61
|
+
return await fn();
|
|
62
|
+
this.emit("▶", chalk.cyan, name);
|
|
63
|
+
this.depth++;
|
|
64
|
+
const start = Date.now();
|
|
65
|
+
try {
|
|
66
|
+
const out = await fn();
|
|
67
|
+
this.depth--;
|
|
68
|
+
this.emit("✓", chalk.green, name, `${Date.now() - start}ms`);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
this.depth--;
|
|
73
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
74
|
+
this.emit("✗", chalk.red, `${name} — ${redact(msg)}`, `${Date.now() - start}ms`);
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Shared tracer instance — cheap to re-create but convenient as a default. */
|
|
80
|
+
export const tracer = new Tracer();
|