noctrace 0.9.0 → 1.1.0
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 +3 -0
- package/bin/docker-watcher.sh +108 -0
- package/bin/noctrace-mcp.js +47 -8
- package/bin/noctrace.js +158 -0
- package/dist/server/server/docker.js +217 -0
- package/dist/server/server/routes/api.js +100 -1
- package/hooks/hooks.json +9 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,6 +87,7 @@ Requires Node.js 20+. Optional `--install-hooks` flag enables real-time hook eve
|
|
|
87
87
|
- **API Error Markers** — rate limit, billing, and auth failures appear as full-width red alert banners on the timeline
|
|
88
88
|
- **Agent Teams Panel** — detects running Agent Teams at `~/.claude/teams/`, shows members and task counts in a flyout
|
|
89
89
|
- **Context Startup Flyout** — shows which instruction files (CLAUDE.md and others) loaded at session start with estimated token counts, parsed from JSONL system records
|
|
90
|
+
- **Docker Support** — `npx noctrace --docker <container>` attaches to a running Docker container, injects a lightweight watcher, and streams JSONL events back to your host in real time. Zero container setup required
|
|
90
91
|
|
|
91
92
|

|
|
92
93
|
|
|
@@ -141,6 +142,8 @@ No config files. No cloud. Everything stays local. Optional hooks for richer rea
|
|
|
141
142
|
|
|
142
143
|
| CLI Flag | Description |
|
|
143
144
|
|----------|-------------|
|
|
145
|
+
| `--docker <container>` | Attach to a running Docker container and stream its Claude Code sessions back to your host. Zero container setup |
|
|
146
|
+
| `--devcontainer <path>` | Resolve the running devcontainer for a local folder path and attach to it. Pass `.` for the current directory. Falls back to `--docker` if you pass a container name directly |
|
|
144
147
|
| `--install-hooks` | Configure Claude Code to push real-time events to noctrace |
|
|
145
148
|
| `--uninstall-hooks` | Remove noctrace hooks from Claude Code |
|
|
146
149
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Noctrace Docker Watcher — injected into containers to stream JSONL to host.
|
|
3
|
+
# Usage: docker-watcher.sh <claude_dir> <noctrace_url> <container_name>
|
|
4
|
+
# Streams JSONL lines via curl POST to the noctrace /api/docker/stream endpoint.
|
|
5
|
+
# Sends heartbeats every 10 seconds to /api/docker/heartbeat.
|
|
6
|
+
|
|
7
|
+
CLAUDE_DIR="$1"
|
|
8
|
+
NOCTRACE_URL="$2"
|
|
9
|
+
CONTAINER_NAME="$3"
|
|
10
|
+
PROJECTS_DIR="$CLAUDE_DIR/projects"
|
|
11
|
+
|
|
12
|
+
if [ -z "$CLAUDE_DIR" ] || [ -z "$NOCTRACE_URL" ] || [ -z "$CONTAINER_NAME" ]; then
|
|
13
|
+
echo "Usage: docker-watcher.sh <claude_dir> <noctrace_url> <container_name>" >&2
|
|
14
|
+
exit 1
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
# Track watched files to avoid duplicate tail processes
|
|
18
|
+
WATCHED="/tmp/.noctrace-watched-$$"
|
|
19
|
+
PIDS="/tmp/.noctrace-pids-$$"
|
|
20
|
+
touch "$WATCHED" "$PIDS"
|
|
21
|
+
|
|
22
|
+
cleanup() {
|
|
23
|
+
# Kill all tail processes we started
|
|
24
|
+
while IFS= read -r pid; do
|
|
25
|
+
kill "$pid" 2>/dev/null
|
|
26
|
+
done < "$PIDS"
|
|
27
|
+
rm -f "$WATCHED" "$PIDS"
|
|
28
|
+
exit 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
trap cleanup TERM INT
|
|
32
|
+
|
|
33
|
+
# Detect HTTP client
|
|
34
|
+
if command -v curl >/dev/null 2>&1; then
|
|
35
|
+
HTTP_CMD="curl"
|
|
36
|
+
elif command -v wget >/dev/null 2>&1; then
|
|
37
|
+
HTTP_CMD="wget"
|
|
38
|
+
else
|
|
39
|
+
echo "[noctrace-watcher] Neither curl nor wget found in container" >&2
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
post_stream() {
|
|
44
|
+
file="$1"
|
|
45
|
+
if [ "$HTTP_CMD" = "curl" ]; then
|
|
46
|
+
curl -s -X POST "$NOCTRACE_URL/api/docker/stream" \
|
|
47
|
+
-H "Content-Type: text/plain" \
|
|
48
|
+
-H "X-Container-Name: $CONTAINER_NAME" \
|
|
49
|
+
-H "X-Container-Path: $file" \
|
|
50
|
+
--data-binary @- || true
|
|
51
|
+
else
|
|
52
|
+
wget -q -O /dev/null --post-file=- \
|
|
53
|
+
--header="Content-Type: text/plain" \
|
|
54
|
+
--header="X-Container-Name: $CONTAINER_NAME" \
|
|
55
|
+
--header="X-Container-Path: $file" \
|
|
56
|
+
"$NOCTRACE_URL/api/docker/stream" 2>/dev/null || true
|
|
57
|
+
fi
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
post_heartbeat() {
|
|
61
|
+
if [ "$HTTP_CMD" = "curl" ]; then
|
|
62
|
+
curl -s -X POST "$NOCTRACE_URL/api/docker/heartbeat" \
|
|
63
|
+
-H "X-Container-Name: $CONTAINER_NAME" || true
|
|
64
|
+
else
|
|
65
|
+
wget -q -O /dev/null --post-data="" \
|
|
66
|
+
--header="X-Container-Name: $CONTAINER_NAME" \
|
|
67
|
+
"$NOCTRACE_URL/api/docker/heartbeat" 2>/dev/null || true
|
|
68
|
+
fi
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
watch_file() {
|
|
72
|
+
file="$1"
|
|
73
|
+
# Send full file content then follow new lines
|
|
74
|
+
tail -f -n +1 "$file" 2>/dev/null | while IFS= read -r line; do
|
|
75
|
+
printf '%s\n' "$line" | post_stream "$file"
|
|
76
|
+
done &
|
|
77
|
+
echo $! >> "$PIDS"
|
|
78
|
+
echo "$file" >> "$WATCHED"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Initial scan — watch all existing JSONL files
|
|
82
|
+
if [ -d "$PROJECTS_DIR" ]; then
|
|
83
|
+
find "$PROJECTS_DIR" -name "*.jsonl" -type f 2>/dev/null | while IFS= read -r f; do
|
|
84
|
+
watch_file "$f"
|
|
85
|
+
done
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# Main loop: scan for new files + send heartbeat
|
|
89
|
+
HEARTBEAT_COUNTER=0
|
|
90
|
+
while true; do
|
|
91
|
+
sleep 3
|
|
92
|
+
|
|
93
|
+
# Scan for new JSONL files (including subagents)
|
|
94
|
+
if [ -d "$PROJECTS_DIR" ]; then
|
|
95
|
+
find "$PROJECTS_DIR" -name "*.jsonl" -type f 2>/dev/null | while IFS= read -r f; do
|
|
96
|
+
if ! grep -qxF "$f" "$WATCHED" 2>/dev/null; then
|
|
97
|
+
watch_file "$f"
|
|
98
|
+
fi
|
|
99
|
+
done
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
# Heartbeat every ~10 seconds (3s sleep * 3 iterations)
|
|
103
|
+
HEARTBEAT_COUNTER=$((HEARTBEAT_COUNTER + 1))
|
|
104
|
+
if [ "$HEARTBEAT_COUNTER" -ge 3 ]; then
|
|
105
|
+
post_heartbeat
|
|
106
|
+
HEARTBEAT_COUNTER=0
|
|
107
|
+
fi
|
|
108
|
+
done
|
package/bin/noctrace-mcp.js
CHANGED
|
@@ -20,9 +20,14 @@ import fs from 'node:fs/promises';
|
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import os from 'node:os';
|
|
22
22
|
|
|
23
|
-
const VERSION = '0.
|
|
24
|
-
const NOCTRACE_PORT = 4117;
|
|
25
|
-
|
|
23
|
+
const VERSION = '0.9.0';
|
|
24
|
+
const NOCTRACE_PORT = parseInt(process.env.NOCTRACE_PORT ?? '4117', 10);
|
|
25
|
+
|
|
26
|
+
// Validate NOCTRACE_HOST: only allow localhost, 127.0.0.1, and host.docker.internal
|
|
27
|
+
const rawHost = process.env.NOCTRACE_HOST ?? 'localhost';
|
|
28
|
+
const ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'host.docker.internal'];
|
|
29
|
+
const NOCTRACE_HOST = ALLOWED_HOSTS.includes(rawHost) ? rawHost : 'localhost';
|
|
30
|
+
const BASE_URL = `http://${NOCTRACE_HOST}:${NOCTRACE_PORT}`;
|
|
26
31
|
|
|
27
32
|
// ---------------------------------------------------------------------------
|
|
28
33
|
// Session path discovery
|
|
@@ -71,26 +76,56 @@ async function newestJsonl(dir) {
|
|
|
71
76
|
return newest;
|
|
72
77
|
}
|
|
73
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Translate a container-internal path to the host-side path.
|
|
81
|
+
* Uses NOCTRACE_PATH_MAP env var: "container_prefix:host_prefix"
|
|
82
|
+
* Example: NOCTRACE_PATH_MAP="/root/.claude:/Users/lam/.claude"
|
|
83
|
+
*
|
|
84
|
+
* @param {string} containerPath
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function translatePath(containerPath) {
|
|
88
|
+
const pathMap = process.env.NOCTRACE_PATH_MAP;
|
|
89
|
+
if (!pathMap) return containerPath;
|
|
90
|
+
const sep = pathMap.indexOf(':');
|
|
91
|
+
if (sep === -1) return containerPath;
|
|
92
|
+
const containerPrefix = pathMap.slice(0, sep);
|
|
93
|
+
const hostPrefix = pathMap.slice(sep + 1);
|
|
94
|
+
if (containerPath.startsWith(containerPrefix)) {
|
|
95
|
+
const translated = hostPrefix + containerPath.slice(containerPrefix.length);
|
|
96
|
+
// Validate no path traversal: resolved path must stay under hostPrefix
|
|
97
|
+
const resolved = path.resolve(translated);
|
|
98
|
+
if (!resolved.startsWith(path.resolve(hostPrefix) + path.sep) && resolved !== path.resolve(hostPrefix)) {
|
|
99
|
+
process.stderr.write(`[noctrace-mcp] Path traversal blocked: ${containerPath}\n`);
|
|
100
|
+
return containerPath;
|
|
101
|
+
}
|
|
102
|
+
return translated;
|
|
103
|
+
}
|
|
104
|
+
return containerPath;
|
|
105
|
+
}
|
|
106
|
+
|
|
74
107
|
/**
|
|
75
108
|
* Discover the JSONL session path for the current Claude Code session.
|
|
109
|
+
* Returns the host-translated path when NOCTRACE_PATH_MAP is set.
|
|
76
110
|
*
|
|
77
111
|
* @returns {Promise<string|null>}
|
|
78
112
|
*/
|
|
79
113
|
async function discoverSessionPath() {
|
|
80
114
|
// Option a: direct env var
|
|
81
115
|
if (process.env.CLAUDE_SESSION_PATH) {
|
|
82
|
-
return process.env.CLAUDE_SESSION_PATH;
|
|
116
|
+
return translatePath(process.env.CLAUDE_SESSION_PATH);
|
|
83
117
|
}
|
|
84
118
|
|
|
85
119
|
// Option b: derive from project directory
|
|
86
120
|
const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.env.PWD ?? null;
|
|
87
121
|
if (!projectDir) return null;
|
|
88
122
|
|
|
89
|
-
const claudeHome = process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.claude');
|
|
123
|
+
const claudeHome = process.env.CLAUDE_CONFIG_DIR ?? process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.claude');
|
|
90
124
|
const slug = pathToSlug(projectDir);
|
|
91
125
|
const projectSessionDir = path.join(claudeHome, 'projects', slug);
|
|
92
126
|
|
|
93
|
-
|
|
127
|
+
const sessionPath = await newestJsonl(projectSessionDir);
|
|
128
|
+
return sessionPath ? translatePath(sessionPath) : null;
|
|
94
129
|
}
|
|
95
130
|
|
|
96
131
|
// ---------------------------------------------------------------------------
|
|
@@ -149,7 +184,7 @@ async function postSessionAction(action, sessionPath) {
|
|
|
149
184
|
return new Promise((resolve) => {
|
|
150
185
|
const req = http.request(
|
|
151
186
|
{
|
|
152
|
-
hostname:
|
|
187
|
+
hostname: NOCTRACE_HOST,
|
|
153
188
|
port: NOCTRACE_PORT,
|
|
154
189
|
path: `/api/sessions/${action}`,
|
|
155
190
|
method: 'POST',
|
|
@@ -254,10 +289,14 @@ function handleMessage(line) {
|
|
|
254
289
|
async function main() {
|
|
255
290
|
let sessionPath = await discoverSessionPath();
|
|
256
291
|
|
|
292
|
+
// When running inside Docker (NOCTRACE_HOST != localhost), skip server start —
|
|
293
|
+
// the noctrace server runs on the host, not inside the container.
|
|
294
|
+
const isRemote = NOCTRACE_HOST !== 'localhost' && NOCTRACE_HOST !== '127.0.0.1';
|
|
295
|
+
|
|
257
296
|
// Check if noctrace is already running; if not, start it (first MCP process wins).
|
|
258
297
|
// Use a retry loop to handle the race where two MCP processes start simultaneously.
|
|
259
298
|
const running = await isServerRunning();
|
|
260
|
-
if (!running) {
|
|
299
|
+
if (!running && !isRemote) {
|
|
261
300
|
process.stderr.write('[noctrace-mcp] Starting noctrace server...\n');
|
|
262
301
|
try {
|
|
263
302
|
await startNoctraceServer();
|
package/bin/noctrace.js
CHANGED
|
@@ -197,6 +197,164 @@ if (args.includes('--disable')) {
|
|
|
197
197
|
process.exit(0);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Run Docker watcher mode with a known, already-validated container ID.
|
|
202
|
+
* Shared by --docker and --devcontainer.
|
|
203
|
+
*/
|
|
204
|
+
async function runDockerMode(containerArg) {
|
|
205
|
+
const {
|
|
206
|
+
isValidContainerName,
|
|
207
|
+
assertContainerRunning,
|
|
208
|
+
resolveClaudeDir,
|
|
209
|
+
detectHttpTool,
|
|
210
|
+
resolveHostUrl,
|
|
211
|
+
copyWatcherScript,
|
|
212
|
+
spawnWatcher,
|
|
213
|
+
cleanupWatcher,
|
|
214
|
+
defaultDockerRunner,
|
|
215
|
+
} = await import('../dist/server/server/docker.js');
|
|
216
|
+
|
|
217
|
+
const { readFileSync, writeFileSync, unlinkSync } = await import('node:fs');
|
|
218
|
+
|
|
219
|
+
// Validate container name to prevent command injection
|
|
220
|
+
if (!isValidContainerName(containerArg)) {
|
|
221
|
+
console.error(`[noctrace] Invalid container name: "${containerArg}"`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Verify container exists and is running
|
|
226
|
+
try {
|
|
227
|
+
assertContainerRunning(containerArg, defaultDockerRunner);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
console.error(`[noctrace] ${err.message}`);
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Find Claude config dir inside the container
|
|
234
|
+
const claudeDir = resolveClaudeDir(containerArg, defaultDockerRunner);
|
|
235
|
+
console.log(`[noctrace] Connecting to container "${containerArg}" (claude dir: ${claudeDir})`);
|
|
236
|
+
|
|
237
|
+
// Check if curl or wget exists in the container
|
|
238
|
+
const httpTool = detectHttpTool(containerArg, defaultDockerRunner);
|
|
239
|
+
if (httpTool === 'none') {
|
|
240
|
+
console.error('[noctrace] Container has neither curl nor wget. Cannot stream sessions.');
|
|
241
|
+
console.error('[noctrace] Install curl in the container: apt-get install -y curl');
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Resolve host URL that the container can reach
|
|
246
|
+
const hostUrl = resolveHostUrl(containerArg, defaultDockerRunner);
|
|
247
|
+
|
|
248
|
+
// Start noctrace server on the host
|
|
249
|
+
process.env.NOCTRACE_NO_AUTOSTART = '1';
|
|
250
|
+
process.env.NODE_ENV = 'production';
|
|
251
|
+
const { startServer } = await import('../dist/server/server/index.js');
|
|
252
|
+
const openMod = await import('open');
|
|
253
|
+
const port = await startServer();
|
|
254
|
+
const url = `http://localhost:${port}`;
|
|
255
|
+
const containerTargetUrl = `${hostUrl}:${port}`;
|
|
256
|
+
console.log(`[noctrace] Dashboard: ${url}`);
|
|
257
|
+
console.log(`[noctrace] Container will stream to: ${containerTargetUrl}`);
|
|
258
|
+
await openMod.default(url);
|
|
259
|
+
|
|
260
|
+
// Read the watcher script from the package
|
|
261
|
+
const watcherScript = readFileSync(
|
|
262
|
+
new URL('./docker-watcher.sh', import.meta.url), 'utf8'
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// Copy watcher script into the container
|
|
266
|
+
console.log(`[noctrace] Injecting watcher into container...`);
|
|
267
|
+
const tmpScript = path.join(os.tmpdir(), `noctrace-watcher-${Date.now()}.sh`);
|
|
268
|
+
writeFileSync(tmpScript, watcherScript);
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
copyWatcherScript(containerArg, tmpScript, defaultDockerRunner);
|
|
272
|
+
} finally {
|
|
273
|
+
unlinkSync(tmpScript);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Run the watcher in the background inside the container
|
|
277
|
+
spawnWatcher(containerArg, claudeDir, containerTargetUrl, defaultDockerRunner);
|
|
278
|
+
|
|
279
|
+
console.log(`[noctrace] Watcher injected. Streaming sessions in real-time.`);
|
|
280
|
+
console.log(`[noctrace] Press Ctrl+C to stop.`);
|
|
281
|
+
|
|
282
|
+
// Monitor heartbeats
|
|
283
|
+
let lastHeartbeatCheck = Date.now();
|
|
284
|
+
const heartbeatInterval = setInterval(async () => {
|
|
285
|
+
try {
|
|
286
|
+
const { default: http } = await import('node:http');
|
|
287
|
+
const result = await new Promise((resolve) => {
|
|
288
|
+
const req = http.get(`http://localhost:${port}/api/docker/status`, { timeout: 2000 }, (res) => {
|
|
289
|
+
let data = '';
|
|
290
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
291
|
+
res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(null); } });
|
|
292
|
+
});
|
|
293
|
+
req.on('error', () => resolve(null));
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (result && result.containers) {
|
|
297
|
+
const container = result.containers.find((c) => c.name === containerArg);
|
|
298
|
+
if (container && container.stale && Date.now() - lastHeartbeatCheck > 30000) {
|
|
299
|
+
console.log(`[noctrace] Warning: No heartbeat from container "${containerArg}" in 30s. It may have stopped.`);
|
|
300
|
+
lastHeartbeatCheck = Date.now();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} catch { /* heartbeat check is best-effort */ }
|
|
304
|
+
}, 15000);
|
|
305
|
+
|
|
306
|
+
// Cleanup on exit
|
|
307
|
+
process.on('SIGINT', () => {
|
|
308
|
+
console.log('\n[noctrace] Stopping...');
|
|
309
|
+
clearInterval(heartbeatInterval);
|
|
310
|
+
cleanupWatcher(containerArg, defaultDockerRunner);
|
|
311
|
+
console.log('[noctrace] Stopped.');
|
|
312
|
+
process.exit(0);
|
|
313
|
+
});
|
|
314
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
315
|
+
|
|
316
|
+
// Keep process alive
|
|
317
|
+
await new Promise(() => {});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (args.includes('--docker')) {
|
|
321
|
+
const containerArg = args[args.indexOf('--docker') + 1];
|
|
322
|
+
if (!containerArg || containerArg.startsWith('--')) {
|
|
323
|
+
console.error('[noctrace] Usage: npx noctrace --docker <container-name-or-id>');
|
|
324
|
+
console.error('[noctrace] Example: npx noctrace --docker my-claude-container');
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
await runDockerMode(containerArg);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (args.includes('--devcontainer')) {
|
|
331
|
+
const devcontainerArg = args[args.indexOf('--devcontainer') + 1];
|
|
332
|
+
if (!devcontainerArg || devcontainerArg.startsWith('--')) {
|
|
333
|
+
console.error('[noctrace] Usage: npx noctrace --devcontainer <path-or-container>');
|
|
334
|
+
console.error('[noctrace] Examples:');
|
|
335
|
+
console.error('[noctrace] npx noctrace --devcontainer .');
|
|
336
|
+
console.error('[noctrace] npx noctrace --devcontainer /Users/me/myproject');
|
|
337
|
+
console.error('[noctrace] npx noctrace --devcontainer my-devcontainer-id');
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const { resolveDevcontainerContainer, defaultDockerRunner } =
|
|
342
|
+
await import('../dist/server/server/docker.js');
|
|
343
|
+
|
|
344
|
+
let resolvedContainer;
|
|
345
|
+
try {
|
|
346
|
+
resolvedContainer = resolveDevcontainerContainer(devcontainerArg, defaultDockerRunner);
|
|
347
|
+
} catch (err) {
|
|
348
|
+
const lines = err.message.split('\n');
|
|
349
|
+
for (const line of lines) {
|
|
350
|
+
console.error(`[noctrace] ${line}`);
|
|
351
|
+
}
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
await runDockerMode(resolvedContainer);
|
|
356
|
+
}
|
|
357
|
+
|
|
200
358
|
if (args.includes('--mcp')) {
|
|
201
359
|
// MCP mode: boot the Express server and speak JSON-RPC over stdio.
|
|
202
360
|
// stdout is the JSON-RPC channel — all logging must go to stderr.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker support for noctrace.
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates container inspection, HTTP-tool detection, host URL resolution,
|
|
5
|
+
* watcher injection, and cleanup. All Docker commands go through the
|
|
6
|
+
* DockerRunner interface so callers (and tests) can swap in a stub.
|
|
7
|
+
*/
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Default runner (real child_process)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
|
|
14
|
+
export const defaultDockerRunner = {
|
|
15
|
+
execSync(cmd, args, opts = {}) {
|
|
16
|
+
return execFileSync(cmd, args, { stdio: opts.stdio ?? 'pipe', timeout: opts.timeout })
|
|
17
|
+
.toString();
|
|
18
|
+
},
|
|
19
|
+
spawn(cmd, args, opts = {}) {
|
|
20
|
+
return nodeSpawn(cmd, args, opts);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Validation
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const CONTAINER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
27
|
+
/**
|
|
28
|
+
* Returns true when the container name is syntactically safe to pass as an
|
|
29
|
+
* argv element. Rejects names that could be used for command injection or
|
|
30
|
+
* path traversal.
|
|
31
|
+
*/
|
|
32
|
+
export function isValidContainerName(name) {
|
|
33
|
+
return CONTAINER_NAME_RE.test(name);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Returns true when a path is free of directory-traversal sequences.
|
|
37
|
+
* Any segment equal to `..` is rejected regardless of surrounding context.
|
|
38
|
+
*/
|
|
39
|
+
export function isValidContainerPath(p) {
|
|
40
|
+
return !p.split('/').includes('..');
|
|
41
|
+
}
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Container state
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
/**
|
|
46
|
+
* Verify the container is running. Throws with a user-friendly message when
|
|
47
|
+
* the container is not found or not running.
|
|
48
|
+
*/
|
|
49
|
+
export function assertContainerRunning(containerArg, runner) {
|
|
50
|
+
try {
|
|
51
|
+
runner.execSync('docker', ['inspect', '--format', '{{.State.Running}}', containerArg], { stdio: 'pipe' });
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new Error(`Container "${containerArg}" not found or not running.\nCheck: docker ps`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Claude config dir inside container
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
/**
|
|
61
|
+
* Ask the container for the Claude config directory (respects CLAUDE_CONFIG_DIR).
|
|
62
|
+
*/
|
|
63
|
+
export function resolveClaudeDir(containerArg, runner) {
|
|
64
|
+
return runner
|
|
65
|
+
.execSync('docker', ['exec', containerArg, 'sh', '-c', 'echo ${CLAUDE_CONFIG_DIR:-$HOME/.claude}'], { stdio: 'pipe' })
|
|
66
|
+
.trim();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Determine which HTTP client is available inside the container.
|
|
70
|
+
* Tries curl first, falls back to wget, returns 'none' if neither is present.
|
|
71
|
+
*/
|
|
72
|
+
export function detectHttpTool(containerArg, runner) {
|
|
73
|
+
try {
|
|
74
|
+
runner.execSync('docker', ['exec', containerArg, 'which', 'curl'], { stdio: 'pipe' });
|
|
75
|
+
return 'curl';
|
|
76
|
+
}
|
|
77
|
+
catch { /* curl not found */ }
|
|
78
|
+
try {
|
|
79
|
+
runner.execSync('docker', ['exec', containerArg, 'which', 'wget'], { stdio: 'pipe' });
|
|
80
|
+
return 'wget';
|
|
81
|
+
}
|
|
82
|
+
catch { /* wget not found */ }
|
|
83
|
+
return 'none';
|
|
84
|
+
}
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Host URL resolution
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the URL the container can use to reach the host.
|
|
90
|
+
*
|
|
91
|
+
* Priority:
|
|
92
|
+
* 1. `host.docker.internal` (works on macOS/Windows Docker Desktop)
|
|
93
|
+
* 2. Gateway IP from `docker inspect` (Linux Docker)
|
|
94
|
+
* 3. Fallback to `host.docker.internal` (best-effort)
|
|
95
|
+
*/
|
|
96
|
+
export function resolveHostUrl(containerArg, runner) {
|
|
97
|
+
try {
|
|
98
|
+
runner.execSync('docker', ['exec', containerArg, 'getent', 'hosts', 'host.docker.internal'], { stdio: 'pipe' });
|
|
99
|
+
return 'http://host.docker.internal';
|
|
100
|
+
}
|
|
101
|
+
catch { /* not available — try gateway IP */ }
|
|
102
|
+
try {
|
|
103
|
+
const gatewayIp = runner
|
|
104
|
+
.execSync('docker', [
|
|
105
|
+
'inspect',
|
|
106
|
+
'--format',
|
|
107
|
+
'{{range .NetworkSettings.Networks}}{{.Gateway}}{{end}}',
|
|
108
|
+
containerArg,
|
|
109
|
+
], { stdio: 'pipe' })
|
|
110
|
+
.trim();
|
|
111
|
+
if (gatewayIp) {
|
|
112
|
+
return `http://${gatewayIp}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch { /* ignore */ }
|
|
116
|
+
return 'http://host.docker.internal';
|
|
117
|
+
}
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Watcher injection
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
/**
|
|
122
|
+
* Copy a local script file into the container at `/tmp/noctrace-watcher.sh`,
|
|
123
|
+
* mark it executable, and return. The caller is responsible for running it.
|
|
124
|
+
*/
|
|
125
|
+
export function copyWatcherScript(containerArg, localScriptPath, runner) {
|
|
126
|
+
runner.execSync('docker', ['cp', localScriptPath, `${containerArg}:/tmp/noctrace-watcher.sh`], { stdio: 'pipe' });
|
|
127
|
+
runner.execSync('docker', ['exec', containerArg, 'chmod', '+x', '/tmp/noctrace-watcher.sh'], { stdio: 'pipe' });
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Start the injected watcher script inside the container in the background.
|
|
131
|
+
* Returns the spawned process handle (callers can swallow its errors).
|
|
132
|
+
*/
|
|
133
|
+
export function spawnWatcher(containerArg, claudeDir, containerTargetUrl, runner) {
|
|
134
|
+
const proc = runner.spawn('docker', [
|
|
135
|
+
'exec', '-d', containerArg,
|
|
136
|
+
'sh', '-c', '/tmp/noctrace-watcher.sh "$1" "$2" "$3"', '--',
|
|
137
|
+
claudeDir, containerTargetUrl, containerArg,
|
|
138
|
+
], { stdio: 'ignore' });
|
|
139
|
+
proc.on('error', () => { });
|
|
140
|
+
return proc;
|
|
141
|
+
}
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Cleanup
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
/**
|
|
146
|
+
* Kill the noctrace-watcher process inside the container.
|
|
147
|
+
* Safe to call after container exit — errors are swallowed.
|
|
148
|
+
*/
|
|
149
|
+
export function cleanupWatcher(containerArg, runner) {
|
|
150
|
+
try {
|
|
151
|
+
runner.execSync('docker', ['exec', containerArg, 'sh', '-c', 'pkill -f noctrace-watcher 2>/dev/null || true'], { stdio: 'pipe', timeout: 3000 });
|
|
152
|
+
}
|
|
153
|
+
catch { /* container may be gone */ }
|
|
154
|
+
}
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Devcontainer support
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
/**
|
|
159
|
+
* Look up a running container by an exact Docker label match.
|
|
160
|
+
* Returns the container ID (short form), or null when nothing matches.
|
|
161
|
+
*
|
|
162
|
+
* Uses `docker ps --filter "label=<label>=<value>" --format "{{.ID}}"`.
|
|
163
|
+
* The label and value are passed as a single `label=key=value` filter argument
|
|
164
|
+
* so no shell interpolation occurs.
|
|
165
|
+
*/
|
|
166
|
+
export function findContainerByLabel(label, value, runner) {
|
|
167
|
+
let output;
|
|
168
|
+
try {
|
|
169
|
+
output = runner.execSync('docker', ['ps', '--filter', `label=${label}=${value}`, '--format', '{{.ID}}'], { stdio: 'pipe' });
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const id = output.trim().split('\n')[0]?.trim() ?? '';
|
|
175
|
+
return id.length > 0 ? id : null;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Resolve a devcontainer argument to a concrete container ID.
|
|
179
|
+
*
|
|
180
|
+
* If `input` looks like a path (starts with `/`, `.`, `./`, or `~/`) it is
|
|
181
|
+
* resolved to an absolute path and looked up via the canonical
|
|
182
|
+
* `devcontainer.local_folder` label, falling back to the older
|
|
183
|
+
* `vsch.local.folder` label. When neither label matches an error is thrown
|
|
184
|
+
* with a clear hint pointing the user at `docker ps --filter "label=devcontainer.*"`.
|
|
185
|
+
*
|
|
186
|
+
* If `input` is not a path it is treated as a container name/ID.
|
|
187
|
+
* `isValidContainerName` is checked and the value is returned directly.
|
|
188
|
+
*
|
|
189
|
+
* @param cwd - Working directory used to resolve relative paths. Defaults to `process.cwd()`.
|
|
190
|
+
*/
|
|
191
|
+
export function resolveDevcontainerContainer(input, runner, cwd) {
|
|
192
|
+
const isPath = input.startsWith('/') || input.startsWith('.') || input.startsWith('~/');
|
|
193
|
+
if (!isPath) {
|
|
194
|
+
if (!isValidContainerName(input)) {
|
|
195
|
+
throw new Error(`Invalid container name: "${input}"`);
|
|
196
|
+
}
|
|
197
|
+
return input;
|
|
198
|
+
}
|
|
199
|
+
// Resolve to an absolute path — devcontainer labels always store absolute paths.
|
|
200
|
+
// path.resolve does not expand ~ so handle that explicitly.
|
|
201
|
+
let absPath;
|
|
202
|
+
if (input.startsWith('~/')) {
|
|
203
|
+
absPath = path.join(os.homedir(), input.slice(2));
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
absPath = path.resolve(cwd ?? process.cwd(), input);
|
|
207
|
+
}
|
|
208
|
+
// Try canonical label first, then the older VS Code label.
|
|
209
|
+
const id = findContainerByLabel('devcontainer.local_folder', absPath, runner) ??
|
|
210
|
+
findContainerByLabel('vsch.local.folder', absPath, runner);
|
|
211
|
+
if (id === null) {
|
|
212
|
+
throw new Error(`No devcontainer found for path: ${absPath}\n` +
|
|
213
|
+
`Hint: make sure the devcontainer is running, then check:\n` +
|
|
214
|
+
` docker ps --filter "label=devcontainer.local_folder"`);
|
|
215
|
+
}
|
|
216
|
+
return id;
|
|
217
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* REST API routes for project and session data.
|
|
3
3
|
* All data is read from JSONL files on disk — no in-memory caching.
|
|
4
4
|
*/
|
|
5
|
-
import { Router } from 'express';
|
|
5
|
+
import express, { Router } from 'express';
|
|
6
6
|
import fs from 'node:fs/promises';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { WebSocket } from 'ws';
|
|
@@ -76,6 +76,8 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
76
76
|
* When non-empty the client operates in "MCP mode" and shows only these sessions.
|
|
77
77
|
*/
|
|
78
78
|
const registeredSessionPaths = new Set();
|
|
79
|
+
/** Last heartbeat timestamp from Docker container watchers, keyed by container name. */
|
|
80
|
+
const dockerHeartbeats = new Map();
|
|
79
81
|
/** Broadcast a message to all connected WebSocket clients. */
|
|
80
82
|
function broadcast(msg) {
|
|
81
83
|
const payload = JSON.stringify(msg);
|
|
@@ -543,6 +545,103 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
543
545
|
}
|
|
544
546
|
});
|
|
545
547
|
// ---------------------------------------------------------------------------
|
|
548
|
+
// POST /api/docker/stream
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
/**
|
|
551
|
+
* Receive streamed JSONL content from a Docker container watcher.
|
|
552
|
+
* Appends raw text to a local sync file under the projects directory.
|
|
553
|
+
* Chokidar picks up the file change and handles parsing + WebSocket broadcasting.
|
|
554
|
+
*/
|
|
555
|
+
router.post('/docker/stream', express.text({ type: 'text/plain', limit: '1mb' }), async (req, res) => {
|
|
556
|
+
try {
|
|
557
|
+
const containerName = req.headers['x-container-name'];
|
|
558
|
+
const containerPath = req.headers['x-container-path'];
|
|
559
|
+
if (typeof containerName !== 'string' || !containerName) {
|
|
560
|
+
res.status(400).json({ error: 'X-Container-Name header required' });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (typeof containerPath !== 'string' || !containerPath) {
|
|
564
|
+
res.status(400).json({ error: 'X-Container-Path header required' });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(containerName)) {
|
|
568
|
+
res.status(400).json({ error: 'Invalid container name format' });
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const body = req.body;
|
|
572
|
+
if (!body || !body.trim()) {
|
|
573
|
+
res.status(400).json({ error: 'Empty body' });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
// Extract relative path after /projects/
|
|
577
|
+
const projectsIdx = containerPath.indexOf('/projects/');
|
|
578
|
+
if (projectsIdx === -1) {
|
|
579
|
+
res.status(400).json({ error: 'Container path must contain /projects/' });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const relativePath = containerPath.slice(projectsIdx + '/projects/'.length);
|
|
583
|
+
// Reject path traversal patterns in the relative path
|
|
584
|
+
if (relativePath.includes('..')) {
|
|
585
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const slashIdx = relativePath.indexOf('/');
|
|
589
|
+
if (slashIdx === -1) {
|
|
590
|
+
res.status(400).json({ error: 'Invalid container path structure' });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const containerSlug = relativePath.slice(0, slashIdx);
|
|
594
|
+
const sessionFile = relativePath.slice(slashIdx + 1);
|
|
595
|
+
const localSlug = `docker--${containerName}--${containerSlug}`;
|
|
596
|
+
const localPath = path.join(projectsDir, localSlug, sessionFile);
|
|
597
|
+
try {
|
|
598
|
+
assertWithinBase(localPath, projectsDir);
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
await fs.mkdir(path.dirname(localPath), { recursive: true });
|
|
605
|
+
await fs.appendFile(localPath, body.endsWith('\n') ? body : body + '\n');
|
|
606
|
+
// Auto-register the session
|
|
607
|
+
if (localPath.endsWith('.jsonl') && !registeredSessionPaths.has(localPath)) {
|
|
608
|
+
const resolvedPath = path.resolve(localPath);
|
|
609
|
+
registeredSessionPaths.add(resolvedPath);
|
|
610
|
+
broadcast({ type: 'session-registered', sessionPath: resolvedPath });
|
|
611
|
+
}
|
|
612
|
+
res.json({ ok: true });
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
616
|
+
res.status(500).json({ error: message });
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
// POST /api/docker/heartbeat
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
/** Keepalive endpoint for Docker container watchers. */
|
|
623
|
+
router.post('/docker/heartbeat', (req, res) => {
|
|
624
|
+
const containerName = req.headers['x-container-name'];
|
|
625
|
+
if (typeof containerName !== 'string' || !containerName) {
|
|
626
|
+
res.status(400).json({ error: 'X-Container-Name header required' });
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
dockerHeartbeats.set(containerName, Date.now());
|
|
630
|
+
res.json({ ok: true });
|
|
631
|
+
});
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
// GET /api/docker/status
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
/** Returns the status of connected Docker containers. */
|
|
636
|
+
router.get('/docker/status', (_req, res) => {
|
|
637
|
+
const containers = [];
|
|
638
|
+
const now = Date.now();
|
|
639
|
+
for (const [name, ts] of dockerHeartbeats) {
|
|
640
|
+
containers.push({ name, lastHeartbeat: ts, stale: now - ts > 30_000 });
|
|
641
|
+
}
|
|
642
|
+
res.json({ containers });
|
|
643
|
+
});
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
546
645
|
// POST /api/hooks
|
|
547
646
|
// ---------------------------------------------------------------------------
|
|
548
647
|
/**
|
package/hooks/hooks.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"hooks": [
|
|
5
5
|
{
|
|
6
6
|
"type": "command",
|
|
7
|
-
"command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
7
|
+
"command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
8
8
|
"async": true
|
|
9
9
|
}
|
|
10
10
|
]
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"hooks": [
|
|
16
16
|
{
|
|
17
17
|
"type": "command",
|
|
18
|
-
"command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
18
|
+
"command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
19
19
|
"async": true
|
|
20
20
|
}
|
|
21
21
|
]
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"hooks": [
|
|
27
27
|
{
|
|
28
28
|
"type": "command",
|
|
29
|
-
"command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
29
|
+
"command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
30
30
|
"async": true
|
|
31
31
|
}
|
|
32
32
|
]
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"hooks": [
|
|
38
38
|
{
|
|
39
39
|
"type": "command",
|
|
40
|
-
"command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
40
|
+
"command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
41
41
|
"async": true
|
|
42
42
|
}
|
|
43
43
|
]
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"hooks": [
|
|
49
49
|
{
|
|
50
50
|
"type": "command",
|
|
51
|
-
"command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
51
|
+
"command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
52
52
|
"async": true
|
|
53
53
|
}
|
|
54
54
|
]
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"hooks": [
|
|
60
60
|
{
|
|
61
61
|
"type": "command",
|
|
62
|
-
"command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
62
|
+
"command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
63
63
|
"async": true
|
|
64
64
|
}
|
|
65
65
|
]
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"hooks": [
|
|
71
71
|
{
|
|
72
72
|
"type": "command",
|
|
73
|
-
"command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
73
|
+
"command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
74
74
|
"async": true
|
|
75
75
|
}
|
|
76
76
|
]
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"hooks": [
|
|
82
82
|
{
|
|
83
83
|
"type": "command",
|
|
84
|
-
"command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
84
|
+
"command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
85
85
|
"async": true
|
|
86
86
|
}
|
|
87
87
|
]
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"hooks": [
|
|
93
93
|
{
|
|
94
94
|
"type": "command",
|
|
95
|
-
"command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
95
|
+
"command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
96
96
|
"async": true
|
|
97
97
|
}
|
|
98
98
|
]
|
package/package.json
CHANGED