@ahmd-sh/hntui 0.3.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
@@ -1,6 +1,10 @@
1
- # hntui
1
+ <h1 align="center">hntui: Hacker News in your terminal!</h1>
2
2
 
3
- Hacker News in your terminal! (formerly published as `@ahmd-sh/hackernuis`)
3
+ <p align="center">A sleek and elegant tui for browsing one of the best tech news sources.</p>
4
+
5
+ <p align="center">
6
+ <img src=".github/assets/hntui-showcase.webp" alt="hntui — Hacker News in your terminal" width="800">
7
+ </p>
4
8
 
5
9
  ```
6
10
  curl -fsSL https://raw.githubusercontent.com/ahmd-sh/hntui/main/install.sh | sh
@@ -30,7 +34,7 @@ The prebuilt binaries have no dependencies. Installing through npm (or hacking o
30
34
  curl -fsSL https://raw.githubusercontent.com/ahmd-sh/hntui/main/install.sh | sh
31
35
  ```
32
36
 
33
- Installs a self-contained binary (runtime included, nothing else needed) to `~/.local/bin`. You can also grab a binary for your platform directly from the [releases page](https://github.com/ahmd-sh/hntui/releases).
37
+ Installs a self-contained binary to `~/.local/bin`. You can also grab a binary for your platform directly from the [releases page](https://github.com/ahmd-sh/hntui/releases). Binaries cover macOS (Apple Silicon) and Linux (x64, arm64) — on an Intel Mac, use the Bun install below.
34
38
 
35
39
  ### With Bun
36
40
 
@@ -38,7 +42,7 @@ Installs a self-contained binary (runtime included, nothing else needed) to `~/.
38
42
  bun add -g @ahmd-sh/hntui
39
43
  ```
40
44
 
41
- Either way the command is `hntui`. Or run it once without installing:
45
+ Or run it once without installing:
42
46
 
43
47
  ```bash
44
48
  bunx @ahmd-sh/hntui
@@ -52,6 +56,14 @@ hntui
52
56
 
53
57
  Press `q` (or `Ctrl-C`) to quit.
54
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
+
55
67
  ## Keybindings
56
68
 
57
69
  ### Story list
@@ -59,13 +71,13 @@ Press `q` (or `Ctrl-C`) to quit.
59
71
  | Key | Action |
60
72
  |---|---|
61
73
  | `j` / `↓`, `k` / `↑` | Move cursor |
62
- | `gg`, `Shift-G` | Jump to first or last |
74
+ | `gg`, `G` | Jump to first or last |
63
75
  | `Ctrl-D`, `Ctrl-U`, `PgDown`, `PgUp` | Scroll a half page |
64
76
  | `c`, `Enter` | Open the story and read its comments |
65
77
  | `h` / `←`, `l` / `→` | Previous or next tab |
66
78
  | `Tab`, `Shift-Tab` | Cycle through tabs |
67
79
  | `1` through `6` | Jump to a specific category |
68
- | `Shift-S` | Jump to the Saved list |
80
+ | `S` | Jump to the Saved list |
69
81
  | `s` | Save or unsave the highlighted post |
70
82
  | `o` | Open the story's URL in your browser |
71
83
  | `r` | Refresh the current feed |
@@ -77,7 +89,7 @@ Press `q` (or `Ctrl-C`) to quit.
77
89
  | Key | Action |
78
90
  |---|---|
79
91
  | `j` / `↓`, `k` / `↑` | Move the comment cursor |
80
- | `gg`, `Shift-G` | Jump to first or last comment |
92
+ | `gg`, `G` | Jump to first or last comment |
81
93
  | `Ctrl-D`, `Ctrl-U`, `PgDown`, `PgUp` | Scroll a half page |
82
94
  | `Space` | Collapse or expand the current subtree |
83
95
  | `Enter` | Open the links popup for the current comment |
@@ -92,7 +104,7 @@ Press `q` (or `Ctrl-C`) to quit.
92
104
  | Key | Action |
93
105
  |---|---|
94
106
  | `j`, `k`, `↑`, `↓` | Move |
95
- | `gg`, `Shift-G` | First or last link |
107
+ | `gg`, `G` | First or last link |
96
108
  | `o`, `Enter` | Open the highlighted link |
97
109
  | `Esc`, `Backspace` | Close the popup |
98
110
 
@@ -133,7 +145,7 @@ Press `t` to toggle. The dark theme is mostly black with orange accents. The lig
133
145
 
134
146
  Press `s` on any story to save it. Saved posts get a small star next to the title and show up in the Saved tab on the right side of the tab strip. The list persists across sessions in `~/.config/hntui/saved.json` as a small JSON file. (Config from the app's `hackernuis` days is migrated automatically on first run.) Press `s` again to remove a post from the list.
135
147
 
136
- `Shift-S` jumps straight to the Saved list from anywhere.
148
+ `S` jumps straight to the Saved list from anywhere.
137
149
 
138
150
  ## Development
139
151
 
@@ -164,6 +176,7 @@ git push --follow-tags
164
176
 
165
177
  - [OpenTUI](https://github.com/anomalyco/opentui) by Anomaly. The native TUI core that makes all of this possible.
166
178
  - [opentui-spinner](https://github.com/msmps/opentui-spinner) by Matt Simpson. The Knight Rider loading scanner is adapted from `examples/knight-rider/utils.ts` (MIT).
179
+ - [Effect](https://effect.website) for empowering the data layer under the hood.
167
180
  - [Hacker News](https://news.ycombinator.com) for the content and the open API.
168
181
 
169
182
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahmd-sh/hntui",
3
- "version": "0.3.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"
@@ -20,6 +21,7 @@ import { resolveStory } from "./api/hn"
20
21
  import type { HnError, HnItemGone } from "./api/hn"
21
22
  import { AppRuntime } from "./runtime"
22
23
  import { hnErrorMessage } from "./utils/errors"
24
+ import { startThemeWipe } from "./utils/themeWipe"
23
25
  import { LinksPopup } from "./components/LinksPopup"
24
26
  import { HelpOverlay } from "./components/HelpOverlay"
25
27
  import { ContextMenu, type MenuItem } from "./components/ContextMenu"
@@ -49,6 +51,7 @@ export function App() {
49
51
  const { items: feedItems, loading: feedItemsLoading } = useItems(visibleIds)
50
52
  const { entries: savedEntries, idSet: savedIds, isSaved, toggle: toggleSave } = useSaved()
51
53
  const { entries: historyEntries, idSet: viewedIds, markViewed, clear: clearHistory } = useHistory()
54
+ const updateAvailable = useUpdateCheck()
52
55
 
53
56
  const savedIdList = useMemo(() => savedEntries.map((e) => e.id), [savedEntries])
54
57
  const { items: savedItemsRaw, loading: savedLoading } = useItems(
@@ -360,7 +363,10 @@ export function App() {
360
363
  }
361
364
 
362
365
  if (name === "t") {
363
- setTheme((cur) => (cur.name === "dark" ? lightTheme : darkTheme))
366
+ const next = theme.name === "dark" ? lightTheme : darkTheme
367
+ // snapshot the old-theme frame BEFORE React re-renders in the new theme
368
+ if (renderer) startThemeWipe(renderer, String(next.accent))
369
+ setTheme(next)
364
370
  return
365
371
  }
366
372
 
@@ -580,6 +586,7 @@ export function App() {
580
586
  view={view.kind === "detail" ? "detail" : "list"}
581
587
  category={category}
582
588
  loading={statusLoading}
589
+ updateAvailable={updateAvailable}
583
590
  message={
584
591
  view.kind === "resolveError"
585
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
+ }
@@ -0,0 +1,109 @@
1
+ import { RGBA } from "@opentui/core"
2
+ import type { CliRenderer, OptimizedBuffer } from "@opentui/core"
3
+
4
+ // Animated theme switch: a wave sweeps out of a random point on a random
5
+ // screen edge and every cell flips to the new theme as the front passes it.
6
+ //
7
+ // It works as a renderer post-process, below React entirely: at trigger time
8
+ // we snapshot the last OLD-theme frame (char/fg/bg/attributes per cell), let
9
+ // React re-render the whole tree in the NEW theme underneath, and then — for
10
+ // the duration — repaint the snapshot onto every cell the wave hasn't reached
11
+ // yet. Cells just behind the front glow in the accent color for a beat.
12
+
13
+ const DURATION_MS = 500
14
+ const EDGE_WIDTH = 1.5
15
+ const X_SCALE = 0.55
16
+
17
+ let cancelActive: (() => void) | null = null
18
+
19
+ export function startThemeWipe(renderer: CliRenderer, accentHex: string): void {
20
+ // re-triggering mid-wipe restarts from whatever is on screen right now
21
+ cancelActive?.()
22
+
23
+ const src = renderer.currentRenderBuffer
24
+ const width = src.width
25
+ const height = src.height
26
+ const cells = width * height
27
+ const snap = src.buffers
28
+ const snapChar = snap.char.slice(0, cells)
29
+ const snapAttr = snap.attributes.slice(0, cells)
30
+ const snapFg = snap.fg.slice(0, cells * 4)
31
+ const snapBg = snap.bg.slice(0, cells * 4)
32
+
33
+ // a fresh origin per wipe: anywhere along any edge (0 top, 1 right, 2 bottom, 3 left)
34
+ const edge = (Math.random() * 4) | 0
35
+ const ox = edge === 1 ? width - 1 : edge === 3 ? 0 : Math.random() * (width - 1)
36
+ const oy = edge === 0 ? 0 : edge === 2 ? height - 1 : Math.random() * (height - 1)
37
+
38
+ // distance of every cell from the origin
39
+ const dist = new Float32Array(cells)
40
+ let maxDist = 0
41
+ for (let y = 0; y < height; y++) {
42
+ for (let x = 0; x < width; x++) {
43
+ const dx = (x - ox) * X_SCALE
44
+ const dy = y - oy
45
+ const d = Math.sqrt(dx * dx + dy * dy)
46
+ dist[y * width + x] = d
47
+ if (d > maxDist) maxDist = d
48
+ }
49
+ }
50
+
51
+ const accent = RGBA.fromHex(accentHex)
52
+ const fgScratch = RGBA.fromValues(0, 0, 0, 1)
53
+ const bgScratch = RGBA.fromValues(0, 0, 0, 1)
54
+ const start = performance.now()
55
+
56
+ let fn: (buffer: OptimizedBuffer, deltaTime: number) => void
57
+
58
+ const cancel = () => {
59
+ renderer.removePostProcessFn(fn)
60
+ renderer.dropLive()
61
+ if (cancelActive === cancel) cancelActive = null
62
+ }
63
+
64
+ fn = (buffer) => {
65
+ // terminal resized mid-wipe: the snapshot grid no longer applies
66
+ if (buffer.width !== width || buffer.height !== height) return cancel()
67
+
68
+ const t = Math.min(1, (performance.now() - start) / DURATION_MS)
69
+ const eased = 1 - (1 - t) * (1 - t) // ease-out: fast ignition, soft landing
70
+ const r = eased * (maxDist + EDGE_WIDTH)
71
+ const live = buffer.buffers
72
+
73
+ for (let i = 0; i < cells; i++) {
74
+ const d = dist[i]!
75
+ if (d <= r - EDGE_WIDTH) continue // wave passed: new theme shows through
76
+
77
+ const x = i % width
78
+ const y = (i / width) | 0
79
+ const o = i * 4
80
+
81
+ if (d <= r) {
82
+ // wavefront band: the just-flipped cell glows on an accent background
83
+ fgScratch.buffer[0] = live.fg[o]!
84
+ fgScratch.buffer[1] = live.fg[o + 1]!
85
+ fgScratch.buffer[2] = live.fg[o + 2]!
86
+ fgScratch.buffer[3] = live.fg[o + 3]!
87
+ buffer.drawChar(live.char[i]!, x, y, fgScratch, accent, live.attributes[i]!)
88
+ continue
89
+ }
90
+
91
+ // not reached yet: hold the old-theme snapshot in place
92
+ fgScratch.buffer[0] = snapFg[o]!
93
+ fgScratch.buffer[1] = snapFg[o + 1]!
94
+ fgScratch.buffer[2] = snapFg[o + 2]!
95
+ fgScratch.buffer[3] = snapFg[o + 3]!
96
+ bgScratch.buffer[0] = snapBg[o]!
97
+ bgScratch.buffer[1] = snapBg[o + 1]!
98
+ bgScratch.buffer[2] = snapBg[o + 2]!
99
+ bgScratch.buffer[3] = snapBg[o + 3]!
100
+ buffer.drawChar(snapChar[i]!, x, y, fgScratch, bgScratch, snapAttr[i]!)
101
+ }
102
+
103
+ if (t >= 1) cancel()
104
+ }
105
+
106
+ cancelActive = cancel
107
+ renderer.addPostProcessFn(fn)
108
+ renderer.requestLive()
109
+ }
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