great-cto 2.77.2 → 2.78.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.
@@ -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.77.2",
5
+ "version": "2.78.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -16,6 +16,7 @@ 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 { log } from './log.mjs';
19
20
 
20
21
  // ── Email alerts (Resend) ─────────────────────────────────────────────────
21
22
  // Dispatch model: read webhooks.json on every fire (idempotent if disabled
@@ -91,12 +92,12 @@ async function fireEmailAlert(eventName, dedupeKey, payload) {
91
92
  });
92
93
  if (!res.ok) {
93
94
  const txt = await res.text().catch(() => '');
94
- console.warn(`fireEmailAlert ${eventName}: relay HTTP ${res.status} ${txt.slice(0, 200)}`);
95
+ log.warn(`fireEmailAlert ${eventName}: relay HTTP ${res.status} ${txt.slice(0, 200)}`);
95
96
  return;
96
97
  }
97
- console.log(`fireEmailAlert: sent ${eventName} (${dedupeKey})`);
98
+ log.info(`fireEmailAlert: sent ${eventName} (${dedupeKey})`);
98
99
  } catch (e) {
99
- console.warn(`fireEmailAlert ${eventName} failed:`, e.message);
100
+ log.warn(`fireEmailAlert ${eventName} failed:`, e.message);
100
101
  }
101
102
  }
102
103
 
@@ -119,12 +120,12 @@ async function firePushAlert(eventName, dedupeKey, payload) {
119
120
  catch (e) {
120
121
  // 410 = subscription expired — browser unsubscribed, clean up
121
122
  if (e.statusCode === 410) removeSubscription(PUSH_SUBS_FILE, sub.endpoint);
122
- else console.warn(`firePushAlert send failed for ${sub.endpoint}:`, e.message);
123
+ else log.warn(`firePushAlert send failed for ${sub.endpoint}:`, e.message);
123
124
  }
124
125
  }
125
126
  fired[pushKey] = new Date().toISOString();
126
127
  writeAlertsFired(fired);
127
- } catch (e) { console.warn('firePushAlert failed:', e.message); }
128
+ } catch (e) { log.warn('firePushAlert failed:', e.message); }
128
129
  }
129
130
 
130
131
  // ── Cron: scan gates / cost / weekly digest ───────────────────────────────
@@ -172,7 +173,7 @@ function startAlertCron() {
172
173
  firePushAlert('incident.p0', dedupeKey, p0Payload);
173
174
  }
174
175
  }
175
- } catch (e) { console.warn('cron incident.p0 failed:', e.message); }
176
+ } catch (e) { log.warn('cron incident.p0 failed:', e.message); }
176
177
  }, FIVE_MIN);
177
178
 
178
179
  // gate.blocked: new BLOCKED verdict from security-officer
@@ -207,7 +208,7 @@ function startAlertCron() {
207
208
  addNotification('gate.blocked', blockedPayload);
208
209
  firePushAlert('gate.blocked', dedupeKey, blockedPayload);
209
210
  }
210
- } catch (e) { console.warn('cron gate.blocked failed:', e.message); }
211
+ } catch (e) { log.warn('cron gate.blocked failed:', e.message); }
211
212
  }, FIVE_MIN);
212
213
 
213
214
  // gate.stale: gate task open between 2h and 7 days.
@@ -238,7 +239,7 @@ function startAlertCron() {
238
239
  firePushAlert('gate.stale', dedupeKey, stalePayload);
239
240
  }
240
241
  }
241
- } catch (e) { console.warn('cron gate.stale failed:', e.message); }
242
+ } catch (e) { log.warn('cron gate.stale failed:', e.message); }
242
243
  }, FIVE_MIN);
243
244
 
244
245
  // cost.threshold: monthly LLM spend at 80% / 100% of budget
@@ -277,7 +278,7 @@ function startAlertCron() {
277
278
  firePushAlert('cost.threshold', dedupeKey, costPayload);
278
279
  }
279
280
  }
280
- } catch (e) { console.warn('cron cost.threshold failed:', e.message); }
281
+ } catch (e) { log.warn('cron cost.threshold failed:', e.message); }
281
282
  }, ONE_HOUR);
282
283
 
283
284
  // digest.weekly: Friday 09:00 UTC ± server tick
@@ -317,7 +318,7 @@ function startAlertCron() {
317
318
  addNotification('digest.weekly', weeklyPayload);
318
319
  await firePushAlert('digest.weekly', dedupeKey, weeklyPayload);
319
320
  }
320
- } catch (e) { console.warn('cron digest.weekly failed:', e.message); }
321
+ } catch (e) { log.warn('cron digest.weekly failed:', e.message); }
321
322
  }, FIVE_MIN);
322
323
 
323
324
  // digest.daily: morning summary (Mon–Fri, 08:00 UTC).
@@ -384,7 +385,7 @@ function startAlertCron() {
384
385
  addNotification('digest.daily', dailyPayload);
385
386
  await firePushAlert('digest.daily', dedupeKey, dailyPayload);
386
387
  }
387
- } catch (e) { console.warn('cron digest.daily failed:', e.message); }
388
+ } catch (e) { log.warn('cron digest.daily failed:', e.message); }
388
389
  }, FIVE_MIN);
389
390
 
390
391
  // report.daily: republish share reports every day at 09:00 UTC
@@ -401,13 +402,13 @@ function startAlertCron() {
401
402
  if (_reportRepublishDedupeSet.has(dedupeKey)) continue;
402
403
  _reportRepublishDedupeSet.add(dedupeKey);
403
404
  toggleShare(true, proj.path, true)
404
- .then(() => console.log(`report.daily: republished ${proj.slug}`))
405
- .catch(e => console.warn(`report.daily: ${proj.slug} failed: ${e.message}`));
405
+ .then(() => log.info(`report.daily: republished ${proj.slug}`))
406
+ .catch(e => log.warn(`report.daily: ${proj.slug} failed: ${e.message}`));
406
407
  }
407
- } catch (e) { console.warn('cron report.daily failed:', e.message); }
408
+ } catch (e) { log.warn('cron report.daily failed:', e.message); }
408
409
  }, FIVE_MIN);
409
410
 
410
- console.log('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)');
411
+ 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)');
411
412
  }
412
413
 
413
414
  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
- console.error('[bd-write-serialised] error:', e?.message || e);
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 ~, use directly
255
- if (slugOrPath.startsWith('/')) return slugOrPath;
256
- if (slugOrPath.startsWith('~')) return slugOrPath.replace(/^~/, os.homedir());
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 and used directly
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; using cwd as fallback.
277
- * Caller should warn the user (header + log).
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('/')) return { cwd: slugOrPath, resolved: 'path' };
282
- if (slugOrPath.startsWith('~')) {
283
- return { cwd: slugOrPath.replace(/^~/, os.homedir()), resolved: 'path' };
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 { PORT, GREAT_CTO_DIR, VAPID_KEYS_FILE, PUSH_SUBS_FILE, BUILD_VERSION } from './config.mjs';
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 match http://localhost:PORT or 127.0.0.1:PORT.
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
- const origin = req.headers.origin || req.headers.referer || '';
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(() => console.log(`report: auto-republished after gate approve (${id})`))
388
- .catch(e => console.warn(`report: republish after gate failed: ${e.message}`));
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 pattern as BH-23 on /api/projects/register.
854
- const origin = req.headers.origin || req.headers.referer || '';
855
- const expectedOrigin = `http://localhost:${PORT}`;
856
- const expectedOrigin2 = `http://127.0.0.1:${PORT}`;
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;
@@ -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
- export { csvCell, originAllowed, eventSurface, readFileSafe };
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
- if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
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
- console.log(`great_cto board → http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`);
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
- 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.`);
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) console.log(` → discovered ${n} project${n === 1 ? '' : 's'} with .great_cto/PROJECT.md`);
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
- console.log(`Port ${PORT} in use — board already running at http://localhost:${PORT}`);
135
+ log.info(`Port ${PORT} in use — board already running at http://localhost:${PORT}`);
129
136
  } else {
130
- console.error(e);
137
+ log.error(e);
131
138
  }
132
139
  process.exit(0);
133
140
  });
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://great-cto-telemetry.alexander-velikiy.workers.dev/v1/event";
31
- // Note: workers.dev URL is the temporary default until telemetry.greatcto.systems
32
- // custom domain is bound. Override anytime with GREAT_CTO_TELEMETRY_ENDPOINT.
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([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.77.2",
3
+ "version": "2.78.0",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",