@ahmd-sh/hntui 0.4.0 → 0.5.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/README.md CHANGED
@@ -56,6 +56,14 @@ hntui
56
56
 
57
57
  Press `q` (or `Ctrl-C`) to quit.
58
58
 
59
+ ## Updating
60
+
61
+ ```bash
62
+ hntui update
63
+ ```
64
+
65
+ Checks the latest release and, for binary installs, replaces itself in place. Bun installs update with `bun add -g @ahmd-sh/hntui` instead (`hntui update` will tell you so). When a newer release exists, the status bar shows a quiet hint. `hntui --version` prints the installed version.
66
+
59
67
  ## Keybindings
60
68
 
61
69
  ### Story list
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahmd-sh/hntui",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Beautiful Hacker News browser for the terminal, built on OpenTUI + React + Bun",
5
5
  "type": "module",
6
6
  "module": "src/index.tsx",
package/src/App.tsx CHANGED
@@ -12,6 +12,7 @@ import { useItems } from "./hooks/useItems"
12
12
  import { flattenTree, useCommentTree } from "./hooks/useCommentTree"
13
13
  import { useSaved } from "./hooks/useSaved"
14
14
  import { useHistory } from "./hooks/useHistory"
15
+ import { useUpdateCheck } from "./hooks/useUpdateCheck"
15
16
  import { ALL_CATEGORIES, FEED_CATEGORIES } from "./api/types"
16
17
  import type { Category, FeedCategory, Item } from "./api/types"
17
18
  import { openUrl } from "./utils/openUrl"
@@ -50,6 +51,7 @@ export function App() {
50
51
  const { items: feedItems, loading: feedItemsLoading } = useItems(visibleIds)
51
52
  const { entries: savedEntries, idSet: savedIds, isSaved, toggle: toggleSave } = useSaved()
52
53
  const { entries: historyEntries, idSet: viewedIds, markViewed, clear: clearHistory } = useHistory()
54
+ const updateAvailable = useUpdateCheck()
53
55
 
54
56
  const savedIdList = useMemo(() => savedEntries.map((e) => e.id), [savedEntries])
55
57
  const { items: savedItemsRaw, loading: savedLoading } = useItems(
@@ -584,6 +586,7 @@ export function App() {
584
586
  view={view.kind === "detail" ? "detail" : "list"}
585
587
  category={category}
586
588
  loading={statusLoading}
589
+ updateAvailable={updateAvailable}
587
590
  message={
588
591
  view.kind === "resolveError"
589
592
  ? view.error._tag === "HnItemGone"
@@ -7,9 +7,10 @@ interface Props {
7
7
  category?: Category
8
8
  loading?: boolean
9
9
  message?: string
10
+ updateAvailable?: string | null
10
11
  }
11
12
 
12
- export function StatusBar({ view, category, loading, message }: Props) {
13
+ export function StatusBar({ view, category, loading, message, updateAvailable }: Props) {
13
14
  const t = useTheme()
14
15
  const hints =
15
16
  view === "list"
@@ -34,6 +35,9 @@ export function StatusBar({ view, category, loading, message }: Props) {
34
35
  {loading ? <Loader /> : null}
35
36
  <text fg={t.statusHint} {...selectionColors(t)}>{message ?? hints}</text>
36
37
  <box flexGrow={1} />
38
+ {updateAvailable ? (
39
+ <text fg={t.textDim} {...selectionColors(t)}>{`v${updateAvailable} · hntui update`}</text>
40
+ ) : null}
37
41
  <text fg={t.statusHint} {...selectionColors(t)}>? help</text>
38
42
  </box>
39
43
  )
@@ -0,0 +1,29 @@
1
+ import { useEffect, useState } from "react"
2
+ import { Effect, Fiber } from "effect"
3
+ import { AppRuntime } from "../runtime"
4
+ import { VERSION } from "../version"
5
+ import { compareVersions, fetchLatestVersion } from "../utils/selfUpdate"
6
+
7
+ // One check per app launch, silently skipped when offline: returns the newer
8
+ // version string when a release is ahead of this build, otherwise null.
9
+ export function useUpdateCheck(): string | null {
10
+ const [latest, setLatest] = useState<string | null>(null)
11
+
12
+ useEffect(() => {
13
+ const fiber = AppRuntime.runFork(
14
+ // fetchLatestVersion never throws (5s timeout, null on any failure)
15
+ Effect.promise(() => fetchLatestVersion()).pipe(
16
+ Effect.andThen((v) =>
17
+ Effect.sync(() => {
18
+ if (v && compareVersions(v, VERSION) > 0) setLatest(v)
19
+ }),
20
+ ),
21
+ ),
22
+ )
23
+ return () => {
24
+ AppRuntime.runFork(Fiber.interrupt(fiber))
25
+ }
26
+ }, [])
27
+
28
+ return latest
29
+ }
package/src/index.tsx CHANGED
@@ -3,6 +3,19 @@ import { createCliRenderer } from "@opentui/core"
3
3
  import { createRoot } from "@opentui/react"
4
4
  import "opentui-spinner/react"
5
5
  import { App } from "./App"
6
+ import { VERSION } from "./version"
7
+ import { selfUpdate } from "./utils/selfUpdate"
6
8
 
7
- const renderer = await createCliRenderer({ useMouse: true })
8
- createRoot(renderer).render(<App />)
9
+ const arg = process.argv[2]
10
+ if (arg === "--version" || arg === "-v" || arg === "version") {
11
+ console.log(`hntui ${VERSION}`)
12
+ process.exit(0)
13
+ } else if (arg === "update") {
14
+ process.exit(await selfUpdate())
15
+ } else if (arg) {
16
+ console.error(`hntui: unknown command "${arg}"\nusage: hntui [update | --version]`)
17
+ process.exit(1)
18
+ } else {
19
+ const renderer = await createCliRenderer({ useMouse: true })
20
+ createRoot(renderer).render(<App />)
21
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { compareVersions, releaseAsset } from "./selfUpdate"
3
+
4
+ describe("compareVersions", () => {
5
+ test("orders plain versions", () => {
6
+ expect(compareVersions("0.4.0", "0.3.0")).toBeGreaterThan(0)
7
+ expect(compareVersions("0.3.0", "0.4.0")).toBeLessThan(0)
8
+ expect(compareVersions("0.4.0", "0.4.0")).toBe(0)
9
+ })
10
+
11
+ test("compares numerically, not lexically", () => {
12
+ expect(compareVersions("0.10.0", "0.9.0")).toBeGreaterThan(0)
13
+ expect(compareVersions("1.0.0", "0.99.99")).toBeGreaterThan(0)
14
+ })
15
+
16
+ test("tolerates missing segments", () => {
17
+ expect(compareVersions("1.0", "1.0.0")).toBe(0)
18
+ expect(compareVersions("1", "1.0.1")).toBeLessThan(0)
19
+ })
20
+ })
21
+
22
+ describe("releaseAsset", () => {
23
+ test("maps supported platforms", () => {
24
+ expect(releaseAsset("darwin", "arm64")).toBe("hntui-darwin-arm64.tar.gz")
25
+ expect(releaseAsset("linux", "x64")).toBe("hntui-linux-x64.tar.gz")
26
+ expect(releaseAsset("linux", "arm64")).toBe("hntui-linux-arm64.tar.gz")
27
+ })
28
+
29
+ test("unsupported platforms get null (Intel macOS, windows)", () => {
30
+ expect(releaseAsset("darwin", "x64")).toBeNull()
31
+ expect(releaseAsset("win32", "x64")).toBeNull()
32
+ })
33
+ })
@@ -0,0 +1,120 @@
1
+ import { chmodSync, mkdtempSync, renameSync, rmSync } from "fs"
2
+ import { dirname, join } from "path"
3
+ import { VERSION } from "../version"
4
+
5
+ const REPO = "ahmd-sh/hntui"
6
+ const LATEST_URL = `https://github.com/${REPO}/releases/latest`
7
+
8
+ // "1.2.3" vs "1.10.0" → negative if a < b, 0 if equal, positive if a > b
9
+ export function compareVersions(a: string, b: string): number {
10
+ const pa = a.split(".").map(Number)
11
+ const pb = b.split(".").map(Number)
12
+ for (let i = 0; i < 3; i++) {
13
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0)
14
+ if (d !== 0) return d
15
+ }
16
+ return 0
17
+ }
18
+
19
+ // which release tarball serves this machine, or null if none exists
20
+ export function releaseAsset(platform: string, arch: string): string | null {
21
+ if (platform === "darwin" && arch === "arm64") return "hntui-darwin-arm64.tar.gz"
22
+ if (platform === "linux" && (arch === "x64" || arch === "arm64"))
23
+ return `hntui-linux-${arch}.tar.gz`
24
+ return null
25
+ }
26
+
27
+ // The releases/latest page redirects to .../tag/vX.Y.Z — the version is in a
28
+ // header of a tiny response: no API, no auth, no rate-limit concerns.
29
+ export async function fetchLatestVersion(timeoutMs = 5000): Promise<string | null> {
30
+ try {
31
+ const res = await fetch(LATEST_URL, {
32
+ redirect: "manual",
33
+ signal: AbortSignal.timeout(timeoutMs),
34
+ })
35
+ const location = res.headers.get("location") ?? ""
36
+ const m = location.match(/\/tag\/v(\d+\.\d+\.\d+)$/)
37
+ return m ? m[1]! : null
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ type Channel = "binary" | "bun" | "dev"
44
+
45
+ // A compiled executable runs from bun's virtual filesystem; under the bun
46
+ // runtime, a global install lives in node_modules, a source checkout doesn't.
47
+ function installChannel(): Channel {
48
+ if (import.meta.url.includes("$bunfs")) return "binary"
49
+ return import.meta.path.includes("node_modules") ? "bun" : "dev"
50
+ }
51
+
52
+ // `hntui update` — returns a process exit code.
53
+ export async function selfUpdate(): Promise<number> {
54
+ const latest = await fetchLatestVersion()
55
+ if (!latest) {
56
+ console.error("hntui: couldn't reach GitHub to check the latest release.")
57
+ return 1
58
+ }
59
+ if (compareVersions(latest, VERSION) <= 0) {
60
+ console.log(`hntui ${VERSION} is up to date (latest release is v${latest}).`)
61
+ return 0
62
+ }
63
+
64
+ console.log(`hntui ${VERSION} → v${latest} available.`)
65
+ const channel = installChannel()
66
+ if (channel === "bun") {
67
+ console.log("This copy was installed with Bun — update it with:\n bun add -g @ahmd-sh/hntui")
68
+ return 0
69
+ }
70
+ if (channel === "dev") {
71
+ console.log("Running from a source checkout — update it with git pull.")
72
+ return 0
73
+ }
74
+
75
+ const asset = releaseAsset(process.platform, process.arch)
76
+ if (!asset) {
77
+ console.error(
78
+ `hntui: no prebuilt binary for ${process.platform}-${process.arch}.\n` +
79
+ "Install with Bun instead: bun add -g @ahmd-sh/hntui",
80
+ )
81
+ return 1
82
+ }
83
+
84
+ const target = process.execPath
85
+ const targetDir = dirname(target)
86
+ console.log(`downloading ${asset} ...`)
87
+ const res = await fetch(`https://github.com/${REPO}/releases/latest/download/${asset}`)
88
+ if (!res.ok) {
89
+ console.error(`hntui: download failed (HTTP ${res.status}).`)
90
+ return 1
91
+ }
92
+
93
+ // extract in the target's own directory: the final rename must not cross
94
+ // filesystems, and renaming over a running executable is safe (the running
95
+ // process keeps its inode; the path points at the new file)
96
+ let tmpDir: string | null = null
97
+ try {
98
+ tmpDir = mkdtempSync(join(targetDir, ".hntui-update-"))
99
+ const tarPath = join(tmpDir, asset)
100
+ await Bun.write(tarPath, res)
101
+ const tar = Bun.spawn(["tar", "-xzf", tarPath, "-C", tmpDir], {
102
+ stdout: "ignore",
103
+ stderr: "pipe",
104
+ })
105
+ if ((await tar.exited) !== 0) {
106
+ console.error(`hntui: extract failed: ${await new Response(tar.stderr).text()}`)
107
+ return 1
108
+ }
109
+ const fresh = join(tmpDir, "hntui")
110
+ chmodSync(fresh, 0o755)
111
+ renameSync(fresh, target)
112
+ console.log(`updated to v${latest} (${target})`)
113
+ return 0
114
+ } catch (err) {
115
+ console.error(`hntui: update failed: ${err}\nIs ${targetDir} writable?`)
116
+ return 1
117
+ } finally {
118
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true })
119
+ }
120
+ }
package/src/version.ts ADDED
@@ -0,0 +1,4 @@
1
+ import pkg from "../package.json"
2
+
3
+ // Inlined at build time — compiled binaries carry their version with them.
4
+ export const VERSION: string = pkg.version