@higherdev/cli 0.22.0 → 0.24.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,11 +570,20 @@ 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
588
  await launchApp();
579
589
  return;
@@ -658,6 +668,7 @@ 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
673
  await launchApp(config.slug);
663
674
  return;
@@ -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 { pendingUpdateNotice, subscribeUpdateNotice } 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, updateNotice = null }) {
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,7 @@ 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 [updateLine, setUpdateLine] = useState(updateNotice);
58
60
  const [ticketKey, setTicketKey] = useState(null);
59
61
  const [ticketOffset, setTicketOffset] = useState(0);
60
62
  const [roadmapOffset, setRoadmapOffset] = useState(0);
@@ -83,6 +85,10 @@ export function App({ initial }) {
83
85
  const acknowledgedMessages = useRef(new Set());
84
86
  const loads = useRef(new WorkspaceLoads(initial.workspace.id));
85
87
  editingRef.current = editing;
88
+ useEffect(() => {
89
+ setUpdateLine((current) => current ?? pendingUpdateNotice());
90
+ return subscribeUpdateNotice(setUpdateLine);
91
+ }, []);
86
92
  useEffect(() => {
87
93
  if (messages.length > 0 || view !== "home")
88
94
  setStarted(true);
@@ -877,7 +883,7 @@ export function App({ initial }) {
877
883
  return _jsx(Bubble, { message: item.message, width: width }, item.key);
878
884
  } }), 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
885
  ? _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) => {
886
+ : _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, updateLine ? _jsxs(Text, { color: UI.warn, children: [updateLine, " "] }) : 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) => {
881
887
  setDraft(next);
882
888
  if (editingRef.current)
883
889
  setEditing({ key: editingRef.current.key, draft: next });
@@ -1,4 +1,5 @@
1
1
  import { loadConfig } from "../config.js";
2
+ import { pendingUpdateNotice } 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`. */
@@ -15,6 +16,7 @@ export async function launchApp(slug) {
15
16
  ]);
16
17
  const instance = render(React.createElement(App, {
17
18
  initial,
19
+ updateNotice: pendingUpdateNotice(),
18
20
  }), { exitOnCtrlC: false });
19
21
  await instance.waitUntilExit();
20
22
  }
@@ -0,0 +1,165 @@
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
+ };
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ export function writeUpdateCheck(state, path = updateCheckPath()) {
66
+ mkdirSync(dirname(path), { recursive: true });
67
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
68
+ }
69
+ export function pendingUpdateNotice(deps = {}) {
70
+ const stored = readUpdateCheck(deps.path ?? updateCheckPath());
71
+ const current = deps.version ?? runningVersion();
72
+ if (!stored || !isNewer(stored.latest, current))
73
+ return null;
74
+ return updateNoticeLine(stored.latest, current);
75
+ }
76
+ export function subscribeUpdateNotice(listener) {
77
+ listeners.add(listener);
78
+ return () => {
79
+ listeners.delete(listener);
80
+ };
81
+ }
82
+ function emitNotice(deps = {}) {
83
+ const notice = pendingUpdateNotice(deps);
84
+ for (const listener of listeners)
85
+ listener(notice);
86
+ }
87
+ export async function fetchLatest(deps = {}) {
88
+ const fetchFn = deps.fetch ?? globalThis.fetch;
89
+ const timeout = deps.timeoutMs ?? UPDATE_CHECK_TIMEOUT_MS;
90
+ // A real timer, not AbortSignal.timeout: on Node 22 that signal's timer does
91
+ // not keep the event loop alive, so a pending check could be abandoned with
92
+ // its promise unresolved (which is exactly what the CI test run reported).
93
+ const controller = new AbortController();
94
+ const timer = setTimeout(() => controller.abort(new Error("update check timed out")), timeout);
95
+ try {
96
+ const response = await fetchFn(CLI_REGISTRY_LATEST, { signal: controller.signal });
97
+ if (!response.ok)
98
+ return null;
99
+ const data = (await response.json());
100
+ return typeof data.version === "string" && data.version ? data.version : null;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ finally {
106
+ clearTimeout(timer);
107
+ }
108
+ }
109
+ /** Fire-and-forget registry check. Never throws. The process may stay up to 2s to store the result. */
110
+ export function beginUpdateCheck(deps = {}) {
111
+ inFlight = (async () => {
112
+ try {
113
+ const latest = await fetchLatest(deps);
114
+ if (latest)
115
+ writeUpdateCheck({ latest, checked_at: (deps.now ?? Date.now)() }, deps.path ?? updateCheckPath());
116
+ }
117
+ catch {
118
+ // Offline, timed out, or unwritable cache: the command still succeeds.
119
+ }
120
+ finally {
121
+ emitNotice(deps);
122
+ }
123
+ })();
124
+ return inFlight;
125
+ }
126
+ export function updateCheckDone() {
127
+ return inFlight;
128
+ }
129
+ async function defaultInstall() {
130
+ await execFileAsync("npm", ["i", "-g", `${CLI_PACKAGE}@latest`]);
131
+ }
132
+ async function defaultVersionAfter() {
133
+ try {
134
+ const { stdout } = await execFileAsync("npm", ["view", CLI_PACKAGE, "version"]);
135
+ const version = stdout.trim();
136
+ return version || runningVersion();
137
+ }
138
+ catch {
139
+ return runningVersion();
140
+ }
141
+ }
142
+ export function relaunchHd() {
143
+ const child = spawn("hd", [], { stdio: "inherit" });
144
+ child.on("exit", (code, signal) => {
145
+ if (signal)
146
+ process.kill(process.pid, signal);
147
+ else
148
+ process.exit(code ?? 0);
149
+ });
150
+ }
151
+ export async function runUpdate(argv, deps = {}) {
152
+ if (argv.length)
153
+ throw new Error(UPDATE_USAGE);
154
+ const before = deps.version ?? runningVersion();
155
+ const log = deps.log ?? ((line) => console.log(line));
156
+ const lines = [`hd ${before}`];
157
+ log(lines[0]);
158
+ await (deps.install ?? defaultInstall)();
159
+ const after = await (deps.versionAfter ?? defaultVersionAfter)();
160
+ lines.push(`hd ${after}`);
161
+ log(lines[1]);
162
+ if (deps.tuiRunning)
163
+ (deps.relaunch ?? relaunchHd)();
164
+ return lines;
165
+ }
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/craig-higherops/hdx.git",
8
+ "directory": "apps/cli"
9
+ },
5
10
  "bin": {
6
11
  "hd": "dist/index.js"
7
12
  },