great-cto 2.78.0 → 2.80.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.80.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";
@@ -54,6 +55,7 @@ function parseArgs(argv) {
54
55
  useLlm: false,
55
56
  noLlm: false,
56
57
  host: null,
58
+ upgradeSelf: false,
57
59
  positional: [],
58
60
  };
59
61
  const rest = [];
@@ -119,6 +121,8 @@ function parseArgs(argv) {
119
121
  args.command = "report";
120
122
  else if (a === "upgrade")
121
123
  args.command = "upgrade";
124
+ else if (a === "--self")
125
+ args.upgradeSelf = true;
122
126
  else if (a === "task") {
123
127
  args.command = "task";
124
128
  args.taskArgs = argv.slice(i + 1);
@@ -355,6 +359,7 @@ ${bold("Usage:")}
355
359
  npx great-cto adapt [--dry-run]
356
360
  npx great-cto serve [--port 3142]
357
361
  npx great-cto upgrade [superpowers|beads] Re-clone companions to latest tag + re-apply overlays
362
+ npx great-cto upgrade --self Upgrade the great-cto CLI itself, in place
358
363
  npx great-cto help
359
364
  npx great-cto version
360
365
 
@@ -385,6 +390,7 @@ ${bold("Upgrade:")}
385
390
  great-cto upgrade Upgrade superpowers + beads to latest, re-apply critic overlays
386
391
  great-cto upgrade superpowers Upgrade superpowers only
387
392
  great-cto upgrade beads Upgrade beads only
393
+ great-cto upgrade --self Upgrade the great-cto CLI itself (also: upgrade self)
388
394
  ${dim("(Safe to run any time — idempotent if already on latest)")}
389
395
 
390
396
  ${bold("CI gate:")}
@@ -998,12 +1004,36 @@ function installPrePushHook(projectDir) {
998
1004
  // Best-effort: hook failure must never block init
999
1005
  }
1000
1006
  }
1001
- async function runUpgrade(rawArgv) {
1007
+ async function runSelfUpgrade() {
1008
+ const { performSelfUpgrade, resolveRunningBinaryPath } = await import("./self-upgrade.js");
1009
+ const currentVersion = getCliVersion();
1010
+ const binaryPath = resolveRunningBinaryPath();
1011
+ log(`${bold("great-cto upgrade --self")} — current version ${cyan(currentVersion)}`);
1012
+ log(dim(` running binary: ${binaryPath}`));
1013
+ log("");
1014
+ const result = performSelfUpgrade({ currentVersion, binaryPath });
1015
+ if (result.manager === "npx") {
1016
+ log(result.message);
1017
+ return 0;
1018
+ }
1019
+ if (result.exitCode !== 0) {
1020
+ error(result.message);
1021
+ return result.exitCode;
1022
+ }
1023
+ success(result.message);
1024
+ return 0;
1025
+ }
1026
+ async function runUpgrade(rawArgv, args) {
1027
+ // `upgrade --self` / `upgrade self` — upgrade the CLI itself, not companion plugins.
1028
+ const upgradeIdx = rawArgv.indexOf("upgrade");
1029
+ const firstArgAfterUpgrade = upgradeIdx >= 0 ? rawArgv[upgradeIdx + 1] : undefined;
1030
+ if (args.upgradeSelf || firstArgAfterUpgrade === "self") {
1031
+ return runSelfUpgrade();
1032
+ }
1002
1033
  const { upgradePlugin, upgradeAll } = await import("./upgrade.js");
1003
1034
  const { COMPANION_PLUGINS } = await import("./companion.js");
1004
1035
  // Optional positional: great-cto upgrade [plugin-name]
1005
- const upgradeIdx = rawArgv.indexOf("upgrade");
1006
- const pluginArg = upgradeIdx >= 0 ? rawArgv[upgradeIdx + 1] : undefined;
1036
+ const pluginArg = firstArgAfterUpgrade;
1007
1037
  const targetPlugin = pluginArg && !pluginArg.startsWith("--") ? pluginArg : undefined;
1008
1038
  let results;
1009
1039
  if (targetPlugin) {
@@ -1047,6 +1077,18 @@ async function main() {
1047
1077
  });
1048
1078
  }
1049
1079
  catch { /* telemetry never affects the exit */ }
1080
+ // Update hint / prompt — printed after the command's own output, never
1081
+ // blocks on network (reads a local cache; spawns a detached refresh if
1082
+ // stale). May await a single Y/n keystroke for up to 15s, but ONLY when
1083
+ // a newer version is already cached, stderr+stdin are TTYs, and this
1084
+ // version hasn't been prompted before (see shouldPrompt() in
1085
+ // update-check.ts). Excluded automatically for mcp/worker/task via
1086
+ // PROTOCOL_SENSITIVE_COMMANDS inside checkForUpdate — worker/task don't
1087
+ // even route through finish().
1088
+ try {
1089
+ await checkForUpdate({ currentVersion: getCliVersion(), command: args.command });
1090
+ }
1091
+ catch { /* update hint never affects the exit */ }
1050
1092
  process.exit(code);
1051
1093
  };
1052
1094
  // `great-cto telemetry <on|off|status|whoami>` — inspect / toggle, never sends.
@@ -1180,7 +1222,7 @@ async function main() {
1180
1222
  }
1181
1223
  if (args.command === "upgrade") {
1182
1224
  try {
1183
- const code = await runUpgrade(rawArgv);
1225
+ const code = await runUpgrade(rawArgv, args);
1184
1226
  await finish(code);
1185
1227
  }
1186
1228
  catch (e) {
@@ -0,0 +1,156 @@
1
+ /**
2
+ * self-upgrade.ts — `great-cto upgrade --self` (and `great-cto upgrade self`).
3
+ *
4
+ * Upgrades the great-cto CLI itself, in place, by detecting HOW the running
5
+ * binary was installed and running the matching package-manager command.
6
+ *
7
+ * Detection is driven entirely by the resolved (symlinks-followed) path to
8
+ * the currently-running binary — never by "which npm is on PATH" or similar,
9
+ * because a machine can have multiple package managers and multiple install
10
+ * prefixes at once (e.g. a Volta-style toolchain manager alongside a plain
11
+ * nvm-style Node version manager). The whole point of resolving from
12
+ * process.argv[1] is to upgrade the exact binary that is actually running,
13
+ * not just "some" great-cto install found elsewhere on the machine.
14
+ *
15
+ * Detection is a pure function (binaryPath: string -> Plan) so it's fully
16
+ * unit-testable without spawning any process or touching the filesystem.
17
+ */
18
+ import { realpathSync } from "node:fs";
19
+ import { spawnSync } from "node:child_process";
20
+ import { sep } from "node:path";
21
+ /**
22
+ * Pure function: given the resolved path to the running binary, decide which
23
+ * package manager "owns" it and what command would upgrade it in place.
24
+ *
25
+ * Resolution order (first match wins):
26
+ * 1. npx / npm exec cache -> no-op (npx always runs latest already)
27
+ * 2. Volta shim/install directory -> `volta install great-cto@latest`
28
+ * 3. pnpm global install directory -> `pnpm add -g great-cto@latest`
29
+ * 4. anything else (plain npm/nvm) -> `npm install -g great-cto@latest --prefix <prefix>`
30
+ *
31
+ * `pathSep` is injectable purely so tests can exercise POSIX-style paths
32
+ * deterministically regardless of the host OS running the test.
33
+ */
34
+ export function detectInstall(binaryPath, pathSep = sep) {
35
+ const normalized = binaryPath.split("\\").join("/");
36
+ // 1. npx / npm exec cache — npx always fetches+runs the latest version on
37
+ // every invocation, so there is nothing to "upgrade": the next `npx
38
+ // great-cto` already gets the newest release. Installing here would be
39
+ // a no-op at best and would litter the npx cache at worst.
40
+ if (normalized.includes("/_npx/") || normalized.includes("/.npm/_npx/") || /\/\.npm\/[^/]*_cacache/.test(normalized)) {
41
+ return { manager: "npx", prefix: null, command: null };
42
+ }
43
+ // 2. Volta — toolchain manager that shims global installs under ~/.volta
44
+ // (real-world installs use the dotfile form ".volta"; match both so the
45
+ // detector works against the actual directory Volta creates).
46
+ if (normalized.includes("/volta/") || normalized.includes("/.volta/")) {
47
+ return { manager: "volta", prefix: null, command: ["volta", "install", "great-cto@latest"] };
48
+ }
49
+ // 3. pnpm — global installs live under a pnpm-managed store/bin directory
50
+ // (real-world installs use the dotfile form ".pnpm"/".local/share/pnpm";
51
+ // match both bare and dotfile forms for the same reason as Volta above).
52
+ if (normalized.includes("/pnpm/") || normalized.includes("/.pnpm/")) {
53
+ return { manager: "pnpm", prefix: null, command: ["pnpm", "add", "-g", "great-cto@latest"] };
54
+ }
55
+ // 4. Plain npm (including nvm-style per-version prefixes, and custom
56
+ // prefixes like a dotfile-managed toolchain directory). Derive the
57
+ // global prefix from the binary path: a global npm bin shim always
58
+ // lives at "<prefix>/bin/<name>", so the prefix is the parent of the
59
+ // "bin" directory. For nvm-style layouts (~/.nvm/versions/node/vX.Y.Z/bin/great-cto)
60
+ // that parent *is* the version's own prefix, which is exactly right —
61
+ // npm install -g scoped with --prefix installs into that same version's
62
+ // global node_modules, so the binary that's running gets upgraded, not
63
+ // some other prefix on the machine.
64
+ const prefix = derivePrefixFromBinPath(normalized, pathSep);
65
+ if (prefix) {
66
+ return { manager: "npm", prefix, command: ["npm", "install", "-g", "great-cto@latest", "--prefix", prefix] };
67
+ }
68
+ // Derivation failed (e.g. binary isn't inside a "bin" dir at all) — fall
69
+ // back to a plain global install and let npm pick whatever prefix is
70
+ // currently configured (npm config get prefix / NPM_CONFIG_PREFIX).
71
+ return { manager: "npm", prefix: null, command: ["npm", "install", "-g", "great-cto@latest"] };
72
+ }
73
+ /** …/bin/great-cto -> prefix is the directory above "bin". Returns null if no "bin" segment is found. */
74
+ function derivePrefixFromBinPath(normalizedPath, _pathSep) {
75
+ const parts = normalizedPath.split("/").filter((p) => p !== "");
76
+ const binIdx = parts.lastIndexOf("bin");
77
+ if (binIdx <= 0)
78
+ return null; // no "bin" dir, or "bin" is the root itself
79
+ const prefixParts = parts.slice(0, binIdx);
80
+ const isAbsolute = normalizedPath.startsWith("/");
81
+ return (isAbsolute ? "/" : "") + prefixParts.join("/");
82
+ }
83
+ /**
84
+ * Resolve the real (symlinks-followed) path to the currently-running binary.
85
+ * Exported so callers/tests can compute it once and pass it through.
86
+ */
87
+ export function resolveRunningBinaryPath(argv1 = process.argv[1] ?? "") {
88
+ try {
89
+ return realpathSync(argv1);
90
+ }
91
+ catch {
92
+ return argv1;
93
+ }
94
+ }
95
+ /**
96
+ * Perform the actual self-upgrade: run the install command synchronously
97
+ * with inherited stdio, then verify by spawning the SAME binary path with
98
+ * --version.
99
+ *
100
+ * Never throws — all failure modes are captured in the returned result so
101
+ * callers can decide the process exit code themselves.
102
+ */
103
+ export function performSelfUpgrade(opts) {
104
+ const binaryPath = opts.binaryPath ?? resolveRunningBinaryPath();
105
+ const spawnFn = opts.spawnFn ?? spawnSync;
106
+ const plan = detectInstall(binaryPath);
107
+ if (plan.manager === "npx" || plan.command === null) {
108
+ return {
109
+ exitCode: 0,
110
+ manager: "npx",
111
+ prefix: null,
112
+ oldVersion: opts.currentVersion,
113
+ newVersion: opts.currentVersion,
114
+ message: "running via npx — npx always fetches the latest release on every run, nothing to upgrade.",
115
+ };
116
+ }
117
+ const [cmd, ...cmdArgs] = plan.command;
118
+ const installRun = spawnFn(cmd, cmdArgs, { stdio: "inherit" });
119
+ if (installRun.error) {
120
+ return {
121
+ exitCode: 1,
122
+ manager: plan.manager,
123
+ prefix: plan.prefix,
124
+ oldVersion: opts.currentVersion,
125
+ newVersion: null,
126
+ message: `self-upgrade failed: could not run '${plan.command.join(" ")}' — ${installRun.error.message}`,
127
+ };
128
+ }
129
+ const installExitCode = installRun.status ?? 1;
130
+ if (installExitCode !== 0) {
131
+ return {
132
+ exitCode: installExitCode,
133
+ manager: plan.manager,
134
+ prefix: plan.prefix,
135
+ oldVersion: opts.currentVersion,
136
+ newVersion: null,
137
+ message: `self-upgrade failed: '${plan.command.join(" ")}' exited with code ${installExitCode}`,
138
+ };
139
+ }
140
+ // Verify: spawn the SAME binary path with --version and report old -> new.
141
+ const verifyRun = spawnFn(process.execPath, [binaryPath, "--version"], { encoding: "utf8" });
142
+ const newVersion = verifyRun.status === 0 ? (verifyRun.stdout ?? "").trim() || null : null;
143
+ const prefixNote = plan.prefix ? ` (prefix: ${plan.prefix})` : "";
144
+ return {
145
+ exitCode: 0,
146
+ manager: plan.manager,
147
+ prefix: plan.prefix,
148
+ oldVersion: opts.currentVersion,
149
+ newVersion,
150
+ message: newVersion
151
+ ? `upgraded via ${plan.manager}${prefixNote}: ${opts.currentVersion} → ${newVersion}`
152
+ : `install via ${plan.manager}${prefixNote} succeeded, but could not verify the new version (--version check failed)`,
153
+ };
154
+ }
155
+ // Re-export for callers that only need the prefix-derivation logic directly.
156
+ export { derivePrefixFromBinPath as _derivePrefixFromBinPath };
@@ -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,323 @@
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
+ //
26
+ // Interactive prompt: when a newer version is found AND both stderr and
27
+ // stdin are TTYs AND the check isn't suppressed AND this specific version
28
+ // hasn't been prompted for before, checkForUpdate() asks `Update to X? [Y/n]`
29
+ // on stderr with a 15s timeout (node:readline). "Yes" runs the same
30
+ // self-upgrade code path as `great-cto upgrade --self`, in-process. Either
31
+ // way the chosen (or timed-out) version is recorded in the cache file's
32
+ // `promptedFor` field so a given release is prompted for AT MOST ONCE —
33
+ // every later run falls back to the one-line hint.
34
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
35
+ import { spawn } from "node:child_process";
36
+ import { createInterface } from "node:readline";
37
+ import { homedir } from "node:os";
38
+ import { dirname, join } from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+ import { semverDescending } from "./semver.js";
41
+ export const REGISTRY_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/great-cto/dist-tags";
42
+ export const CACHE_FRESH_MS = 24 * 60 * 60 * 1000; // 24h
43
+ export const PACKAGE_NAME = "great-cto";
44
+ export const PROMPT_TIMEOUT_MS = 15_000;
45
+ /** Commands where stdout/stderr are machine-parsed — never print a hint, never spawn a check. */
46
+ export const PROTOCOL_SENSITIVE_COMMANDS = new Set([
47
+ "mcp", // stdio JSON-RPC protocol — any stray byte on stdout/stderr breaks the client
48
+ "worker", // long-running daemon; status is polled programmatically
49
+ "task", // machine-invoked task runner (spawned by worker)
50
+ ]);
51
+ function defaultCachePath() {
52
+ return join(homedir(), ".great_cto", "update-check.json");
53
+ }
54
+ /** Resolve the cache file path — GREAT_CTO_HOME lets tests/worker isolate state, same convention as worker.ts/task-queue.ts. */
55
+ export function cachePath() {
56
+ const base = process.env.GREAT_CTO_HOME || join(homedir(), ".great_cto");
57
+ return join(base, "update-check.json");
58
+ }
59
+ /** Pure function: is a suppression condition active? Fail-open to "suppressed" on any ambiguity. */
60
+ export function isSuppressed(opts = {}) {
61
+ const env = opts.env ?? process.env;
62
+ if (env.CI != null && env.CI !== "" && env.CI !== "0" && env.CI !== "false")
63
+ return true;
64
+ if (env.GREAT_CTO_NO_UPDATE_CHECK === "1")
65
+ return true;
66
+ if (opts.command && PROTOCOL_SENSITIVE_COMMANDS.has(opts.command))
67
+ return true;
68
+ if (opts.stderrIsTTY === false)
69
+ return true;
70
+ return false;
71
+ }
72
+ /**
73
+ * Pure function: should the interactive `Update to X? [Y/n]` prompt fire?
74
+ *
75
+ * All of the following must hold:
76
+ * - not suppressed (isSuppressed() — reuses the same CI/opt-out/protocol/TTY rules)
77
+ * - stdin is ALSO a TTY (isSuppressed only checks stderr; a prompt needs to
78
+ * read a keystroke, so a piped/redirected stdin must never block on it)
79
+ * - a newer version is actually available
80
+ * - this exact `latest` version hasn't already been prompted for
81
+ * (cache.promptedFor === latest means "already asked, at most once per release")
82
+ */
83
+ export function shouldPrompt(opts) {
84
+ if (isSuppressed({ env: opts.env, command: opts.command, stderrIsTTY: opts.stderrIsTTY }))
85
+ return false;
86
+ if (opts.stdinIsTTY !== true)
87
+ return false;
88
+ if (!opts.cache)
89
+ return false;
90
+ if (!isNewerVersion(opts.currentVersion, opts.cache.latest))
91
+ return false;
92
+ if (opts.cache.promptedFor === opts.cache.latest)
93
+ return false;
94
+ return true;
95
+ }
96
+ /** Pure function: read + parse cache JSON. Returns null on any error (missing/corrupt). */
97
+ export function readCache(readFileFn = (p) => readFileSync(p, "utf8"), path = cachePath()) {
98
+ try {
99
+ const raw = readFileFn(path);
100
+ const parsed = JSON.parse(raw);
101
+ if (typeof parsed.checkedAt !== "string" || typeof parsed.latest !== "string")
102
+ return null;
103
+ const cache = { checkedAt: parsed.checkedAt, latest: parsed.latest };
104
+ if (typeof parsed.promptedFor === "string")
105
+ cache.promptedFor = parsed.promptedFor;
106
+ return cache;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ /**
113
+ * Pure function: write `promptedFor` into the cache file, preserving the
114
+ * other fields. Best-effort — swallows write errors (a failed write just
115
+ * means the prompt might fire again next run, which is safe, not silent
116
+ * data loss).
117
+ */
118
+ export function recordPromptedFor(version, readFileFn = (p) => readFileSync(p, "utf8"), writeFileFn = writeFileSync, path = cachePath()) {
119
+ try {
120
+ const existing = readCache(readFileFn, path);
121
+ const cache = existing
122
+ ? { ...existing, promptedFor: version }
123
+ : { checkedAt: new Date().toISOString(), latest: version, promptedFor: version };
124
+ writeFileFn(path, JSON.stringify(cache, null, 2) + "\n");
125
+ }
126
+ catch {
127
+ /* best-effort — never break the CLI over a cache write failure */
128
+ }
129
+ }
130
+ /** Pure function: is a cache entry still fresh (<24h old)? */
131
+ export function isCacheFresh(cache, now = Date.now(), freshMs = CACHE_FRESH_MS) {
132
+ if (!cache)
133
+ return false;
134
+ const checkedAtMs = Date.parse(cache.checkedAt);
135
+ if (Number.isNaN(checkedAtMs))
136
+ return false;
137
+ return now - checkedAtMs < freshMs;
138
+ }
139
+ /** Pure function: does `latest` represent a newer version than `current`? */
140
+ export function isNewerVersion(current, latest) {
141
+ if (!current || !latest || current === "unknown")
142
+ return false;
143
+ // semverDescending(a, b) < 0 means a > b (descending sort). latest > current => descending(latest, current) < 0.
144
+ return semverDescending(latest, current) < 0;
145
+ }
146
+ /** Pure function: build the one-line stderr hint text. `styler` lets callers inject color helpers (matches ui.ts). */
147
+ export function formatHint(current, latest, styler = {
148
+ cyan: (s) => s,
149
+ dim: (s) => s,
150
+ bold: (s) => s,
151
+ }) {
152
+ return `${styler.dim("update available:")} ${styler.dim(current)} ${styler.dim("→")} ${styler.bold(styler.cyan(latest))} ${styler.dim(`run ${styler.cyan("great-cto upgrade --self")} to upgrade`)}`;
153
+ }
154
+ /**
155
+ * Ask `question` on stderr and read one line of input from stdin via
156
+ * node:readline, with a hard timeout. Resolves `true` for an empty/"y"/"Y"
157
+ * answer, `false` for anything else INCLUDING a timeout or "n". Never
158
+ * rejects. Only called when both stderr and stdin are already confirmed
159
+ * TTYs (see shouldPrompt), so this never blocks a piped/CI invocation.
160
+ *
161
+ * `input`/`output` are injectable (defaulting to the real process streams)
162
+ * so tests can exercise real readline behavior — including the timeout —
163
+ * against isolated streams instead of monkey-patching process.stdin.
164
+ */
165
+ export function promptYesNo(question, timeoutMs = PROMPT_TIMEOUT_MS, input = process.stdin, output = process.stderr) {
166
+ return new Promise((resolve) => {
167
+ const rl = createInterface({ input, output, terminal: false });
168
+ let settled = false;
169
+ const finish = (answer) => {
170
+ if (settled)
171
+ return;
172
+ settled = true;
173
+ clearTimeout(timer);
174
+ rl.close();
175
+ resolve(answer);
176
+ };
177
+ // Intentionally NOT unref'd: this timer is the only thing standing
178
+ // between "user is deciding" and "give up and fall back to the hint" —
179
+ // it must keep the event loop alive for up to timeoutMs so the prompt
180
+ // reliably resolves (and, on success, so recordPromptedFor()/finish()
181
+ // in main.ts still run). It is always cleared on any resolution path
182
+ // (answer or timeout), so it never actually blocks exit beyond that.
183
+ const timer = setTimeout(() => finish(false), timeoutMs);
184
+ try {
185
+ rl.question(question, (answer) => {
186
+ const normalized = answer.trim().toLowerCase();
187
+ finish(normalized === "" || normalized === "y" || normalized === "yes");
188
+ });
189
+ }
190
+ catch {
191
+ finish(false);
192
+ }
193
+ });
194
+ }
195
+ /**
196
+ * Foreground entry point. Call once per CLI invocation, after the command's
197
+ * normal output has been printed. Never throws.
198
+ *
199
+ * - If suppressed: no-op.
200
+ * - If cache is fresh:
201
+ * - if a newer version exists and shouldPrompt() says yes, show the
202
+ * interactive `Update to X? [Y/n]` prompt (15s timeout) and, on yes,
203
+ * run the self-upgrade in-process before returning.
204
+ * - otherwise, print the one-line hint (if newer) exactly as before.
205
+ * - If cache is missing/stale: spawn a detached background refresh and
206
+ * return immediately (no hint/prompt this run — the cache isn't warm yet).
207
+ *
208
+ * Only the prompt path awaits anything (stdin, up to 15s); every other path
209
+ * returns synchronously-fast, matching the original never-block guarantee.
210
+ */
211
+ export async function checkForUpdate(opts) {
212
+ try {
213
+ const env = opts.env ?? process.env;
214
+ const stderrIsTTY = opts.stderrIsTTY ?? Boolean(process.stderr.isTTY);
215
+ const stdinIsTTY = opts.stdinIsTTY ?? Boolean(process.stdin.isTTY);
216
+ if (isSuppressed({ env, command: opts.command, stderrIsTTY }))
217
+ return;
218
+ const cache = readCache();
219
+ if (isCacheFresh(cache, opts.now)) {
220
+ if (!cache || !isNewerVersion(opts.currentVersion, cache.latest))
221
+ return;
222
+ if (shouldPrompt({ currentVersion: opts.currentVersion, cache, env, command: opts.command, stderrIsTTY, stdinIsTTY })) {
223
+ const accepted = await promptYesNo(`Update to ${cache.latest}? [Y/n] `);
224
+ recordPromptedFor(cache.latest);
225
+ if (accepted) {
226
+ await runSelfUpgradeInProcess(opts.currentVersion);
227
+ }
228
+ return;
229
+ }
230
+ printHint(opts.currentVersion, cache.latest);
231
+ return;
232
+ }
233
+ spawnBackgroundCheck();
234
+ }
235
+ catch {
236
+ // Fail-silent — an update hint must never break or slow down a real command.
237
+ }
238
+ }
239
+ /**
240
+ * Run the same self-upgrade code path as `great-cto upgrade --self`,
241
+ * in-process, after the user accepted the interactive prompt. Isolated into
242
+ * its own function (rather than importing self-upgrade.ts at module scope)
243
+ * so update-check.ts's own unit tests never need to touch child_process.
244
+ */
245
+ async function runSelfUpgradeInProcess(currentVersion) {
246
+ try {
247
+ const { performSelfUpgrade, resolveRunningBinaryPath } = await import("./self-upgrade.js");
248
+ const binaryPath = resolveRunningBinaryPath();
249
+ const result = performSelfUpgrade({ currentVersion, binaryPath });
250
+ process.stderr.write((result.exitCode === 0 ? result.message : `error: ${result.message}`) + "\n");
251
+ }
252
+ catch {
253
+ /* fail-silent — an accepted prompt must never crash the command that triggered it */
254
+ }
255
+ }
256
+ function printHint(current, latest) {
257
+ try {
258
+ // Local, minimal color helpers mirroring ui.ts's NO_COLOR-aware wrap(),
259
+ // but gated on stderr TTY (ui.ts gates on stdout, which is the wrong
260
+ // stream for a hint that must print to stderr).
261
+ const useColor = Boolean(process.stderr.isTTY) && process.env.NO_COLOR !== "1";
262
+ const wrap = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
263
+ const styler = { cyan: wrap("36"), dim: wrap("2"), bold: wrap("1") };
264
+ process.stderr.write(formatHint(current, latest, styler) + "\n");
265
+ }
266
+ catch {
267
+ /* fail-silent */
268
+ }
269
+ }
270
+ /** Spawn a detached, unref'd background process that refreshes the cache. Never awaited by the caller. */
271
+ function spawnBackgroundCheck() {
272
+ try {
273
+ // Test-only escape hatch: unit tests exercise checkForUpdate()'s control
274
+ // flow (including "cache missing/stale -> spawn") without ever launching
275
+ // a real child process or touching the network. Never set in production.
276
+ if (process.env.GREAT_CTO_UPDATE_CHECK_NO_SPAWN === "1")
277
+ return;
278
+ const here = dirname(fileURLToPath(import.meta.url));
279
+ const entry = join(here, "update-check-worker.js");
280
+ if (!existsSync(entry))
281
+ return; // dist not built (e.g. running from src in dev) — skip rather than crash
282
+ const child = spawn(process.execPath, [entry], {
283
+ detached: true,
284
+ stdio: "ignore",
285
+ env: process.env,
286
+ });
287
+ child.unref();
288
+ }
289
+ catch {
290
+ /* fail-silent — network/update check is best-effort only */
291
+ }
292
+ }
293
+ /**
294
+ * Background worker body — invoked as a standalone script by
295
+ * update-check-worker.ts. Fetches the registry dist-tags and writes the
296
+ * cache. Exported so it's independently unit-testable with an injected
297
+ * fetcher, without needing to actually spawn a child process.
298
+ */
299
+ export async function refreshCache(fetchFn = fetch, writeFileFn = writeFileSync, mkdirFn = (p) => mkdirSync(p, { recursive: true }), path = cachePath()) {
300
+ try {
301
+ const ctrl = new AbortController();
302
+ const timer = setTimeout(() => ctrl.abort(), 3000);
303
+ let latest;
304
+ try {
305
+ const res = await fetchFn(REGISTRY_DIST_TAGS_URL, { signal: ctrl.signal });
306
+ if (res.ok) {
307
+ const body = (await res.json());
308
+ latest = body.latest;
309
+ }
310
+ }
311
+ finally {
312
+ clearTimeout(timer);
313
+ }
314
+ if (!latest)
315
+ return;
316
+ mkdirFn(dirname(path));
317
+ const cache = { checkedAt: new Date().toISOString(), latest };
318
+ writeFileFn(path, JSON.stringify(cache, null, 2) + "\n");
319
+ }
320
+ catch {
321
+ /* offline / registry unreachable — fail-silent, next invocation retries */
322
+ }
323
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.78.0",
3
+ "version": "2.80.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",