great-cto 2.77.2 → 2.79.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/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/alerts.mjs +38 -16
- package/board/packages/board/lib/beads.mjs +2 -1
- package/board/packages/board/lib/log.mjs +44 -0
- package/board/packages/board/lib/projects.mjs +33 -10
- package/board/packages/board/lib/routes.mjs +13 -24
- package/board/packages/board/lib/update-alert.mjs +107 -0
- package/board/packages/board/lib/util.mjs +14 -1
- package/board/packages/board/server.mjs +15 -8
- package/dist/main.js +9 -0
- package/dist/telemetry.js +3 -3
- package/dist/update-check-worker.js +6 -0
- package/dist/update-check.js +193 -0
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "great_cto",
|
|
3
3
|
"id": "great_cto",
|
|
4
4
|
"description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.79.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
loadSubscriptions,
|
|
7
7
|
removeSubscription,
|
|
8
8
|
} from '../push-adapter.mjs';
|
|
9
|
-
import { GREAT_CTO_DIR, PUSH_SUBS_FILE, VAPID_KEYS_FILE, VAPID_SUBJECT } from './config.mjs';
|
|
9
|
+
import { GREAT_CTO_DIR, PUSH_SUBS_FILE, VAPID_KEYS_FILE, VAPID_SUBJECT, BUILD_VERSION } from './config.mjs';
|
|
10
10
|
import { _reportRepublishDedupeSet } from './state.mjs';
|
|
11
11
|
import { listProjects, readProjectMd } from './projects.mjs';
|
|
12
12
|
import { addNotification } from './notifications.mjs';
|
|
@@ -16,6 +16,8 @@ import { getTasks } from './beads.mjs';
|
|
|
16
16
|
import { readVerdicts } from './verdicts.mjs';
|
|
17
17
|
import { isFailure } from './fleet.mjs';
|
|
18
18
|
import { getShareState, toggleShare } from './share.mjs';
|
|
19
|
+
import { checkForRelease, buildUpdatePayload } from './update-alert.mjs';
|
|
20
|
+
import { log } from './log.mjs';
|
|
19
21
|
|
|
20
22
|
// ── Email alerts (Resend) ─────────────────────────────────────────────────
|
|
21
23
|
// Dispatch model: read webhooks.json on every fire (idempotent if disabled
|
|
@@ -91,12 +93,12 @@ async function fireEmailAlert(eventName, dedupeKey, payload) {
|
|
|
91
93
|
});
|
|
92
94
|
if (!res.ok) {
|
|
93
95
|
const txt = await res.text().catch(() => '');
|
|
94
|
-
|
|
96
|
+
log.warn(`fireEmailAlert ${eventName}: relay HTTP ${res.status} ${txt.slice(0, 200)}`);
|
|
95
97
|
return;
|
|
96
98
|
}
|
|
97
|
-
|
|
99
|
+
log.info(`fireEmailAlert: sent ${eventName} (${dedupeKey})`);
|
|
98
100
|
} catch (e) {
|
|
99
|
-
|
|
101
|
+
log.warn(`fireEmailAlert ${eventName} failed:`, e.message);
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
@@ -119,12 +121,12 @@ async function firePushAlert(eventName, dedupeKey, payload) {
|
|
|
119
121
|
catch (e) {
|
|
120
122
|
// 410 = subscription expired — browser unsubscribed, clean up
|
|
121
123
|
if (e.statusCode === 410) removeSubscription(PUSH_SUBS_FILE, sub.endpoint);
|
|
122
|
-
else
|
|
124
|
+
else log.warn(`firePushAlert send failed for ${sub.endpoint}:`, e.message);
|
|
123
125
|
}
|
|
124
126
|
}
|
|
125
127
|
fired[pushKey] = new Date().toISOString();
|
|
126
128
|
writeAlertsFired(fired);
|
|
127
|
-
} catch (e) {
|
|
129
|
+
} catch (e) { log.warn('firePushAlert failed:', e.message); }
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
// ── Cron: scan gates / cost / weekly digest ───────────────────────────────
|
|
@@ -133,6 +135,7 @@ async function firePushAlert(eventName, dedupeKey, payload) {
|
|
|
133
135
|
function startAlertCron() {
|
|
134
136
|
const FIVE_MIN = 5 * 60 * 1000;
|
|
135
137
|
const ONE_HOUR = 60 * 60 * 1000;
|
|
138
|
+
const ONE_DAY = 24 * 60 * 60 * 1000;
|
|
136
139
|
|
|
137
140
|
// incident.p0: open P0 task with recent activity (last 24h).
|
|
138
141
|
// Older P0s are existing backlog — surfacing them via email is spam.
|
|
@@ -172,7 +175,7 @@ function startAlertCron() {
|
|
|
172
175
|
firePushAlert('incident.p0', dedupeKey, p0Payload);
|
|
173
176
|
}
|
|
174
177
|
}
|
|
175
|
-
} catch (e) {
|
|
178
|
+
} catch (e) { log.warn('cron incident.p0 failed:', e.message); }
|
|
176
179
|
}, FIVE_MIN);
|
|
177
180
|
|
|
178
181
|
// gate.blocked: new BLOCKED verdict from security-officer
|
|
@@ -207,7 +210,7 @@ function startAlertCron() {
|
|
|
207
210
|
addNotification('gate.blocked', blockedPayload);
|
|
208
211
|
firePushAlert('gate.blocked', dedupeKey, blockedPayload);
|
|
209
212
|
}
|
|
210
|
-
} catch (e) {
|
|
213
|
+
} catch (e) { log.warn('cron gate.blocked failed:', e.message); }
|
|
211
214
|
}, FIVE_MIN);
|
|
212
215
|
|
|
213
216
|
// gate.stale: gate task open between 2h and 7 days.
|
|
@@ -238,7 +241,7 @@ function startAlertCron() {
|
|
|
238
241
|
firePushAlert('gate.stale', dedupeKey, stalePayload);
|
|
239
242
|
}
|
|
240
243
|
}
|
|
241
|
-
} catch (e) {
|
|
244
|
+
} catch (e) { log.warn('cron gate.stale failed:', e.message); }
|
|
242
245
|
}, FIVE_MIN);
|
|
243
246
|
|
|
244
247
|
// cost.threshold: monthly LLM spend at 80% / 100% of budget
|
|
@@ -277,7 +280,7 @@ function startAlertCron() {
|
|
|
277
280
|
firePushAlert('cost.threshold', dedupeKey, costPayload);
|
|
278
281
|
}
|
|
279
282
|
}
|
|
280
|
-
} catch (e) {
|
|
283
|
+
} catch (e) { log.warn('cron cost.threshold failed:', e.message); }
|
|
281
284
|
}, ONE_HOUR);
|
|
282
285
|
|
|
283
286
|
// digest.weekly: Friday 09:00 UTC ± server tick
|
|
@@ -317,7 +320,7 @@ function startAlertCron() {
|
|
|
317
320
|
addNotification('digest.weekly', weeklyPayload);
|
|
318
321
|
await firePushAlert('digest.weekly', dedupeKey, weeklyPayload);
|
|
319
322
|
}
|
|
320
|
-
} catch (e) {
|
|
323
|
+
} catch (e) { log.warn('cron digest.weekly failed:', e.message); }
|
|
321
324
|
}, FIVE_MIN);
|
|
322
325
|
|
|
323
326
|
// digest.daily: morning summary (Mon–Fri, 08:00 UTC).
|
|
@@ -384,7 +387,7 @@ function startAlertCron() {
|
|
|
384
387
|
addNotification('digest.daily', dailyPayload);
|
|
385
388
|
await firePushAlert('digest.daily', dedupeKey, dailyPayload);
|
|
386
389
|
}
|
|
387
|
-
} catch (e) {
|
|
390
|
+
} catch (e) { log.warn('cron digest.daily failed:', e.message); }
|
|
388
391
|
}, FIVE_MIN);
|
|
389
392
|
|
|
390
393
|
// report.daily: republish share reports every day at 09:00 UTC
|
|
@@ -401,13 +404,32 @@ function startAlertCron() {
|
|
|
401
404
|
if (_reportRepublishDedupeSet.has(dedupeKey)) continue;
|
|
402
405
|
_reportRepublishDedupeSet.add(dedupeKey);
|
|
403
406
|
toggleShare(true, proj.path, true)
|
|
404
|
-
.then(() =>
|
|
405
|
-
.catch(e =>
|
|
407
|
+
.then(() => log.info(`report.daily: republished ${proj.slug}`))
|
|
408
|
+
.catch(e => log.warn(`report.daily: ${proj.slug} failed: ${e.message}`));
|
|
406
409
|
}
|
|
407
|
-
} catch (e) {
|
|
410
|
+
} catch (e) { log.warn('cron report.daily failed:', e.message); }
|
|
408
411
|
}, FIVE_MIN);
|
|
409
412
|
|
|
410
|
-
|
|
413
|
+
// update.available: daily check for a newer great-cto npm release.
|
|
414
|
+
// Dedupe key embeds the latest version string (see update-alert.mjs), so a
|
|
415
|
+
// given release notifies exactly once no matter how many daily ticks pass
|
|
416
|
+
// while it remains latest — a fresh key is only minted when npm publishes
|
|
417
|
+
// a newer version. Fails silent offline; skipped entirely when
|
|
418
|
+
// GREAT_CTO_NO_UPDATE_CHECK=1 (checkForRelease honors the env var itself).
|
|
419
|
+
setInterval(() => {
|
|
420
|
+
checkForRelease({
|
|
421
|
+
currentVersion: BUILD_VERSION,
|
|
422
|
+
isFired: (dedupeKey) => Boolean(readAlertsFired()[dedupeKey]),
|
|
423
|
+
notify: (current, latest, dedupeKey) => {
|
|
424
|
+
const payload = buildUpdatePayload(current, latest);
|
|
425
|
+
fireEmailAlert('update.available', dedupeKey, payload);
|
|
426
|
+
addNotification('update.available', payload);
|
|
427
|
+
firePushAlert('update.available', dedupeKey, payload);
|
|
428
|
+
},
|
|
429
|
+
}).catch(e => log.warn('cron update.available failed:', e.message));
|
|
430
|
+
}, ONE_DAY);
|
|
431
|
+
|
|
432
|
+
log.info('Alert cron started: gate.stale (5min), sla.escalate (5min), connector.health (5min), cost.threshold (1h), digest.daily (Mon–Fri 08:00), digest.weekly (Fri 09:00), report.daily (09:00), update.available (24h)');
|
|
411
433
|
}
|
|
412
434
|
|
|
413
435
|
export { readAlertsFired, writeAlertsFired, fireEmailAlert, firePushAlert, startAlertCron };
|
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import { spawnSync } from 'child_process';
|
|
5
5
|
import { bdCache } from './state.mjs';
|
|
6
|
+
import { log } from './log.mjs';
|
|
6
7
|
|
|
7
8
|
// ── Beads data ─────────────────────────────────────────────────────────────────
|
|
8
9
|
// Cache bdList output per cwd for BD_CACHE_TTL_MS. Invalidated when the project's
|
|
@@ -85,7 +86,7 @@ function checkBeadsAvailable(cwd) {
|
|
|
85
86
|
let _bdWriteChain = Promise.resolve();
|
|
86
87
|
function bdWriteSerialised(fn) {
|
|
87
88
|
const next = _bdWriteChain.then(() => fn()).catch((e) => {
|
|
88
|
-
|
|
89
|
+
log.error('[bd-write-serialised]', e?.message || e);
|
|
89
90
|
return null;
|
|
90
91
|
});
|
|
91
92
|
_bdWriteChain = next.then(() => undefined).catch(() => undefined);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Zero-dep structured logger for the great_cto board server.
|
|
2
|
+
//
|
|
3
|
+
// Design:
|
|
4
|
+
// - Levels: debug < info < warn < error. Threshold read once from
|
|
5
|
+
// GREAT_CTO_LOG_LEVEL (default 'info'); unrecognized values fall back to 'info'.
|
|
6
|
+
// - Output: single-line `<ISO timestamp> <LEVEL> <message>` per call.
|
|
7
|
+
// - Routing: error → stderr; debug/info/warn → stdout. This matches Unix
|
|
8
|
+
// convention (stdout = normal operation, stderr = attention-worthy) and
|
|
9
|
+
// keeps the board's stdout free of interleaved error noise.
|
|
10
|
+
// - No external deps, ESM only, node: prefix for built-ins.
|
|
11
|
+
|
|
12
|
+
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
13
|
+
|
|
14
|
+
function currentThreshold() {
|
|
15
|
+
const raw = (process.env.GREAT_CTO_LOG_LEVEL || 'info').toLowerCase();
|
|
16
|
+
return LEVELS[raw] ?? LEVELS.info;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function format(level, args) {
|
|
20
|
+
const ts = new Date().toISOString();
|
|
21
|
+
const msg = args
|
|
22
|
+
.map(a => (typeof a === 'string' ? a : a instanceof Error ? (a.stack || a.message) : JSON.stringify(a)))
|
|
23
|
+
.join(' ');
|
|
24
|
+
return `${ts} ${level.toUpperCase()} ${msg}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function emit(level, args) {
|
|
28
|
+
if (LEVELS[level] < currentThreshold()) return;
|
|
29
|
+
const line = format(level, args);
|
|
30
|
+
if (level === 'error') {
|
|
31
|
+
process.stderr.write(line + '\n');
|
|
32
|
+
} else {
|
|
33
|
+
process.stdout.write(line + '\n');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const log = {
|
|
38
|
+
debug: (...args) => emit('debug', args),
|
|
39
|
+
info: (...args) => emit('info', args),
|
|
40
|
+
warn: (...args) => emit('warn', args),
|
|
41
|
+
error: (...args) => emit('error', args),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default log;
|
|
@@ -4,6 +4,20 @@ import os from 'os';
|
|
|
4
4
|
import { spawnSync } from 'child_process';
|
|
5
5
|
import { planGates } from '../../../scripts/lib/gate-plan.mjs';
|
|
6
6
|
import { GREAT_CTO_DIR, PROJECTS_FILE } from './config.mjs';
|
|
7
|
+
import { isInsideDir } from './util.mjs';
|
|
8
|
+
|
|
9
|
+
// Same HOME-boundary policy /api/projects/register enforces (lib/routes.mjs):
|
|
10
|
+
// a raw absolute/tilde path must resolve inside the operator's home directory,
|
|
11
|
+
// no exceptions. Without this, a request like ?project=/etc or ?project=/tmp
|
|
12
|
+
// makes the board read/write project data (.beads, verdicts/, PROJECT.md)
|
|
13
|
+
// at an arbitrary filesystem location the operator never registered. Returns
|
|
14
|
+
// null (not process.cwd()) so callers can distinguish "not a raw path" from
|
|
15
|
+
// "raw path rejected" and fall back explicitly.
|
|
16
|
+
function resolveRawPathWithinHome(slugOrPath) {
|
|
17
|
+
const raw = slugOrPath.startsWith('~') ? slugOrPath.replace(/^~/, os.homedir()) : slugOrPath;
|
|
18
|
+
const resolved = path.resolve(raw);
|
|
19
|
+
return isInsideDir(os.homedir(), resolved) ? resolved : null;
|
|
20
|
+
}
|
|
7
21
|
|
|
8
22
|
// ── Project registry ───────────────────────────────────────────────────────────
|
|
9
23
|
function readProjectsRegistry() {
|
|
@@ -251,12 +265,19 @@ function listProjects() {
|
|
|
251
265
|
}
|
|
252
266
|
function resolveProjectCwd(slugOrPath) {
|
|
253
267
|
if (!slugOrPath) return process.cwd();
|
|
254
|
-
// If absolute path or starts with ~,
|
|
255
|
-
|
|
256
|
-
|
|
268
|
+
// If absolute path or starts with ~, it's raw user input — enforce the same
|
|
269
|
+
// HOME-boundary policy /api/projects/register uses. A path outside HOME
|
|
270
|
+
// falls back to process.cwd() (same fallback already used below for an
|
|
271
|
+
// unknown slug) rather than being honored verbatim.
|
|
272
|
+
if (slugOrPath.startsWith('/') || slugOrPath.startsWith('~')) {
|
|
273
|
+
const withinHome = resolveRawPathWithinHome(slugOrPath);
|
|
274
|
+
return withinHome || process.cwd();
|
|
275
|
+
}
|
|
257
276
|
// Else look up in registry by slug — among duplicate slugs (great_cto-7xfc),
|
|
258
277
|
// prefer the entry whose path exists on disk; among several existing (or
|
|
259
|
-
// several missing), prefer the most recently added_at.
|
|
278
|
+
// several missing), prefer the most recently added_at. Registry entries were
|
|
279
|
+
// already validated at registration time (register enforces the HOME
|
|
280
|
+
// boundary before writing), so slug resolution is untouched.
|
|
260
281
|
const reg = readProjectsRegistry();
|
|
261
282
|
const matches = reg.projects.filter(p => p.slug === slugOrPath);
|
|
262
283
|
const found = pickBestBySlug(matches);
|
|
@@ -271,16 +292,18 @@ function resolveProjectCwd(slugOrPath) {
|
|
|
271
292
|
*
|
|
272
293
|
* resolved values:
|
|
273
294
|
* 'cwd' — no project param passed; using server cwd as documented
|
|
274
|
-
* 'path' — absolute / tilde path passed
|
|
295
|
+
* 'path' — absolute / tilde path passed, resolved inside HOME, used directly
|
|
275
296
|
* 'slug' — slug found in registry
|
|
276
|
-
* 'fallback' — slug requested but NOT in registry
|
|
277
|
-
*
|
|
297
|
+
* 'fallback' — slug requested but NOT in registry, OR a raw path was requested
|
|
298
|
+
* that resolves outside HOME (great_cto-qvg9); using cwd as
|
|
299
|
+
* fallback. Caller should warn the user (header + log).
|
|
278
300
|
*/
|
|
279
301
|
function resolveProjectInfo(slugOrPath) {
|
|
280
302
|
if (!slugOrPath) return { cwd: process.cwd(), resolved: 'cwd' };
|
|
281
|
-
if (slugOrPath.startsWith('/')
|
|
282
|
-
|
|
283
|
-
return { cwd:
|
|
303
|
+
if (slugOrPath.startsWith('/') || slugOrPath.startsWith('~')) {
|
|
304
|
+
const withinHome = resolveRawPathWithinHome(slugOrPath);
|
|
305
|
+
if (withinHome) return { cwd: withinHome, resolved: 'path' };
|
|
306
|
+
return { cwd: process.cwd(), resolved: 'fallback', requested: slugOrPath };
|
|
284
307
|
}
|
|
285
308
|
const reg = readProjectsRegistry();
|
|
286
309
|
const matches = reg.projects.filter(p => p.slug === slugOrPath);
|
|
@@ -6,13 +6,14 @@ import {
|
|
|
6
6
|
addSubscription,
|
|
7
7
|
removeSubscription,
|
|
8
8
|
} from '../push-adapter.mjs';
|
|
9
|
-
import {
|
|
10
|
-
import { eventSurface, readFileSafe } from './util.mjs';
|
|
9
|
+
import { GREAT_CTO_DIR, VAPID_KEYS_FILE, PUSH_SUBS_FILE, BUILD_VERSION } from './config.mjs';
|
|
10
|
+
import { eventSurface, readFileSafe, originAllowed } from './util.mjs';
|
|
11
11
|
import { sseClients, notifHistory } from './state.mjs';
|
|
12
12
|
import { autoRegisterProject, listProjects, resolveProjectCwd, getChangeTier } from './projects.mjs';
|
|
13
13
|
import { broadcastTasks } from './sse.mjs';
|
|
14
14
|
import { saveNotifHistory } from './notifications.mjs';
|
|
15
15
|
import { getMemory, getPipeline, getCostHistory, getInbox } from './data-readers.mjs';
|
|
16
|
+
import { log } from './log.mjs';
|
|
16
17
|
import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks } from './beads.mjs';
|
|
17
18
|
import { getMetrics } from './metrics.mjs';
|
|
18
19
|
import { readVerdicts } from './verdicts.mjs';
|
|
@@ -54,18 +55,12 @@ async function dispatch(req, res, url, cwd) {
|
|
|
54
55
|
// it MUST reject cross-origin requests. The board listens on 127.0.0.1
|
|
55
56
|
// but a malicious page the user visits can still issue text/plain POSTs
|
|
56
57
|
// (simple CORS request — no preflight) to localhost. Two gates:
|
|
57
|
-
// 1) Origin / Referer must
|
|
58
|
+
// 1) Origin / Referer must be same-origin (originAllowed() in lib/util.mjs —
|
|
59
|
+
// also enforced as the top-level CSRF guard in server.mjs before dispatch()
|
|
60
|
+
// is ever called, so this is defense-in-depth, not the only check).
|
|
58
61
|
// 2) Resolved target path must live inside HOME — no /tmp, no /etc.
|
|
59
62
|
if (pathname === '/api/projects/register' && req.method === 'POST') {
|
|
60
|
-
|
|
61
|
-
const expectedOrigin = `http://localhost:${PORT}`;
|
|
62
|
-
const expectedOrigin2 = `http://127.0.0.1:${PORT}`;
|
|
63
|
-
const originOk = !origin
|
|
64
|
-
|| origin === expectedOrigin
|
|
65
|
-
|| origin === expectedOrigin2
|
|
66
|
-
|| origin.startsWith(expectedOrigin + '/')
|
|
67
|
-
|| origin.startsWith(expectedOrigin2 + '/');
|
|
68
|
-
if (!originOk) {
|
|
63
|
+
if (!originAllowed(req)) {
|
|
69
64
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
70
65
|
res.end(JSON.stringify({ error: 'origin not allowed' }));
|
|
71
66
|
return true;
|
|
@@ -384,8 +379,8 @@ async function dispatch(req, res, url, cwd) {
|
|
|
384
379
|
const shareState = getShareState(gateCwd);
|
|
385
380
|
if (shareState.enabled) {
|
|
386
381
|
toggleShare(true, gateCwd, true)
|
|
387
|
-
.then(() =>
|
|
388
|
-
.catch(e =>
|
|
382
|
+
.then(() => log.info(`report: auto-republished after gate approve (${id})`))
|
|
383
|
+
.catch(e => log.warn(`report: republish after gate failed: ${e.message}`));
|
|
389
384
|
}
|
|
390
385
|
}
|
|
391
386
|
});
|
|
@@ -850,16 +845,10 @@ async function dispatch(req, res, url, cwd) {
|
|
|
850
845
|
// (DESIGN-agents-fleet-view §9 Top-2 #2: reversible sidecar chosen over
|
|
851
846
|
// filesystem move; founder may revise.)
|
|
852
847
|
if (pathname.startsWith('/api/agents/') && req.method === 'POST') {
|
|
853
|
-
// Cross-origin guard — same
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
const originOk = !origin
|
|
858
|
-
|| origin === expectedOrigin
|
|
859
|
-
|| origin === expectedOrigin2
|
|
860
|
-
|| origin.startsWith(expectedOrigin + '/')
|
|
861
|
-
|| origin.startsWith(expectedOrigin2 + '/');
|
|
862
|
-
if (!originOk) {
|
|
848
|
+
// Cross-origin guard — same helper as BH-23 on /api/projects/register
|
|
849
|
+
// (originAllowed() in lib/util.mjs; also enforced as the top-level CSRF
|
|
850
|
+
// guard in server.mjs before dispatch() is ever called).
|
|
851
|
+
if (!originAllowed(req)) {
|
|
863
852
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
864
853
|
res.end(JSON.stringify({ error: 'origin_not_allowed' }));
|
|
865
854
|
return true;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Release notification — daily check for a newer great-cto npm release,
|
|
2
|
+
// surfaced through the board's existing notification + push-alert pipeline.
|
|
3
|
+
//
|
|
4
|
+
// Design mirrors the CLI's update-check.ts (same registry endpoint, same
|
|
5
|
+
// zero-dependency approach) but fires through addNotification()/firePushAlert()
|
|
6
|
+
// instead of a stderr hint, since the board is a long-running server, not a
|
|
7
|
+
// one-shot CLI invocation.
|
|
8
|
+
//
|
|
9
|
+
// Registry endpoint: https://registry.npmjs.org/-/package/great-cto/dist-tags
|
|
10
|
+
// Verified with curl to return only `{"latest":"x.y.z"}`.
|
|
11
|
+
//
|
|
12
|
+
// Dedupe: alerts-fired.json (existing mechanism in alerts.mjs) is keyed by
|
|
13
|
+
// dedupeKey; the key here embeds the latest version string so each new
|
|
14
|
+
// release notifies exactly once, no matter how many daily ticks pass while
|
|
15
|
+
// that version remains the latest.
|
|
16
|
+
//
|
|
17
|
+
// Opt-out: GREAT_CTO_NO_UPDATE_CHECK=1 (same env var as the CLI hint).
|
|
18
|
+
|
|
19
|
+
const REGISTRY_DIST_TAGS_URL = 'https://registry.npmjs.org/-/package/great-cto/dist-tags';
|
|
20
|
+
const FETCH_TIMEOUT_MS = 3000;
|
|
21
|
+
|
|
22
|
+
/** Pure function: parse "x.y.z" into a 3-tuple of ints (missing/garbage segments -> 0). */
|
|
23
|
+
function parseSemver(v) {
|
|
24
|
+
return String(v || '').split('.').map(n => parseInt(n, 10) || 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Pure function: true when `latest` is strictly newer than `current` (semver order). */
|
|
28
|
+
function isNewerVersion(current, latest) {
|
|
29
|
+
if (!current || !latest || current === 'unknown') return false;
|
|
30
|
+
const a = parseSemver(latest);
|
|
31
|
+
const b = parseSemver(current);
|
|
32
|
+
for (let i = 0; i < 3; i++) {
|
|
33
|
+
const d = (a[i] ?? 0) - (b[i] ?? 0);
|
|
34
|
+
if (d !== 0) return d > 0;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Pure function: the dedupe key for a given latest version — one notification per new release, ever. */
|
|
40
|
+
function updateDedupeKey(latest) {
|
|
41
|
+
return `update.available:${latest}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pure function: build the notification payload for a given current/latest pair. */
|
|
45
|
+
function buildUpdatePayload(current, latest) {
|
|
46
|
+
return {
|
|
47
|
+
title: `great_cto v${latest} released (you run v${current})`,
|
|
48
|
+
body: `A new great-cto release is available. Upgrade with: npx great-cto upgrade`,
|
|
49
|
+
level: 'info',
|
|
50
|
+
project: 'great_cto',
|
|
51
|
+
link: 'https://github.com/avelikiy/great_cto/releases',
|
|
52
|
+
action: 'View release',
|
|
53
|
+
kv: { current, latest },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Fetch {latest} from the npm registry. Returns null on any error/timeout/offline — fail-silent by design. */
|
|
58
|
+
async function fetchLatestVersion(fetchFn = fetch) {
|
|
59
|
+
try {
|
|
60
|
+
const ctrl = new AbortController();
|
|
61
|
+
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetchFn(REGISTRY_DIST_TAGS_URL, { signal: ctrl.signal });
|
|
64
|
+
if (!res.ok) return null;
|
|
65
|
+
const body = await res.json();
|
|
66
|
+
return typeof body?.latest === 'string' ? body.latest : null;
|
|
67
|
+
} finally {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run one update check. Injectable deps for testing — no real network calls
|
|
77
|
+
* or fs/notification side effects when `fetchFn`/`isFired`/`notify` are stubs.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} opts
|
|
80
|
+
* @param {string} opts.currentVersion BUILD_VERSION from lib/config.mjs
|
|
81
|
+
* @param {(url: string, init?: object) => Promise<Response>} [opts.fetchFn]
|
|
82
|
+
* @param {(dedupeKey: string) => boolean} [opts.isFired] dedupe lookup (alerts-fired.json)
|
|
83
|
+
* @param {(current: string, latest: string, dedupeKey: string) => void} [opts.notify]
|
|
84
|
+
* called once when a newer, undelivered version is found
|
|
85
|
+
* @returns {Promise<{checked: boolean, latest: string|null, notified: boolean}>}
|
|
86
|
+
*/
|
|
87
|
+
async function checkForRelease({ currentVersion, fetchFn = fetch, isFired = () => false, notify = () => {} }) {
|
|
88
|
+
if (process.env.GREAT_CTO_NO_UPDATE_CHECK === '1') {
|
|
89
|
+
return { checked: false, latest: null, notified: false };
|
|
90
|
+
}
|
|
91
|
+
const latest = await fetchLatestVersion(fetchFn);
|
|
92
|
+
if (!latest) return { checked: true, latest: null, notified: false };
|
|
93
|
+
if (!isNewerVersion(currentVersion, latest)) return { checked: true, latest, notified: false };
|
|
94
|
+
const dedupeKey = updateDedupeKey(latest);
|
|
95
|
+
if (isFired(dedupeKey)) return { checked: true, latest, notified: false };
|
|
96
|
+
notify(currentVersion, latest, dedupeKey);
|
|
97
|
+
return { checked: true, latest, notified: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export {
|
|
101
|
+
REGISTRY_DIST_TAGS_URL,
|
|
102
|
+
isNewerVersion,
|
|
103
|
+
updateDedupeKey,
|
|
104
|
+
buildUpdatePayload,
|
|
105
|
+
fetchLatestVersion,
|
|
106
|
+
checkForRelease,
|
|
107
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
2
3
|
import { PORT } from './config.mjs';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -33,4 +34,16 @@ function readFileSafe(p) {
|
|
|
33
34
|
try { return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null; } catch { return null; }
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
// Path-containment check used by any handler that joins user-controlled input
|
|
38
|
+
// onto a base directory (static file serving, doc reads, etc.). Resolves both
|
|
39
|
+
// sides and requires `target` to be exactly `base`, or a real descendant of
|
|
40
|
+
// it (base + path.sep prefix) — this is what actually blocks ".." escapes,
|
|
41
|
+
// since a naive startsWith(base) would wrongly allow a sibling directory
|
|
42
|
+
// like "/public-evil" when base is "/public".
|
|
43
|
+
function isInsideDir(base, target) {
|
|
44
|
+
const resolvedBase = path.resolve(base);
|
|
45
|
+
const resolvedTarget = path.resolve(target);
|
|
46
|
+
return resolvedTarget === resolvedBase || resolvedTarget.startsWith(resolvedBase + path.sep);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { csvCell, originAllowed, eventSurface, readFileSafe, isInsideDir };
|
|
@@ -13,11 +13,12 @@ import fs from 'fs';
|
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import { spawnSync } from 'child_process';
|
|
15
15
|
import { PORT, PUBLIC, HOST } from './lib/config.mjs';
|
|
16
|
-
import { originAllowed } from './lib/util.mjs';
|
|
16
|
+
import { originAllowed, isInsideDir } from './lib/util.mjs';
|
|
17
17
|
import { discoverProjects, resolveProjectInfo } from './lib/projects.mjs';
|
|
18
18
|
import { startAlertCron } from './lib/alerts.mjs';
|
|
19
19
|
import { watchBeads, watchVerdicts } from './lib/watchers.mjs';
|
|
20
20
|
import { dispatch } from './lib/routes.mjs';
|
|
21
|
+
import { log } from './lib/log.mjs';
|
|
21
22
|
|
|
22
23
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -77,7 +78,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
77
78
|
// Static files
|
|
78
79
|
let filePath = pathname === '/' ? '/index.html' : pathname;
|
|
79
80
|
const fullPath = path.join(PUBLIC, filePath);
|
|
80
|
-
|
|
81
|
+
// Path-traversal guard (mirrors the /api/doc containment check in lib/routes.mjs):
|
|
82
|
+
// the joined path must resolve to somewhere inside PUBLIC. Without this, a decoded
|
|
83
|
+
// pathname like /..%2F..%2Fetc%2Fpasswd can escape the public/ directory via
|
|
84
|
+
// path.join's ".." collapsing. Reject with 404 (same as the generic static 404
|
|
85
|
+
// below) so a traversal attempt is indistinguishable from a missing file — no
|
|
86
|
+
// signal to a prober about what exists outside PUBLIC.
|
|
87
|
+
if (isInsideDir(PUBLIC, fullPath) && fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
|
|
81
88
|
const ext = path.extname(fullPath);
|
|
82
89
|
const mime = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
|
|
83
90
|
// HTML must never cache — board UI is iterated daily and stale layouts
|
|
@@ -102,15 +109,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
102
109
|
|
|
103
110
|
// ── Start ──────────────────────────────────────────────────────────────────────
|
|
104
111
|
server.listen(PORT, HOST, () => {
|
|
105
|
-
|
|
112
|
+
log.info(`great_cto board → http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`);
|
|
106
113
|
if (HOST !== '127.0.0.1' && HOST !== 'localhost') {
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
log.info(` ⚠ bound to ${HOST} — reachable beyond this machine. Operators authenticate via invite`);
|
|
115
|
+
log.info(` links; put your reverse-proxy auth in front for anything admin-grade.`);
|
|
109
116
|
}
|
|
110
117
|
// Discover all great_cto projects on disk asynchronously — don't block
|
|
111
118
|
// the listening event so /api/tasks is available immediately.
|
|
112
119
|
discoverProjects().then(n => {
|
|
113
|
-
if (n > 0)
|
|
120
|
+
if (n > 0) log.info(` → discovered ${n} project${n === 1 ? '' : 's'} with .great_cto/PROJECT.md`);
|
|
114
121
|
}).catch(() => {}); // non-fatal
|
|
115
122
|
watchBeads();
|
|
116
123
|
startAlertCron();
|
|
@@ -125,9 +132,9 @@ server.listen(PORT, HOST, () => {
|
|
|
125
132
|
|
|
126
133
|
server.on('error', e => {
|
|
127
134
|
if (e.code === 'EADDRINUSE') {
|
|
128
|
-
|
|
135
|
+
log.info(`Port ${PORT} in use — board already running at http://localhost:${PORT}`);
|
|
129
136
|
} else {
|
|
130
|
-
|
|
137
|
+
log.error(e);
|
|
131
138
|
}
|
|
132
139
|
process.exit(0);
|
|
133
140
|
});
|
package/dist/main.js
CHANGED
|
@@ -21,6 +21,7 @@ import { bootstrap } from "./bootstrap.js";
|
|
|
21
21
|
import { compileFlow } from "./flow.js";
|
|
22
22
|
import { shouldUseLlmFallback, suggestArchetypeFromLlm } from "./llm-fallback.js";
|
|
23
23
|
import { sendUsagePing, sendInstallPing, telemetrySubcommand, isTelemetryEnabled, computeAnonId } from "./telemetry.js";
|
|
24
|
+
import { checkForUpdate } from "./update-check.js";
|
|
24
25
|
import { findBoardServerPath } from "./board-path.js";
|
|
25
26
|
import { readFileSync, writeFileSync, copyFileSync, chmodSync, mkdirSync, unlinkSync, existsSync as fsExistsSync } from "node:fs";
|
|
26
27
|
import { dirname, join } from "node:path";
|
|
@@ -1047,6 +1048,14 @@ async function main() {
|
|
|
1047
1048
|
});
|
|
1048
1049
|
}
|
|
1049
1050
|
catch { /* telemetry never affects the exit */ }
|
|
1051
|
+
// Update hint — printed after the command's own output, never blocks on
|
|
1052
|
+
// network (reads a local cache; spawns a detached refresh if stale).
|
|
1053
|
+
// Excluded automatically for mcp/worker/task via PROTOCOL_SENSITIVE_COMMANDS
|
|
1054
|
+
// inside checkForUpdate — worker/task don't even route through finish().
|
|
1055
|
+
try {
|
|
1056
|
+
checkForUpdate({ currentVersion: getCliVersion(), command: args.command });
|
|
1057
|
+
}
|
|
1058
|
+
catch { /* update hint never affects the exit */ }
|
|
1050
1059
|
process.exit(code);
|
|
1051
1060
|
};
|
|
1052
1061
|
// `great-cto telemetry <on|off|status|whoami>` — inspect / toggle, never sends.
|
package/dist/telemetry.js
CHANGED
|
@@ -27,9 +27,9 @@ import * as path from "node:path";
|
|
|
27
27
|
import * as os from "node:os";
|
|
28
28
|
import * as crypto from "node:crypto";
|
|
29
29
|
const TELEMETRY_ENDPOINT = process.env.GREAT_CTO_TELEMETRY_ENDPOINT
|
|
30
|
-
|| "https://
|
|
31
|
-
//
|
|
32
|
-
//
|
|
30
|
+
|| "https://telemetry.greatcto.systems/v1/event";
|
|
31
|
+
// Override anytime with GREAT_CTO_TELEMETRY_ENDPOINT (e.g. to point at a
|
|
32
|
+
// workers.dev URL during local worker development).
|
|
33
33
|
const TELEMETRY_TIMEOUT_MS = 1000;
|
|
34
34
|
// Allowlist — anything else is dropped client-side and server-side.
|
|
35
35
|
const ALLOWED_COMMANDS = new Set([
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Standalone entry point for the detached background update-check process.
|
|
2
|
+
// Spawned by update-check.ts (spawnBackgroundCheck) via `node dist/update-check-worker.js`.
|
|
3
|
+
// Deliberately tiny: just calls refreshCache() and exits. stdio is "ignore"
|
|
4
|
+
// from the parent, so nothing here should assume a console is attached.
|
|
5
|
+
import { refreshCache } from "./update-check.js";
|
|
6
|
+
await refreshCache();
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Update notifier — classic update-notifier pattern, zero dependency.
|
|
2
|
+
//
|
|
3
|
+
// Design:
|
|
4
|
+
// - Foreground NEVER waits on the network. It only reads a local cache
|
|
5
|
+
// file (~/.great_cto/update-check.json) written by a previous run.
|
|
6
|
+
// - When the cache is missing or stale (>24h), a DETACHED background
|
|
7
|
+
// process is spawned (node, stdio ignored, unref'd) that hits the npm
|
|
8
|
+
// registry and refreshes the cache for the *next* invocation to read.
|
|
9
|
+
// - This means the very first run (and the first run after 24h) never
|
|
10
|
+
// shows a hint — the hint appears one run later, once the cache is
|
|
11
|
+
// warm. That's the standard update-notifier trade-off: never block,
|
|
12
|
+
// never slow down the command the user actually asked for.
|
|
13
|
+
//
|
|
14
|
+
// Registry endpoint: https://registry.npmjs.org/-/package/great-cto/dist-tags
|
|
15
|
+
// Verified with curl to return only `{"latest":"x.y.z"}` — much smaller
|
|
16
|
+
// than the full package document (https://registry.npmjs.org/great-cto),
|
|
17
|
+
// which would pull the entire versions/times history.
|
|
18
|
+
//
|
|
19
|
+
// Privacy: read-only GET against the public npm registry, equivalent to the
|
|
20
|
+
// request `npm install` already performs. No project data, no PII, nothing
|
|
21
|
+
// user-specific is sent. See docs/PRIVACY.md.
|
|
22
|
+
//
|
|
23
|
+
// Opt-out: GREAT_CTO_NO_UPDATE_CHECK=1 (checked by both the foreground read
|
|
24
|
+
// and the background refresh entry point).
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { spawn } from "node:child_process";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { dirname, join } from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { semverDescending } from "./semver.js";
|
|
31
|
+
export const REGISTRY_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/great-cto/dist-tags";
|
|
32
|
+
export const CACHE_FRESH_MS = 24 * 60 * 60 * 1000; // 24h
|
|
33
|
+
export const PACKAGE_NAME = "great-cto";
|
|
34
|
+
/** Commands where stdout/stderr are machine-parsed — never print a hint, never spawn a check. */
|
|
35
|
+
export const PROTOCOL_SENSITIVE_COMMANDS = new Set([
|
|
36
|
+
"mcp", // stdio JSON-RPC protocol — any stray byte on stdout/stderr breaks the client
|
|
37
|
+
"worker", // long-running daemon; status is polled programmatically
|
|
38
|
+
"task", // machine-invoked task runner (spawned by worker)
|
|
39
|
+
]);
|
|
40
|
+
function defaultCachePath() {
|
|
41
|
+
return join(homedir(), ".great_cto", "update-check.json");
|
|
42
|
+
}
|
|
43
|
+
/** Resolve the cache file path — GREAT_CTO_HOME lets tests/worker isolate state, same convention as worker.ts/task-queue.ts. */
|
|
44
|
+
export function cachePath() {
|
|
45
|
+
const base = process.env.GREAT_CTO_HOME || join(homedir(), ".great_cto");
|
|
46
|
+
return join(base, "update-check.json");
|
|
47
|
+
}
|
|
48
|
+
/** Pure function: is a suppression condition active? Fail-open to "suppressed" on any ambiguity. */
|
|
49
|
+
export function isSuppressed(opts = {}) {
|
|
50
|
+
const env = opts.env ?? process.env;
|
|
51
|
+
if (env.CI != null && env.CI !== "" && env.CI !== "0" && env.CI !== "false")
|
|
52
|
+
return true;
|
|
53
|
+
if (env.GREAT_CTO_NO_UPDATE_CHECK === "1")
|
|
54
|
+
return true;
|
|
55
|
+
if (opts.command && PROTOCOL_SENSITIVE_COMMANDS.has(opts.command))
|
|
56
|
+
return true;
|
|
57
|
+
if (opts.stderrIsTTY === false)
|
|
58
|
+
return true;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
/** Pure function: read + parse cache JSON. Returns null on any error (missing/corrupt). */
|
|
62
|
+
export function readCache(readFileFn = (p) => readFileSync(p, "utf8"), path = cachePath()) {
|
|
63
|
+
try {
|
|
64
|
+
const raw = readFileFn(path);
|
|
65
|
+
const parsed = JSON.parse(raw);
|
|
66
|
+
if (typeof parsed.checkedAt !== "string" || typeof parsed.latest !== "string")
|
|
67
|
+
return null;
|
|
68
|
+
return { checkedAt: parsed.checkedAt, latest: parsed.latest };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Pure function: is a cache entry still fresh (<24h old)? */
|
|
75
|
+
export function isCacheFresh(cache, now = Date.now(), freshMs = CACHE_FRESH_MS) {
|
|
76
|
+
if (!cache)
|
|
77
|
+
return false;
|
|
78
|
+
const checkedAtMs = Date.parse(cache.checkedAt);
|
|
79
|
+
if (Number.isNaN(checkedAtMs))
|
|
80
|
+
return false;
|
|
81
|
+
return now - checkedAtMs < freshMs;
|
|
82
|
+
}
|
|
83
|
+
/** Pure function: does `latest` represent a newer version than `current`? */
|
|
84
|
+
export function isNewerVersion(current, latest) {
|
|
85
|
+
if (!current || !latest || current === "unknown")
|
|
86
|
+
return false;
|
|
87
|
+
// semverDescending(a, b) < 0 means a > b (descending sort). latest > current => descending(latest, current) < 0.
|
|
88
|
+
return semverDescending(latest, current) < 0;
|
|
89
|
+
}
|
|
90
|
+
/** Pure function: build the one-line stderr hint text. `styler` lets callers inject color helpers (matches ui.ts). */
|
|
91
|
+
export function formatHint(current, latest, styler = {
|
|
92
|
+
cyan: (s) => s,
|
|
93
|
+
dim: (s) => s,
|
|
94
|
+
bold: (s) => s,
|
|
95
|
+
}) {
|
|
96
|
+
return `${styler.dim("update available:")} ${styler.dim(current)} ${styler.dim("→")} ${styler.bold(styler.cyan(latest))} ${styler.dim(`run ${styler.cyan("npm i -g great-cto")} to upgrade`)}`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Foreground entry point. Call once per CLI invocation, after the command's
|
|
100
|
+
* normal output has been printed. Never throws, never awaits network I/O.
|
|
101
|
+
*
|
|
102
|
+
* - If suppressed: no-op.
|
|
103
|
+
* - If cache is fresh: print hint (if newer) synchronously, no spawn.
|
|
104
|
+
* - If cache is missing/stale: spawn a detached background refresh and
|
|
105
|
+
* return immediately (no hint this run — the cache isn't warm yet).
|
|
106
|
+
*/
|
|
107
|
+
export function checkForUpdate(opts) {
|
|
108
|
+
try {
|
|
109
|
+
const env = opts.env ?? process.env;
|
|
110
|
+
const stderrIsTTY = opts.stderrIsTTY ?? Boolean(process.stderr.isTTY);
|
|
111
|
+
if (isSuppressed({ env, command: opts.command, stderrIsTTY }))
|
|
112
|
+
return;
|
|
113
|
+
const cache = readCache();
|
|
114
|
+
if (isCacheFresh(cache, opts.now)) {
|
|
115
|
+
if (cache && isNewerVersion(opts.currentVersion, cache.latest)) {
|
|
116
|
+
printHint(opts.currentVersion, cache.latest);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
spawnBackgroundCheck();
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Fail-silent — an update hint must never break or slow down a real command.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function printHint(current, latest) {
|
|
127
|
+
try {
|
|
128
|
+
// Local, minimal color helpers mirroring ui.ts's NO_COLOR-aware wrap(),
|
|
129
|
+
// but gated on stderr TTY (ui.ts gates on stdout, which is the wrong
|
|
130
|
+
// stream for a hint that must print to stderr).
|
|
131
|
+
const useColor = Boolean(process.stderr.isTTY) && process.env.NO_COLOR !== "1";
|
|
132
|
+
const wrap = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
133
|
+
const styler = { cyan: wrap("36"), dim: wrap("2"), bold: wrap("1") };
|
|
134
|
+
process.stderr.write(formatHint(current, latest, styler) + "\n");
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* fail-silent */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Spawn a detached, unref'd background process that refreshes the cache. Never awaited by the caller. */
|
|
141
|
+
function spawnBackgroundCheck() {
|
|
142
|
+
try {
|
|
143
|
+
// Test-only escape hatch: unit tests exercise checkForUpdate()'s control
|
|
144
|
+
// flow (including "cache missing/stale -> spawn") without ever launching
|
|
145
|
+
// a real child process or touching the network. Never set in production.
|
|
146
|
+
if (process.env.GREAT_CTO_UPDATE_CHECK_NO_SPAWN === "1")
|
|
147
|
+
return;
|
|
148
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
149
|
+
const entry = join(here, "update-check-worker.js");
|
|
150
|
+
if (!existsSync(entry))
|
|
151
|
+
return; // dist not built (e.g. running from src in dev) — skip rather than crash
|
|
152
|
+
const child = spawn(process.execPath, [entry], {
|
|
153
|
+
detached: true,
|
|
154
|
+
stdio: "ignore",
|
|
155
|
+
env: process.env,
|
|
156
|
+
});
|
|
157
|
+
child.unref();
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
/* fail-silent — network/update check is best-effort only */
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Background worker body — invoked as a standalone script by
|
|
165
|
+
* update-check-worker.ts. Fetches the registry dist-tags and writes the
|
|
166
|
+
* cache. Exported so it's independently unit-testable with an injected
|
|
167
|
+
* fetcher, without needing to actually spawn a child process.
|
|
168
|
+
*/
|
|
169
|
+
export async function refreshCache(fetchFn = fetch, writeFileFn = writeFileSync, mkdirFn = (p) => mkdirSync(p, { recursive: true }), path = cachePath()) {
|
|
170
|
+
try {
|
|
171
|
+
const ctrl = new AbortController();
|
|
172
|
+
const timer = setTimeout(() => ctrl.abort(), 3000);
|
|
173
|
+
let latest;
|
|
174
|
+
try {
|
|
175
|
+
const res = await fetchFn(REGISTRY_DIST_TAGS_URL, { signal: ctrl.signal });
|
|
176
|
+
if (res.ok) {
|
|
177
|
+
const body = (await res.json());
|
|
178
|
+
latest = body.latest;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
}
|
|
184
|
+
if (!latest)
|
|
185
|
+
return;
|
|
186
|
+
mkdirFn(dirname(path));
|
|
187
|
+
const cache = { checkedAt: new Date().toISOString(), latest };
|
|
188
|
+
writeFileFn(path, JSON.stringify(cache, null, 2) + "\n");
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
/* offline / registry unreachable — fail-silent, next invocation retries */
|
|
192
|
+
}
|
|
193
|
+
}
|
package/package.json
CHANGED