@jonit-dev/night-watch-cli 1.7.17 → 1.7.19

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.
Files changed (264) hide show
  1. package/bin/night-watch.mjs +1 -1
  2. package/dist/src/cli.js +13 -4
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/server/index.d.ts.map +1 -1
  5. package/dist/src/server/index.js +12 -1
  6. package/dist/src/server/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/web/dist/assets/index-DOzZY27d.js +473 -0
  9. package/web/dist/assets/index-w6Q6gxCS.css +1 -0
  10. package/web/dist/index.html +2 -2
  11. package/dist/board/factory.d.ts +0 -3
  12. package/dist/board/factory.d.ts.map +0 -1
  13. package/dist/board/factory.js +0 -10
  14. package/dist/board/factory.js.map +0 -1
  15. package/dist/board/providers/github-graphql.d.ts +0 -16
  16. package/dist/board/providers/github-graphql.d.ts.map +0 -1
  17. package/dist/board/providers/github-graphql.js +0 -43
  18. package/dist/board/providers/github-graphql.js.map +0 -1
  19. package/dist/board/providers/github-projects.d.ts +0 -51
  20. package/dist/board/providers/github-projects.d.ts.map +0 -1
  21. package/dist/board/providers/github-projects.js +0 -672
  22. package/dist/board/providers/github-projects.js.map +0 -1
  23. package/dist/board/types.d.ts +0 -60
  24. package/dist/board/types.d.ts.map +0 -1
  25. package/dist/board/types.js +0 -4
  26. package/dist/board/types.js.map +0 -1
  27. package/dist/cli.d.ts +0 -3
  28. package/dist/cli.d.ts.map +0 -1
  29. package/dist/cli.js +0 -80
  30. package/dist/cli.js.map +0 -1
  31. package/dist/commands/board.d.ts +0 -9
  32. package/dist/commands/board.d.ts.map +0 -1
  33. package/dist/commands/board.js +0 -294
  34. package/dist/commands/board.js.map +0 -1
  35. package/dist/commands/cancel.d.ts +0 -46
  36. package/dist/commands/cancel.d.ts.map +0 -1
  37. package/dist/commands/cancel.js +0 -241
  38. package/dist/commands/cancel.js.map +0 -1
  39. package/dist/commands/dashboard/tab-actions.d.ts +0 -10
  40. package/dist/commands/dashboard/tab-actions.d.ts.map +0 -1
  41. package/dist/commands/dashboard/tab-actions.js +0 -245
  42. package/dist/commands/dashboard/tab-actions.js.map +0 -1
  43. package/dist/commands/dashboard/tab-config.d.ts +0 -21
  44. package/dist/commands/dashboard/tab-config.d.ts.map +0 -1
  45. package/dist/commands/dashboard/tab-config.js +0 -829
  46. package/dist/commands/dashboard/tab-config.js.map +0 -1
  47. package/dist/commands/dashboard/tab-logs.d.ts +0 -10
  48. package/dist/commands/dashboard/tab-logs.d.ts.map +0 -1
  49. package/dist/commands/dashboard/tab-logs.js +0 -178
  50. package/dist/commands/dashboard/tab-logs.js.map +0 -1
  51. package/dist/commands/dashboard/tab-schedules.d.ts +0 -21
  52. package/dist/commands/dashboard/tab-schedules.d.ts.map +0 -1
  53. package/dist/commands/dashboard/tab-schedules.js +0 -304
  54. package/dist/commands/dashboard/tab-schedules.js.map +0 -1
  55. package/dist/commands/dashboard/tab-status.d.ts +0 -32
  56. package/dist/commands/dashboard/tab-status.d.ts.map +0 -1
  57. package/dist/commands/dashboard/tab-status.js +0 -421
  58. package/dist/commands/dashboard/tab-status.js.map +0 -1
  59. package/dist/commands/dashboard/types.d.ts +0 -43
  60. package/dist/commands/dashboard/types.d.ts.map +0 -1
  61. package/dist/commands/dashboard/types.js +0 -5
  62. package/dist/commands/dashboard/types.js.map +0 -1
  63. package/dist/commands/dashboard.d.ts +0 -11
  64. package/dist/commands/dashboard.d.ts.map +0 -1
  65. package/dist/commands/dashboard.js +0 -239
  66. package/dist/commands/dashboard.js.map +0 -1
  67. package/dist/commands/doctor.d.ts +0 -16
  68. package/dist/commands/doctor.d.ts.map +0 -1
  69. package/dist/commands/doctor.js +0 -202
  70. package/dist/commands/doctor.js.map +0 -1
  71. package/dist/commands/history.d.ts +0 -7
  72. package/dist/commands/history.d.ts.map +0 -1
  73. package/dist/commands/history.js +0 -56
  74. package/dist/commands/history.js.map +0 -1
  75. package/dist/commands/init.d.ts +0 -25
  76. package/dist/commands/init.d.ts.map +0 -1
  77. package/dist/commands/init.js +0 -534
  78. package/dist/commands/init.js.map +0 -1
  79. package/dist/commands/install.d.ts +0 -48
  80. package/dist/commands/install.d.ts.map +0 -1
  81. package/dist/commands/install.js +0 -303
  82. package/dist/commands/install.js.map +0 -1
  83. package/dist/commands/logs.d.ts +0 -15
  84. package/dist/commands/logs.d.ts.map +0 -1
  85. package/dist/commands/logs.js +0 -104
  86. package/dist/commands/logs.js.map +0 -1
  87. package/dist/commands/prd-state.d.ts +0 -12
  88. package/dist/commands/prd-state.d.ts.map +0 -1
  89. package/dist/commands/prd-state.js +0 -47
  90. package/dist/commands/prd-state.js.map +0 -1
  91. package/dist/commands/prd.d.ts +0 -24
  92. package/dist/commands/prd.d.ts.map +0 -1
  93. package/dist/commands/prd.js +0 -283
  94. package/dist/commands/prd.js.map +0 -1
  95. package/dist/commands/prds.d.ts +0 -13
  96. package/dist/commands/prds.d.ts.map +0 -1
  97. package/dist/commands/prds.js +0 -196
  98. package/dist/commands/prds.js.map +0 -1
  99. package/dist/commands/prs.d.ts +0 -14
  100. package/dist/commands/prs.d.ts.map +0 -1
  101. package/dist/commands/prs.js +0 -106
  102. package/dist/commands/prs.js.map +0 -1
  103. package/dist/commands/qa.d.ts +0 -30
  104. package/dist/commands/qa.d.ts.map +0 -1
  105. package/dist/commands/qa.js +0 -159
  106. package/dist/commands/qa.js.map +0 -1
  107. package/dist/commands/retry.d.ts +0 -9
  108. package/dist/commands/retry.d.ts.map +0 -1
  109. package/dist/commands/retry.js +0 -72
  110. package/dist/commands/retry.js.map +0 -1
  111. package/dist/commands/review.d.ts +0 -35
  112. package/dist/commands/review.d.ts.map +0 -1
  113. package/dist/commands/review.js +0 -252
  114. package/dist/commands/review.js.map +0 -1
  115. package/dist/commands/run.d.ts +0 -61
  116. package/dist/commands/run.d.ts.map +0 -1
  117. package/dist/commands/run.js +0 -364
  118. package/dist/commands/run.js.map +0 -1
  119. package/dist/commands/serve.d.ts +0 -7
  120. package/dist/commands/serve.d.ts.map +0 -1
  121. package/dist/commands/serve.js +0 -27
  122. package/dist/commands/serve.js.map +0 -1
  123. package/dist/commands/slice.d.ts +0 -26
  124. package/dist/commands/slice.d.ts.map +0 -1
  125. package/dist/commands/slice.js +0 -175
  126. package/dist/commands/slice.js.map +0 -1
  127. package/dist/commands/state.d.ts +0 -8
  128. package/dist/commands/state.d.ts.map +0 -1
  129. package/dist/commands/state.js +0 -56
  130. package/dist/commands/state.js.map +0 -1
  131. package/dist/commands/status.d.ts +0 -14
  132. package/dist/commands/status.d.ts.map +0 -1
  133. package/dist/commands/status.js +0 -147
  134. package/dist/commands/status.js.map +0 -1
  135. package/dist/commands/uninstall.d.ts +0 -25
  136. package/dist/commands/uninstall.d.ts.map +0 -1
  137. package/dist/commands/uninstall.js +0 -141
  138. package/dist/commands/uninstall.js.map +0 -1
  139. package/dist/commands/update.d.ts +0 -21
  140. package/dist/commands/update.d.ts.map +0 -1
  141. package/dist/commands/update.js +0 -87
  142. package/dist/commands/update.js.map +0 -1
  143. package/dist/config.d.ts +0 -23
  144. package/dist/config.d.ts.map +0 -1
  145. package/dist/config.js +0 -601
  146. package/dist/config.js.map +0 -1
  147. package/dist/constants.d.ts +0 -59
  148. package/dist/constants.d.ts.map +0 -1
  149. package/dist/constants.js +0 -110
  150. package/dist/constants.js.map +0 -1
  151. package/dist/server/index.d.ts +0 -23
  152. package/dist/server/index.d.ts.map +0 -1
  153. package/dist/server/index.js +0 -1074
  154. package/dist/server/index.js.map +0 -1
  155. package/dist/storage/json-state-migrator.d.ts +0 -24
  156. package/dist/storage/json-state-migrator.d.ts.map +0 -1
  157. package/dist/storage/json-state-migrator.js +0 -197
  158. package/dist/storage/json-state-migrator.js.map +0 -1
  159. package/dist/storage/repositories/index.d.ts +0 -23
  160. package/dist/storage/repositories/index.d.ts.map +0 -1
  161. package/dist/storage/repositories/index.js +0 -37
  162. package/dist/storage/repositories/index.js.map +0 -1
  163. package/dist/storage/repositories/interfaces.d.ts +0 -37
  164. package/dist/storage/repositories/interfaces.d.ts.map +0 -1
  165. package/dist/storage/repositories/interfaces.js +0 -6
  166. package/dist/storage/repositories/interfaces.js.map +0 -1
  167. package/dist/storage/repositories/sqlite/execution-history-repository.d.ts +0 -21
  168. package/dist/storage/repositories/sqlite/execution-history-repository.d.ts.map +0 -1
  169. package/dist/storage/repositories/sqlite/execution-history-repository.js +0 -94
  170. package/dist/storage/repositories/sqlite/execution-history-repository.js.map +0 -1
  171. package/dist/storage/repositories/sqlite/prd-state-repository.d.ts +0 -17
  172. package/dist/storage/repositories/sqlite/prd-state-repository.d.ts.map +0 -1
  173. package/dist/storage/repositories/sqlite/prd-state-repository.js +0 -74
  174. package/dist/storage/repositories/sqlite/prd-state-repository.js.map +0 -1
  175. package/dist/storage/repositories/sqlite/project-registry-repository.d.ts +0 -16
  176. package/dist/storage/repositories/sqlite/project-registry-repository.d.ts.map +0 -1
  177. package/dist/storage/repositories/sqlite/project-registry-repository.js +0 -34
  178. package/dist/storage/repositories/sqlite/project-registry-repository.js.map +0 -1
  179. package/dist/storage/repositories/sqlite/roadmap-state-repository.d.ts +0 -14
  180. package/dist/storage/repositories/sqlite/roadmap-state-repository.d.ts.map +0 -1
  181. package/dist/storage/repositories/sqlite/roadmap-state-repository.js +0 -47
  182. package/dist/storage/repositories/sqlite/roadmap-state-repository.js.map +0 -1
  183. package/dist/storage/sqlite/client.d.ts +0 -23
  184. package/dist/storage/sqlite/client.d.ts.map +0 -1
  185. package/dist/storage/sqlite/client.js +0 -47
  186. package/dist/storage/sqlite/client.js.map +0 -1
  187. package/dist/storage/sqlite/migrations.d.ts +0 -11
  188. package/dist/storage/sqlite/migrations.d.ts.map +0 -1
  189. package/dist/storage/sqlite/migrations.js +0 -57
  190. package/dist/storage/sqlite/migrations.js.map +0 -1
  191. package/dist/templates/prd-template.d.ts +0 -11
  192. package/dist/templates/prd-template.d.ts.map +0 -1
  193. package/dist/templates/prd-template.js +0 -166
  194. package/dist/templates/prd-template.js.map +0 -1
  195. package/dist/templates/slicer-prompt.d.ts +0 -54
  196. package/dist/templates/slicer-prompt.d.ts.map +0 -1
  197. package/dist/templates/slicer-prompt.js +0 -163
  198. package/dist/templates/slicer-prompt.js.map +0 -1
  199. package/dist/types.d.ts +0 -123
  200. package/dist/types.d.ts.map +0 -1
  201. package/dist/types.js +0 -5
  202. package/dist/types.js.map +0 -1
  203. package/dist/utils/checks.d.ts +0 -55
  204. package/dist/utils/checks.d.ts.map +0 -1
  205. package/dist/utils/checks.js +0 -246
  206. package/dist/utils/checks.js.map +0 -1
  207. package/dist/utils/config-writer.d.ts +0 -16
  208. package/dist/utils/config-writer.d.ts.map +0 -1
  209. package/dist/utils/config-writer.js +0 -45
  210. package/dist/utils/config-writer.js.map +0 -1
  211. package/dist/utils/crontab.d.ts +0 -62
  212. package/dist/utils/crontab.d.ts.map +0 -1
  213. package/dist/utils/crontab.js +0 -168
  214. package/dist/utils/crontab.js.map +0 -1
  215. package/dist/utils/execution-history.d.ts +0 -54
  216. package/dist/utils/execution-history.d.ts.map +0 -1
  217. package/dist/utils/execution-history.js +0 -80
  218. package/dist/utils/execution-history.js.map +0 -1
  219. package/dist/utils/github.d.ts +0 -40
  220. package/dist/utils/github.d.ts.map +0 -1
  221. package/dist/utils/github.js +0 -126
  222. package/dist/utils/github.js.map +0 -1
  223. package/dist/utils/notify.d.ts +0 -63
  224. package/dist/utils/notify.d.ts.map +0 -1
  225. package/dist/utils/notify.js +0 -264
  226. package/dist/utils/notify.js.map +0 -1
  227. package/dist/utils/prd-states.d.ts +0 -16
  228. package/dist/utils/prd-states.d.ts.map +0 -1
  229. package/dist/utils/prd-states.js +0 -28
  230. package/dist/utils/prd-states.js.map +0 -1
  231. package/dist/utils/registry.d.ts +0 -44
  232. package/dist/utils/registry.d.ts.map +0 -1
  233. package/dist/utils/registry.js +0 -86
  234. package/dist/utils/registry.js.map +0 -1
  235. package/dist/utils/roadmap-parser.d.ts +0 -45
  236. package/dist/utils/roadmap-parser.d.ts.map +0 -1
  237. package/dist/utils/roadmap-parser.js +0 -136
  238. package/dist/utils/roadmap-parser.js.map +0 -1
  239. package/dist/utils/roadmap-scanner.d.ts +0 -92
  240. package/dist/utils/roadmap-scanner.d.ts.map +0 -1
  241. package/dist/utils/roadmap-scanner.js +0 -349
  242. package/dist/utils/roadmap-scanner.js.map +0 -1
  243. package/dist/utils/roadmap-state.d.ts +0 -90
  244. package/dist/utils/roadmap-state.d.ts.map +0 -1
  245. package/dist/utils/roadmap-state.js +0 -154
  246. package/dist/utils/roadmap-state.js.map +0 -1
  247. package/dist/utils/script-result.d.ts +0 -12
  248. package/dist/utils/script-result.d.ts.map +0 -1
  249. package/dist/utils/script-result.js +0 -46
  250. package/dist/utils/script-result.js.map +0 -1
  251. package/dist/utils/shell.d.ts +0 -27
  252. package/dist/utils/shell.d.ts.map +0 -1
  253. package/dist/utils/shell.js +0 -64
  254. package/dist/utils/shell.js.map +0 -1
  255. package/dist/utils/status-data.d.ts +0 -148
  256. package/dist/utils/status-data.d.ts.map +0 -1
  257. package/dist/utils/status-data.js +0 -593
  258. package/dist/utils/status-data.js.map +0 -1
  259. package/dist/utils/ui.d.ts +0 -55
  260. package/dist/utils/ui.d.ts.map +0 -1
  261. package/dist/utils/ui.js +0 -121
  262. package/dist/utils/ui.js.map +0 -1
  263. package/web/dist/assets/index-BtxQU4oX.css +0 -1
  264. package/web/dist/assets/index-CzAWcldp.js +0 -473
@@ -1,1074 +0,0 @@
1
- /**
2
- * HTTP API Server for Night Watch CLI
3
- * Provides REST API endpoints for the Web UI
4
- * Supports both single-project and global (multi-project) modes
5
- */
6
- import express, { Router } from "express";
7
- import cors from "cors";
8
- import { execSync, spawn } from "child_process";
9
- import * as fs from "fs";
10
- import * as path from "path";
11
- import { dirname } from "path";
12
- import { fileURLToPath } from "url";
13
- import { CLAIM_FILE_EXTENSION, CONFIG_FILE_NAME, LOG_DIR, LOG_FILE_NAMES } from "../constants.js";
14
- import { loadConfig } from "../config.js";
15
- import { createBoardProvider } from "../board/factory.js";
16
- import { BOARD_COLUMNS } from "../board/types.js";
17
- import { validateWebhook } from "../commands/doctor.js";
18
- import { checkLockFile, collectPrInfo, collectPrdInfo, executorLockPath, fetchStatusSnapshot, getLastLogLines, reviewerLockPath } from "../utils/status-data.js";
19
- import { performCancel } from "../commands/cancel.js";
20
- import { saveConfig } from "../utils/config-writer.js";
21
- import { loadRegistry, validateRegistry } from "../utils/registry.js";
22
- import { generateMarker, getEntries, getProjectEntries } from "../utils/crontab.js";
23
- import { CronExpressionParser } from "cron-parser";
24
- import { sendNotifications } from "../utils/notify.js";
25
- import { getRoadmapStatus, scanRoadmap } from "../utils/roadmap-scanner.js";
26
- import { loadRoadmapState } from "../utils/roadmap-state.js";
27
- const __filename = fileURLToPath(import.meta.url);
28
- const __dirname = dirname(__filename);
29
- // Track spawned processes
30
- const spawnedProcesses = new Map();
31
- /**
32
- * Broadcast an SSE event to all connected clients
33
- */
34
- function broadcastSSE(clients, event, data) {
35
- const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
36
- for (const client of clients) {
37
- try {
38
- client.write(msg);
39
- }
40
- catch {
41
- clients.delete(client);
42
- }
43
- }
44
- }
45
- /**
46
- * Start the SSE status change watcher that broadcasts when snapshot changes
47
- */
48
- function startSseStatusWatcher(clients, projectDir, getConfig) {
49
- let lastSnapshotHash = "";
50
- return setInterval(() => {
51
- if (clients.size === 0)
52
- return;
53
- try {
54
- const snapshot = fetchStatusSnapshot(projectDir, getConfig());
55
- const hash = JSON.stringify({
56
- processes: snapshot.processes,
57
- prds: snapshot.prds.map((p) => ({ n: p.name, s: p.status })),
58
- });
59
- if (hash !== lastSnapshotHash) {
60
- lastSnapshotHash = hash;
61
- broadcastSSE(clients, "status_changed", snapshot);
62
- }
63
- }
64
- catch {
65
- // Silently ignore errors during status polling
66
- }
67
- }, 2000);
68
- }
69
- /**
70
- * Error handler middleware
71
- */
72
- function errorHandler(err, _req, res, _next) {
73
- console.error("API Error:", err);
74
- res.status(500).json({ error: err.message });
75
- }
76
- /**
77
- * Validate PRD name to prevent path traversal
78
- */
79
- function validatePrdName(name) {
80
- return /^[a-zA-Z0-9_-]+(\.md)?$/.test(name) && !name.includes("..");
81
- }
82
- // ==================== Extracted Route Handlers ====================
83
- function handleGetStatus(projectDir, config, _req, res) {
84
- try {
85
- const snapshot = fetchStatusSnapshot(projectDir, config);
86
- res.json(snapshot);
87
- }
88
- catch (error) {
89
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
90
- }
91
- }
92
- function handleGetPrds(projectDir, config, _req, res) {
93
- try {
94
- const prds = collectPrdInfo(projectDir, config.prdDir, config.maxRuntime);
95
- const prdsWithContent = prds.map((prd) => {
96
- const prdPath = path.join(projectDir, config.prdDir, `${prd.name}.md`);
97
- let content = "";
98
- if (fs.existsSync(prdPath)) {
99
- try {
100
- content = fs.readFileSync(prdPath, "utf-8");
101
- }
102
- catch {
103
- content = "";
104
- }
105
- }
106
- return { ...prd, content };
107
- });
108
- res.json(prdsWithContent);
109
- }
110
- catch (error) {
111
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
112
- }
113
- }
114
- function handleGetPrdByName(projectDir, config, req, res) {
115
- try {
116
- const { name } = req.params;
117
- if (!validatePrdName(name)) {
118
- res.status(400).json({ error: "Invalid PRD name" });
119
- return;
120
- }
121
- const nameStr = name;
122
- const filename = nameStr.endsWith(".md") ? nameStr : `${nameStr}.md`;
123
- const prdPath = path.join(projectDir, config.prdDir, filename);
124
- if (!fs.existsSync(prdPath)) {
125
- res.status(404).json({ error: "PRD not found" });
126
- return;
127
- }
128
- const content = fs.readFileSync(prdPath, "utf-8");
129
- res.json({ name: filename.replace(/\.md$/, ""), content });
130
- }
131
- catch (error) {
132
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
133
- }
134
- }
135
- function handleGetPrs(projectDir, config, _req, res) {
136
- try {
137
- const prs = collectPrInfo(projectDir, config.branchPatterns);
138
- res.json(prs);
139
- }
140
- catch (error) {
141
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
142
- }
143
- }
144
- function handleGetLogs(projectDir, _config, req, res) {
145
- try {
146
- const { name } = req.params;
147
- const validNames = ["executor", "reviewer", "qa"];
148
- if (!validNames.includes(name)) {
149
- res.status(400).json({ error: `Invalid log name. Must be one of: ${validNames.join(", ")}` });
150
- return;
151
- }
152
- const linesParam = req.query.lines;
153
- const lines = typeof linesParam === "string" ? parseInt(linesParam, 10) : 200;
154
- const linesToRead = isNaN(lines) || lines < 1 ? 200 : Math.min(lines, 10000);
155
- // Map logical name (executor/reviewer) to actual file name (night-watch/night-watch-pr-reviewer)
156
- const fileName = LOG_FILE_NAMES[name] || name;
157
- const logPath = path.join(projectDir, LOG_DIR, `${fileName}.log`);
158
- const logLines = getLastLogLines(logPath, linesToRead);
159
- res.json({ name, lines: logLines });
160
- }
161
- catch (error) {
162
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
163
- }
164
- }
165
- function handleGetConfig(config, _req, res) {
166
- try {
167
- res.json(config);
168
- }
169
- catch (error) {
170
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
171
- }
172
- }
173
- function handlePutConfig(projectDir, getConfig, reloadConfig, req, res) {
174
- try {
175
- const changes = req.body;
176
- if (typeof changes !== "object" || changes === null) {
177
- res.status(400).json({ error: "Invalid request body" });
178
- return;
179
- }
180
- if (changes.provider !== undefined) {
181
- const validProviders = ["claude", "codex"];
182
- if (!validProviders.includes(changes.provider)) {
183
- res.status(400).json({ error: `Invalid provider. Must be one of: ${validProviders.join(", ")}` });
184
- return;
185
- }
186
- }
187
- if (changes.reviewerEnabled !== undefined) {
188
- if (typeof changes.reviewerEnabled !== "boolean") {
189
- res.status(400).json({ error: "reviewerEnabled must be a boolean" });
190
- return;
191
- }
192
- }
193
- if (changes.maxRuntime !== undefined) {
194
- if (typeof changes.maxRuntime !== "number" || changes.maxRuntime < 60) {
195
- res.status(400).json({ error: "maxRuntime must be a number >= 60" });
196
- return;
197
- }
198
- }
199
- if (changes.reviewerMaxRuntime !== undefined) {
200
- if (typeof changes.reviewerMaxRuntime !== "number" || changes.reviewerMaxRuntime < 60) {
201
- res.status(400).json({ error: "reviewerMaxRuntime must be a number >= 60" });
202
- return;
203
- }
204
- }
205
- if (changes.minReviewScore !== undefined) {
206
- if (typeof changes.minReviewScore !== "number" || changes.minReviewScore < 0 || changes.minReviewScore > 100) {
207
- res.status(400).json({ error: "minReviewScore must be a number between 0 and 100" });
208
- return;
209
- }
210
- }
211
- if (changes.maxLogSize !== undefined) {
212
- if (typeof changes.maxLogSize !== "number" || changes.maxLogSize < 0) {
213
- res.status(400).json({ error: "maxLogSize must be a positive number" });
214
- return;
215
- }
216
- }
217
- if (changes.branchPatterns !== undefined) {
218
- if (!Array.isArray(changes.branchPatterns) || !changes.branchPatterns.every((p) => typeof p === "string")) {
219
- res.status(400).json({ error: "branchPatterns must be an array of strings" });
220
- return;
221
- }
222
- }
223
- if (changes.prdPriority !== undefined) {
224
- if (!Array.isArray(changes.prdPriority) || !changes.prdPriority.every((p) => typeof p === "string")) {
225
- res.status(400).json({ error: "prdPriority must be an array of strings" });
226
- return;
227
- }
228
- }
229
- if (changes.cronSchedule !== undefined) {
230
- if (typeof changes.cronSchedule !== "string" || changes.cronSchedule.trim().length === 0) {
231
- res.status(400).json({ error: "cronSchedule must be a non-empty string" });
232
- return;
233
- }
234
- }
235
- if (changes.reviewerSchedule !== undefined) {
236
- if (typeof changes.reviewerSchedule !== "string" || changes.reviewerSchedule.trim().length === 0) {
237
- res.status(400).json({ error: "reviewerSchedule must be a non-empty string" });
238
- return;
239
- }
240
- }
241
- if (changes.notifications?.webhooks !== undefined) {
242
- if (!Array.isArray(changes.notifications.webhooks)) {
243
- res.status(400).json({ error: "notifications.webhooks must be an array" });
244
- return;
245
- }
246
- for (const webhook of changes.notifications.webhooks) {
247
- const issues = validateWebhook(webhook);
248
- if (issues.length > 0) {
249
- res.status(400).json({ error: `Invalid webhook: ${issues.join(", ")}` });
250
- return;
251
- }
252
- }
253
- }
254
- if (changes.roadmapScanner !== undefined) {
255
- const rs = changes.roadmapScanner;
256
- if (typeof rs !== "object" || rs === null) {
257
- res.status(400).json({ error: "roadmapScanner must be an object" });
258
- return;
259
- }
260
- if (rs.enabled !== undefined && typeof rs.enabled !== "boolean") {
261
- res.status(400).json({ error: "roadmapScanner.enabled must be a boolean" });
262
- return;
263
- }
264
- if (rs.roadmapPath !== undefined) {
265
- if (typeof rs.roadmapPath !== "string" || rs.roadmapPath.trim().length === 0) {
266
- res.status(400).json({ error: "roadmapScanner.roadmapPath must be a non-empty string" });
267
- return;
268
- }
269
- }
270
- if (rs.autoScanInterval !== undefined) {
271
- if (typeof rs.autoScanInterval !== "number" || rs.autoScanInterval < 30) {
272
- res.status(400).json({ error: "roadmapScanner.autoScanInterval must be a number >= 30" });
273
- return;
274
- }
275
- }
276
- }
277
- const result = saveConfig(projectDir, changes);
278
- if (!result.success) {
279
- res.status(500).json({ error: result.error });
280
- return;
281
- }
282
- reloadConfig();
283
- res.json(getConfig());
284
- }
285
- catch (error) {
286
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
287
- }
288
- }
289
- function handleGetDoctor(projectDir, config, _req, res) {
290
- try {
291
- const checks = [];
292
- try {
293
- execSync("git rev-parse --is-inside-work-tree", { cwd: projectDir, stdio: "pipe" });
294
- checks.push({ name: "git", status: "pass", detail: "Git repository detected" });
295
- }
296
- catch {
297
- checks.push({ name: "git", status: "fail", detail: "Not a git repository" });
298
- }
299
- try {
300
- execSync(`which ${config.provider}`, { stdio: "pipe" });
301
- checks.push({ name: "provider", status: "pass", detail: `Provider CLI found: ${config.provider}` });
302
- }
303
- catch {
304
- checks.push({ name: "provider", status: "fail", detail: `Provider CLI not found: ${config.provider}` });
305
- }
306
- try {
307
- const projectName = path.basename(projectDir);
308
- const marker = generateMarker(projectName);
309
- const crontabEntries = [...getEntries(marker), ...getProjectEntries(projectDir)];
310
- if (crontabEntries.length > 0) {
311
- checks.push({
312
- name: "crontab",
313
- status: "pass",
314
- detail: `${crontabEntries.length} crontab entr(y/ies) installed`,
315
- });
316
- }
317
- else {
318
- checks.push({ name: "crontab", status: "warn", detail: "No crontab entries installed" });
319
- }
320
- }
321
- catch (_error) {
322
- checks.push({ name: "crontab", status: "fail", detail: "Failed to check crontab" });
323
- }
324
- const configPath = path.join(projectDir, CONFIG_FILE_NAME);
325
- if (fs.existsSync(configPath)) {
326
- checks.push({ name: "config", status: "pass", detail: "Config file exists" });
327
- }
328
- else {
329
- checks.push({ name: "config", status: "warn", detail: "Config file not found (using defaults)" });
330
- }
331
- const prdDir = path.join(projectDir, config.prdDir);
332
- if (fs.existsSync(prdDir)) {
333
- const prds = fs.readdirSync(prdDir).filter((f) => f.endsWith(".md") && f !== "NIGHT-WATCH-SUMMARY.md");
334
- checks.push({ name: "prdDir", status: "pass", detail: `PRD directory exists (${prds.length} PRDs)` });
335
- }
336
- else {
337
- checks.push({ name: "prdDir", status: "warn", detail: `PRD directory not found: ${config.prdDir}` });
338
- }
339
- res.json(checks);
340
- }
341
- catch (error) {
342
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
343
- }
344
- }
345
- function handleSpawnAction(projectDir, command, req, res, onSpawned) {
346
- try {
347
- // Prevent duplicate execution: check the lock file before spawning
348
- const lockPath = command[0] === "run"
349
- ? executorLockPath(projectDir)
350
- : command[0] === "review"
351
- ? reviewerLockPath(projectDir)
352
- : null;
353
- if (lockPath) {
354
- const lock = checkLockFile(lockPath);
355
- if (lock.running) {
356
- const processType = command[0] === "run" ? "Executor" : "Reviewer";
357
- res.status(409).json({
358
- error: `${processType} is already running (PID ${lock.pid})`,
359
- pid: lock.pid,
360
- });
361
- return;
362
- }
363
- }
364
- // Extract optional prdName for priority execution (only for "run" command)
365
- const prdName = command[0] === "run" ? req.body?.prdName : undefined;
366
- // Build extra env vars for priority hint
367
- const extraEnv = {};
368
- if (prdName) {
369
- extraEnv.NW_PRD_PRIORITY = prdName; // bash script respects NW_PRD_PRIORITY
370
- }
371
- const child = spawn("night-watch", command, {
372
- detached: true,
373
- stdio: "ignore",
374
- cwd: projectDir,
375
- env: { ...process.env, ...extraEnv },
376
- });
377
- child.unref();
378
- if (child.pid !== undefined) {
379
- spawnedProcesses.set(child.pid, child);
380
- // Fire notification for executor start (non-blocking)
381
- if (command[0] === "run") {
382
- const config = loadConfig(projectDir);
383
- sendNotifications(config, {
384
- event: "run_started",
385
- projectName: path.basename(projectDir),
386
- exitCode: 0,
387
- provider: config.provider,
388
- }).catch(() => { });
389
- }
390
- // Notify SSE clients about executor start
391
- if (onSpawned) {
392
- onSpawned(child.pid);
393
- }
394
- res.json({ started: true, pid: child.pid });
395
- }
396
- else {
397
- res.status(500).json({ error: "Failed to spawn process: no PID assigned" });
398
- }
399
- }
400
- catch (error) {
401
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
402
- }
403
- }
404
- function handleGetScheduleInfo(projectDir, config, _req, res) {
405
- try {
406
- const snapshot = fetchStatusSnapshot(projectDir, config);
407
- const installed = snapshot.crontab.installed;
408
- const entries = snapshot.crontab.entries;
409
- const computeNextRun = (cronExpr) => {
410
- try {
411
- const interval = CronExpressionParser.parse(cronExpr);
412
- return interval.next().toISOString();
413
- }
414
- catch {
415
- return null;
416
- }
417
- };
418
- res.json({
419
- executor: {
420
- schedule: config.cronSchedule,
421
- installed,
422
- nextRun: installed ? computeNextRun(config.cronSchedule) : null,
423
- },
424
- reviewer: {
425
- schedule: config.reviewerSchedule,
426
- installed: installed && config.reviewerEnabled,
427
- nextRun: installed && config.reviewerEnabled ? computeNextRun(config.reviewerSchedule) : null,
428
- },
429
- qa: {
430
- schedule: config.qa.schedule,
431
- installed: installed && config.qa.enabled,
432
- nextRun: installed && config.qa.enabled ? computeNextRun(config.qa.schedule) : null,
433
- },
434
- paused: !installed,
435
- entries,
436
- });
437
- }
438
- catch (error) {
439
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
440
- }
441
- }
442
- function handleGetRoadmap(projectDir, config, _req, res) {
443
- try {
444
- const status = getRoadmapStatus(projectDir, config);
445
- const prdDir = path.join(projectDir, config.prdDir);
446
- const state = loadRoadmapState(prdDir);
447
- res.json({
448
- ...status,
449
- lastScan: state.lastScan || null,
450
- autoScanInterval: config.roadmapScanner.autoScanInterval,
451
- });
452
- }
453
- catch (error) {
454
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
455
- }
456
- }
457
- async function handlePostRoadmapScan(projectDir, config, _req, res) {
458
- try {
459
- if (!config.roadmapScanner.enabled) {
460
- res.status(409).json({ error: "Roadmap scanner is disabled" });
461
- return;
462
- }
463
- const result = await scanRoadmap(projectDir, config);
464
- res.json(result);
465
- }
466
- catch (error) {
467
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
468
- }
469
- }
470
- function handlePutRoadmapToggle(projectDir, getConfig, reloadConfig, req, res) {
471
- try {
472
- const { enabled } = req.body;
473
- if (typeof enabled !== "boolean") {
474
- res.status(400).json({ error: "enabled must be a boolean" });
475
- return;
476
- }
477
- const currentConfig = getConfig();
478
- const result = saveConfig(projectDir, {
479
- roadmapScanner: {
480
- ...currentConfig.roadmapScanner,
481
- enabled,
482
- },
483
- });
484
- if (!result.success) {
485
- res.status(500).json({ error: result.error });
486
- return;
487
- }
488
- reloadConfig();
489
- res.json(getConfig());
490
- }
491
- catch (error) {
492
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
493
- }
494
- }
495
- const BOARD_CACHE_TTL_MS = 60_000; // 60 seconds
496
- const boardCacheMap = new Map();
497
- function getCachedBoardData(projectDir) {
498
- const entry = boardCacheMap.get(projectDir);
499
- if (!entry)
500
- return null;
501
- if (Date.now() - entry.timestamp > BOARD_CACHE_TTL_MS) {
502
- boardCacheMap.delete(projectDir);
503
- return null;
504
- }
505
- return entry.data;
506
- }
507
- function setCachedBoardData(projectDir, data) {
508
- boardCacheMap.set(projectDir, { data, timestamp: Date.now() });
509
- }
510
- function invalidateBoardCache(projectDir) {
511
- boardCacheMap.delete(projectDir);
512
- }
513
- // ==================== Board Handlers ====================
514
- function getBoardProvider(config, projectDir) {
515
- if (!config.boardProvider?.enabled || !config.boardProvider?.projectNumber) {
516
- return null;
517
- }
518
- return createBoardProvider(config.boardProvider, projectDir);
519
- }
520
- async function handleGetBoardStatus(projectDir, config, _req, res) {
521
- try {
522
- const provider = getBoardProvider(config, projectDir);
523
- if (!provider) {
524
- res.status(404).json({ error: "Board not configured" });
525
- return;
526
- }
527
- const cached = getCachedBoardData(projectDir);
528
- if (cached) {
529
- res.json(cached);
530
- return;
531
- }
532
- const issues = await provider.getAllIssues();
533
- const columns = {
534
- Draft: [], Ready: [], "In Progress": [], Review: [], Done: [],
535
- };
536
- for (const issue of issues) {
537
- const col = issue.column ?? "Draft";
538
- columns[col].push(issue);
539
- }
540
- const result = { enabled: true, columns };
541
- setCachedBoardData(projectDir, result);
542
- res.json(result);
543
- }
544
- catch (error) {
545
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
546
- }
547
- }
548
- async function handleGetBoardIssues(projectDir, config, _req, res) {
549
- try {
550
- const provider = getBoardProvider(config, projectDir);
551
- if (!provider) {
552
- res.status(404).json({ error: "Board not configured" });
553
- return;
554
- }
555
- const issues = await provider.getAllIssues();
556
- res.json(issues);
557
- }
558
- catch (error) {
559
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
560
- }
561
- }
562
- async function handlePostBoardIssue(projectDir, config, req, res) {
563
- try {
564
- const provider = getBoardProvider(config, projectDir);
565
- if (!provider) {
566
- res.status(404).json({ error: "Board not configured" });
567
- return;
568
- }
569
- const { title, body, column } = req.body;
570
- if (!title || typeof title !== "string" || title.trim().length === 0) {
571
- res.status(400).json({ error: "title is required" });
572
- return;
573
- }
574
- if (column && !BOARD_COLUMNS.includes(column)) {
575
- res.status(400).json({ error: `Invalid column. Must be one of: ${BOARD_COLUMNS.join(", ")}` });
576
- return;
577
- }
578
- const issue = await provider.createIssue({ title: title.trim(), body: body ?? "", column });
579
- invalidateBoardCache(projectDir);
580
- res.status(201).json(issue);
581
- }
582
- catch (error) {
583
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
584
- }
585
- }
586
- async function handlePatchBoardIssueMove(projectDir, config, req, res) {
587
- try {
588
- const provider = getBoardProvider(config, projectDir);
589
- if (!provider) {
590
- res.status(404).json({ error: "Board not configured" });
591
- return;
592
- }
593
- const issueNumber = parseInt(req.params.number, 10);
594
- if (isNaN(issueNumber)) {
595
- res.status(400).json({ error: "Invalid issue number" });
596
- return;
597
- }
598
- const { column } = req.body;
599
- if (!column || !BOARD_COLUMNS.includes(column)) {
600
- res.status(400).json({ error: `Invalid column. Must be one of: ${BOARD_COLUMNS.join(", ")}` });
601
- return;
602
- }
603
- await provider.moveIssue(issueNumber, column);
604
- invalidateBoardCache(projectDir);
605
- res.json({ moved: true });
606
- }
607
- catch (error) {
608
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
609
- }
610
- }
611
- async function handlePostBoardIssueComment(projectDir, config, req, res) {
612
- try {
613
- const provider = getBoardProvider(config, projectDir);
614
- if (!provider) {
615
- res.status(404).json({ error: "Board not configured" });
616
- return;
617
- }
618
- const issueNumber = parseInt(req.params.number, 10);
619
- if (isNaN(issueNumber)) {
620
- res.status(400).json({ error: "Invalid issue number" });
621
- return;
622
- }
623
- const { body } = req.body;
624
- if (!body || typeof body !== "string" || body.trim().length === 0) {
625
- res.status(400).json({ error: "body is required" });
626
- return;
627
- }
628
- await provider.commentOnIssue(issueNumber, body);
629
- invalidateBoardCache(projectDir);
630
- res.json({ commented: true });
631
- }
632
- catch (error) {
633
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
634
- }
635
- }
636
- async function handleDeleteBoardIssue(projectDir, config, req, res) {
637
- try {
638
- const provider = getBoardProvider(config, projectDir);
639
- if (!provider) {
640
- res.status(404).json({ error: "Board not configured" });
641
- return;
642
- }
643
- const issueNumber = parseInt(req.params.number, 10);
644
- if (isNaN(issueNumber)) {
645
- res.status(400).json({ error: "Invalid issue number" });
646
- return;
647
- }
648
- await provider.closeIssue(issueNumber);
649
- invalidateBoardCache(projectDir);
650
- res.json({ closed: true });
651
- }
652
- catch (error) {
653
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
654
- }
655
- }
656
- async function handleCancelAction(projectDir, req, res) {
657
- try {
658
- const { type = "all" } = req.body;
659
- const validTypes = ["run", "review", "all"];
660
- if (!validTypes.includes(type)) {
661
- res.status(400).json({ error: `Invalid type. Must be one of: ${validTypes.join(", ")}` });
662
- return;
663
- }
664
- const results = await performCancel(projectDir, { type: type, force: true });
665
- const hasFailure = results.some((r) => !r.success);
666
- res.status(hasFailure ? 500 : 200).json({ results });
667
- }
668
- catch (error) {
669
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
670
- }
671
- }
672
- function handleRetryAction(projectDir, config, req, res) {
673
- try {
674
- const { prdName } = req.body;
675
- if (!prdName || typeof prdName !== "string") {
676
- res.status(400).json({ error: "prdName is required" });
677
- return;
678
- }
679
- if (!validatePrdName(prdName)) {
680
- res.status(400).json({ error: "Invalid PRD name" });
681
- return;
682
- }
683
- const prdDir = path.join(projectDir, config.prdDir);
684
- const normalized = prdName.endsWith(".md") ? prdName : `${prdName}.md`;
685
- const pendingPath = path.join(prdDir, normalized);
686
- const donePath = path.join(prdDir, "done", normalized);
687
- if (fs.existsSync(pendingPath)) {
688
- res.json({ message: `"${normalized}" is already pending` });
689
- return;
690
- }
691
- if (!fs.existsSync(donePath)) {
692
- res.status(404).json({ error: `PRD "${normalized}" not found in done/` });
693
- return;
694
- }
695
- fs.renameSync(donePath, pendingPath);
696
- res.json({ message: `Moved "${normalized}" back to pending` });
697
- }
698
- catch (error) {
699
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
700
- }
701
- }
702
- /**
703
- * Handle clearing stale executor lock and orphaned claim files.
704
- * Returns 409 if executor is actively running (should use Stop instead).
705
- */
706
- function handleClearLockAction(projectDir, config, sseClients, _req, res) {
707
- try {
708
- const lockPath = executorLockPath(projectDir);
709
- const lock = checkLockFile(lockPath);
710
- if (lock.running) {
711
- res.status(409).json({ error: "Executor is actively running — use Stop instead" });
712
- return;
713
- }
714
- // Remove the stale lock file if it exists
715
- if (fs.existsSync(lockPath)) {
716
- fs.unlinkSync(lockPath);
717
- }
718
- // Clean up any orphaned claim files
719
- const prdDir = path.join(projectDir, config.prdDir);
720
- if (fs.existsSync(prdDir)) {
721
- cleanOrphanedClaims(prdDir);
722
- }
723
- // Broadcast updated status via SSE
724
- broadcastSSE(sseClients, "status_changed", fetchStatusSnapshot(projectDir, config));
725
- res.json({ cleared: true });
726
- }
727
- catch (error) {
728
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
729
- }
730
- }
731
- /**
732
- * Recursively clean up orphaned claim files in the PRD directory.
733
- * A claim is orphaned if the executor is not running.
734
- */
735
- function cleanOrphanedClaims(dir) {
736
- let entries;
737
- try {
738
- entries = fs.readdirSync(dir, { withFileTypes: true });
739
- }
740
- catch {
741
- return;
742
- }
743
- for (const entry of entries) {
744
- const fullPath = path.join(dir, entry.name);
745
- if (entry.isDirectory() && entry.name !== "done") {
746
- cleanOrphanedClaims(fullPath);
747
- }
748
- else if (entry.name.endsWith(CLAIM_FILE_EXTENSION)) {
749
- // This is a claim file - remove it since executor is not running
750
- try {
751
- fs.unlinkSync(fullPath);
752
- }
753
- catch {
754
- // Ignore errors during cleanup
755
- }
756
- }
757
- }
758
- }
759
- // ==================== Static Files + SPA Fallback ====================
760
- function setupStaticFiles(app) {
761
- const webDistPath = path.resolve(__dirname, "../../web/dist");
762
- if (fs.existsSync(webDistPath)) {
763
- app.use(express.static(webDistPath));
764
- }
765
- app.use((req, res, next) => {
766
- if (req.path.startsWith("/api/")) {
767
- next();
768
- return;
769
- }
770
- const indexPath = path.resolve(webDistPath, "index.html");
771
- if (fs.existsSync(indexPath)) {
772
- res.sendFile(indexPath, (err) => {
773
- if (err)
774
- next();
775
- });
776
- }
777
- else {
778
- next();
779
- }
780
- });
781
- }
782
- // ==================== Single-Project Mode ====================
783
- /**
784
- * Create and configure the Express application (single-project mode)
785
- */
786
- export function createApp(projectDir) {
787
- const app = express();
788
- app.use(cors());
789
- app.use(express.json());
790
- let config = loadConfig(projectDir);
791
- const reloadConfig = () => {
792
- config = loadConfig(projectDir);
793
- };
794
- // SSE client registry for real-time push
795
- const sseClients = new Set();
796
- // SSE endpoint for real-time status updates
797
- app.get("/api/status/events", (req, res) => {
798
- res.setHeader("Content-Type", "text/event-stream");
799
- res.setHeader("Cache-Control", "no-cache");
800
- res.setHeader("Connection", "keep-alive");
801
- res.flushHeaders();
802
- sseClients.add(res);
803
- // Send current snapshot immediately on connect
804
- try {
805
- const snapshot = fetchStatusSnapshot(projectDir, config);
806
- res.write(`event: status_changed\ndata: ${JSON.stringify(snapshot)}\n\n`);
807
- }
808
- catch {
809
- // Ignore errors during initial snapshot
810
- }
811
- req.on("close", () => {
812
- sseClients.delete(res);
813
- });
814
- });
815
- // Start the SSE status watcher (runs until process exits)
816
- startSseStatusWatcher(sseClients, projectDir, () => config);
817
- // API Routes
818
- app.get("/api/status", (req, res) => handleGetStatus(projectDir, config, req, res));
819
- app.get("/api/schedule-info", (req, res) => handleGetScheduleInfo(projectDir, config, req, res));
820
- app.get("/api/prds", (req, res) => handleGetPrds(projectDir, config, req, res));
821
- app.get("/api/prds/:name", (req, res) => handleGetPrdByName(projectDir, config, req, res));
822
- app.get("/api/prs", (req, res) => handleGetPrs(projectDir, config, req, res));
823
- app.get("/api/logs/:name", (req, res) => handleGetLogs(projectDir, config, req, res));
824
- app.get("/api/config", (req, res) => handleGetConfig(config, req, res));
825
- app.put("/api/config", (req, res) => handlePutConfig(projectDir, () => config, reloadConfig, req, res));
826
- app.get("/api/doctor", (req, res) => handleGetDoctor(projectDir, config, req, res));
827
- app.post("/api/actions/run", (req, res) => handleSpawnAction(projectDir, ["run"], req, res, (pid) => {
828
- broadcastSSE(sseClients, "executor_started", { pid });
829
- }));
830
- app.post("/api/actions/review", (req, res) => handleSpawnAction(projectDir, ["review"], req, res));
831
- app.post("/api/actions/install-cron", (req, res) => handleSpawnAction(projectDir, ["install"], req, res));
832
- app.post("/api/actions/uninstall-cron", (req, res) => handleSpawnAction(projectDir, ["uninstall"], req, res));
833
- app.post("/api/actions/cancel", (req, res) => handleCancelAction(projectDir, req, res));
834
- app.post("/api/actions/retry", (req, res) => handleRetryAction(projectDir, config, req, res));
835
- app.post("/api/actions/clear-lock", (req, res) => handleClearLockAction(projectDir, config, sseClients, req, res));
836
- app.get("/api/roadmap", (req, res) => handleGetRoadmap(projectDir, config, req, res));
837
- app.post("/api/roadmap/scan", (req, res) => handlePostRoadmapScan(projectDir, config, req, res));
838
- app.put("/api/roadmap/toggle", (req, res) => handlePutRoadmapToggle(projectDir, () => config, reloadConfig, req, res));
839
- // Board routes
840
- app.get("/api/board/status", (req, res) => handleGetBoardStatus(projectDir, config, req, res));
841
- app.get("/api/board/issues", (req, res) => handleGetBoardIssues(projectDir, config, req, res));
842
- app.post("/api/board/issues", (req, res) => handlePostBoardIssue(projectDir, config, req, res));
843
- app.patch("/api/board/issues/:number/move", (req, res) => handlePatchBoardIssueMove(projectDir, config, req, res));
844
- app.post("/api/board/issues/:number/comment", (req, res) => handlePostBoardIssueComment(projectDir, config, req, res));
845
- app.delete("/api/board/issues/:number", (req, res) => handleDeleteBoardIssue(projectDir, config, req, res));
846
- // Auto-scan timer
847
- let autoScanTimer = null;
848
- function startAutoScan() {
849
- stopAutoScan();
850
- const currentConfig = loadConfig(projectDir);
851
- if (!currentConfig.roadmapScanner.enabled)
852
- return;
853
- const intervalMs = currentConfig.roadmapScanner.autoScanInterval * 1000;
854
- autoScanTimer = setInterval(() => {
855
- const cfg = loadConfig(projectDir);
856
- if (!cfg.roadmapScanner.enabled)
857
- return;
858
- const status = getRoadmapStatus(projectDir, cfg);
859
- if (status.status === "complete" || status.status === "no-roadmap")
860
- return;
861
- // Fire and forget - async scan
862
- scanRoadmap(projectDir, cfg).catch(() => {
863
- // Silently ignore auto-scan errors
864
- });
865
- }, intervalMs);
866
- }
867
- function stopAutoScan() {
868
- if (autoScanTimer) {
869
- clearInterval(autoScanTimer);
870
- autoScanTimer = null;
871
- }
872
- }
873
- if (config.roadmapScanner.enabled) {
874
- startAutoScan();
875
- }
876
- setupStaticFiles(app);
877
- app.use(errorHandler);
878
- return app;
879
- }
880
- // ==================== Global (Multi-Project) Mode ====================
881
- /**
882
- * Middleware that resolves a project from the registry by :projectId param
883
- */
884
- function resolveProject(req, res, next) {
885
- const projectId = req.params.projectId;
886
- // Decode ~ back to / (frontend encodes / as ~ to avoid Express 5 %2F routing issues)
887
- const decodedId = decodeURIComponent(projectId).replace(/~/g, "/");
888
- const entries = loadRegistry();
889
- const entry = entries.find((e) => e.name === decodedId);
890
- if (!entry) {
891
- res.status(404).json({ error: `Project not found: ${decodedId}` });
892
- return;
893
- }
894
- if (!fs.existsSync(entry.path) || !fs.existsSync(path.join(entry.path, CONFIG_FILE_NAME))) {
895
- res.status(404).json({ error: `Project path invalid or missing config: ${entry.path}` });
896
- return;
897
- }
898
- req.projectDir = entry.path;
899
- req.projectConfig = loadConfig(entry.path);
900
- next();
901
- }
902
- /**
903
- * Create a router with all project-scoped endpoints
904
- */
905
- function createProjectRouter() {
906
- const router = Router({ mergeParams: true });
907
- // Per-project SSE client registry and watchers
908
- const projectSseClients = new Map();
909
- const projectSseWatchers = new Map();
910
- const dir = (req) => req.projectDir;
911
- const cfg = (req) => req.projectConfig;
912
- // SSE endpoint for project-scoped status updates
913
- router.get("/status/events", (req, res) => {
914
- const projectDir = dir(req);
915
- const config = cfg(req);
916
- // Initialize client set for this project if not exists
917
- if (!projectSseClients.has(projectDir)) {
918
- projectSseClients.set(projectDir, new Set());
919
- }
920
- const clients = projectSseClients.get(projectDir);
921
- // Start watcher for this project if not already running
922
- if (!projectSseWatchers.has(projectDir)) {
923
- const watcher = startSseStatusWatcher(clients, projectDir, () => loadConfig(projectDir));
924
- projectSseWatchers.set(projectDir, watcher);
925
- }
926
- res.setHeader("Content-Type", "text/event-stream");
927
- res.setHeader("Cache-Control", "no-cache");
928
- res.setHeader("Connection", "keep-alive");
929
- res.flushHeaders();
930
- clients.add(res);
931
- // Send current snapshot immediately on connect
932
- try {
933
- const snapshot = fetchStatusSnapshot(projectDir, config);
934
- res.write(`event: status_changed\ndata: ${JSON.stringify(snapshot)}\n\n`);
935
- }
936
- catch {
937
- // Ignore errors during initial snapshot
938
- }
939
- req.on("close", () => {
940
- clients.delete(res);
941
- });
942
- });
943
- router.get("/status", (req, res) => handleGetStatus(dir(req), cfg(req), req, res));
944
- router.get("/schedule-info", (req, res) => handleGetScheduleInfo(dir(req), cfg(req), req, res));
945
- router.get("/prds", (req, res) => handleGetPrds(dir(req), cfg(req), req, res));
946
- router.get("/prds/:name", (req, res) => handleGetPrdByName(dir(req), cfg(req), req, res));
947
- router.get("/prs", (req, res) => handleGetPrs(dir(req), cfg(req), req, res));
948
- router.get("/logs/:name", (req, res) => handleGetLogs(dir(req), cfg(req), req, res));
949
- router.get("/config", (req, res) => handleGetConfig(cfg(req), req, res));
950
- router.put("/config", (req, res) => {
951
- const projectDir = dir(req);
952
- let config = cfg(req);
953
- handlePutConfig(projectDir, () => config, () => { config = loadConfig(projectDir); }, req, res);
954
- });
955
- router.get("/doctor", (req, res) => handleGetDoctor(dir(req), cfg(req), req, res));
956
- router.post("/actions/run", (req, res) => {
957
- const projectDir = dir(req);
958
- handleSpawnAction(projectDir, ["run"], req, res, (pid) => {
959
- const clients = projectSseClients.get(projectDir);
960
- if (clients) {
961
- broadcastSSE(clients, "executor_started", { pid });
962
- }
963
- });
964
- });
965
- router.post("/actions/review", (req, res) => handleSpawnAction(dir(req), ["review"], req, res));
966
- router.post("/actions/install-cron", (req, res) => handleSpawnAction(dir(req), ["install"], req, res));
967
- router.post("/actions/uninstall-cron", (req, res) => handleSpawnAction(dir(req), ["uninstall"], req, res));
968
- router.post("/actions/cancel", (req, res) => handleCancelAction(dir(req), req, res));
969
- router.post("/actions/retry", (req, res) => handleRetryAction(dir(req), cfg(req), req, res));
970
- router.post("/actions/clear-lock", (req, res) => {
971
- const projectDir = dir(req);
972
- const config = cfg(req);
973
- const clients = projectSseClients.get(projectDir);
974
- handleClearLockAction(projectDir, config, clients ?? new Set(), req, res);
975
- });
976
- router.get("/roadmap", (req, res) => handleGetRoadmap(dir(req), cfg(req), req, res));
977
- router.post("/roadmap/scan", (req, res) => handlePostRoadmapScan(dir(req), cfg(req), req, res));
978
- router.put("/roadmap/toggle", (req, res) => {
979
- const projectDir = dir(req);
980
- let config = cfg(req);
981
- handlePutRoadmapToggle(projectDir, () => config, () => {
982
- config = loadConfig(projectDir);
983
- }, req, res);
984
- });
985
- // Board routes
986
- router.get("/board/status", (req, res) => handleGetBoardStatus(dir(req), cfg(req), req, res));
987
- router.get("/board/issues", (req, res) => handleGetBoardIssues(dir(req), cfg(req), req, res));
988
- router.post("/board/issues", (req, res) => handlePostBoardIssue(dir(req), cfg(req), req, res));
989
- router.patch("/board/issues/:number/move", (req, res) => handlePatchBoardIssueMove(dir(req), cfg(req), req, res));
990
- router.post("/board/issues/:number/comment", (req, res) => handlePostBoardIssueComment(dir(req), cfg(req), req, res));
991
- router.delete("/board/issues/:number", (req, res) => handleDeleteBoardIssue(dir(req), cfg(req), req, res));
992
- return router;
993
- }
994
- /**
995
- * Create the Express application for global (multi-project) mode
996
- */
997
- export function createGlobalApp() {
998
- const app = express();
999
- app.use(cors());
1000
- app.use(express.json());
1001
- // List all registered projects
1002
- app.get("/api/projects", (_req, res) => {
1003
- try {
1004
- const entries = loadRegistry();
1005
- const { invalid } = validateRegistry();
1006
- const invalidPaths = new Set(invalid.map((e) => e.path));
1007
- res.json(entries.map((e) => ({
1008
- name: e.name,
1009
- path: e.path,
1010
- valid: !invalidPaths.has(e.path),
1011
- })));
1012
- }
1013
- catch (error) {
1014
- res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
1015
- }
1016
- });
1017
- // Project-scoped routes
1018
- app.use("/api/projects/:projectId", resolveProject, createProjectRouter());
1019
- setupStaticFiles(app);
1020
- app.use(errorHandler);
1021
- return app;
1022
- }
1023
- // ==================== Server Startup ====================
1024
- /**
1025
- * Graceful shutdown handler
1026
- */
1027
- function setupGracefulShutdown(server) {
1028
- process.on("SIGTERM", () => {
1029
- console.log("SIGTERM received, shutting down server...");
1030
- server.close(() => {
1031
- console.log("Server closed");
1032
- process.exit(0);
1033
- });
1034
- });
1035
- process.on("SIGINT", () => {
1036
- console.log("\nSIGINT received, shutting down server...");
1037
- server.close(() => {
1038
- console.log("Server closed");
1039
- process.exit(0);
1040
- });
1041
- });
1042
- }
1043
- /**
1044
- * Start the HTTP server (single-project mode)
1045
- */
1046
- export function startServer(projectDir, port) {
1047
- const app = createApp(projectDir);
1048
- const server = app.listen(port, () => {
1049
- console.log(`Night Watch UI running at http://localhost:${port}`);
1050
- });
1051
- setupGracefulShutdown(server);
1052
- }
1053
- /**
1054
- * Start the HTTP server (global multi-project mode)
1055
- */
1056
- export function startGlobalServer(port) {
1057
- const entries = loadRegistry();
1058
- if (entries.length === 0) {
1059
- console.error("No projects registered. Run 'night-watch init' in a project first.");
1060
- process.exit(1);
1061
- }
1062
- const { valid, invalid } = validateRegistry();
1063
- if (invalid.length > 0) {
1064
- console.warn(`Warning: ${invalid.length} registered project(s) have invalid paths and will be skipped.`);
1065
- }
1066
- console.log(`Managing ${valid.length} project(s):`);
1067
- valid.forEach((p) => console.log(` - ${p.name} (${p.path})`));
1068
- const app = createGlobalApp();
1069
- const server = app.listen(port, () => {
1070
- console.log(`Night Watch Global UI running at http://localhost:${port}`);
1071
- });
1072
- setupGracefulShutdown(server);
1073
- }
1074
- //# sourceMappingURL=index.js.map