@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 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 [cmd, ...rest] = argv;
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) {
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",