great-cto 2.78.0 → 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.
@@ -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.78.0",
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,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 { checkForRelease, buildUpdatePayload } from './update-alert.mjs';
19
20
  import { log } from './log.mjs';
20
21
 
21
22
  // ── Email alerts (Resend) ─────────────────────────────────────────────────
@@ -134,6 +135,7 @@ async function firePushAlert(eventName, dedupeKey, payload) {
134
135
  function startAlertCron() {
135
136
  const FIVE_MIN = 5 * 60 * 1000;
136
137
  const ONE_HOUR = 60 * 60 * 1000;
138
+ const ONE_DAY = 24 * 60 * 60 * 1000;
137
139
 
138
140
  // incident.p0: open P0 task with recent activity (last 24h).
139
141
  // Older P0s are existing backlog — surfacing them via email is spam.
@@ -408,7 +410,26 @@ function startAlertCron() {
408
410
  } catch (e) { log.warn('cron report.daily failed:', e.message); }
409
411
  }, FIVE_MIN);
410
412
 
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)');
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)');
412
433
  }
413
434
 
414
435
  export { readAlertsFired, writeAlertsFired, fireEmailAlert, firePushAlert, startAlertCron };
@@ -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
+ };
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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.78.0",
3
+ "version": "2.79.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",