@term-hub/term-hub 0.1.1 → 0.1.2

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,3 +1,5 @@
1
+ <p align="center"><img src="https://raw.githubusercontent.com/AayushGour/terminal-hub/main/assets/imgs/terminal-cube.svg" alt="Terminal Hub logo" width="120"></p>
2
+
1
3
  # Terminal Hub
2
4
 
3
5
  **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.
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.2",
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.2",
43
+ "@term-hub/darwin-x64": "0.1.2",
44
+ "@term-hub/linux-x64": "0.1.2",
45
+ "@term-hub/linux-arm64": "0.1.2"
43
46
  },
44
47
  "engines": {
45
48
  "node": ">=18"
@@ -1,116 +1,277 @@
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
+ const REPO = "AayushGour/terminal-hub";
33
+ const LATEST_JSON_URL = `https://github.com/${REPO}/releases/latest/download/latest.json`;
34
+ let PUBKEY = null;
35
+ try {
36
+ PUBKEY = require("../lib/updater-pubkey");
37
+ } catch {
38
+ /* corrupted/partial install -- nothing safe to verify against */
39
+ }
40
+ if (!PUBKEY) process.exit(0);
41
+
42
+ // Runs `fn`, prefixing any thrown error's message with `stageName` so a
43
+ // failure deep in the pipeline (download vs. verify vs. install) is
44
+ // identifiable from the one message `safe()` ultimately prints.
45
+ function stage(stageName, fn) {
46
+ try {
47
+ return fn();
48
+ } catch (e) {
49
+ e.message = `${stageName}: ${e.message}`;
50
+ throw e;
51
+ }
52
+ }
14
53
 
15
- function safe(label, fn) {
54
+ function safe(fn) {
16
55
  try {
17
56
  fn();
18
57
  } catch (e) {
19
- console.error(`Terminal Hub: could not register ${label} (${e.message}). You can still run \`term-hub\`.`);
58
+ 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
59
  }
21
60
  }
22
61
 
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;
62
+ // Only darwin/linux ship a real bundle today (win32 has no prebuilt binary
63
+ // at all yet -- resolve.js's SUPPORTED set already gates that).
64
+ if (process.platform !== "darwin" && process.platform !== "linux") process.exit(0);
65
+
66
+ let hubBin = null;
26
67
  try {
27
- const p = require("../lib/resolve").exe("hub-app");
28
- if (fs.existsSync(p)) appBin = p;
68
+ const p = require("../lib/resolve").exe("hub");
69
+ if (fs.existsSync(p)) hubBin = p;
29
70
  } catch {
30
- /* no binary for this platform */
71
+ /* CLI wasn't installed for this platform either -- nothing to verify with */
31
72
  }
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
- });
73
+ if (!hubBin) process.exit(0);
74
+
75
+ // Tauri's {{target}}/{{arch}} keys, e.g. "darwin-aarch64", "linux-x86_64".
76
+ const TARGET = { darwin: "darwin", linux: "linux" }[process.platform];
77
+ const ARCH = { x64: "x86_64", arm64: "aarch64" }[process.arch];
78
+ const PLATFORM_KEY = ARCH ? `${TARGET}-${ARCH}` : null;
79
+ if (!PLATFORM_KEY) process.exit(0); // unsupported arch (e.g. ia32) -- nothing to do
80
+
81
+ // Numeric-aware "a >= b" for plain "x.y.z" version strings (no pre-release/
82
+ // build-metadata handling -- this project doesn't publish those).
83
+ function versionAtLeast(a, b) {
84
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
85
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
86
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
87
+ const x = pa[i] || 0, y = pb[i] || 0;
88
+ if (x !== y) return x > y;
89
+ }
90
+ return true; // equal
91
+ }
92
+
93
+ function installedVersionMac() {
94
+ const plistPath = path.join(os.homedir(), "Applications", "Terminal Hub.app", "Contents", "Info.plist");
95
+ try {
96
+ const xml = fs.readFileSync(plistPath, "utf8");
97
+ const m = xml.match(/<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/);
98
+ return m ? m[1] : null;
99
+ } catch {
100
+ return null; // not installed, or unreadable -- treat as "needs install"
101
+ }
102
+ }
103
+
104
+ function installedVersionLinux() {
105
+ const versionFile = path.join(os.homedir(), ".local", "share", "term-hub", "hub-app.version");
106
+ try {
107
+ return fs.readFileSync(versionFile, "utf8").trim() || null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ // Fetching latest.json is the one step that runs unconditionally on every
114
+ // `npm install`, including when there's nothing to do -- so its failure
115
+ // (offline, GitHub unreachable) must be silent, not routed through safe()'s
116
+ // error message.
117
+ let manifest = null;
118
+ try {
119
+ manifest = fetchJsonSync(LATEST_JSON_URL);
120
+ } catch {
121
+ process.exit(0);
122
+ }
123
+
124
+ const entry = manifest.platforms && manifest.platforms[PLATFORM_KEY];
125
+ if (!entry) process.exit(0); // no release artifact for this platform/arch yet
126
+
127
+ const installed = process.platform === "darwin" ? installedVersionMac() : installedVersionLinux();
128
+ if (installed && versionAtLeast(installed, manifest.version)) {
129
+ process.exit(0); // already current -- install exactly once, never redundantly re-download/re-extract
69
130
  }
70
131
 
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";
132
+ safe(() => {
133
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hub-app-"));
134
+ try {
135
+ const tmpFile = path.join(tmpDir, "artifact");
136
+ stage("download", () => downloadSync(entry.url, tmpFile));
137
+
138
+ stage("signature verification", () =>
139
+ execFileSync(
140
+ hubBin,
141
+ ["verify-update-signature", "--artifact", tmpFile, "--sig-b64", entry.signature, "--pubkey-b64", PUBKEY],
142
+ { stdio: ["ignore", "ignore", "pipe"] },
143
+ ), // throws on non-zero exit
144
+ );
145
+
146
+ stage(process.platform === "darwin" ? "macOS install" : "Linux install", () => {
147
+ if (process.platform === "darwin") installMacAtomic(tmpFile);
148
+ else installLinuxAtomic(tmpFile, manifest.version);
149
+ });
150
+
151
+ console.log(`Terminal Hub: GUI installed (v${manifest.version}).`);
152
+ } finally {
153
+ fs.rmSync(tmpDir, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ function fetchJsonSync(url) {
158
+ // Node's global fetch (18+) is async-only; this script's callers (npm
159
+ // lifecycle hooks) don't await, so route through a tiny sync XHR-style
160
+ // wait using Atomics on a worker would be overkill here -- instead use
161
+ // execFileSync + curl, which every darwin/linux target already ships.
162
+ const out = execFileSync("curl", ["-fsSL", url], { encoding: "utf8" });
163
+ return JSON.parse(out);
164
+ }
165
+
166
+ function downloadSync(url, dest) {
167
+ execFileSync("curl", ["-fsSL", "-o", dest, url]);
168
+ }
169
+
170
+ // Extract the new bundle into a staging dir NEXT TO the real target (same
171
+ // filesystem => the commit step is a single atomic rename()), then swap:
172
+ // old bundle (if any) is renamed aside as a backup, the staged bundle is
173
+ // renamed into the real target path, and only then is the backup removed.
174
+ // If the process dies at any point before "rename staged -> target"
175
+ // completes, the target path still holds the complete OLD bundle (or never
176
+ // existed at all) -- it can never be left half-extracted.
177
+ function installMacAtomic(tarGzPath) {
178
+ const tar = require("tar");
179
+ const appsDir = path.join(os.homedir(), "Applications");
180
+ const appDir = path.join(appsDir, "Terminal Hub.app");
181
+ fs.mkdirSync(appsDir, { recursive: true });
182
+
183
+ // A prior run hard-killed between the two renames below would leave one of
184
+ // these behind; sweep them so they don't accumulate across installs.
185
+ for (const name of fs.readdirSync(appsDir)) {
186
+ if (name.startsWith(".hub-stage-") || name.startsWith(".hub-backup-")) {
187
+ try {
188
+ fs.rmSync(path.join(appsDir, name), { recursive: true, force: true });
189
+ } catch {
190
+ /* best-effort sweep; not this install's problem if it fails */
191
+ }
192
+ }
193
+ }
194
+
195
+ const stageDir = path.join(appsDir, `.hub-stage-${crypto.randomBytes(6).toString("hex")}`);
196
+ fs.mkdirSync(stageDir, { recursive: true });
197
+ let backupDir = null;
198
+ let committed = false;
199
+ try {
200
+ tar.extract({ file: tarGzPath, cwd: stageDir, sync: true });
201
+ const stagedApp = path.join(stageDir, "Terminal Hub.app");
202
+ if (!fs.existsSync(stagedApp)) throw new Error("extracted archive did not contain Terminal Hub.app");
203
+
204
+ if (fs.existsSync(appDir)) {
205
+ backupDir = path.join(appsDir, `.hub-backup-${crypto.randomBytes(6).toString("hex")}`);
206
+ fs.renameSync(appDir, backupDir); // still same filesystem -- atomic
207
+ }
208
+ fs.renameSync(stagedApp, appDir); // the commit point -- install has now succeeded
209
+ committed = true;
210
+ console.log("Terminal Hub: added to ~/Applications — find it in Launchpad/Spotlight.");
211
+ } catch (e) {
212
+ // Roll back: if we'd already moved the old app aside but never reached
213
+ // the commit rename, put it back so the user isn't left without an app.
214
+ if (!committed && backupDir && fs.existsSync(backupDir) && !fs.existsSync(appDir)) {
215
+ fs.renameSync(backupDir, appDir);
83
216
  }
217
+ throw e;
218
+ } finally {
219
+ fs.rmSync(stageDir, { recursive: true, force: true });
220
+ // Cleanup after a successful commit is best-effort -- it must never turn
221
+ // an already-succeeded install into a reported failure.
222
+ if (committed && backupDir) {
223
+ try {
224
+ fs.rmSync(backupDir, { recursive: true, force: true });
225
+ } catch {
226
+ /* orphaned backup dir; harmless, swept on the next install */
227
+ }
228
+ }
229
+ }
230
+ }
84
231
 
85
- const desktop = `[Desktop Entry]
232
+ // AppImages are a single file, so the atomic swap is simpler than macOS's
233
+ // directory case: write the new file next to the target (same dir => same
234
+ // filesystem) and rename() it onto the fixed path in one step.
235
+ function installLinuxAtomic(appImagePath, version) {
236
+ const fixedDir = path.join(os.homedir(), ".local", "share", "term-hub");
237
+ const fixedPath = path.join(fixedDir, "hub-app.AppImage");
238
+ fs.mkdirSync(fixedDir, { recursive: true });
239
+
240
+ const stagedPath = path.join(fixedDir, `.hub-app.AppImage.tmp-${crypto.randomBytes(6).toString("hex")}`);
241
+ try {
242
+ fs.copyFileSync(appImagePath, stagedPath);
243
+ fs.chmodSync(stagedPath, 0o755);
244
+ fs.renameSync(stagedPath, fixedPath); // the commit point -- same filesystem, atomic
245
+ } finally {
246
+ fs.rmSync(stagedPath, { force: true }); // no-op once renamed away; catches a failed rename
247
+ }
248
+
249
+ const iconsDir = path.join(os.homedir(), ".local", "share", "icons");
250
+ fs.mkdirSync(iconsDir, { recursive: true });
251
+ const png = path.join(__dirname, "..", "assets", "icon.png");
252
+ let icon = "utilities-terminal";
253
+ if (fs.existsSync(png)) {
254
+ fs.copyFileSync(png, path.join(iconsDir, "term-hub.png"));
255
+ icon = "term-hub";
256
+ }
257
+
258
+ const appsDir = path.join(os.homedir(), ".local", "share", "applications");
259
+ fs.mkdirSync(appsDir, { recursive: true });
260
+ const desktop = `[Desktop Entry]
86
261
  Type=Application
87
- Name=${NAME}
262
+ Name=Terminal Hub
88
263
  Comment=Capture and manage all your terminals
89
- Exec="${appBin}" %U
264
+ Exec="${fixedPath}" %U
90
265
  Icon=${icon}
91
266
  Terminal=false
92
267
  Categories=Utility;System;TerminalEmulator;
93
268
  `;
94
- fs.writeFileSync(path.join(appsDir, "term-hub.desktop"), desktop);
95
- console.log("Terminal Hub: added to your applications menu.");
96
- });
97
- }
269
+ fs.writeFileSync(path.join(appsDir, "term-hub.desktop"), desktop);
98
270
 
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
- });
271
+ // Written LAST, only once the binary + menu entry are both in place: the
272
+ // version-skip gate above treats this file's presence/content as "fully
273
+ // installed," so recording it before the desktop entry exists would let a
274
+ // transient desktop-entry failure go unretried on the next `npm install`.
275
+ fs.writeFileSync(path.join(fixedDir, "hub-app.version"), version + "\n");
276
+ console.log("Terminal Hub: added to your applications menu.");
116
277
  }
@@ -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();