@term-hub/term-hub 0.1.1 → 0.1.3

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,12 +1,32 @@
1
- # Terminal Hub
1
+ <p align="center"><img src="https://cdn.jsdelivr.net/npm/@term-hub/term-hub@latest/assets/icon.png" alt="Terminal Hub logo" width="120"></p>
2
2
 
3
- **Terminal mission control.** Capture every interactive shell you open (Terminal.app, iTerm, VS Code, tmux panes, …) and manage them all from one desktop app — plus spawn your own, independent of any terminal.
3
+ <h1 align="center">Terminal Hub</h1>
4
+
5
+ <p align="center"><b>Terminal mission control.</b> Capture every interactive shell you open (Terminal.app, iTerm, VS Code, tmux panes, …) and manage them all from one desktop app — plus spawn your own, independent of any terminal.</p>
6
+
7
+ <p align="center">
8
+ <img alt="macOS" src="https://img.shields.io/badge/macOS-supported-brightgreen">
9
+ <img alt="Linux" src="https://img.shields.io/badge/Linux-supported-brightgreen">
10
+ <img alt="Windows" src="https://img.shields.io/badge/Windows-in%20progress-orange">
11
+ </p>
4
12
 
5
13
  Capture is **opt-in, consent-gated, and fully reversible**. Nothing is captured until you say yes; one command reverts everything.
6
14
 
7
15
  > A terminal you kill from Terminal Hub really dies. A hub-owned session you spawn keeps running until you kill it. The background router can crash and **your shells survive** — there's no system-wide single point of failure.
8
16
 
9
- Full docs, architecture, and source: **https://github.com/AayushGour/terminal-hub**
17
+ ## Table of contents
18
+
19
+ - [Install](#install)
20
+ - [Features](#features)
21
+ - [How it works](#how-it-works)
22
+ - [Platform support](#platform-support)
23
+ - [Usage](#usage)
24
+ - [Configuration](#configuration)
25
+ - [Update](#update)
26
+ - [Uninstall](#uninstall)
27
+ - [Roadmap](#roadmap)
28
+ - [Security](#security)
29
+ - [License](#license)
10
30
 
11
31
  ## Install
12
32
 
@@ -16,33 +36,117 @@ npm i -g @term-hub/term-hub
16
36
 
17
37
  This installs the `term-hub` and `hub` commands and registers a launcher in your OS apps menu (Launchpad/Spotlight on macOS, the app grid on Linux, the Start Menu on Windows).
18
38
 
39
+ - **Launch the GUI:** run `term-hub`, or click **Terminal Hub** in your apps menu. The install registers it automatically.
40
+ - **Use the CLI:** `hub status`, `hub kill <id>`, `hub update`, `hub uninstall`, …
41
+
42
+ On **first launch**, Terminal Hub asks to enable capture — click **Enable**, then open a new terminal and it shows up.
43
+
44
+ `npm i -g @term-hub/term-hub` pulls in exactly one platform-specific binary package (`@term-hub/darwin-arm64`, `@term-hub/darwin-x64`, `@term-hub/linux-x64`, or `@term-hub/linux-arm64`) via `optionalDependencies` — npm resolves this automatically based on your OS/CPU, so you never download binaries for a platform you're not on.
45
+
46
+ ## Features
47
+
48
+ **Capture & control**
49
+ - **Auto-capture** every interactive shell via a one-time, consent-gated shell-rc hook.
50
+ - **Two views of one shell** — drive a session from hub *and* from the terminal it was spawned in.
51
+ - **Spawn hub-owned sessions** that persist independently of any terminal.
52
+ - **Session buckets** — Healthy / Ghost (dead socket) / Orphan (live, no record), so nothing gets lost.
53
+
54
+ **The canvas**
55
+ - Terminals are **floating windows** — drag the title bar to move, drag the corner to resize.
56
+ - **Infinite pannable / zoomable canvas** — drag empty space to pan, ⌘/Ctrl-scroll to zoom, **Fit** to frame them all.
57
+ - Attach / **Detach** (stop viewing, session keeps running) and **Kill** (end the shell), with in-button progress.
58
+
59
+ **Correct & safe**
60
+ - **No single point of failure** — each session owns its own pty in a detached process; killing the router never kills a shell.
61
+ - **Authenticated** — per-install token + same-uid peer-credential check on every socket.
62
+ - **Real terminal behavior** — raw-mode passthrough (arrow keys, tab-completion, Ctrl-C all work), no double-echo.
63
+ - **Reversible** — uninstall reverts the rc change, removes `~/.hub`, and removes the installed app.
64
+ - **Self-updating GUI** — the desktop app checks for, downloads, verifies (Ed25519-signed), and installs new versions of itself; no reinstall needed.
65
+
66
+ ## How it works
67
+
68
+ - **`hub-relay`** — one process per session. It **owns the pty** and a headless vt100 screen (for replay), fully detached. Because the shell lives here, the router can die and restart without touching your shells.
69
+ - **`hub-daemon`** — a non-pty **reverse-proxy router** over a single unix socket. It knows every session and fans viewers out to the right relay. It owns no pty, so it is never a single point of failure.
70
+ - **`hub-cli` (`hub`)** — install / uninstall / update / kill / status. Owns the rc-file editing (marker-guarded, byte-exact backup, edit-preserving restore) and autostart (launchd on macOS, systemd on Linux).
71
+ - **`hub-app`** — the desktop GUI: an infinite canvas of terminal windows, per-tile connection to the daemon.
72
+
73
+ ## Platform support
74
+
75
+ | Platform | Status | Notes |
76
+ |---|:---:|---|
77
+ | **macOS** (Apple Silicon & Intel) | ✅ Supported | launchd autostart, real signed `.app` bundle, self-updating |
78
+ | **Linux** (x86_64 & arm64) | ✅ Supported | systemd user unit, AppImage, self-updating |
79
+ | **Windows** | 🚧 In progress | CLI/engine work is underway; no GUI binaries published yet |
80
+
81
+ Notes:
82
+ - **macOS:** the app is ad-hoc signed (no Developer ID yet) — Gatekeeper won't complain about a locally-built or self-updated copy, but a fresh download from a browser may show an "unidentified developer" warning once.
83
+ - **Linux:** the GUI needs the webkit runtime — `sudo apt install libwebkit2gtk-4.1-0` (Debian/Ubuntu) or `sudo dnf install webkit2gtk4.1` (Fedora); the launcher tells you if it's missing.
84
+ - **Windows:** binaries aren't published yet — see [Roadmap](#roadmap).
85
+
19
86
  ## Usage
20
87
 
21
- - **Launch the GUI:** run `term-hub`, or click **Terminal Hub** in your apps menu.
22
- - **Enable capture:** on first launch, click **Enable** then open a new terminal and it shows up automatically.
23
- - **Manage from the CLI:**
88
+ - **Capture a terminal** just open a new terminal after enabling. It appears under **Healthy** as an `External` session.
89
+ - **Open a tile** click a session (or **Open**) to bring up its live terminal on the canvas.
90
+ - **Move / resize** — drag the title bar to move a window; drag the bottom-right corner to resize (the shell's size follows).
91
+ - **Pan / zoom** — drag empty canvas to pan, ⌘/Ctrl-scroll to zoom toward the cursor, **Fit** to frame all windows.
92
+ - **New hub session** — **+ New session** spawns a hub-owned shell that persists until killed.
93
+ - **Detach vs Kill** — **Detach** stops viewing (the session keeps running); **Kill** ends the shell for everyone.
94
+ - **CLI:**
24
95
  ```bash
25
96
  hub status # list live sessions (healthy / ghost / orphan)
26
97
  hub kill <id> # end a session's shell
27
- hub update # swap in a freshly built hub, in place, without dropping live sessions
28
- hub uninstall # revert everything
29
98
  ```
30
99
 
31
- ## Platform notes
100
+ ## Configuration
32
101
 
33
- - **macOS:** the launcher isn't quarantined, so there's no Gatekeeper warning and no code-signing needed.
34
- - **Linux:** the GUI needs the webkit runtime if the window doesn't open, `sudo apt install libwebkit2gtk-4.1-0` (Debian/Ubuntu) or `sudo dnf install webkit2gtk4.1` (Fedora).
35
- - **Windows:** GUI binaries are on the roadmap; not yet published.
102
+ - **Bypass capture for one shell:** `HUB_DISABLE=1` in the environment skips the hook.
103
+ - **Turn capture off globally:** uninstall (below), or comment out the marked block in your rc file.
104
+ - **Scrollback buffer:** set per-tile scrollback in the app's Settings (gear icon, bottom of the sidebar) — applies to newly opened tiles.
105
+ - **Update prompts:** the app checks for updates on launch and every 24h while open; accepting a prompt restarts the app onto the new version.
36
106
 
37
- `npm i -g @term-hub/term-hub` pulls in exactly one platform-specific binary package (`@term-hub/darwin-arm64`, `@term-hub/darwin-x64`, `@term-hub/linux-x64`, or `@term-hub/linux-arm64`) via `optionalDependencies` — npm resolves this automatically based on your OS/CPU, so you never download binaries for a platform you're not on.
107
+ ## Update
38
108
 
39
- ## Uninstall
109
+ The desktop app **self-updates** — it checks for new versions automatically and prompts you to restart when one's available. You don't need to reinstall via npm for GUI updates.
110
+
111
+ The CLI engine can also be swapped in place without dropping live sessions:
40
112
 
41
113
  ```bash
42
- hub uninstall # revert the shell-rc capture hook, stop the background daemon, delete ~/.hub
43
- npm rm -g @term-hub/term-hub # remove the CLI + apps-menu entry
114
+ hub update --bin-src <dir> [--yes]
44
115
  ```
45
116
 
117
+ - `--bin-src <dir>` — copies `hub`, `hub-daemon`, `hub-relay` from `<dir>` into `~/.hub/bin`. Omit it to leave the binaries on disk untouched and just restart the daemon.
118
+ - `--yes` — skip the confirmation prompt.
119
+
120
+ Under the hood: the daemon *process* is stopped and a fresh one started (binaries are swapped via an atomic rename). Your terminals' shells and ptys are owned by independent relay processes that are never touched — the new daemon re-discovers every still-running session on its own startup. Expect a sub-second window where `hub status` / the GUI can't reach the daemon; the sessions themselves never blink.
121
+
122
+ Requires a prior `hub install`; running it without one fails with a clear error rather than doing anything.
123
+
124
+ ## Uninstall
125
+
126
+ - **From the app:** Settings → **Uninstall hub & remove app** — reverts the rc line (preserving any edits you made after install), stops the service, deletes `~/.hub`, and removes the installed app.
127
+ - **From the CLI:**
128
+ ```bash
129
+ hub uninstall # revert the shell-rc capture hook, stop the daemon, delete ~/.hub
130
+ npm rm -g @term-hub/term-hub # remove the CLI + the installed GUI app + apps-menu entry
131
+ ```
132
+
133
+ Either path is a complete, equivalent teardown — `npm uninstall` and the in-app button both leave the exact same clean state. Uninstall is surgical: it removes **only** hub's own block from your rc file and leaves everything else untouched.
134
+
135
+ ## Roadmap
136
+
137
+ - **Windows support (in progress)** — the current engine leans on POSIX (unix sockets, `fork`/`setsid`, termios). Windows needs named-pipe transport, a `CreateProcess`-based detach, console-mode raw handling, a PowerShell-profile capture hook, Windows peer-credential auth, and Task Scheduler autostart.
138
+ - **Layouts & workspaces** — save/restore canvas arrangements; named workspaces.
139
+ - **Persistence options** — opt-in "keep external sessions alive after their terminal closes."
140
+ - **Canvas polish** — edge-resize (not just corner), snap/tiling helpers, minimap.
141
+ - **Remote sessions** — securely attach to relays on another host.
142
+
143
+ ## Security
144
+
145
+ - Every daemon/relay socket requires a per-install token (`~/.hub/token`, mode 0600) **and** a same-uid peer-credential check — a different user can't read the token or connect.
146
+ - Capture is off until you consent, and the injected rc block is guarded (`HUB_ACTIVE` / `HUB_DISABLE` / interactive / tty / `command -v hub`) so it degrades safely and never bricks your shell.
147
+ - The daemon owns no pty and never sees your shell's environment on the wire.
148
+ - The desktop app only ever installs updates whose Ed25519 signature verifies against a pinned public key — a tampered or unsigned build is rejected, both on first install and on every self-update.
149
+
46
150
  ## License
47
151
 
48
152
  MIT
package/assets/icon.icns CHANGED
Binary file
package/assets/icon.ico CHANGED
Binary file
package/assets/icon.png CHANGED
Binary file
@@ -0,0 +1,5 @@
1
+ // Same public key as hub/app/src-tauri/tauri.conf.json's plugins.updater.pubkey.
2
+ // Public data (not the private key) -- duplicating it here is safe; it must
3
+ // stay byte-identical to the tauri.conf.json value or signature checks fail.
4
+ "use strict";
5
+ module.exports = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhGMTMzMTk0MUExQjdBNkUKUldSdWVoc2FsREVUajZnU2JrK1BobGlRdFphZFUrckdPTjFCdmFVTy83MmNtekhkTFdjY2J2dE8K";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@term-hub/term-hub",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Terminal Hub — capture every terminal you open and manage them all from one window. One-command cross-platform install (macOS / Linux / Windows).",
5
5
  "keywords": [
6
6
  "terminal",
@@ -20,6 +20,9 @@
20
20
  "license": "MIT",
21
21
  "author": "Aayush Gour <ag14906@gmail.com>",
22
22
  "type": "commonjs",
23
+ "dependencies": {
24
+ "tar": "^7"
25
+ },
23
26
  "bin": {
24
27
  "term-hub": "bin/term-hub.js",
25
28
  "hub": "bin/hub.js"
@@ -36,10 +39,10 @@
36
39
  },
37
40
  "//": "optionalDependencies: npm installs ONLY the package whose os/cpu matches the host, so a mac user downloads just the darwin binaries, etc. Versions are kept in lockstep with this package and bumped together by CI.",
38
41
  "optionalDependencies": {
39
- "@term-hub/darwin-arm64": "0.1.1",
40
- "@term-hub/darwin-x64": "0.1.1",
41
- "@term-hub/linux-x64": "0.1.1",
42
- "@term-hub/linux-arm64": "0.1.1"
42
+ "@term-hub/darwin-arm64": "0.1.3",
43
+ "@term-hub/darwin-x64": "0.1.3",
44
+ "@term-hub/linux-x64": "0.1.3",
45
+ "@term-hub/linux-arm64": "0.1.3"
43
46
  },
44
47
  "engines": {
45
48
  "node": ">=18"
@@ -1,116 +1,280 @@
1
1
  #!/usr/bin/env node
2
- // Register a desktop launcher so Terminal Hub shows up in the OS apps menu
3
- // (Launchpad / Linux app grid / Start Menu) and is clickable, not just runnable
4
- // from a terminal. Best-effort: any failure here never fails `npm install`.
2
+ // Downloads, verifies, and installs the real signed GUI bundle from the
3
+ // latest GitHub Release -- replaces the old thin-launcher-shim approach now
4
+ // that the app self-updates via tauri-plugin-updater. Best-effort: any
5
+ // failure here never fails `npm install` (the CLI tools are the essential
6
+ // part of this package; the GUI bundle is best-effort convenience).
7
+ //
8
+ // Install-exactly-once + atomic-swap design:
9
+ // 1. Always fetch latest.json first (cheap) and compare its version against
10
+ // whatever's already installed (offline: an Info.plist field on macOS, a
11
+ // version sidecar file on Linux). If already current, skip the (large)
12
+ // download/verify/install entirely -- re-running `npm install -g`
13
+ // repeatedly must not redundantly re-download/re-extract every time.
14
+ // A failure to even reach latest.json (offline, GitHub down) is treated
15
+ // the same as "nothing to do" -- silent, since this runs on every
16
+ // `npm install` and a fully-installed, offline user must not see a
17
+ // scary error for a no-op.
18
+ // 2. The actual install step never leaves a partially-written bundle at the
19
+ // real target path: the new bundle is staged next to the target (same
20
+ // filesystem, so the final step is a single atomic `rename()`), and only
21
+ // once staging succeeds does the swap happen. A crash/interruption
22
+ // before the swap leaves the OLD bundle untouched; the swap itself is a
23
+ // single filesystem syscall that either fully lands or doesn't happen.
5
24
  "use strict";
6
25
 
7
26
  const fs = require("fs");
8
27
  const os = require("os");
9
28
  const path = require("path");
29
+ const crypto = require("crypto");
30
+ const { execFileSync } = require("child_process");
10
31
 
11
- const NAME = "Terminal Hub";
12
- const pkgDir = path.resolve(__dirname, "..");
13
- const assets = path.join(pkgDir, "assets");
32
+ // Releases live in a separate public repo, not the private source repo --
33
+ // GitHub 404s release assets on a private repo for anonymous requests, which
34
+ // this postinstall download always is.
35
+ const REPO = "AayushGour/terminal-hub-releases";
36
+ const LATEST_JSON_URL = `https://github.com/${REPO}/releases/latest/download/latest.json`;
37
+ let PUBKEY = null;
38
+ try {
39
+ PUBKEY = require("../lib/updater-pubkey");
40
+ } catch {
41
+ /* corrupted/partial install -- nothing safe to verify against */
42
+ }
43
+ if (!PUBKEY) process.exit(0);
44
+
45
+ // Runs `fn`, prefixing any thrown error's message with `stageName` so a
46
+ // failure deep in the pipeline (download vs. verify vs. install) is
47
+ // identifiable from the one message `safe()` ultimately prints.
48
+ function stage(stageName, fn) {
49
+ try {
50
+ return fn();
51
+ } catch (e) {
52
+ e.message = `${stageName}: ${e.message}`;
53
+ throw e;
54
+ }
55
+ }
14
56
 
15
- function safe(label, fn) {
57
+ function safe(fn) {
16
58
  try {
17
59
  fn();
18
60
  } catch (e) {
19
- console.error(`Terminal Hub: could not register ${label} (${e.message}). You can still run \`term-hub\`.`);
61
+ console.error(`Terminal Hub: could not install the GUI (${e.message}). The \`hub\`/\`term-hub\` CLI still works; retry the GUI install later with a fresh \`npm install -g @term-hub/term-hub\`.`);
20
62
  }
21
63
  }
22
64
 
23
- // Resolve the GUI binary for this platform; if there isn't one, there's nothing
24
- // to register (e.g. optional deps were skipped, or unsupported platform).
25
- let appBin = null;
65
+ // Only darwin/linux ship a real bundle today (win32 has no prebuilt binary
66
+ // at all yet -- resolve.js's SUPPORTED set already gates that).
67
+ if (process.platform !== "darwin" && process.platform !== "linux") process.exit(0);
68
+
69
+ let hubBin = null;
26
70
  try {
27
- const p = require("../lib/resolve").exe("hub-app");
28
- if (fs.existsSync(p)) appBin = p;
71
+ const p = require("../lib/resolve").exe("hub");
72
+ if (fs.existsSync(p)) hubBin = p;
29
73
  } catch {
30
- /* no binary for this platform */
74
+ /* CLI wasn't installed for this platform either -- nothing to verify with */
31
75
  }
32
- if (!appBin) process.exit(0);
33
-
34
- if (process.platform === "darwin") registerMac();
35
- else if (process.platform === "linux") registerLinux();
36
- else if (process.platform === "win32") registerWindows();
37
-
38
- function registerMac() {
39
- safe("the Applications entry", () => {
40
- const appDir = path.join(os.homedir(), "Applications", `${NAME}.app`);
41
- const macos = path.join(appDir, "Contents", "MacOS");
42
- const res = path.join(appDir, "Contents", "Resources");
43
- fs.mkdirSync(macos, { recursive: true });
44
- fs.mkdirSync(res, { recursive: true });
45
-
46
- const launcher = path.join(macos, "term-hub");
47
- fs.writeFileSync(launcher, `#!/bin/sh\nexec "${appBin}" "$@"\n`);
48
- fs.chmodSync(launcher, 0o755);
49
-
50
- const icns = path.join(assets, "icon.icns");
51
- if (fs.existsSync(icns)) fs.copyFileSync(icns, path.join(res, "icon.icns"));
52
-
53
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
54
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
55
- <plist version="1.0"><dict>
56
- <key>CFBundleName</key><string>${NAME}</string>
57
- <key>CFBundleDisplayName</key><string>${NAME}</string>
58
- <key>CFBundleIdentifier</key><string>dev.hub.launcher</string>
59
- <key>CFBundleExecutable</key><string>term-hub</string>
60
- <key>CFBundleIconFile</key><string>icon</string>
61
- <key>CFBundlePackageType</key><string>APPL</string>
62
- </dict></plist>
63
- `;
64
- fs.writeFileSync(path.join(appDir, "Contents", "Info.plist"), plist);
65
- // A launcher .app created locally (not downloaded) isn't quarantined, so it
66
- // opens with no Gatekeeper warning.
67
- console.log(`Terminal Hub: added to ~/Applications — find it in Launchpad/Spotlight.`);
68
- });
76
+ if (!hubBin) process.exit(0);
77
+
78
+ // Tauri's {{target}}/{{arch}} keys, e.g. "darwin-aarch64", "linux-x86_64".
79
+ const TARGET = { darwin: "darwin", linux: "linux" }[process.platform];
80
+ const ARCH = { x64: "x86_64", arm64: "aarch64" }[process.arch];
81
+ const PLATFORM_KEY = ARCH ? `${TARGET}-${ARCH}` : null;
82
+ if (!PLATFORM_KEY) process.exit(0); // unsupported arch (e.g. ia32) -- nothing to do
83
+
84
+ // Numeric-aware "a >= b" for plain "x.y.z" version strings (no pre-release/
85
+ // build-metadata handling -- this project doesn't publish those).
86
+ function versionAtLeast(a, b) {
87
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
88
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
89
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
90
+ const x = pa[i] || 0, y = pb[i] || 0;
91
+ if (x !== y) return x > y;
92
+ }
93
+ return true; // equal
94
+ }
95
+
96
+ function installedVersionMac() {
97
+ const plistPath = path.join(os.homedir(), "Applications", "Terminal Hub.app", "Contents", "Info.plist");
98
+ try {
99
+ const xml = fs.readFileSync(plistPath, "utf8");
100
+ const m = xml.match(/<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/);
101
+ return m ? m[1] : null;
102
+ } catch {
103
+ return null; // not installed, or unreadable -- treat as "needs install"
104
+ }
105
+ }
106
+
107
+ function installedVersionLinux() {
108
+ const versionFile = path.join(os.homedir(), ".local", "share", "term-hub", "hub-app.version");
109
+ try {
110
+ return fs.readFileSync(versionFile, "utf8").trim() || null;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ // Fetching latest.json is the one step that runs unconditionally on every
117
+ // `npm install`, including when there's nothing to do -- so its failure
118
+ // (offline, GitHub unreachable) must be silent, not routed through safe()'s
119
+ // error message.
120
+ let manifest = null;
121
+ try {
122
+ manifest = fetchJsonSync(LATEST_JSON_URL);
123
+ } catch {
124
+ process.exit(0);
125
+ }
126
+
127
+ const entry = manifest.platforms && manifest.platforms[PLATFORM_KEY];
128
+ if (!entry) process.exit(0); // no release artifact for this platform/arch yet
129
+
130
+ const installed = process.platform === "darwin" ? installedVersionMac() : installedVersionLinux();
131
+ if (installed && versionAtLeast(installed, manifest.version)) {
132
+ process.exit(0); // already current -- install exactly once, never redundantly re-download/re-extract
69
133
  }
70
134
 
71
- function registerLinux() {
72
- safe("the applications menu entry", () => {
73
- const appsDir = path.join(os.homedir(), ".local", "share", "applications");
74
- const iconsDir = path.join(os.homedir(), ".local", "share", "icons");
75
- fs.mkdirSync(appsDir, { recursive: true });
76
- fs.mkdirSync(iconsDir, { recursive: true });
77
-
78
- const png = path.join(assets, "icon.png");
79
- let icon = "utilities-terminal";
80
- if (fs.existsSync(png)) {
81
- fs.copyFileSync(png, path.join(iconsDir, "term-hub.png"));
82
- icon = "term-hub";
135
+ safe(() => {
136
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hub-app-"));
137
+ try {
138
+ const tmpFile = path.join(tmpDir, "artifact");
139
+ stage("download", () => downloadSync(entry.url, tmpFile));
140
+
141
+ stage("signature verification", () =>
142
+ execFileSync(
143
+ hubBin,
144
+ ["verify-update-signature", "--artifact", tmpFile, "--sig-b64", entry.signature, "--pubkey-b64", PUBKEY],
145
+ { stdio: ["ignore", "ignore", "pipe"] },
146
+ ), // throws on non-zero exit
147
+ );
148
+
149
+ stage(process.platform === "darwin" ? "macOS install" : "Linux install", () => {
150
+ if (process.platform === "darwin") installMacAtomic(tmpFile);
151
+ else installLinuxAtomic(tmpFile, manifest.version);
152
+ });
153
+
154
+ console.log(`Terminal Hub: GUI installed (v${manifest.version}).`);
155
+ } finally {
156
+ fs.rmSync(tmpDir, { recursive: true, force: true });
157
+ }
158
+ });
159
+
160
+ function fetchJsonSync(url) {
161
+ // Node's global fetch (18+) is async-only; this script's callers (npm
162
+ // lifecycle hooks) don't await, so route through a tiny sync XHR-style
163
+ // wait using Atomics on a worker would be overkill here -- instead use
164
+ // execFileSync + curl, which every darwin/linux target already ships.
165
+ const out = execFileSync("curl", ["-fsSL", url], { encoding: "utf8" });
166
+ return JSON.parse(out);
167
+ }
168
+
169
+ function downloadSync(url, dest) {
170
+ execFileSync("curl", ["-fsSL", "-o", dest, url]);
171
+ }
172
+
173
+ // Extract the new bundle into a staging dir NEXT TO the real target (same
174
+ // filesystem => the commit step is a single atomic rename()), then swap:
175
+ // old bundle (if any) is renamed aside as a backup, the staged bundle is
176
+ // renamed into the real target path, and only then is the backup removed.
177
+ // If the process dies at any point before "rename staged -> target"
178
+ // completes, the target path still holds the complete OLD bundle (or never
179
+ // existed at all) -- it can never be left half-extracted.
180
+ function installMacAtomic(tarGzPath) {
181
+ const tar = require("tar");
182
+ const appsDir = path.join(os.homedir(), "Applications");
183
+ const appDir = path.join(appsDir, "Terminal Hub.app");
184
+ fs.mkdirSync(appsDir, { recursive: true });
185
+
186
+ // A prior run hard-killed between the two renames below would leave one of
187
+ // these behind; sweep them so they don't accumulate across installs.
188
+ for (const name of fs.readdirSync(appsDir)) {
189
+ if (name.startsWith(".hub-stage-") || name.startsWith(".hub-backup-")) {
190
+ try {
191
+ fs.rmSync(path.join(appsDir, name), { recursive: true, force: true });
192
+ } catch {
193
+ /* best-effort sweep; not this install's problem if it fails */
194
+ }
195
+ }
196
+ }
197
+
198
+ const stageDir = path.join(appsDir, `.hub-stage-${crypto.randomBytes(6).toString("hex")}`);
199
+ fs.mkdirSync(stageDir, { recursive: true });
200
+ let backupDir = null;
201
+ let committed = false;
202
+ try {
203
+ tar.extract({ file: tarGzPath, cwd: stageDir, sync: true });
204
+ const stagedApp = path.join(stageDir, "Terminal Hub.app");
205
+ if (!fs.existsSync(stagedApp)) throw new Error("extracted archive did not contain Terminal Hub.app");
206
+
207
+ if (fs.existsSync(appDir)) {
208
+ backupDir = path.join(appsDir, `.hub-backup-${crypto.randomBytes(6).toString("hex")}`);
209
+ fs.renameSync(appDir, backupDir); // still same filesystem -- atomic
210
+ }
211
+ fs.renameSync(stagedApp, appDir); // the commit point -- install has now succeeded
212
+ committed = true;
213
+ console.log("Terminal Hub: added to ~/Applications — find it in Launchpad/Spotlight.");
214
+ } catch (e) {
215
+ // Roll back: if we'd already moved the old app aside but never reached
216
+ // the commit rename, put it back so the user isn't left without an app.
217
+ if (!committed && backupDir && fs.existsSync(backupDir) && !fs.existsSync(appDir)) {
218
+ fs.renameSync(backupDir, appDir);
83
219
  }
220
+ throw e;
221
+ } finally {
222
+ fs.rmSync(stageDir, { recursive: true, force: true });
223
+ // Cleanup after a successful commit is best-effort -- it must never turn
224
+ // an already-succeeded install into a reported failure.
225
+ if (committed && backupDir) {
226
+ try {
227
+ fs.rmSync(backupDir, { recursive: true, force: true });
228
+ } catch {
229
+ /* orphaned backup dir; harmless, swept on the next install */
230
+ }
231
+ }
232
+ }
233
+ }
84
234
 
85
- const desktop = `[Desktop Entry]
235
+ // AppImages are a single file, so the atomic swap is simpler than macOS's
236
+ // directory case: write the new file next to the target (same dir => same
237
+ // filesystem) and rename() it onto the fixed path in one step.
238
+ function installLinuxAtomic(appImagePath, version) {
239
+ const fixedDir = path.join(os.homedir(), ".local", "share", "term-hub");
240
+ const fixedPath = path.join(fixedDir, "hub-app.AppImage");
241
+ fs.mkdirSync(fixedDir, { recursive: true });
242
+
243
+ const stagedPath = path.join(fixedDir, `.hub-app.AppImage.tmp-${crypto.randomBytes(6).toString("hex")}`);
244
+ try {
245
+ fs.copyFileSync(appImagePath, stagedPath);
246
+ fs.chmodSync(stagedPath, 0o755);
247
+ fs.renameSync(stagedPath, fixedPath); // the commit point -- same filesystem, atomic
248
+ } finally {
249
+ fs.rmSync(stagedPath, { force: true }); // no-op once renamed away; catches a failed rename
250
+ }
251
+
252
+ const iconsDir = path.join(os.homedir(), ".local", "share", "icons");
253
+ fs.mkdirSync(iconsDir, { recursive: true });
254
+ const png = path.join(__dirname, "..", "assets", "icon.png");
255
+ let icon = "utilities-terminal";
256
+ if (fs.existsSync(png)) {
257
+ fs.copyFileSync(png, path.join(iconsDir, "term-hub.png"));
258
+ icon = "term-hub";
259
+ }
260
+
261
+ const appsDir = path.join(os.homedir(), ".local", "share", "applications");
262
+ fs.mkdirSync(appsDir, { recursive: true });
263
+ const desktop = `[Desktop Entry]
86
264
  Type=Application
87
- Name=${NAME}
265
+ Name=Terminal Hub
88
266
  Comment=Capture and manage all your terminals
89
- Exec="${appBin}" %U
267
+ Exec="${fixedPath}" %U
90
268
  Icon=${icon}
91
269
  Terminal=false
92
270
  Categories=Utility;System;TerminalEmulator;
93
271
  `;
94
- fs.writeFileSync(path.join(appsDir, "term-hub.desktop"), desktop);
95
- console.log("Terminal Hub: added to your applications menu.");
96
- });
97
- }
272
+ fs.writeFileSync(path.join(appsDir, "term-hub.desktop"), desktop);
98
273
 
99
- function registerWindows() {
100
- safe("the Start Menu shortcut", () => {
101
- const { execFileSync } = require("child_process");
102
- const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
103
- const startMenu = path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs");
104
- fs.mkdirSync(startMenu, { recursive: true });
105
- const lnk = path.join(startMenu, `${NAME}.lnk`);
106
- const ico = path.join(assets, "icon.ico");
107
- const q = (s) => s.replace(/'/g, "''");
108
- const ps =
109
- `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${q(lnk)}');` +
110
- `$s.TargetPath='${q(appBin)}';` +
111
- (fs.existsSync(ico) ? `$s.IconLocation='${q(ico)}';` : "") +
112
- `$s.Save()`;
113
- execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], { stdio: "ignore" });
114
- console.log("Terminal Hub: added to the Start Menu.");
115
- });
274
+ // Written LAST, only once the binary + menu entry are both in place: the
275
+ // version-skip gate above treats this file's presence/content as "fully
276
+ // installed," so recording it before the desktop entry exists would let a
277
+ // transient desktop-entry failure go unretried on the next `npm install`.
278
+ fs.writeFileSync(path.join(fixedDir, "hub-app.version"), version + "\n");
279
+ console.log("Terminal Hub: added to your applications menu.");
116
280
  }
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
- // Remove the desktop launcher created by postinstall.js on `npm rm -g`.
3
- // Best-effort; never throws.
2
+ // Full-parity uninstall: `npm uninstall -g` must leave the same end state as
3
+ // clicking "Uninstall hub & remove app" in the GUI -- rc files restored,
4
+ // daemon stopped, ~/.hub deleted, the GUI bundle gone, and any running GUI
5
+ // window force-quit. Best-effort throughout; never throws (npm lifecycle
6
+ // hooks that exit non-zero produce noisy, confusing `npm uninstall` output).
4
7
  "use strict";
5
8
 
6
9
  const fs = require("fs");
7
10
  const os = require("os");
8
11
  const path = require("path");
12
+ const { execFileSync } = require("child_process");
9
13
 
10
- const NAME = "Terminal Hub";
11
14
  const rm = (p) => {
12
15
  try {
13
16
  fs.rmSync(p, { recursive: true, force: true });
@@ -16,12 +19,71 @@ const rm = (p) => {
16
19
  }
17
20
  };
18
21
 
19
- if (process.platform === "darwin") {
20
- rm(path.join(os.homedir(), "Applications", `${NAME}.app`));
21
- } else if (process.platform === "linux") {
22
- rm(path.join(os.homedir(), ".local", "share", "applications", "term-hub.desktop"));
23
- rm(path.join(os.homedir(), ".local", "share", "icons", "term-hub.png"));
24
- } else if (process.platform === "win32") {
25
- const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
26
- rm(path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", `${NAME}.lnk`));
22
+ const hubHome = path.join(os.homedir(), ".hub");
23
+
24
+ function isHubCaptureInstalled() {
25
+ return (
26
+ fs.existsSync(path.join(hubHome, "install-manifest.json")) ||
27
+ fs.existsSync(path.join(hubHome, "bin", "hub"))
28
+ );
29
+ }
30
+
31
+ function resolveHubBinary() {
32
+ const selfContained = path.join(hubHome, "bin", "hub");
33
+ if (fs.existsSync(selfContained)) return selfContained;
34
+ try {
35
+ return require("../lib/resolve").exe("hub");
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function revertHubCapture() {
42
+ if (!isHubCaptureInstalled()) return; // nothing to revert
43
+ const hubBin = resolveHubBinary();
44
+ if (!hubBin) {
45
+ console.error("Terminal Hub: hub capture looks installed but no `hub` binary could be found to revert it — ~/.hub was left in place.");
46
+ return;
47
+ }
48
+ try {
49
+ execFileSync(hubBin, ["uninstall", "--yes"], { stdio: ["ignore", "ignore", "pipe"] });
50
+ } catch (e) {
51
+ console.error(`Terminal Hub: \`hub uninstall\` failed (${e.message}) — some state under ~/.hub or your shell rc may remain. Run \`hub uninstall\` manually to retry.`);
52
+ }
53
+ }
54
+
55
+ function forceQuitRunningApp() {
56
+ if (process.platform === "darwin") {
57
+ try {
58
+ execFileSync("pkill", ["-f", "Terminal Hub.app/Contents/MacOS/hub-app"], { stdio: "ignore" });
59
+ } catch {
60
+ /* pkill exits non-zero when nothing matches -- expected, not an error */
61
+ }
62
+ } else if (process.platform === "linux") {
63
+ const fixedPath = path.join(os.homedir(), ".local", "share", "term-hub", "hub-app.AppImage");
64
+ try {
65
+ execFileSync("pkill", ["-f", fixedPath], { stdio: "ignore" });
66
+ } catch {
67
+ /* same as above */
68
+ }
69
+ }
27
70
  }
71
+
72
+ function removeGuiBundle() {
73
+ if (process.platform === "darwin") {
74
+ rm(path.join(os.homedir(), "Applications", "Terminal Hub.app"));
75
+ } else if (process.platform === "linux") {
76
+ rm(path.join(os.homedir(), ".local", "share", "term-hub", "hub-app.AppImage"));
77
+ rm(path.join(os.homedir(), ".local", "share", "term-hub", "hub-app.version"));
78
+ rm(path.join(os.homedir(), ".local", "share", "applications", "term-hub.desktop"));
79
+ rm(path.join(os.homedir(), ".local", "share", "icons", "term-hub.png"));
80
+ } else if (process.platform === "win32") {
81
+ // Unaffected by this change -- Windows GUI distribution is still phase 2.
82
+ const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
83
+ rm(path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Terminal Hub.lnk"));
84
+ }
85
+ }
86
+
87
+ revertHubCapture();
88
+ forceQuitRunningApp();
89
+ removeGuiBundle();