@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 +8 -0
- package/package.json +1 -1
- package/src/App.tsx +3 -0
- package/src/components/StatusBar.tsx +5 -1
- package/src/hooks/useUpdateCheck.ts +29 -0
- package/src/index.tsx +15 -2
- package/src/utils/selfUpdate.test.ts +33 -0
- package/src/utils/selfUpdate.ts +120 -0
- package/src/version.ts +4 -0
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
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
|
|
8
|
-
|
|
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