@higherdev/cli 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/host.js +1 -0
- package/dist/index.js +26 -3
- package/dist/out.js +1 -0
- package/dist/tui/App.js +62 -5
- package/dist/tui/launch.js +8 -1
- package/dist/update-check.js +206 -0
- package/package.json +1 -1
package/dist/host.js
CHANGED
|
@@ -171,6 +171,7 @@ export function hostRollCommand(checkout) {
|
|
|
171
171
|
"git pull --ff-only",
|
|
172
172
|
"pnpm install",
|
|
173
173
|
"systemctl --user restart hdx-runner.service",
|
|
174
|
+
"npm i -g @higherdev/cli@latest",
|
|
174
175
|
].join(" && ");
|
|
175
176
|
}
|
|
176
177
|
async function defaultSsh(address, command) {
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { approveEpic, answerDecision, cancelTicket, mergeTicket, createEpic, del
|
|
|
5
5
|
import { formatDrainStatus, hostRoll, initHost, parseHostFlags, parseHostRollFlags } from "./host.js";
|
|
6
6
|
import { login, parseLoginFlags } from "./login.js";
|
|
7
7
|
import { loadConfig } from "./config.js";
|
|
8
|
+
import { beginUpdateCheck, pendingUpdateNotice, runUpdate, shouldUpdateCheck, } from "./update-check.js";
|
|
8
9
|
import { epicProgressRows, readEpicSpec } from "./epics.js";
|
|
9
10
|
import { roadmapText } from "./roadmap.js";
|
|
10
11
|
import { banner, c, statusChip, table, truncate, usage } from "./out.js";
|
|
@@ -569,13 +570,22 @@ async function cmdHost(argv, deps = {}) {
|
|
|
569
570
|
fail(usage);
|
|
570
571
|
}
|
|
571
572
|
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
572
|
-
const
|
|
573
|
+
const args = argv.filter((arg) => arg !== "--no-update-check");
|
|
574
|
+
const [cmd, ...rest] = args;
|
|
575
|
+
const checking = shouldUpdateCheck({
|
|
576
|
+
argv, env: deps.env, isTTY: deps.isTTY,
|
|
577
|
+
});
|
|
578
|
+
if (checking)
|
|
579
|
+
beginUpdateCheck(deps);
|
|
580
|
+
let launchedTui = false;
|
|
581
|
+
let skipNotice = cmd === "update";
|
|
573
582
|
try {
|
|
574
583
|
if (!cmd) {
|
|
575
584
|
const { canLaunchApp } = await import("./tui/capability.js");
|
|
576
585
|
if (canLaunchApp()) {
|
|
586
|
+
launchedTui = true;
|
|
577
587
|
const { launchApp } = await import("./tui/launch.js");
|
|
578
|
-
await launchApp();
|
|
588
|
+
await launchApp(undefined, deps);
|
|
579
589
|
return;
|
|
580
590
|
}
|
|
581
591
|
console.log(banner());
|
|
@@ -658,8 +668,9 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
658
668
|
if (cmd === "login") {
|
|
659
669
|
const config = login(parseLoginFlags(rest));
|
|
660
670
|
console.log(`logged in to ${config.slug}`);
|
|
671
|
+
launchedTui = true;
|
|
661
672
|
const { launchApp } = await import("./tui/launch.js");
|
|
662
|
-
await launchApp(config.slug);
|
|
673
|
+
await launchApp(config.slug, deps);
|
|
663
674
|
return;
|
|
664
675
|
}
|
|
665
676
|
if (cmd === "init" || cmd === "upgrade") {
|
|
@@ -667,11 +678,23 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
667
678
|
console.log(report);
|
|
668
679
|
return;
|
|
669
680
|
}
|
|
681
|
+
if (cmd === "update") {
|
|
682
|
+
const { canLaunchApp } = await import("./tui/capability.js");
|
|
683
|
+
await runUpdate(rest, { ...deps, tuiRunning: deps.tuiRunning ?? canLaunchApp() });
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
670
686
|
fail(usage());
|
|
671
687
|
}
|
|
672
688
|
catch (error) {
|
|
673
689
|
fail(error instanceof Error ? error.message : String(error));
|
|
674
690
|
}
|
|
691
|
+
finally {
|
|
692
|
+
if (checking && !launchedTui && !skipNotice) {
|
|
693
|
+
const line = pendingUpdateNotice(deps);
|
|
694
|
+
if (line)
|
|
695
|
+
(deps.error ?? console.error)(line);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
675
698
|
}
|
|
676
699
|
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href)
|
|
677
700
|
void main();
|
package/dist/out.js
CHANGED
|
@@ -81,5 +81,6 @@ export function usage() {
|
|
|
81
81
|
` ${c.blue("hd login --url URL --api-key KEY")} client setup`,
|
|
82
82
|
` ${c.blue("hd init --host HOST [options]")} host setup`,
|
|
83
83
|
` ${c.blue("hd upgrade --host HOST [options]")} refresh host setup`,
|
|
84
|
+
` ${c.blue("hd update")} install the latest CLI`,
|
|
84
85
|
].join("\n");
|
|
85
86
|
}
|
package/dist/tui/App.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
|
|
4
4
|
import { loadConfig } from "../config.js";
|
|
5
|
+
import { pendingTuiUpdate, relaunchHd, runPromptedUpdate, skipUpdate, subscribeUpdateNotice, tuiUpdatePrompt, updatePromptAction, } from "../update-check.js";
|
|
5
6
|
import { epicProgressRows } from "../epics.js";
|
|
6
7
|
import { promptOnStdin } from "../prompt.js";
|
|
7
8
|
import { ticketNew } from "../ticket-commands.js";
|
|
@@ -35,7 +36,7 @@ import { WorkspaceLoads } from "./workspace-load.js";
|
|
|
35
36
|
let messageSeq = 0;
|
|
36
37
|
const nextId = () => `m${messageSeq++}`;
|
|
37
38
|
const tuiPrompt = (question) => promptOnStdin(question, true);
|
|
38
|
-
export function App({ initial }) {
|
|
39
|
+
export function App({ initial, availableUpdate: initialUpdate = null, updateDeps = {}, onRelaunch, }) {
|
|
39
40
|
const { exit, suspendTerminal } = useApp();
|
|
40
41
|
const { stdout } = useStdout();
|
|
41
42
|
const columns = stdout?.columns && stdout.columns > 0 ? stdout.columns : 80;
|
|
@@ -55,6 +56,9 @@ export function App({ initial }) {
|
|
|
55
56
|
const [draft, setDraft] = useState("");
|
|
56
57
|
const [busy, setBusy] = useState(false);
|
|
57
58
|
const [notice, setNotice] = useState(null);
|
|
59
|
+
const [availableUpdate, setAvailableUpdate] = useState(initialUpdate);
|
|
60
|
+
const [updateProgress, setUpdateProgress] = useState(null);
|
|
61
|
+
const [updating, setUpdating] = useState(false);
|
|
58
62
|
const [ticketKey, setTicketKey] = useState(null);
|
|
59
63
|
const [ticketOffset, setTicketOffset] = useState(0);
|
|
60
64
|
const [roadmapOffset, setRoadmapOffset] = useState(0);
|
|
@@ -83,6 +87,49 @@ export function App({ initial }) {
|
|
|
83
87
|
const acknowledgedMessages = useRef(new Set());
|
|
84
88
|
const loads = useRef(new WorkspaceLoads(initial.workspace.id));
|
|
85
89
|
editingRef.current = editing;
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
return subscribeUpdateNotice(() => setAvailableUpdate(pendingTuiUpdate(updateDeps)));
|
|
92
|
+
}, []);
|
|
93
|
+
const dismissUpdate = useCallback(() => {
|
|
94
|
+
if (!availableUpdate)
|
|
95
|
+
return;
|
|
96
|
+
try {
|
|
97
|
+
skipUpdate(availableUpdate, updateDeps);
|
|
98
|
+
setAvailableUpdate(pendingTuiUpdate(updateDeps));
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
setNotice(`Could not remember skipped update: ${error instanceof Error ? error.message : String(error)}`);
|
|
102
|
+
}
|
|
103
|
+
}, [availableUpdate, updateDeps]);
|
|
104
|
+
const acceptUpdate = useCallback(async () => {
|
|
105
|
+
if (!availableUpdate || updating)
|
|
106
|
+
return;
|
|
107
|
+
setUpdating(true);
|
|
108
|
+
setUpdateProgress(`Updating hd ${availableUpdate.current}...`);
|
|
109
|
+
try {
|
|
110
|
+
await runPromptedUpdate(availableUpdate, {
|
|
111
|
+
...updateDeps,
|
|
112
|
+
log: (line) => {
|
|
113
|
+
updateDeps.log?.(line);
|
|
114
|
+
if (line !== `hd ${availableUpdate.current}`) {
|
|
115
|
+
setUpdateProgress(`Installed ${line}. Relaunching...`);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
relaunch: () => {
|
|
119
|
+
exit();
|
|
120
|
+
if (onRelaunch)
|
|
121
|
+
onRelaunch();
|
|
122
|
+
else
|
|
123
|
+
setTimeout(updateDeps.relaunch ?? relaunchHd, 0);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
setNotice(`Update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
129
|
+
setUpdateProgress(null);
|
|
130
|
+
setUpdating(false);
|
|
131
|
+
}
|
|
132
|
+
}, [availableUpdate, exit, onRelaunch, updateDeps, updating]);
|
|
86
133
|
useEffect(() => {
|
|
87
134
|
if (messages.length > 0 || view !== "home")
|
|
88
135
|
setStarted(true);
|
|
@@ -831,8 +878,18 @@ export function App({ initial }) {
|
|
|
831
878
|
changeWorkspace, config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering,
|
|
832
879
|
selectedDecisionId, submitDecision, ticketKey, ticketCollapsed, ticketOffset, width]);
|
|
833
880
|
useInput((input, key) => {
|
|
834
|
-
if (key.ctrl && input === "c")
|
|
881
|
+
if (key.ctrl && input === "c") {
|
|
835
882
|
exit();
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (availableUpdate && !updating) {
|
|
886
|
+
const action = updatePromptAction(input, key.escape);
|
|
887
|
+
if (action === "skip")
|
|
888
|
+
dismissUpdate();
|
|
889
|
+
else if (action === "accept")
|
|
890
|
+
void acceptUpdate();
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
836
893
|
});
|
|
837
894
|
const decisions = board.decisions;
|
|
838
895
|
const answeringDecision = answering ? decisions.find((decision) => decision.id === answering) : null;
|
|
@@ -864,7 +921,7 @@ export function App({ initial }) {
|
|
|
864
921
|
const plan = planLayout({
|
|
865
922
|
rows, columns, width, splash, ready, decision: chatting ? 0 : decisionRows(decisions),
|
|
866
923
|
inFlight: chatting ? 0 : inFlight.reduce((total, message) => total + bubbleRows(message, width), 0),
|
|
867
|
-
notice: Boolean(notice), home: view === "home",
|
|
924
|
+
notice: Boolean(notice || availableUpdate || updateProgress), home: view === "home",
|
|
868
925
|
});
|
|
869
926
|
const agentsView = splitPanels(plan.panels);
|
|
870
927
|
const running = board.runs.filter((run) => run.status === "running").length;
|
|
@@ -877,11 +934,11 @@ export function App({ initial }) {
|
|
|
877
934
|
return _jsx(Bubble, { message: item.message, width: width }, item.key);
|
|
878
935
|
} }), splash ? (_jsx(Splash, { columns: columns, rows: rows, width: width, ready: ready, helpFull: plan.helpFull, animate: plan.fits, onDone: () => setReady(true) })) : null, _jsxs(Box, { flexDirection: "column", width: width, children: [view === "board" && plan.panels > 0 ? _jsx(BoardPanel, { board: board, width: width, rows: plan.panels, cursor: cursor }) : null, view === "roadmap" && plan.panels > 0 ? (_jsx(RoadmapPanel, { board: board, width: width, rows: plan.panels, offset: roadmapOffset })) : null, view === "agents" && plan.panels > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentsPanel, { board: board, width: width, rows: agentsView.top }), agentsView.bottom > 0 ? _jsxs(_Fragment, { children: [_jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: agentsView.bottom, live: running > 0 })] }) : null] })) : null, view === "feed" && plan.panels > 0 ? _jsx(FeedPanel, { entries: feed, width: width, rows: plan.panels }) : null, view === "settings" && plan.panels > 0 ? _jsx(SettingsPanel, { entries: settings, width: width, rows: plan.panels, title: "Settings", cursor: field, editing: editing }) : null, view === "inbox" && plan.panels > 0 ? _jsx(InboxPanel, { board: board, width: width, rows: plan.panels, focus: inboxFocus, selectedId: selectedDecisionId, answeringId: answering }) : null, view === "ticket" && plan.panels > 0 ? ticket
|
|
879
936
|
? _jsx(TicketPanel, { ticket: ticket, width: width, rows: plan.panels, offset: ticketOffset, collapsed: ticketCollapsed })
|
|
880
|
-
: _jsxs(Text, { color: UI.warn, children: ["No ticket ", ticketKey, " here."] }) : null, view === "home" && plan.cockpit > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Cockpit, { board: board, width: width, rows: plan.cockpit, cursor: cursor }), _jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: plan.stream, live: running > 0 })] })) : null, chatting && plan.panels > 0 && activeThread ? (_jsx(ChatPanel, { thread: activeThread, width: width, rows: plan.panels, offset: chatOffset, label: chatLabel, now: now })) : null, chatting ? null : _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision, selectedId: selectedDecisionId }), notice ? _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: UI.warn, children: notice }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: UI.text, bold: true, children: workspace.slug }), _jsxs(Text, { color: UI.dim, children: [" ", workspace.repo, " "] }), _jsx(Text, { color: live === "live" ? UI.accent : UI.dim, children: "\u25CF " }), _jsxs(Text, { color: UI.dim, children: [live === "live" ? "5s poll" : live, " "] }), _jsx(Text, { color: UI.dim, children: running ? `${running} running ` : "" }), board.decisions.length ? _jsxs(Text, { color: UI.warn, children: [board.decisions.length, " decisions "] }) : null, workspace.paused ? _jsx(Text, { color: UI.warn, children: "paused " }) : null, logsFilter || rawLogs ? _jsxs(Text, { color: UI.warn, children: ["logs ", rawLogs ? "raw " : "", logsFilter ?? "all", " "] }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", chatting ? chatLabel : mode, answering ? " esc cancels" : chatting ? " ↑↓ scroll · pgup/pgdn · esc hides" : view === "ticket" ? " ↑↓ scroll · pgup/pgdn · enter folds" : view === "roadmap" ? " ↑↓ scroll · pgup/pgdn" : view === "inbox" ? " ↑↓ decisions · enter answers" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
|
|
937
|
+
: _jsxs(Text, { color: UI.warn, children: ["No ticket ", ticketKey, " here."] }) : null, view === "home" && plan.cockpit > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Cockpit, { board: board, width: width, rows: plan.cockpit, cursor: cursor }), _jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: plan.stream, live: running > 0 })] })) : null, chatting && plan.panels > 0 && activeThread ? (_jsx(ChatPanel, { thread: activeThread, width: width, rows: plan.panels, offset: chatOffset, label: chatLabel, now: now })) : null, chatting ? null : _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision, selectedId: selectedDecisionId }), notice ? _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: UI.warn, children: notice }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: UI.text, bold: true, children: workspace.slug }), _jsxs(Text, { color: UI.dim, children: [" ", workspace.repo, " "] }), _jsx(Text, { color: live === "live" ? UI.accent : UI.dim, children: "\u25CF " }), _jsxs(Text, { color: UI.dim, children: [live === "live" ? "5s poll" : live, " "] }), _jsx(Text, { color: UI.dim, children: running ? `${running} running ` : "" }), board.decisions.length ? _jsxs(Text, { color: UI.warn, children: [board.decisions.length, " decisions "] }) : null, workspace.paused ? _jsx(Text, { color: UI.warn, children: "paused " }) : null, logsFilter || rawLogs ? _jsxs(Text, { color: UI.warn, children: ["logs ", rawLogs ? "raw " : "", logsFilter ?? "all", " "] }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", chatting ? chatLabel : mode, answering ? " esc cancels" : chatting ? " ↑↓ scroll · pgup/pgdn · esc hides" : view === "ticket" ? " ↑↓ scroll · pgup/pgdn · enter folds" : view === "roadmap" ? " ↑↓ scroll · pgup/pgdn" : view === "inbox" ? " ↑↓ decisions · enter answers" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), availableUpdate || updateProgress ? (_jsx(Box, { children: _jsx(Text, { color: UI.warn, children: updateProgress ?? tuiUpdatePrompt(availableUpdate) }) })) : null, _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
|
|
881
938
|
setDraft(next);
|
|
882
939
|
if (editingRef.current)
|
|
883
940
|
setEditing({ key: editingRef.current.key, draft: next });
|
|
884
|
-
}, onSubmit: (value) => void run(value), isActive: inputActive({ busy, pendingChats: inFlight.length }), placeholder: answering
|
|
941
|
+
}, onSubmit: (value) => void run(value), isActive: !availableUpdate && !updating && inputActive({ busy, pendingChats: inFlight.length }), placeholder: answering
|
|
885
942
|
? (answeringOptions.length ? `1-${answeringOptions.length} or your answer` : "your answer")
|
|
886
943
|
: promptPlaceholder({ busy, pendingChats: inFlight.length }), prompt: _jsx(Text, { color: answering || chatting || mode !== "browse" ? UI.cream : UI.dim, children: answering ? `answer ${answeringNumber}> ` : chatting ? `${chatLabel}> ` : mode === "browse" ? "> " : `${mode}> ` }), color: UI.text, onCancel: () => {
|
|
887
944
|
if (answering) {
|
package/dist/tui/launch.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { loadConfig } from "../config.js";
|
|
2
|
+
import { pendingTuiUpdate, relaunchHd } from "../update-check.js";
|
|
2
3
|
import { canLaunchApp } from "./capability.js";
|
|
3
4
|
import { loadSnapshot } from "./data.js";
|
|
4
5
|
/** Opens HigherDEV. Shared by `hd` and by the end of `hd login`. */
|
|
5
|
-
export async function launchApp(slug) {
|
|
6
|
+
export async function launchApp(slug, updateDeps = {}) {
|
|
6
7
|
// Keep this guard here as well as at command call sites. Ink cannot enter raw
|
|
7
8
|
// mode unless both streams are terminals.
|
|
8
9
|
if (!canLaunchApp())
|
|
@@ -13,8 +14,14 @@ export async function launchApp(slug) {
|
|
|
13
14
|
import("react"),
|
|
14
15
|
import("./App.js"),
|
|
15
16
|
]);
|
|
17
|
+
let shouldRelaunch = false;
|
|
16
18
|
const instance = render(React.createElement(App, {
|
|
17
19
|
initial,
|
|
20
|
+
availableUpdate: pendingTuiUpdate(updateDeps),
|
|
21
|
+
updateDeps,
|
|
22
|
+
onRelaunch: () => { shouldRelaunch = true; },
|
|
18
23
|
}), { exitOnCtrlC: false });
|
|
19
24
|
await instance.waitUntilExit();
|
|
25
|
+
if (shouldRelaunch)
|
|
26
|
+
(updateDeps.relaunch ?? relaunchHd)();
|
|
20
27
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { configPath } from "./config.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
export const CLI_PACKAGE = "@higherdev/cli";
|
|
10
|
+
export const CLI_REGISTRY_LATEST = `https://registry.npmjs.org/${CLI_PACKAGE}/latest`;
|
|
11
|
+
export const UPDATE_CHECK_TIMEOUT_MS = 2_000;
|
|
12
|
+
export const UPDATE_USAGE = "usage: hd update";
|
|
13
|
+
const listeners = new Set();
|
|
14
|
+
let inFlight = Promise.resolve();
|
|
15
|
+
export function runningVersion() {
|
|
16
|
+
return require("../package.json").version;
|
|
17
|
+
}
|
|
18
|
+
export function updateCheckPath(configFile = configPath()) {
|
|
19
|
+
return join(dirname(configFile), "update-check.json");
|
|
20
|
+
}
|
|
21
|
+
/** Dotted numeric versions. Pre-release suffixes sort before a release. */
|
|
22
|
+
export function isNewer(candidate, current) {
|
|
23
|
+
const parts = (value) => value
|
|
24
|
+
.split("-")[0]
|
|
25
|
+
.split(".")
|
|
26
|
+
.map((part) => Number.parseInt(part, 10) || 0);
|
|
27
|
+
const a = parts(candidate);
|
|
28
|
+
const b = parts(current);
|
|
29
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
30
|
+
const left = a[i] ?? 0;
|
|
31
|
+
const right = b[i] ?? 0;
|
|
32
|
+
if (left !== right)
|
|
33
|
+
return left > right;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
export function updateNoticeLine(latest, current) {
|
|
38
|
+
return `hd ${latest} is available, you have ${current}. Run hd update.`;
|
|
39
|
+
}
|
|
40
|
+
export function shouldUpdateCheck(opts = {}) {
|
|
41
|
+
const argv = opts.argv ?? [];
|
|
42
|
+
const env = opts.env ?? process.env;
|
|
43
|
+
if (argv.includes("--no-update-check"))
|
|
44
|
+
return false;
|
|
45
|
+
if (env.HD_NO_UPDATE_CHECK === "1")
|
|
46
|
+
return false;
|
|
47
|
+
if (argv.includes("--json"))
|
|
48
|
+
return false;
|
|
49
|
+
return (opts.isTTY ?? process.stdout.isTTY) === true;
|
|
50
|
+
}
|
|
51
|
+
export function readUpdateCheck(path = updateCheckPath()) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
54
|
+
if (typeof parsed.latest !== "string" || !parsed.latest)
|
|
55
|
+
return null;
|
|
56
|
+
return {
|
|
57
|
+
latest: parsed.latest,
|
|
58
|
+
checked_at: typeof parsed.checked_at === "number" ? parsed.checked_at : 0,
|
|
59
|
+
...(typeof parsed.skipped_version === "string" && parsed.skipped_version
|
|
60
|
+
? { skipped_version: parsed.skipped_version }
|
|
61
|
+
: {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function writeUpdateCheck(state, path = updateCheckPath()) {
|
|
69
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
70
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
71
|
+
}
|
|
72
|
+
export function pendingUpdateNotice(deps = {}) {
|
|
73
|
+
const stored = readUpdateCheck(deps.path ?? updateCheckPath());
|
|
74
|
+
const current = deps.version ?? runningVersion();
|
|
75
|
+
if (!stored || !isNewer(stored.latest, current))
|
|
76
|
+
return null;
|
|
77
|
+
return updateNoticeLine(stored.latest, current);
|
|
78
|
+
}
|
|
79
|
+
export function pendingTuiUpdate(deps = {}) {
|
|
80
|
+
const stored = readUpdateCheck(deps.path ?? updateCheckPath());
|
|
81
|
+
const current = deps.version ?? runningVersion();
|
|
82
|
+
if (!stored || !isNewer(stored.latest, current))
|
|
83
|
+
return null;
|
|
84
|
+
if (stored.skipped_version && !isNewer(stored.latest, stored.skipped_version))
|
|
85
|
+
return null;
|
|
86
|
+
return { latest: stored.latest, current };
|
|
87
|
+
}
|
|
88
|
+
export function tuiUpdatePrompt(update) {
|
|
89
|
+
return `hd ${update.latest} is available, you have ${update.current}. Update now? (y/n)`;
|
|
90
|
+
}
|
|
91
|
+
export function updatePromptAction(input, escape = false) {
|
|
92
|
+
if (escape || input.toLowerCase() === "n")
|
|
93
|
+
return "skip";
|
|
94
|
+
if (input.toLowerCase() === "y")
|
|
95
|
+
return "accept";
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
export function skipUpdate(update, deps = {}) {
|
|
99
|
+
const path = deps.path ?? updateCheckPath();
|
|
100
|
+
const stored = readUpdateCheck(path);
|
|
101
|
+
writeUpdateCheck({
|
|
102
|
+
latest: stored?.latest ?? update.latest,
|
|
103
|
+
checked_at: stored?.checked_at ?? (deps.now ?? Date.now)(),
|
|
104
|
+
skipped_version: update.latest,
|
|
105
|
+
}, path);
|
|
106
|
+
}
|
|
107
|
+
export function subscribeUpdateNotice(listener) {
|
|
108
|
+
listeners.add(listener);
|
|
109
|
+
return () => {
|
|
110
|
+
listeners.delete(listener);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function emitNotice(deps = {}) {
|
|
114
|
+
const notice = pendingUpdateNotice(deps);
|
|
115
|
+
for (const listener of listeners)
|
|
116
|
+
listener(notice);
|
|
117
|
+
}
|
|
118
|
+
export async function fetchLatest(deps = {}) {
|
|
119
|
+
const fetchFn = deps.fetch ?? globalThis.fetch;
|
|
120
|
+
const timeout = deps.timeoutMs ?? UPDATE_CHECK_TIMEOUT_MS;
|
|
121
|
+
// A real timer, not AbortSignal.timeout: on Node 22 that signal's timer does
|
|
122
|
+
// not keep the event loop alive, so a pending check could be abandoned with
|
|
123
|
+
// its promise unresolved (which is exactly what the CI test run reported).
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
const timer = setTimeout(() => controller.abort(new Error("update check timed out")), timeout);
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetchFn(CLI_REGISTRY_LATEST, { signal: controller.signal });
|
|
128
|
+
if (!response.ok)
|
|
129
|
+
return null;
|
|
130
|
+
const data = (await response.json());
|
|
131
|
+
return typeof data.version === "string" && data.version ? data.version : null;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Fire-and-forget registry check. Never throws. The process may stay up to 2s to store the result. */
|
|
141
|
+
export function beginUpdateCheck(deps = {}) {
|
|
142
|
+
inFlight = (async () => {
|
|
143
|
+
try {
|
|
144
|
+
const latest = await fetchLatest(deps);
|
|
145
|
+
if (latest) {
|
|
146
|
+
const path = deps.path ?? updateCheckPath();
|
|
147
|
+
const stored = readUpdateCheck(path);
|
|
148
|
+
writeUpdateCheck({
|
|
149
|
+
latest,
|
|
150
|
+
checked_at: (deps.now ?? Date.now)(),
|
|
151
|
+
...(stored?.skipped_version ? { skipped_version: stored.skipped_version } : {}),
|
|
152
|
+
}, path);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Offline, timed out, or unwritable cache: the command still succeeds.
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
emitNotice(deps);
|
|
160
|
+
}
|
|
161
|
+
})();
|
|
162
|
+
return inFlight;
|
|
163
|
+
}
|
|
164
|
+
export function updateCheckDone() {
|
|
165
|
+
return inFlight;
|
|
166
|
+
}
|
|
167
|
+
async function defaultInstall() {
|
|
168
|
+
await execFileAsync("npm", ["i", "-g", `${CLI_PACKAGE}@latest`]);
|
|
169
|
+
}
|
|
170
|
+
async function defaultVersionAfter() {
|
|
171
|
+
try {
|
|
172
|
+
const { stdout } = await execFileAsync("npm", ["view", CLI_PACKAGE, "version"]);
|
|
173
|
+
const version = stdout.trim();
|
|
174
|
+
return version || runningVersion();
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return runningVersion();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export function relaunchHd() {
|
|
181
|
+
const child = spawn("hd", [], { stdio: "inherit" });
|
|
182
|
+
child.on("exit", (code, signal) => {
|
|
183
|
+
if (signal)
|
|
184
|
+
process.kill(process.pid, signal);
|
|
185
|
+
else
|
|
186
|
+
process.exit(code ?? 0);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
export async function runUpdate(argv, deps = {}) {
|
|
190
|
+
if (argv.length)
|
|
191
|
+
throw new Error(UPDATE_USAGE);
|
|
192
|
+
const before = deps.version ?? runningVersion();
|
|
193
|
+
const log = deps.log ?? ((line) => console.log(line));
|
|
194
|
+
const lines = [`hd ${before}`];
|
|
195
|
+
log(lines[0]);
|
|
196
|
+
await (deps.install ?? defaultInstall)();
|
|
197
|
+
const after = await (deps.versionAfter ?? defaultVersionAfter)();
|
|
198
|
+
lines.push(`hd ${after}`);
|
|
199
|
+
log(lines[1]);
|
|
200
|
+
if (deps.tuiRunning)
|
|
201
|
+
(deps.relaunch ?? relaunchHd)();
|
|
202
|
+
return lines;
|
|
203
|
+
}
|
|
204
|
+
export function runPromptedUpdate(update, deps = {}) {
|
|
205
|
+
return runUpdate([], { ...deps, version: update.current, tuiRunning: true });
|
|
206
|
+
}
|