@term-hub/term-hub 0.1.0 → 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 +50 -0
- package/assets/icon.icns +0 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/bin/term-hub.js +39 -15
- package/lib/updater-pubkey.js +5 -0
- package/package.json +15 -7
- package/scripts/postinstall.js +248 -87
- package/scripts/preuninstall.js +73 -11
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
|
|
3
|
+
# Terminal Hub
|
|
4
|
+
|
|
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.
|
|
6
|
+
|
|
7
|
+
Capture is **opt-in, consent-gated, and fully reversible**. Nothing is captured until you say yes; one command reverts everything.
|
|
8
|
+
|
|
9
|
+
> 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.
|
|
10
|
+
|
|
11
|
+
Full docs, architecture, and source: **https://github.com/AayushGour/terminal-hub**
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm i -g @term-hub/term-hub
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
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).
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
- **Launch the GUI:** run `term-hub`, or click **Terminal Hub** in your apps menu.
|
|
24
|
+
- **Enable capture:** on first launch, click **Enable** — then open a new terminal and it shows up automatically.
|
|
25
|
+
- **Manage from the CLI:**
|
|
26
|
+
```bash
|
|
27
|
+
hub status # list live sessions (healthy / ghost / orphan)
|
|
28
|
+
hub kill <id> # end a session's shell
|
|
29
|
+
hub update # swap in a freshly built hub, in place, without dropping live sessions
|
|
30
|
+
hub uninstall # revert everything
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Platform notes
|
|
34
|
+
|
|
35
|
+
- **macOS:** the launcher isn't quarantined, so there's no Gatekeeper warning and no code-signing needed.
|
|
36
|
+
- **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).
|
|
37
|
+
- **Windows:** GUI binaries are on the roadmap; not yet published.
|
|
38
|
+
|
|
39
|
+
`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.
|
|
40
|
+
|
|
41
|
+
## Uninstall
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
hub uninstall # revert the shell-rc capture hook, stop the background daemon, delete ~/.hub
|
|
45
|
+
npm rm -g @term-hub/term-hub # remove the CLI + apps-menu entry
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
package/assets/icon.icns
CHANGED
|
Binary file
|
package/assets/icon.ico
CHANGED
|
Binary file
|
package/assets/icon.png
CHANGED
|
Binary file
|
package/bin/term-hub.js
CHANGED
|
@@ -1,21 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Launch the Terminal Hub GUI
|
|
2
|
+
// Launch the Terminal Hub GUI (the real installed bundle -- postinstall.js
|
|
3
|
+
// puts it at a fixed location per platform, it is NOT shipped as a raw
|
|
4
|
+
// binary inside this npm package anymore, see Task 11's D1 change).
|
|
3
5
|
"use strict";
|
|
4
6
|
|
|
5
7
|
const { spawn } = require("child_process");
|
|
6
|
-
const
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
const path = require("path");
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
function launchDarwin() {
|
|
13
|
+
const appPath = path.join(os.homedir(), "Applications", "Terminal Hub.app");
|
|
14
|
+
if (!fs.existsSync(appPath)) {
|
|
15
|
+
console.error(
|
|
16
|
+
`Terminal Hub: the app isn't installed at ${appPath}. Retry the GUI install with a fresh \`npm install -g @term-hub/term-hub\`.`
|
|
17
|
+
);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
const extraArgs = process.argv.slice(2);
|
|
21
|
+
return spawn("open", [appPath, ...(extraArgs.length ? ["--args", ...extraArgs] : [])], {
|
|
22
|
+
stdio: "ignore",
|
|
23
|
+
detached: true,
|
|
24
|
+
});
|
|
14
25
|
}
|
|
15
26
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
function launchLinux() {
|
|
28
|
+
const appImagePath = path.join(os.homedir(), ".local", "share", "term-hub", "hub-app.AppImage");
|
|
29
|
+
if (!fs.existsSync(appImagePath)) {
|
|
30
|
+
console.error(
|
|
31
|
+
`Terminal Hub: the app isn't installed at ${appImagePath}. Retry the GUI install with a fresh \`npm install -g @term-hub/term-hub\`.`
|
|
32
|
+
);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
// Same webkit2gtk hint as before -- the AppImage needs it at runtime.
|
|
19
36
|
const hasWebkit = ["/usr/lib", "/usr/lib/x86_64-linux-gnu", "/usr/lib/aarch64-linux-gnu", "/usr/lib64"].some(
|
|
20
37
|
(d) => {
|
|
21
38
|
try {
|
|
@@ -32,12 +49,19 @@ if (process.platform === "linux") {
|
|
|
32
49
|
" Fedora: sudo dnf install webkit2gtk4.1"
|
|
33
50
|
);
|
|
34
51
|
}
|
|
52
|
+
return spawn(appImagePath, process.argv.slice(2), { stdio: "ignore", detached: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let child;
|
|
56
|
+
if (process.platform === "darwin") {
|
|
57
|
+
child = launchDarwin();
|
|
58
|
+
} else if (process.platform === "linux") {
|
|
59
|
+
child = launchLinux();
|
|
60
|
+
} else {
|
|
61
|
+
console.error("Terminal Hub: the GUI isn't available on this platform yet.");
|
|
62
|
+
process.exit(1);
|
|
35
63
|
}
|
|
36
64
|
|
|
37
|
-
const child = spawn(bin, process.argv.slice(2), {
|
|
38
|
-
stdio: "ignore",
|
|
39
|
-
detached: true,
|
|
40
|
-
});
|
|
41
65
|
child.on("error", (err) => {
|
|
42
66
|
console.error(`Terminal Hub: failed to launch (${err.message}).`);
|
|
43
67
|
process.exit(1);
|
|
@@ -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.
|
|
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",
|
|
@@ -11,10 +11,18 @@
|
|
|
11
11
|
"terminal-manager",
|
|
12
12
|
"tauri"
|
|
13
13
|
],
|
|
14
|
-
"homepage": "https://github.com/
|
|
14
|
+
"homepage": "https://github.com/AayushGour/terminal-hub#readme",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/AayushGour/terminal-hub.git",
|
|
18
|
+
"directory": "hub/npm/term-hub"
|
|
19
|
+
},
|
|
15
20
|
"license": "MIT",
|
|
16
|
-
"author": "
|
|
21
|
+
"author": "Aayush Gour <ag14906@gmail.com>",
|
|
17
22
|
"type": "commonjs",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"tar": "^7"
|
|
25
|
+
},
|
|
18
26
|
"bin": {
|
|
19
27
|
"term-hub": "bin/term-hub.js",
|
|
20
28
|
"hub": "bin/hub.js"
|
|
@@ -31,10 +39,10 @@
|
|
|
31
39
|
},
|
|
32
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.",
|
|
33
41
|
"optionalDependencies": {
|
|
34
|
-
"@term-hub/darwin-arm64": "0.1.
|
|
35
|
-
"@term-hub/darwin-x64": "0.1.
|
|
36
|
-
"@term-hub/linux-x64": "0.1.
|
|
37
|
-
"@term-hub/linux-arm64": "0.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"
|
|
38
46
|
},
|
|
39
47
|
"engines": {
|
|
40
48
|
"node": ">=18"
|
package/scripts/postinstall.js
CHANGED
|
@@ -1,116 +1,277 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
12
|
-
const
|
|
13
|
-
|
|
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(
|
|
54
|
+
function safe(fn) {
|
|
16
55
|
try {
|
|
17
56
|
fn();
|
|
18
57
|
} catch (e) {
|
|
19
|
-
console.error(`Terminal Hub: could not
|
|
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
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
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
|
|
28
|
-
if (fs.existsSync(p))
|
|
68
|
+
const p = require("../lib/resolve").exe("hub");
|
|
69
|
+
if (fs.existsSync(p)) hubBin = p;
|
|
29
70
|
} catch {
|
|
30
|
-
/*
|
|
71
|
+
/* CLI wasn't installed for this platform either -- nothing to verify with */
|
|
31
72
|
}
|
|
32
|
-
if (!
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
262
|
+
Name=Terminal Hub
|
|
88
263
|
Comment=Capture and manage all your terminals
|
|
89
|
-
Exec="${
|
|
264
|
+
Exec="${fixedPath}" %U
|
|
90
265
|
Icon=${icon}
|
|
91
266
|
Terminal=false
|
|
92
267
|
Categories=Utility;System;TerminalEmulator;
|
|
93
268
|
`;
|
|
94
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
}
|
package/scripts/preuninstall.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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();
|