great-cto 2.76.0 → 2.77.1
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/board/.claude-plugin/plugin.json +256 -0
- package/board/packages/board/lib/alerts.mjs +413 -0
- package/board/packages/board/lib/beads.mjs +233 -0
- package/board/packages/board/lib/config.mjs +45 -0
- package/board/packages/board/lib/data-readers.mjs +340 -0
- package/board/packages/board/lib/fleet.mjs +363 -0
- package/board/packages/board/lib/metrics.mjs +282 -0
- package/board/packages/board/lib/notifications.mjs +50 -0
- package/board/packages/board/lib/projects.mjs +256 -0
- package/board/packages/board/lib/routes.mjs +1036 -0
- package/board/packages/board/lib/share.mjs +143 -0
- package/board/packages/board/lib/sse.mjs +20 -0
- package/board/packages/board/lib/state.mjs +31 -0
- package/board/packages/board/lib/util.mjs +36 -0
- package/board/packages/board/lib/verdicts.mjs +168 -0
- package/board/packages/board/lib/watchers.mjs +87 -0
- package/board/packages/board/mcp-server.mjs +310 -0
- package/board/packages/board/public/assets/apple-touch-icon.png +0 -0
- package/board/packages/board/public/assets/favicon-16.png +0 -0
- package/board/packages/board/public/assets/favicon-32.png +0 -0
- package/board/packages/board/public/assets/favicon.ico +0 -0
- package/board/packages/board/public/assets/favicon.svg +8 -0
- package/board/packages/board/public/index.html +5452 -0
- package/board/packages/board/public/share.html +1190 -0
- package/board/packages/board/public/sw.js +79 -0
- package/board/packages/board/push-adapter.mjs +222 -0
- package/board/packages/board/server.mjs +133 -0
- package/board/packages/cli/dist/archetypes.js +1461 -0
- package/board/scripts/lib/change-tier.mjs +119 -0
- package/board/scripts/lib/gate-plan.mjs +119 -0
- package/board/scripts/lib/judge-model.mjs +42 -0
- package/dist/board-path.js +47 -0
- package/dist/main.js +3 -31
- package/package.json +3 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Worker for great_cto board — Web Push support
|
|
3
|
+
*
|
|
4
|
+
* Strategy: empty-body push (SW fetches content from server on push).
|
|
5
|
+
* This avoids payload encryption complexity while keeping notifications fresh.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const BOARD_ORIGIN = self.location.origin;
|
|
9
|
+
|
|
10
|
+
self.addEventListener('activate', (event) => {
|
|
11
|
+
// Take control of all clients immediately — no reload required after install
|
|
12
|
+
event.waitUntil(self.clients.claim());
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
self.addEventListener('push', (event) => {
|
|
16
|
+
event.waitUntil(handlePush());
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function handlePush() {
|
|
20
|
+
let notif = null;
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(`${BOARD_ORIGIN}/api/notif-history?unread=1&limit=1`);
|
|
23
|
+
if (res.ok) {
|
|
24
|
+
const data = await res.json();
|
|
25
|
+
if (Array.isArray(data) && data.length > 0) {
|
|
26
|
+
notif = data[0];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} catch { /* network error — show a generic notification */ }
|
|
30
|
+
|
|
31
|
+
const title = notif?.title || 'great_cto';
|
|
32
|
+
const body = notif?.body || 'You have a new notification.';
|
|
33
|
+
const tag = notif?.id || 'gcto-push';
|
|
34
|
+
const notifId = notif?.id || null;
|
|
35
|
+
|
|
36
|
+
const options = {
|
|
37
|
+
body,
|
|
38
|
+
tag,
|
|
39
|
+
// icon may not exist — browsers handle missing icon gracefully
|
|
40
|
+
icon: `${BOARD_ORIGIN}/assets/logo-192.png`,
|
|
41
|
+
badge: `${BOARD_ORIGIN}/assets/favicon-32.png`,
|
|
42
|
+
data: {
|
|
43
|
+
url: `${BOARD_ORIGIN}/`,
|
|
44
|
+
notifId,
|
|
45
|
+
},
|
|
46
|
+
requireInteraction: false,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return self.registration.showNotification(title, options);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
self.addEventListener('notificationclick', (event) => {
|
|
53
|
+
event.notification.close();
|
|
54
|
+
const { url, notifId } = event.notification.data || {};
|
|
55
|
+
|
|
56
|
+
event.waitUntil((async () => {
|
|
57
|
+
// Mark notification as read
|
|
58
|
+
if (notifId) {
|
|
59
|
+
try {
|
|
60
|
+
await fetch(`${BOARD_ORIGIN}/api/notif-history/read`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({ id: notifId }),
|
|
64
|
+
});
|
|
65
|
+
} catch { /* best-effort */ }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Focus existing board window or open a new one
|
|
69
|
+
const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
70
|
+
for (const client of clientList) {
|
|
71
|
+
if (client.url.startsWith(BOARD_ORIGIN) && 'focus' in client) {
|
|
72
|
+
return client.focus();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (self.clients.openWindow) {
|
|
76
|
+
return self.clients.openWindow(url || `${BOARD_ORIGIN}/`);
|
|
77
|
+
}
|
|
78
|
+
})());
|
|
79
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* push-adapter.mjs — zero-dependency VAPID Web Push helper
|
|
3
|
+
*
|
|
4
|
+
* Uses only Node.js built-in modules: node:crypto, node:https, node:http, node:fs, node:url
|
|
5
|
+
*
|
|
6
|
+
* Why zero-dep: server.mjs is intentionally dependency-free so the board
|
|
7
|
+
* starts with just `node server.mjs` — no npm install required.
|
|
8
|
+
*/
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import https from 'node:https';
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { URL } from 'node:url';
|
|
15
|
+
|
|
16
|
+
// ── Helpers: base64url ────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function toBase64url(buf) {
|
|
19
|
+
return Buffer.from(buf).toString('base64url');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fromBase64url(str) {
|
|
23
|
+
return Buffer.from(str, 'base64url');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── DER → raw R||S (64 bytes) ─────────────────────────────────────────────
|
|
27
|
+
/**
|
|
28
|
+
* Convert DER-encoded ECDSA signature to raw R||S (64 bytes).
|
|
29
|
+
* DER structure: 30 [total_len] 02 [r_len] [r_bytes] 02 [s_len] [s_bytes]
|
|
30
|
+
* Handles both short (1-byte) and long-form (multi-byte) length encodings.
|
|
31
|
+
*/
|
|
32
|
+
export function derToRaw(der) {
|
|
33
|
+
let offset = 0;
|
|
34
|
+
// Skip 0x30 (SEQUENCE tag)
|
|
35
|
+
if (der[offset++] !== 0x30) throw new Error('DER: expected SEQUENCE tag 0x30');
|
|
36
|
+
// Skip total length (may be long form)
|
|
37
|
+
if (der[offset] & 0x80) {
|
|
38
|
+
offset += (der[offset] & 0x7f) + 1;
|
|
39
|
+
} else {
|
|
40
|
+
offset++;
|
|
41
|
+
}
|
|
42
|
+
// Read r
|
|
43
|
+
if (der[offset++] !== 0x02) throw new Error('DER: expected INTEGER tag 0x02 for r');
|
|
44
|
+
const rLen = der[offset++];
|
|
45
|
+
const r = der.slice(offset, offset + rLen);
|
|
46
|
+
offset += rLen;
|
|
47
|
+
// Read s
|
|
48
|
+
if (der[offset++] !== 0x02) throw new Error('DER: expected INTEGER tag 0x02 for s');
|
|
49
|
+
const sLen = der[offset++];
|
|
50
|
+
const s = der.slice(offset, offset + sLen);
|
|
51
|
+
|
|
52
|
+
// Pad r and s to 32 bytes each (DER may prepend 0x00 for sign, or be shorter)
|
|
53
|
+
const result = Buffer.alloc(64, 0);
|
|
54
|
+
// r: right-align, ignore leading 0x00 padding
|
|
55
|
+
const rStart = Math.max(0, r.length - 32);
|
|
56
|
+
const rDest = 32 - Math.min(r.length, 32);
|
|
57
|
+
r.copy(result, rDest, rStart);
|
|
58
|
+
// s: right-align
|
|
59
|
+
const sStart = Math.max(0, s.length - 32);
|
|
60
|
+
const sDest = 64 - Math.min(s.length, 32);
|
|
61
|
+
s.copy(result, sDest, sStart);
|
|
62
|
+
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── VAPID key management ───────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generate or load VAPID key pair.
|
|
70
|
+
* Persists as JWK JSON at keyPath. Returns { publicKey: base64url, privateKey: JWK }.
|
|
71
|
+
* The publicKey is the uncompressed P-256 point: 0x04 || x || y (65 bytes, base64url).
|
|
72
|
+
*/
|
|
73
|
+
export function getVapidKeys(keyPath) {
|
|
74
|
+
// Load existing keys from disk
|
|
75
|
+
if (fs.existsSync(keyPath)) {
|
|
76
|
+
try {
|
|
77
|
+
const stored = JSON.parse(fs.readFileSync(keyPath, 'utf8'));
|
|
78
|
+
if (stored.publicKey && stored.privateKey) return stored;
|
|
79
|
+
} catch { /* regenerate if corrupt */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Generate new P-256 key pair
|
|
83
|
+
const { privateKey: privObj } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
84
|
+
const jwk = privObj.export({ format: 'jwk' });
|
|
85
|
+
// jwk = { kty: 'EC', crv: 'P-256', x, y, d }
|
|
86
|
+
|
|
87
|
+
// Build uncompressed public key: 0x04 || x || y
|
|
88
|
+
const xBuf = fromBase64url(jwk.x);
|
|
89
|
+
const yBuf = fromBase64url(jwk.y);
|
|
90
|
+
const pubRaw = Buffer.concat([Buffer.from([0x04]), xBuf, yBuf]);
|
|
91
|
+
const publicKey = toBase64url(pubRaw);
|
|
92
|
+
|
|
93
|
+
const keys = { publicKey, privateKey: jwk };
|
|
94
|
+
|
|
95
|
+
// Persist — best effort
|
|
96
|
+
try { fs.mkdirSync(path.dirname(keyPath), { recursive: true }); } catch { /* ignore */ }
|
|
97
|
+
try { fs.writeFileSync(keyPath, JSON.stringify(keys, null, 2)); } catch { /* best-effort */ }
|
|
98
|
+
|
|
99
|
+
return keys;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── VAPID JWT ─────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build an ES256 JWT for VAPID authorization.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} endpoint - push service endpoint URL
|
|
108
|
+
* @param {object} privateKeyJwk - JWK { kty, crv, x, y, d }
|
|
109
|
+
* @param {string} subject - mailto: or https: contact URI
|
|
110
|
+
* @returns {string} compact JWT (header.payload.signature)
|
|
111
|
+
*/
|
|
112
|
+
export function createVapidJwt(endpoint, privateKeyJwk, subject) {
|
|
113
|
+
// aud = origin of the push endpoint
|
|
114
|
+
const url = new URL(endpoint);
|
|
115
|
+
const aud = `${url.protocol}//${url.host}`;
|
|
116
|
+
const exp = Math.floor(Date.now() / 1000) + 12 * 3600; // 12h
|
|
117
|
+
|
|
118
|
+
const header = toBase64url(JSON.stringify({ typ: 'JWT', alg: 'ES256' }));
|
|
119
|
+
const payload = toBase64url(JSON.stringify({ aud, exp, sub: subject }));
|
|
120
|
+
const signingInput = `${header}.${payload}`;
|
|
121
|
+
|
|
122
|
+
// Re-import JWK as crypto private key for signing
|
|
123
|
+
const privKey = crypto.createPrivateKey({ key: privateKeyJwk, format: 'jwk' });
|
|
124
|
+
|
|
125
|
+
// Sign with ECDSA SHA-256 → DER output
|
|
126
|
+
const sign = crypto.createSign('SHA256');
|
|
127
|
+
sign.update(signingInput);
|
|
128
|
+
const derSig = sign.sign(privKey);
|
|
129
|
+
|
|
130
|
+
// Convert DER → raw R||S (64 bytes)
|
|
131
|
+
const rawSig = derToRaw(derSig);
|
|
132
|
+
const signature = toBase64url(rawSig);
|
|
133
|
+
|
|
134
|
+
return `${signingInput}.${signature}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── HTTP POST to push service ─────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Send a Web Push notification (empty body — SW fetches content from server).
|
|
141
|
+
*
|
|
142
|
+
* @param {object} subscription - { endpoint, keys: { p256dh, auth } }
|
|
143
|
+
* @param {object} vapidKeys - { publicKey, privateKey }
|
|
144
|
+
* @param {string} subject - VAPID contact URI
|
|
145
|
+
* @returns {Promise<void>}
|
|
146
|
+
* @throws on non-2xx (except 201). 410 = subscription expired — caller removes it.
|
|
147
|
+
*/
|
|
148
|
+
export function sendWebPush(subscription, vapidKeys, subject) {
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const endpoint = subscription.endpoint;
|
|
151
|
+
const jwt = createVapidJwt(endpoint, vapidKeys.privateKey, subject);
|
|
152
|
+
const authorization = `vapid t=${jwt},k=${vapidKeys.publicKey}`;
|
|
153
|
+
|
|
154
|
+
let parsedUrl;
|
|
155
|
+
try { parsedUrl = new URL(endpoint); }
|
|
156
|
+
catch (e) { return reject(new Error(`Invalid push endpoint URL: ${endpoint}`)); }
|
|
157
|
+
|
|
158
|
+
const options = {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
hostname: parsedUrl.hostname,
|
|
161
|
+
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
|
162
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
163
|
+
headers: {
|
|
164
|
+
'Authorization': authorization,
|
|
165
|
+
'TTL': '86400',
|
|
166
|
+
'Content-Length': '0',
|
|
167
|
+
'Content-Type': 'application/octet-stream',
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const lib = parsedUrl.protocol === 'https:' ? https : http;
|
|
172
|
+
const req = lib.request(options, (res) => {
|
|
173
|
+
// Drain response body
|
|
174
|
+
res.resume();
|
|
175
|
+
res.on('end', () => {
|
|
176
|
+
const sc = res.statusCode;
|
|
177
|
+
if (sc === 201 || (sc >= 200 && sc < 300)) {
|
|
178
|
+
resolve();
|
|
179
|
+
} else {
|
|
180
|
+
const err = new Error(`Push service returned ${sc}`);
|
|
181
|
+
err.statusCode = sc;
|
|
182
|
+
reject(err);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
req.on('error', reject);
|
|
188
|
+
req.end();
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Subscription persistence ──────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
export function loadSubscriptions(filePath) {
|
|
195
|
+
try {
|
|
196
|
+
if (!fs.existsSync(filePath)) return [];
|
|
197
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
198
|
+
const parsed = JSON.parse(raw);
|
|
199
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
200
|
+
} catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function saveSubscriptions(filePath, subs) {
|
|
206
|
+
try {
|
|
207
|
+
fs.writeFileSync(filePath, JSON.stringify(subs, null, 2));
|
|
208
|
+
} catch { /* best-effort */ }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function addSubscription(filePath, sub) {
|
|
212
|
+
const subs = loadSubscriptions(filePath);
|
|
213
|
+
if (subs.some(s => s.endpoint === sub.endpoint)) return; // dedupe
|
|
214
|
+
subs.push(sub);
|
|
215
|
+
saveSubscriptions(filePath, subs);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function removeSubscription(filePath, endpoint) {
|
|
219
|
+
const subs = loadSubscriptions(filePath);
|
|
220
|
+
const filtered = subs.filter(s => s.endpoint !== endpoint);
|
|
221
|
+
saveSubscriptions(filePath, filtered);
|
|
222
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* great_cto board server
|
|
4
|
+
* Serves Kanban + CTO Dashboard on localhost:3141
|
|
5
|
+
* Data source: bd list --json (Beads), verdicts/*.log, docs/
|
|
6
|
+
*
|
|
7
|
+
* Usage: node server.mjs [--port 3141] [--no-open]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import http from 'http';
|
|
11
|
+
// The build board. (The separate operator-console runtime lives in its own repo.)
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { spawnSync } from 'child_process';
|
|
15
|
+
import { PORT, PUBLIC, HOST } from './lib/config.mjs';
|
|
16
|
+
import { originAllowed } from './lib/util.mjs';
|
|
17
|
+
import { discoverProjects, resolveProjectInfo } from './lib/projects.mjs';
|
|
18
|
+
import { startAlertCron } from './lib/alerts.mjs';
|
|
19
|
+
import { watchBeads, watchVerdicts } from './lib/watchers.mjs';
|
|
20
|
+
import { dispatch } from './lib/routes.mjs';
|
|
21
|
+
|
|
22
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
// ── HTTP router ────────────────────────────────────────────────────────────────
|
|
25
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024; // 2 MB — board payloads are tiny; cap guards against unbounded `body += c` accumulation.
|
|
26
|
+
const server = http.createServer(async (req, res) => {
|
|
27
|
+
// Single-point body-size guard: every route reads `body += c`, so cap cumulative
|
|
28
|
+
// request bytes here and kill the socket on overflow rather than buffering forever.
|
|
29
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
|
|
30
|
+
let _seen = 0;
|
|
31
|
+
req.on('data', (c) => {
|
|
32
|
+
_seen += c.length;
|
|
33
|
+
if (_seen > MAX_BODY_BYTES && !res.headersSent) {
|
|
34
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
35
|
+
res.end(JSON.stringify({ error: 'request body too large' }));
|
|
36
|
+
req.destroy();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
41
|
+
const pathname = url.pathname;
|
|
42
|
+
const proj = url.searchParams.get('project');
|
|
43
|
+
// BH-5 fix: surface project resolution as a response header. Previously
|
|
44
|
+
// ?project=<unknown> silently returned cwd's data — user thought they
|
|
45
|
+
// were viewing projectX but saw projectY. Now: X-Project-Fallback header
|
|
46
|
+
// tells the client what happened.
|
|
47
|
+
const projInfo = resolveProjectInfo(proj);
|
|
48
|
+
const cwd = projInfo.cwd;
|
|
49
|
+
if (projInfo.resolved === 'fallback') {
|
|
50
|
+
res.setHeader('X-Project-Fallback', `requested='${projInfo.requested}' served='cwd'`);
|
|
51
|
+
res.setHeader('X-Project-Resolved', 'fallback');
|
|
52
|
+
} else {
|
|
53
|
+
res.setHeader('X-Project-Resolved', projInfo.resolved);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// CORS
|
|
57
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
58
|
+
// Expose our debug headers to browsers (CORS hides custom headers by default)
|
|
59
|
+
res.setHeader('Access-Control-Expose-Headers', 'X-Project-Fallback, X-Project-Resolved');
|
|
60
|
+
|
|
61
|
+
// ── CSRF guard (BH-31) ──────────────────────────────────────────────────────
|
|
62
|
+
// The board binds 127.0.0.1, but a page the user visits in their browser can still
|
|
63
|
+
// issue *simple* cross-origin POSTs to localhost (no preflight). Every state-changing
|
|
64
|
+
// request must therefore be SAME-ORIGIN — otherwise a malicious page could approve an
|
|
65
|
+
// autopilot gate (→ run an irreversible write), approve a dev gate, or mutate tasks.
|
|
66
|
+
// (originAllowed() permits requests with no Origin — curl, the CLI, server-to-server —
|
|
67
|
+
// and rejects a foreign browser Origin.)
|
|
68
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method) && !originAllowed(req)) {
|
|
69
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
70
|
+
res.end(JSON.stringify({ error: 'cross-origin request blocked — the board only accepts same-origin state changes' }));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Try all /api/* routes + /api/sse first.
|
|
75
|
+
if (await dispatch(req, res, url, cwd)) return;
|
|
76
|
+
|
|
77
|
+
// Static files
|
|
78
|
+
let filePath = pathname === '/' ? '/index.html' : pathname;
|
|
79
|
+
const fullPath = path.join(PUBLIC, filePath);
|
|
80
|
+
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
|
|
81
|
+
const ext = path.extname(fullPath);
|
|
82
|
+
const mime = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
|
|
83
|
+
// HTML must never cache — board UI is iterated daily and stale layouts
|
|
84
|
+
// hide new features (period selector, push toggle, etc.) until hard refresh
|
|
85
|
+
const headers = { 'Content-Type': mime[ext] || 'text/plain' };
|
|
86
|
+
if (ext === '.html' || ext === '.js') headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
|
|
87
|
+
res.writeHead(200, headers);
|
|
88
|
+
res.end(fs.readFileSync(fullPath));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// API requests get a JSON 404 so frontends can JSON.parse() the response
|
|
93
|
+
// without crashing. Static-file 404s stay plain text.
|
|
94
|
+
if (pathname.startsWith('/api/')) {
|
|
95
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
96
|
+
res.end(JSON.stringify({ error: 'Not found', path: pathname, hint: 'Endpoint missing — restart the board after a great-cto update.' }));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
res.writeHead(404);
|
|
100
|
+
res.end('Not found');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── Start ──────────────────────────────────────────────────────────────────────
|
|
104
|
+
server.listen(PORT, HOST, () => {
|
|
105
|
+
console.log(`great_cto board → http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`);
|
|
106
|
+
if (HOST !== '127.0.0.1' && HOST !== 'localhost') {
|
|
107
|
+
console.log(` ⚠ bound to ${HOST} — reachable beyond this machine. Operators authenticate via invite`);
|
|
108
|
+
console.log(` links; put your reverse-proxy auth in front for anything admin-grade.`);
|
|
109
|
+
}
|
|
110
|
+
// Discover all great_cto projects on disk asynchronously — don't block
|
|
111
|
+
// the listening event so /api/tasks is available immediately.
|
|
112
|
+
discoverProjects().then(n => {
|
|
113
|
+
if (n > 0) console.log(` → discovered ${n} project${n === 1 ? '' : 's'} with .great_cto/PROJECT.md`);
|
|
114
|
+
}).catch(() => {}); // non-fatal
|
|
115
|
+
watchBeads();
|
|
116
|
+
startAlertCron();
|
|
117
|
+
watchVerdicts();
|
|
118
|
+
|
|
119
|
+
// Auto-open browser unless --no-open
|
|
120
|
+
if (!process.argv.includes('--no-open')) {
|
|
121
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
122
|
+
spawnSync(opener, [`http://localhost:${PORT}`], { detached: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
server.on('error', e => {
|
|
127
|
+
if (e.code === 'EADDRINUSE') {
|
|
128
|
+
console.log(`Port ${PORT} in use — board already running at http://localhost:${PORT}`);
|
|
129
|
+
} else {
|
|
130
|
+
console.error(e);
|
|
131
|
+
}
|
|
132
|
+
process.exit(0);
|
|
133
|
+
});
|