great-cto 2.79.0 → 2.80.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/board/.claude-plugin/plugin.json +1 -1
- package/dist/main.js +42 -9
- package/dist/self-upgrade.js +156 -0
- package/dist/update-check.js +138 -8
- 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.80.1",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
package/dist/main.js
CHANGED
|
@@ -55,6 +55,7 @@ function parseArgs(argv) {
|
|
|
55
55
|
useLlm: false,
|
|
56
56
|
noLlm: false,
|
|
57
57
|
host: null,
|
|
58
|
+
upgradeSelf: false,
|
|
58
59
|
positional: [],
|
|
59
60
|
};
|
|
60
61
|
const rest = [];
|
|
@@ -120,6 +121,8 @@ function parseArgs(argv) {
|
|
|
120
121
|
args.command = "report";
|
|
121
122
|
else if (a === "upgrade")
|
|
122
123
|
args.command = "upgrade";
|
|
124
|
+
else if (a === "--self")
|
|
125
|
+
args.upgradeSelf = true;
|
|
123
126
|
else if (a === "task") {
|
|
124
127
|
args.command = "task";
|
|
125
128
|
args.taskArgs = argv.slice(i + 1);
|
|
@@ -356,6 +359,7 @@ ${bold("Usage:")}
|
|
|
356
359
|
npx great-cto adapt [--dry-run]
|
|
357
360
|
npx great-cto serve [--port 3142]
|
|
358
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
|
|
359
363
|
npx great-cto help
|
|
360
364
|
npx great-cto version
|
|
361
365
|
|
|
@@ -386,6 +390,7 @@ ${bold("Upgrade:")}
|
|
|
386
390
|
great-cto upgrade Upgrade superpowers + beads to latest, re-apply critic overlays
|
|
387
391
|
great-cto upgrade superpowers Upgrade superpowers only
|
|
388
392
|
great-cto upgrade beads Upgrade beads only
|
|
393
|
+
great-cto upgrade --self Upgrade the great-cto CLI itself (also: upgrade self)
|
|
389
394
|
${dim("(Safe to run any time — idempotent if already on latest)")}
|
|
390
395
|
|
|
391
396
|
${bold("CI gate:")}
|
|
@@ -999,12 +1004,36 @@ function installPrePushHook(projectDir) {
|
|
|
999
1004
|
// Best-effort: hook failure must never block init
|
|
1000
1005
|
}
|
|
1001
1006
|
}
|
|
1002
|
-
async function
|
|
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
|
+
}
|
|
1003
1033
|
const { upgradePlugin, upgradeAll } = await import("./upgrade.js");
|
|
1004
1034
|
const { COMPANION_PLUGINS } = await import("./companion.js");
|
|
1005
1035
|
// Optional positional: great-cto upgrade [plugin-name]
|
|
1006
|
-
const
|
|
1007
|
-
const pluginArg = upgradeIdx >= 0 ? rawArgv[upgradeIdx + 1] : undefined;
|
|
1036
|
+
const pluginArg = firstArgAfterUpgrade;
|
|
1008
1037
|
const targetPlugin = pluginArg && !pluginArg.startsWith("--") ? pluginArg : undefined;
|
|
1009
1038
|
let results;
|
|
1010
1039
|
if (targetPlugin) {
|
|
@@ -1048,12 +1077,16 @@ async function main() {
|
|
|
1048
1077
|
});
|
|
1049
1078
|
}
|
|
1050
1079
|
catch { /* telemetry never affects the exit */ }
|
|
1051
|
-
// Update hint — printed after the command's own output, never
|
|
1052
|
-
// network (reads a local cache; spawns a detached refresh if
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
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().
|
|
1055
1088
|
try {
|
|
1056
|
-
checkForUpdate({ currentVersion: getCliVersion(), command: args.command });
|
|
1089
|
+
await checkForUpdate({ currentVersion: getCliVersion(), command: args.command });
|
|
1057
1090
|
}
|
|
1058
1091
|
catch { /* update hint never affects the exit */ }
|
|
1059
1092
|
process.exit(code);
|
|
@@ -1189,7 +1222,7 @@ async function main() {
|
|
|
1189
1222
|
}
|
|
1190
1223
|
if (args.command === "upgrade") {
|
|
1191
1224
|
try {
|
|
1192
|
-
const code = await runUpgrade(rawArgv);
|
|
1225
|
+
const code = await runUpgrade(rawArgv, args);
|
|
1193
1226
|
await finish(code);
|
|
1194
1227
|
}
|
|
1195
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 };
|
package/dist/update-check.js
CHANGED
|
@@ -22,8 +22,18 @@
|
|
|
22
22
|
//
|
|
23
23
|
// Opt-out: GREAT_CTO_NO_UPDATE_CHECK=1 (checked by both the foreground read
|
|
24
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.
|
|
25
34
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
35
|
import { spawn } from "node:child_process";
|
|
36
|
+
import { createInterface } from "node:readline";
|
|
27
37
|
import { homedir } from "node:os";
|
|
28
38
|
import { dirname, join } from "node:path";
|
|
29
39
|
import { fileURLToPath } from "node:url";
|
|
@@ -31,6 +41,7 @@ import { semverDescending } from "./semver.js";
|
|
|
31
41
|
export const REGISTRY_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/great-cto/dist-tags";
|
|
32
42
|
export const CACHE_FRESH_MS = 24 * 60 * 60 * 1000; // 24h
|
|
33
43
|
export const PACKAGE_NAME = "great-cto";
|
|
44
|
+
export const PROMPT_TIMEOUT_MS = 15_000;
|
|
34
45
|
/** Commands where stdout/stderr are machine-parsed — never print a hint, never spawn a check. */
|
|
35
46
|
export const PROTOCOL_SENSITIVE_COMMANDS = new Set([
|
|
36
47
|
"mcp", // stdio JSON-RPC protocol — any stray byte on stdout/stderr breaks the client
|
|
@@ -58,6 +69,30 @@ export function isSuppressed(opts = {}) {
|
|
|
58
69
|
return true;
|
|
59
70
|
return false;
|
|
60
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
|
+
}
|
|
61
96
|
/** Pure function: read + parse cache JSON. Returns null on any error (missing/corrupt). */
|
|
62
97
|
export function readCache(readFileFn = (p) => readFileSync(p, "utf8"), path = cachePath()) {
|
|
63
98
|
try {
|
|
@@ -65,12 +100,33 @@ export function readCache(readFileFn = (p) => readFileSync(p, "utf8"), path = ca
|
|
|
65
100
|
const parsed = JSON.parse(raw);
|
|
66
101
|
if (typeof parsed.checkedAt !== "string" || typeof parsed.latest !== "string")
|
|
67
102
|
return null;
|
|
68
|
-
|
|
103
|
+
const cache = { checkedAt: parsed.checkedAt, latest: parsed.latest };
|
|
104
|
+
if (typeof parsed.promptedFor === "string")
|
|
105
|
+
cache.promptedFor = parsed.promptedFor;
|
|
106
|
+
return cache;
|
|
69
107
|
}
|
|
70
108
|
catch {
|
|
71
109
|
return null;
|
|
72
110
|
}
|
|
73
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
|
+
}
|
|
74
130
|
/** Pure function: is a cache entry still fresh (<24h old)? */
|
|
75
131
|
export function isCacheFresh(cache, now = Date.now(), freshMs = CACHE_FRESH_MS) {
|
|
76
132
|
if (!cache)
|
|
@@ -93,28 +149,85 @@ export function formatHint(current, latest, styler = {
|
|
|
93
149
|
dim: (s) => s,
|
|
94
150
|
bold: (s) => s,
|
|
95
151
|
}) {
|
|
96
|
-
return `${styler.dim("update available:")} ${styler.dim(current)} ${styler.dim("→")} ${styler.bold(styler.cyan(latest))} ${styler.dim(`run ${styler.cyan("
|
|
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
|
+
});
|
|
97
194
|
}
|
|
98
195
|
/**
|
|
99
196
|
* Foreground entry point. Call once per CLI invocation, after the command's
|
|
100
|
-
* normal output has been printed. Never throws
|
|
197
|
+
* normal output has been printed. Never throws.
|
|
101
198
|
*
|
|
102
199
|
* - If suppressed: no-op.
|
|
103
|
-
* - If cache is fresh:
|
|
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.
|
|
104
205
|
* - If cache is missing/stale: spawn a detached background refresh and
|
|
105
|
-
* return immediately (no hint this run — the cache isn't warm yet).
|
|
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.
|
|
106
210
|
*/
|
|
107
|
-
export function checkForUpdate(opts) {
|
|
211
|
+
export async function checkForUpdate(opts) {
|
|
108
212
|
try {
|
|
109
213
|
const env = opts.env ?? process.env;
|
|
110
214
|
const stderrIsTTY = opts.stderrIsTTY ?? Boolean(process.stderr.isTTY);
|
|
215
|
+
const stdinIsTTY = opts.stdinIsTTY ?? Boolean(process.stdin.isTTY);
|
|
111
216
|
if (isSuppressed({ env, command: opts.command, stderrIsTTY }))
|
|
112
217
|
return;
|
|
113
218
|
const cache = readCache();
|
|
114
219
|
if (isCacheFresh(cache, opts.now)) {
|
|
115
|
-
if (cache
|
|
116
|
-
|
|
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;
|
|
117
229
|
}
|
|
230
|
+
printHint(opts.currentVersion, cache.latest);
|
|
118
231
|
return;
|
|
119
232
|
}
|
|
120
233
|
spawnBackgroundCheck();
|
|
@@ -123,6 +236,23 @@ export function checkForUpdate(opts) {
|
|
|
123
236
|
// Fail-silent — an update hint must never break or slow down a real command.
|
|
124
237
|
}
|
|
125
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
|
+
}
|
|
126
256
|
function printHint(current, latest) {
|
|
127
257
|
try {
|
|
128
258
|
// Local, minimal color helpers mirroring ui.ts's NO_COLOR-aware wrap(),
|
package/package.json
CHANGED