quadwork 0.1.3 → 1.0.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 +58 -83
- package/bin/quadwork.js +372 -58
- package/out/404.html +1 -1
- package/out/__next.__PAGE__.txt +1 -1
- package/out/__next._full.txt +2 -2
- package/out/__next._head.txt +1 -1
- package/out/__next._index.txt +2 -2
- package/out/__next._tree.txt +2 -2
- package/out/_next/static/chunks/0ahp74n0wkel0.js +1 -0
- package/out/_next/static/chunks/{038g944ax83al.js → 0dmi9pk2bd712.js} +3 -3
- package/out/_next/static/chunks/0ezniz80psxr6.js +1 -0
- package/out/_next/static/chunks/0g-nq4.uckan-.js +1 -0
- package/out/_next/static/chunks/0io_y3d0p5v~b.js +2 -0
- package/out/_next/static/chunks/0jt42fqe6jaw6.js +1 -0
- package/out/_next/static/chunks/{0wda-2lcle8c4.js → 0q5hwcek8vu2q.js} +12 -12
- package/out/_next/static/chunks/0r_tb4lmfa_yb.js +1 -0
- package/out/_next/static/chunks/0s8jbc4nxw6y6.css +2 -0
- package/out/_next/static/chunks/0z~0.4hivi.f2.js +31 -0
- package/out/_next/static/chunks/135rms05ismy4.js +13 -0
- package/out/_next/static/chunks/14kr4rvjq-2md.js +1 -0
- package/out/_next/static/chunks/turbopack-0sammtvunroor.js +1 -0
- package/out/_not-found/__next._full.txt +2 -2
- package/out/_not-found/__next._head.txt +1 -1
- package/out/_not-found/__next._index.txt +2 -2
- package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
- package/out/_not-found/__next._not-found.txt +1 -1
- package/out/_not-found/__next._tree.txt +2 -2
- package/out/_not-found.html +1 -1
- package/out/_not-found.txt +2 -2
- package/out/app-shell/__next._full.txt +18 -0
- package/out/app-shell/__next._head.txt +6 -0
- package/out/app-shell/__next._index.txt +6 -0
- package/out/app-shell/__next._tree.txt +3 -0
- package/out/app-shell/__next.app-shell.__PAGE__.txt +5 -0
- package/out/app-shell/__next.app-shell.txt +5 -0
- package/out/app-shell.html +1 -0
- package/out/app-shell.txt +18 -0
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/project/_/__next._full.txt +3 -4
- package/out/project/_/__next._head.txt +1 -1
- package/out/project/_/__next._index.txt +2 -2
- package/out/project/_/__next._tree.txt +2 -3
- package/out/project/_/__next.project.$d$id.__PAGE__.txt +2 -3
- package/out/project/_/__next.project.$d$id.txt +1 -1
- package/out/project/_/__next.project.txt +1 -1
- package/out/project/_/memory/__next._full.txt +3 -3
- package/out/project/_/memory/__next._head.txt +1 -1
- package/out/project/_/memory/__next._index.txt +2 -2
- package/out/project/_/memory/__next._tree.txt +2 -2
- package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +2 -2
- package/out/project/_/memory/__next.project.$d$id.memory.txt +1 -1
- package/out/project/_/memory/__next.project.$d$id.txt +1 -1
- package/out/project/_/memory/__next.project.txt +1 -1
- package/out/project/_/memory.html +1 -1
- package/out/project/_/memory.txt +3 -3
- package/out/project/_/queue/__next._full.txt +3 -3
- package/out/project/_/queue/__next._head.txt +1 -1
- package/out/project/_/queue/__next._index.txt +2 -2
- package/out/project/_/queue/__next._tree.txt +2 -2
- package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +2 -2
- package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
- package/out/project/_/queue/__next.project.$d$id.txt +1 -1
- package/out/project/_/queue/__next.project.txt +1 -1
- package/out/project/_/queue.html +1 -1
- package/out/project/_/queue.txt +3 -3
- package/out/project/_.html +1 -1
- package/out/project/_.txt +3 -4
- package/out/settings/__next._full.txt +3 -3
- package/out/settings/__next._head.txt +1 -1
- package/out/settings/__next._index.txt +2 -2
- package/out/settings/__next._tree.txt +2 -2
- package/out/settings/__next.settings.__PAGE__.txt +2 -2
- package/out/settings/__next.settings.txt +1 -1
- package/out/settings.html +1 -1
- package/out/settings.txt +3 -3
- package/out/setup/__next._full.txt +3 -3
- package/out/setup/__next._head.txt +1 -1
- package/out/setup/__next._index.txt +2 -2
- package/out/setup/__next._tree.txt +2 -2
- package/out/setup/__next.setup.__PAGE__.txt +2 -2
- package/out/setup/__next.setup.txt +1 -1
- package/out/setup.html +1 -1
- package/out/setup.txt +3 -3
- package/package.json +1 -1
- package/server/config.js +25 -1
- package/server/index.js +248 -15
- package/server/routes.js +91 -2
- package/out/_next/static/chunks/0.dzh0qf9zq1l.js +0 -2
- package/out/_next/static/chunks/02ul7y114vj2f.js +0 -13
- package/out/_next/static/chunks/0gy_9ugdx7ueh.js +0 -1
- package/out/_next/static/chunks/0idtc5k0469of.js +0 -1
- package/out/_next/static/chunks/0yxmvmvm1dx_d.css +0 -2
- package/out/_next/static/chunks/13uu.sohs74zg.js +0 -31
- package/out/_next/static/chunks/turbopack-06pqx~0d8czn_.js +0 -1
- /package/out/_next/static/{91YUiFoMbLQ9sZW4uk45J → 4vrILyy2mh_Ox4JMTaqx8}/_buildManifest.js +0 -0
- /package/out/_next/static/{91YUiFoMbLQ9sZW4uk45J → 4vrILyy2mh_Ox4JMTaqx8}/_clientMiddlewareManifest.js +0 -0
- /package/out/_next/static/{91YUiFoMbLQ9sZW4uk45J → 4vrILyy2mh_Ox4JMTaqx8}/_ssgManifest.js +0 -0
package/server/index.js
CHANGED
|
@@ -5,9 +5,10 @@ const fs = require("fs");
|
|
|
5
5
|
const { WebSocketServer } = require("ws");
|
|
6
6
|
const pty = require("node-pty");
|
|
7
7
|
const { spawn } = require("child_process");
|
|
8
|
-
const { readConfig, resolveAgentCwd, resolveAgentCommand, resolveProjectChattr } = require("./config");
|
|
8
|
+
const { readConfig, resolveAgentCwd, resolveAgentCommand, resolveProjectChattr, syncChattrToken } = require("./config");
|
|
9
9
|
const routes = require("./routes");
|
|
10
10
|
|
|
11
|
+
const net = require("net");
|
|
11
12
|
const config = readConfig();
|
|
12
13
|
const PORT = config.port || 8400;
|
|
13
14
|
|
|
@@ -25,6 +26,103 @@ app.get("/api/health", (_req, res) => {
|
|
|
25
26
|
res.json({ status: "ok" });
|
|
26
27
|
});
|
|
27
28
|
|
|
29
|
+
// --- CLI status detection ---
|
|
30
|
+
|
|
31
|
+
const { execSync } = require("child_process");
|
|
32
|
+
|
|
33
|
+
function isCliInstalled(cmd) {
|
|
34
|
+
try {
|
|
35
|
+
execSync(`which ${cmd}`, { encoding: "utf-8", stdio: "pipe" });
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
app.get("/api/cli-status", (_req, res) => {
|
|
43
|
+
res.json({
|
|
44
|
+
claude: isCliInstalled("claude"),
|
|
45
|
+
codex: isCliInstalled("codex"),
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// --- Port availability check ---
|
|
50
|
+
|
|
51
|
+
function checkPort(port) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const srv = net.createServer();
|
|
54
|
+
srv.once("error", () => resolve(false));
|
|
55
|
+
srv.once("listening", () => { srv.close(); resolve(true); });
|
|
56
|
+
srv.listen(port, "127.0.0.1");
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
app.get("/api/port-check", async (req, res) => {
|
|
61
|
+
const port = parseInt(req.query.port, 10);
|
|
62
|
+
if (!port || port < 1 || port > 65535) {
|
|
63
|
+
return res.status(400).json({ error: "Invalid port" });
|
|
64
|
+
}
|
|
65
|
+
const free = await checkPort(port);
|
|
66
|
+
res.json({ port, free });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
app.get("/api/port-check/auto", async (req, res) => {
|
|
70
|
+
const start = parseInt(req.query.start, 10) || 8300;
|
|
71
|
+
const count = Math.min(parseInt(req.query.count, 10) || 3, 10);
|
|
72
|
+
const results = [];
|
|
73
|
+
let port = start;
|
|
74
|
+
for (let i = 0; i < count; i++) {
|
|
75
|
+
while (!(await checkPort(port)) && port < 65535) port++;
|
|
76
|
+
results.push(port);
|
|
77
|
+
port++;
|
|
78
|
+
}
|
|
79
|
+
res.json({ ports: results });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// --- Caffeinate (sleep prevention) ---
|
|
83
|
+
|
|
84
|
+
let caffeinateProcess = { process: null, pid: null, startedAt: null, duration: null };
|
|
85
|
+
|
|
86
|
+
app.post("/api/caffeinate/start", (req, res) => {
|
|
87
|
+
if (process.platform !== "darwin") {
|
|
88
|
+
return res.status(400).json({ ok: false, error: "Sleep prevention is only available on macOS" });
|
|
89
|
+
}
|
|
90
|
+
// Kill existing if running
|
|
91
|
+
if (caffeinateProcess.process) {
|
|
92
|
+
try { caffeinateProcess.process.kill("SIGTERM"); } catch {}
|
|
93
|
+
}
|
|
94
|
+
const duration = req.body?.duration || 0; // seconds, 0 = indefinite
|
|
95
|
+
const args = ["-d", "-i", "-s"];
|
|
96
|
+
if (duration > 0) args.push("-t", String(duration));
|
|
97
|
+
const child = spawn("caffeinate", args, { stdio: "ignore", detached: true });
|
|
98
|
+
child.unref();
|
|
99
|
+
child.on("exit", () => {
|
|
100
|
+
if (caffeinateProcess.process === child) {
|
|
101
|
+
caffeinateProcess = { process: null, pid: null, startedAt: null, duration: null };
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
caffeinateProcess = { process: child, pid: child.pid, startedAt: Date.now(), duration: duration || null };
|
|
105
|
+
res.json({ ok: true, active: true, pid: child.pid, duration });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
app.post("/api/caffeinate/stop", (_req, res) => {
|
|
109
|
+
if (caffeinateProcess.process) {
|
|
110
|
+
try { caffeinateProcess.process.kill("SIGTERM"); } catch {}
|
|
111
|
+
}
|
|
112
|
+
caffeinateProcess = { process: null, pid: null, startedAt: null, duration: null };
|
|
113
|
+
res.json({ ok: true, active: false });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
app.get("/api/caffeinate/status", (_req, res) => {
|
|
117
|
+
const active = !!(caffeinateProcess.process && caffeinateProcess.pid);
|
|
118
|
+
let remaining = null;
|
|
119
|
+
if (active && caffeinateProcess.duration && caffeinateProcess.startedAt) {
|
|
120
|
+
const elapsed = Math.floor((Date.now() - caffeinateProcess.startedAt) / 1000);
|
|
121
|
+
remaining = Math.max(0, caffeinateProcess.duration - elapsed);
|
|
122
|
+
}
|
|
123
|
+
res.json({ active, pid: caffeinateProcess.pid, remaining, platform: process.platform });
|
|
124
|
+
});
|
|
125
|
+
|
|
28
126
|
// --- Unified agent sessions ---
|
|
29
127
|
// Single map: key = "project/agent" → { projectId, agentId, term, ws, state, error }
|
|
30
128
|
// PTY (term) is the source of truth for "running". WS is optional (attaches to view terminal).
|
|
@@ -136,7 +234,20 @@ function handleAgentChattr(req, res) {
|
|
|
136
234
|
chattrProcesses.set(projectId, val);
|
|
137
235
|
}
|
|
138
236
|
|
|
237
|
+
function regenerateConfigToml() {
|
|
238
|
+
// If project has a config.toml, update the port to match current config
|
|
239
|
+
if (!projectConfigToml || !fs.existsSync(projectConfigToml)) return;
|
|
240
|
+
try {
|
|
241
|
+
let content = fs.readFileSync(projectConfigToml, "utf-8");
|
|
242
|
+
content = content.replace(/^port = \d+/m, `port = ${chattrPort}`);
|
|
243
|
+
fs.writeFileSync(projectConfigToml, content);
|
|
244
|
+
} catch {}
|
|
245
|
+
}
|
|
246
|
+
|
|
139
247
|
function spawnChattr() {
|
|
248
|
+
// Sync config.toml port before starting
|
|
249
|
+
regenerateConfigToml();
|
|
250
|
+
|
|
140
251
|
// Use project config.toml if available (isolated data dir + ports), otherwise fall back to --port
|
|
141
252
|
const args = (projectConfigToml && fs.existsSync(projectConfigToml))
|
|
142
253
|
? ["--config", projectConfigToml]
|
|
@@ -146,6 +257,15 @@ function handleAgentChattr(req, res) {
|
|
|
146
257
|
stdio: "ignore",
|
|
147
258
|
detached: true,
|
|
148
259
|
});
|
|
260
|
+
|
|
261
|
+
// If pid is undefined, spawn failed (binary not found)
|
|
262
|
+
if (!child.pid) {
|
|
263
|
+
setProc({ process: null, state: "error", error: "Failed to start AgentChattr — is it installed? Run: pipx install agentchattr" });
|
|
264
|
+
// Still register error handler to prevent unhandled error crash
|
|
265
|
+
child.on("error", () => {});
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
149
269
|
child.unref();
|
|
150
270
|
child.on("error", (err) => {
|
|
151
271
|
setProc({ process: null, state: "error", error: err.message });
|
|
@@ -167,6 +287,12 @@ function handleAgentChattr(req, res) {
|
|
|
167
287
|
}
|
|
168
288
|
try {
|
|
169
289
|
const child = spawnChattr();
|
|
290
|
+
if (!child) {
|
|
291
|
+
const errProc = getProc();
|
|
292
|
+
return res.status(500).json({ ok: false, state: "error", error: errProc.error || "Failed to start AgentChattr" });
|
|
293
|
+
}
|
|
294
|
+
// Sync token after AgentChattr starts (it generates its own)
|
|
295
|
+
setTimeout(() => syncChattrToken(projectId), 2000);
|
|
170
296
|
res.json({ ok: true, state: "running", pid: child.pid });
|
|
171
297
|
} catch (err) {
|
|
172
298
|
setProc({ process: null, state: "error", error: err.message });
|
|
@@ -188,6 +314,12 @@ function handleAgentChattr(req, res) {
|
|
|
188
314
|
setTimeout(() => {
|
|
189
315
|
try {
|
|
190
316
|
const child = spawnChattr();
|
|
317
|
+
if (!child) {
|
|
318
|
+
const errProc = getProc();
|
|
319
|
+
return res.status(500).json({ ok: false, state: "error", error: errProc.error || "Failed to start AgentChattr" });
|
|
320
|
+
}
|
|
321
|
+
// Sync token after AgentChattr restarts
|
|
322
|
+
setTimeout(() => syncChattrToken(projectId), 2000);
|
|
191
323
|
res.json({ ok: true, state: "running", pid: child.pid });
|
|
192
324
|
} catch (err) {
|
|
193
325
|
setProc({ process: null, state: "error", error: err.message });
|
|
@@ -201,6 +333,44 @@ function handleAgentChattr(req, res) {
|
|
|
201
333
|
app.post("/api/agentchattr/:projectOrAction/:action", handleAgentChattr);
|
|
202
334
|
app.post("/api/agentchattr/:projectOrAction", handleAgentChattr);
|
|
203
335
|
|
|
336
|
+
// --- Reset agents: deregister all registered slots ---
|
|
337
|
+
// AgentChattr doesn't expose staleness metadata, so this clears all slots.
|
|
338
|
+
// Agents' wrapper heartbeat will auto-re-register with clean names.
|
|
339
|
+
|
|
340
|
+
app.post("/api/agents/:project/reset", async (req, res) => {
|
|
341
|
+
const projectId = req.params.project;
|
|
342
|
+
const { url: chattrUrl, token: chattrToken } = resolveProjectChattr(projectId);
|
|
343
|
+
const headers = {};
|
|
344
|
+
if (chattrToken) headers["x-session-token"] = chattrToken;
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
// Fetch current agent status from AgentChattr
|
|
348
|
+
const statusRes = await fetch(`${chattrUrl}/api/status`, { headers });
|
|
349
|
+
if (!statusRes.ok) {
|
|
350
|
+
return res.status(statusRes.status).json({ ok: false, error: `AgentChattr status failed: ${statusRes.status}` });
|
|
351
|
+
}
|
|
352
|
+
const status = await statusRes.json();
|
|
353
|
+
const slots = status.agents || status.slots || [];
|
|
354
|
+
|
|
355
|
+
let cleared = 0;
|
|
356
|
+
for (const agent of slots) {
|
|
357
|
+
const name = typeof agent === "string" ? agent : agent.name || agent.sender;
|
|
358
|
+
if (!name) continue;
|
|
359
|
+
try {
|
|
360
|
+
const dereg = await fetch(`${chattrUrl}/api/deregister/${encodeURIComponent(name)}`, {
|
|
361
|
+
method: "POST",
|
|
362
|
+
headers,
|
|
363
|
+
});
|
|
364
|
+
if (dereg.ok) cleared++;
|
|
365
|
+
} catch {}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
res.json({ ok: true, cleared, total: slots.length });
|
|
369
|
+
} catch (err) {
|
|
370
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
204
374
|
// --- Lifecycle: start spawns PTY (visible in terminal panel) ---
|
|
205
375
|
|
|
206
376
|
app.post("/api/agents/:project/:agent/start", (req, res) => {
|
|
@@ -340,29 +510,60 @@ app.get("/api/triggers", (_req, res) => {
|
|
|
340
510
|
lastSent: info.lastSent,
|
|
341
511
|
nextAt: info.nextAt,
|
|
342
512
|
lastError: info.lastError || null,
|
|
513
|
+
expiresAt: info.expiresAt || null,
|
|
343
514
|
};
|
|
344
515
|
}
|
|
345
516
|
res.json(result);
|
|
346
517
|
});
|
|
347
518
|
|
|
519
|
+
function stopTrigger(project) {
|
|
520
|
+
const existing = triggers.get(project);
|
|
521
|
+
if (existing) {
|
|
522
|
+
if (existing.timer) clearInterval(existing.timer);
|
|
523
|
+
if (existing.durationTimer) clearTimeout(existing.durationTimer);
|
|
524
|
+
}
|
|
525
|
+
triggers.delete(project);
|
|
526
|
+
}
|
|
527
|
+
|
|
348
528
|
app.post("/api/triggers/:project/start", (req, res) => {
|
|
349
529
|
const { project } = req.params;
|
|
350
|
-
const { interval } = req.body || {};
|
|
530
|
+
const { interval, duration } = req.body || {};
|
|
351
531
|
const ms = (interval || 30) * 60 * 1000;
|
|
532
|
+
const durationMs = duration ? duration * 60 * 1000 : 0; // duration in minutes, 0 = indefinite
|
|
352
533
|
|
|
353
534
|
const existing = triggers.get(project);
|
|
354
|
-
if (existing
|
|
535
|
+
if (existing) {
|
|
536
|
+
if (existing.timer) clearInterval(existing.timer);
|
|
537
|
+
if (existing.durationTimer) clearTimeout(existing.durationTimer);
|
|
538
|
+
}
|
|
355
539
|
|
|
356
540
|
const timer = setInterval(() => sendTriggerMessage(project), ms);
|
|
357
|
-
|
|
358
|
-
|
|
541
|
+
const expiresAt = durationMs > 0 ? Date.now() + durationMs : null;
|
|
542
|
+
|
|
543
|
+
const triggerInfo = {
|
|
544
|
+
interval: ms,
|
|
545
|
+
timer,
|
|
546
|
+
lastSent: null,
|
|
547
|
+
nextAt: Date.now() + ms,
|
|
548
|
+
lastError: null,
|
|
549
|
+
expiresAt,
|
|
550
|
+
durationTimer: null,
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
// Auto-stop after duration
|
|
554
|
+
if (durationMs > 0) {
|
|
555
|
+
triggerInfo.durationTimer = setTimeout(() => {
|
|
556
|
+
stopTrigger(project);
|
|
557
|
+
}, durationMs);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
triggers.set(project, triggerInfo);
|
|
561
|
+
res.json({ ok: true, enabled: true, interval: ms, nextAt: Date.now() + ms, expiresAt });
|
|
359
562
|
});
|
|
360
563
|
|
|
361
564
|
app.post("/api/triggers/:project/stop", (req, res) => {
|
|
362
565
|
const { project } = req.params;
|
|
363
|
-
|
|
364
|
-
if (existing && existing.timer) clearInterval(existing.timer);
|
|
365
|
-
triggers.delete(project);
|
|
566
|
+
stopTrigger(project);
|
|
366
567
|
res.json({ ok: true, enabled: false });
|
|
367
568
|
});
|
|
368
569
|
|
|
@@ -382,20 +583,46 @@ app.set("syncTriggers", syncTriggersFromConfig);
|
|
|
382
583
|
|
|
383
584
|
// --- Serve static frontend (built Next.js export) ---
|
|
384
585
|
|
|
586
|
+
// Strip trailing slashes (redirect /settings/ → /settings, /setup/ → /setup)
|
|
587
|
+
app.use((req, res, next) => {
|
|
588
|
+
if (req.path !== "/" && req.path.endsWith("/")) {
|
|
589
|
+
const clean = req.path.slice(0, -1);
|
|
590
|
+
const query = req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : "";
|
|
591
|
+
return res.redirect(301, clean + query);
|
|
592
|
+
}
|
|
593
|
+
next();
|
|
594
|
+
});
|
|
595
|
+
|
|
385
596
|
const outDir = path.join(__dirname, "..", "out");
|
|
597
|
+
|
|
598
|
+
// HEAD requests: express.static extensions don't resolve when a same-name
|
|
599
|
+
// directory exists (e.g. /settings dir shadows settings.html for HEAD).
|
|
600
|
+
// Resolve extensionless HEAD requests to their .html file explicitly.
|
|
601
|
+
app.use((req, res, next) => {
|
|
602
|
+
if (req.method !== "HEAD" || req.path.startsWith("/api/") || path.extname(req.path)) {
|
|
603
|
+
return next();
|
|
604
|
+
}
|
|
605
|
+
const htmlPath = path.join(outDir, req.path + ".html");
|
|
606
|
+
if (fs.existsSync(htmlPath)) {
|
|
607
|
+
return res.sendFile(htmlPath);
|
|
608
|
+
}
|
|
609
|
+
next();
|
|
610
|
+
});
|
|
611
|
+
|
|
386
612
|
if (fs.existsSync(outDir)) {
|
|
387
|
-
app.use(express.static(outDir));
|
|
613
|
+
app.use(express.static(outDir, { redirect: false, extensions: ["html"] }));
|
|
388
614
|
}
|
|
389
615
|
|
|
390
|
-
// SPA fallback: serve the
|
|
391
|
-
//
|
|
392
|
-
// so we map real dynamic segments back to those template files.
|
|
616
|
+
// SPA fallback: serve the pre-rendered template for dynamic routes,
|
|
617
|
+
// fall back to index.html for everything else.
|
|
393
618
|
app.use((req, res, next) => {
|
|
394
|
-
if (req.method !== "GET" || req.path.startsWith("/api/")) {
|
|
619
|
+
if ((req.method !== "GET" && req.method !== "HEAD") || req.path.startsWith("/api/")) {
|
|
395
620
|
return next();
|
|
396
621
|
}
|
|
397
622
|
|
|
398
|
-
//
|
|
623
|
+
// Dynamic routes → serve their pre-rendered template (has the right JS chunks).
|
|
624
|
+
// Hydration #418 is cosmetic — dashboard renders and functions correctly.
|
|
625
|
+
// NOTE: app-shell.html does NOT work — it has no route JS chunks and renders blank.
|
|
399
626
|
const dynamicRoutes = [
|
|
400
627
|
{ pattern: /^\/project\/[^/]+\/memory\/?$/, template: "project/_/memory.html" },
|
|
401
628
|
{ pattern: /^\/project\/[^/]+\/queue\/?$/, template: "project/_/queue.html" },
|
|
@@ -411,7 +638,7 @@ app.use((req, res, next) => {
|
|
|
411
638
|
}
|
|
412
639
|
}
|
|
413
640
|
|
|
414
|
-
//
|
|
641
|
+
// Everything else → index.html
|
|
415
642
|
const indexPath = path.join(outDir, "index.html");
|
|
416
643
|
if (fs.existsSync(indexPath)) {
|
|
417
644
|
res.sendFile(indexPath);
|
|
@@ -511,6 +738,7 @@ function syncTriggersFromConfig() {
|
|
|
511
738
|
for (const [id, info] of triggers) {
|
|
512
739
|
if (!activeIds.has(id)) {
|
|
513
740
|
if (info.timer) clearInterval(info.timer);
|
|
741
|
+
if (info.durationTimer) clearTimeout(info.durationTimer);
|
|
514
742
|
triggers.delete(id);
|
|
515
743
|
}
|
|
516
744
|
}
|
|
@@ -521,4 +749,9 @@ function syncTriggersFromConfig() {
|
|
|
521
749
|
server.listen(PORT, "127.0.0.1", () => {
|
|
522
750
|
console.log(`QuadWork server listening on http://127.0.0.1:${PORT}`);
|
|
523
751
|
syncTriggersFromConfig();
|
|
752
|
+
// Sync AgentChattr tokens for all projects on startup
|
|
753
|
+
const startupCfg = readConfig();
|
|
754
|
+
for (const p of (startupCfg.projects || [])) {
|
|
755
|
+
syncChattrToken(p.id);
|
|
756
|
+
}
|
|
524
757
|
});
|
package/server/routes.js
CHANGED
|
@@ -456,6 +456,73 @@ function exec(cmd, args, opts) {
|
|
|
456
456
|
}
|
|
457
457
|
}
|
|
458
458
|
|
|
459
|
+
// ─── GitHub helpers for Setup Wizard ──────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
// GitHub user info
|
|
462
|
+
router.get("/api/github/user", (_req, res) => {
|
|
463
|
+
try {
|
|
464
|
+
const out = execFileSync("gh", ["api", "user", "--jq", "{login: .login}"], { encoding: "utf-8", timeout: 10000 });
|
|
465
|
+
res.json(JSON.parse(out));
|
|
466
|
+
} catch {
|
|
467
|
+
res.status(502).json({ error: "GitHub CLI not authenticated" });
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// GitHub repo list for an owner (only repos with push access)
|
|
472
|
+
router.get("/api/github/repos", (req, res) => {
|
|
473
|
+
const owner = req.query.owner;
|
|
474
|
+
if (!owner) return res.status(400).json({ error: "Missing owner" });
|
|
475
|
+
try {
|
|
476
|
+
const out = execFileSync("gh", ["repo", "list", String(owner), "--json", "name,description,isPrivate,viewerPermission", "--limit", "50"], { encoding: "utf-8", timeout: 15000 });
|
|
477
|
+
const repos = JSON.parse(out);
|
|
478
|
+
// Filter to repos with push access (ADMIN, MAINTAIN, WRITE)
|
|
479
|
+
const pushAccess = new Set(["ADMIN", "MAINTAIN", "WRITE"]);
|
|
480
|
+
res.json(repos.filter((r) => pushAccess.has(r.viewerPermission)));
|
|
481
|
+
} catch {
|
|
482
|
+
res.json([]);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// Auto-detect existing clone of a repo
|
|
487
|
+
router.get("/api/setup/detect-clone", (req, res) => {
|
|
488
|
+
const repoName = req.query.repo; // "owner/repo"
|
|
489
|
+
if (!repoName) return res.status(400).json({ error: "Missing repo" });
|
|
490
|
+
const slug = String(repoName).split("/").pop();
|
|
491
|
+
const home = os.homedir();
|
|
492
|
+
const searchDirs = [
|
|
493
|
+
path.join(home, "Projects"),
|
|
494
|
+
path.join(home, "Developer"),
|
|
495
|
+
path.join(home, "repos"),
|
|
496
|
+
path.join(home, "code"),
|
|
497
|
+
path.join(home, "src"),
|
|
498
|
+
path.join(home, "workspace"),
|
|
499
|
+
home,
|
|
500
|
+
];
|
|
501
|
+
for (const dir of searchDirs) {
|
|
502
|
+
const candidate = path.join(dir, slug);
|
|
503
|
+
if (fs.existsSync(path.join(candidate, ".git"))) {
|
|
504
|
+
return res.json({ found: true, path: candidate, suggested: path.join(searchDirs[0], slug) });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// Not found — suggest a default location
|
|
508
|
+
const defaultDir = fs.existsSync(searchDirs[0]) ? searchDirs[0] : home;
|
|
509
|
+
return res.json({ found: false, path: null, suggested: path.join(defaultDir, slug) });
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// Save reviewer token securely
|
|
513
|
+
router.post("/api/setup/save-token", (req, res) => {
|
|
514
|
+
const { token } = req.body;
|
|
515
|
+
if (!token) return res.status(400).json({ error: "Missing token" });
|
|
516
|
+
const tokenPath = path.join(os.homedir(), ".quadwork", "reviewer-token");
|
|
517
|
+
const dir = path.dirname(tokenPath);
|
|
518
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
519
|
+
fs.writeFileSync(tokenPath, token.trim() + "\n", { mode: 0o600 });
|
|
520
|
+
try { fs.chmodSync(tokenPath, 0o600); } catch {}
|
|
521
|
+
res.json({ ok: true, path: tokenPath });
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// ─── Setup Wizard ─────────────────────────────────────────────────────────
|
|
525
|
+
|
|
459
526
|
router.post("/api/setup", (req, res) => {
|
|
460
527
|
const step = req.query.step;
|
|
461
528
|
const body = req.body || {};
|
|
@@ -464,8 +531,16 @@ router.post("/api/setup", (req, res) => {
|
|
|
464
531
|
case "verify-repo": {
|
|
465
532
|
const repo = body.repo;
|
|
466
533
|
if (!repo || !REPO_RE.test(repo)) return res.json({ ok: false, error: "Invalid repo format (use owner/repo)" });
|
|
467
|
-
const result = exec("gh", ["repo", "view", repo, "--json", "name,owner"]);
|
|
468
|
-
return res.json({ ok:
|
|
534
|
+
const result = exec("gh", ["repo", "view", repo, "--json", "name,owner,viewerPermission"]);
|
|
535
|
+
if (!result.ok) return res.json({ ok: false, error: "Cannot access repo. Check gh auth and repo permissions." });
|
|
536
|
+
try {
|
|
537
|
+
const info = JSON.parse(result.output);
|
|
538
|
+
const pushAccess = new Set(["ADMIN", "MAINTAIN", "WRITE"]);
|
|
539
|
+
if (!pushAccess.has(info.viewerPermission)) {
|
|
540
|
+
return res.json({ ok: false, error: "You don't have push access to this repo. Agents need push access to create branches and PRs." });
|
|
541
|
+
}
|
|
542
|
+
} catch {}
|
|
543
|
+
return res.json({ ok: true });
|
|
469
544
|
}
|
|
470
545
|
case "create-worktrees": {
|
|
471
546
|
const workingDir = body.workingDir;
|
|
@@ -544,6 +619,20 @@ router.post("/api/setup", (req, res) => {
|
|
|
544
619
|
}
|
|
545
620
|
seeded.push(`${agent}/CLAUDE.md`);
|
|
546
621
|
}
|
|
622
|
+
|
|
623
|
+
// .gitignore — ensure token files are never committed
|
|
624
|
+
const gitignorePath = path.join(wtDir, ".gitignore");
|
|
625
|
+
const tokenIgnorePatterns = "reviewer-token\n*-token\n";
|
|
626
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
627
|
+
fs.writeFileSync(gitignorePath, tokenIgnorePatterns);
|
|
628
|
+
seeded.push(`${agent}/.gitignore`);
|
|
629
|
+
} else {
|
|
630
|
+
const existing = fs.readFileSync(gitignorePath, "utf-8");
|
|
631
|
+
if (!existing.includes("*-token")) {
|
|
632
|
+
fs.appendFileSync(gitignorePath, "\n" + tokenIgnorePatterns);
|
|
633
|
+
seeded.push(`${agent}/.gitignore (updated)`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
547
636
|
}
|
|
548
637
|
return res.json({ ok: true, seeded });
|
|
549
638
|
}
|