@statechange/ssh-tunnel-manager 0.1.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/dist/app.mjs +563 -0
- package/dist/cli.mjs +4194 -0
- package/dist/preload.js +10 -0
- package/package.json +27 -0
- package/renderer/add-tunnel.html +127 -0
- package/renderer/add-tunnel.js +48 -0
- package/scripts/postinstall.mjs +117 -0
package/dist/preload.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// src/app/preload.ts
|
|
4
|
+
var import_electron = require("electron");
|
|
5
|
+
import_electron.contextBridge.exposeInMainWorld("tunnelApi", {
|
|
6
|
+
getStatuses: () => import_electron.ipcRenderer.invoke("get-statuses"),
|
|
7
|
+
addTunnel: (data) => import_electron.ipcRenderer.invoke("add-tunnel", data),
|
|
8
|
+
toggleTunnel: (id) => import_electron.ipcRenderer.invoke("toggle-tunnel", id),
|
|
9
|
+
removeTunnel: (id) => import_electron.ipcRenderer.invoke("remove-tunnel", id)
|
|
10
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@statechange/ssh-tunnel-manager",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"ssh-tunnels": "dist/cli.mjs"
|
|
7
|
+
},
|
|
8
|
+
"main": "dist/cli.mjs",
|
|
9
|
+
"files": ["dist/", "renderer/", "scripts/postinstall.mjs"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "node scripts/build.mjs",
|
|
12
|
+
"postinstall": "node scripts/postinstall.mjs",
|
|
13
|
+
"dev:app": "electron dist/app.mjs"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"chokidar": "^4.0.3",
|
|
17
|
+
"tree-kill": "^1.2.2",
|
|
18
|
+
"commander": "^13.0.0",
|
|
19
|
+
"electron": "^34.0.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^5.7.0",
|
|
23
|
+
"@types/node": "^22.0.0",
|
|
24
|
+
"esbuild": "^0.24.0",
|
|
25
|
+
"electron-builder": "^25.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'unsafe-inline'">
|
|
6
|
+
<title>Add SSH Tunnel</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
11
|
+
padding: 20px;
|
|
12
|
+
background: #f5f5f7;
|
|
13
|
+
color: #1d1d1f;
|
|
14
|
+
font-size: 13px;
|
|
15
|
+
}
|
|
16
|
+
h2 { font-size: 16px; margin-bottom: 16px; font-weight: 600; }
|
|
17
|
+
.form-group {
|
|
18
|
+
margin-bottom: 12px;
|
|
19
|
+
}
|
|
20
|
+
label {
|
|
21
|
+
display: block;
|
|
22
|
+
font-weight: 500;
|
|
23
|
+
margin-bottom: 4px;
|
|
24
|
+
color: #6e6e73;
|
|
25
|
+
}
|
|
26
|
+
input {
|
|
27
|
+
width: 100%;
|
|
28
|
+
padding: 8px 10px;
|
|
29
|
+
border: 1px solid #d2d2d7;
|
|
30
|
+
border-radius: 6px;
|
|
31
|
+
font-size: 13px;
|
|
32
|
+
background: white;
|
|
33
|
+
outline: none;
|
|
34
|
+
transition: border-color 0.2s;
|
|
35
|
+
}
|
|
36
|
+
input:focus { border-color: #0071e3; }
|
|
37
|
+
.row { display: flex; gap: 12px; }
|
|
38
|
+
.row .form-group { flex: 1; }
|
|
39
|
+
.checkbox-group {
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
gap: 8px;
|
|
43
|
+
margin-bottom: 12px;
|
|
44
|
+
}
|
|
45
|
+
.checkbox-group input { width: auto; }
|
|
46
|
+
.actions {
|
|
47
|
+
display: flex;
|
|
48
|
+
justify-content: flex-end;
|
|
49
|
+
gap: 8px;
|
|
50
|
+
margin-top: 16px;
|
|
51
|
+
}
|
|
52
|
+
button {
|
|
53
|
+
padding: 8px 16px;
|
|
54
|
+
border-radius: 6px;
|
|
55
|
+
border: 1px solid #d2d2d7;
|
|
56
|
+
background: white;
|
|
57
|
+
font-size: 13px;
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
}
|
|
60
|
+
button:hover { background: #f0f0f0; }
|
|
61
|
+
button.primary {
|
|
62
|
+
background: #0071e3;
|
|
63
|
+
color: white;
|
|
64
|
+
border-color: #0071e3;
|
|
65
|
+
}
|
|
66
|
+
button.primary:hover { background: #0077ED; }
|
|
67
|
+
.error {
|
|
68
|
+
color: #FF3B30;
|
|
69
|
+
margin-top: 8px;
|
|
70
|
+
display: none;
|
|
71
|
+
}
|
|
72
|
+
</style>
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
<h2>Add SSH Tunnel</h2>
|
|
76
|
+
<form id="tunnel-form">
|
|
77
|
+
<div class="form-group">
|
|
78
|
+
<label for="name">Name</label>
|
|
79
|
+
<input type="text" id="name" placeholder="Production Postgres" required>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="row">
|
|
82
|
+
<div class="form-group">
|
|
83
|
+
<label for="host">SSH Host</label>
|
|
84
|
+
<input type="text" id="host" placeholder="bastion.example.com" required>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="form-group">
|
|
87
|
+
<label for="user">User</label>
|
|
88
|
+
<input type="text" id="user" placeholder="ray">
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="row">
|
|
92
|
+
<div class="form-group">
|
|
93
|
+
<label for="localPort">Local Port</label>
|
|
94
|
+
<input type="number" id="localPort" placeholder="5433" required>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="form-group">
|
|
97
|
+
<label for="remoteHost">Remote Host</label>
|
|
98
|
+
<input type="text" id="remoteHost" placeholder="db.internal" required>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="form-group">
|
|
101
|
+
<label for="remotePort">Remote Port</label>
|
|
102
|
+
<input type="number" id="remotePort" placeholder="5432" required>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="row">
|
|
106
|
+
<div class="form-group">
|
|
107
|
+
<label for="identityFile">Identity File (optional)</label>
|
|
108
|
+
<input type="text" id="identityFile" placeholder="~/.ssh/id_ed25519">
|
|
109
|
+
</div>
|
|
110
|
+
<div class="form-group">
|
|
111
|
+
<label for="sshPort">SSH Port</label>
|
|
112
|
+
<input type="number" id="sshPort" placeholder="22" value="22">
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
<div class="checkbox-group">
|
|
116
|
+
<input type="checkbox" id="enabled">
|
|
117
|
+
<label for="enabled" style="margin-bottom: 0; color: #1d1d1f;">Enable immediately</label>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="error" id="error"></div>
|
|
120
|
+
<div class="actions">
|
|
121
|
+
<button type="button" id="cancel-btn">Cancel</button>
|
|
122
|
+
<button type="submit" class="primary">Add Tunnel</button>
|
|
123
|
+
</div>
|
|
124
|
+
</form>
|
|
125
|
+
<script src="add-tunnel.js"></script>
|
|
126
|
+
</body>
|
|
127
|
+
</html>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const form = document.getElementById("tunnel-form");
|
|
2
|
+
const errorEl = document.getElementById("error");
|
|
3
|
+
const cancelBtn = document.getElementById("cancel-btn");
|
|
4
|
+
|
|
5
|
+
console.log("tunnelApi available:", !!window.tunnelApi);
|
|
6
|
+
if (!window.tunnelApi) {
|
|
7
|
+
errorEl.textContent = "Error: tunnelApi not available (preload script failed to load)";
|
|
8
|
+
errorEl.style.display = "block";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
cancelBtn.addEventListener("click", () => {
|
|
12
|
+
window.close();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
form.addEventListener("submit", async (e) => {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
errorEl.style.display = "none";
|
|
18
|
+
|
|
19
|
+
if (!window.tunnelApi) {
|
|
20
|
+
errorEl.textContent = "Error: tunnelApi not available";
|
|
21
|
+
errorEl.style.display = "block";
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const data = {
|
|
26
|
+
name: document.getElementById("name").value.trim(),
|
|
27
|
+
host: document.getElementById("host").value.trim(),
|
|
28
|
+
user: document.getElementById("user").value.trim() || undefined,
|
|
29
|
+
localPort: parseInt(document.getElementById("localPort").value, 10),
|
|
30
|
+
remoteHost: document.getElementById("remoteHost").value.trim(),
|
|
31
|
+
remotePort: parseInt(document.getElementById("remotePort").value, 10),
|
|
32
|
+
identityFile: document.getElementById("identityFile").value.trim() || undefined,
|
|
33
|
+
sshPort: parseInt(document.getElementById("sshPort").value, 10) || 22,
|
|
34
|
+
enabled: document.getElementById("enabled").checked,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
console.log("Submitting tunnel data:", JSON.stringify(data));
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const result = await window.tunnelApi.addTunnel(data);
|
|
41
|
+
console.log("Add tunnel result:", result);
|
|
42
|
+
window.close();
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error("Add tunnel error:", err);
|
|
45
|
+
errorEl.textContent = err.message || "Failed to add tunnel";
|
|
46
|
+
errorEl.style.display = "block";
|
|
47
|
+
}
|
|
48
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir, platform } from "node:os";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const home = homedir();
|
|
9
|
+
|
|
10
|
+
// 1. Create directories
|
|
11
|
+
const baseDir = join(home, ".ssh-tunnels");
|
|
12
|
+
const pidsDir = join(baseDir, "pids");
|
|
13
|
+
const historyDir = join(baseDir, "history");
|
|
14
|
+
|
|
15
|
+
await mkdir(baseDir, { recursive: true });
|
|
16
|
+
await mkdir(pidsDir, { recursive: true });
|
|
17
|
+
await mkdir(historyDir, { recursive: true });
|
|
18
|
+
console.log(`Created directories: ${baseDir}/{pids,history}`);
|
|
19
|
+
|
|
20
|
+
// 2. macOS LaunchAgent setup
|
|
21
|
+
if (platform() !== "darwin") {
|
|
22
|
+
console.log("Not macOS — skipping LaunchAgent setup.");
|
|
23
|
+
console.log("Done.");
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Resolve paths
|
|
28
|
+
const packageRoot = join(__dirname, "..");
|
|
29
|
+
const appEntry = join(packageRoot, "dist", "app.mjs");
|
|
30
|
+
|
|
31
|
+
// Find node and npx absolute paths
|
|
32
|
+
let nodeBin;
|
|
33
|
+
try {
|
|
34
|
+
nodeBin = execSync("which node", { encoding: "utf-8" }).trim();
|
|
35
|
+
} catch {
|
|
36
|
+
console.warn("Could not find node in PATH — skipping LaunchAgent setup.");
|
|
37
|
+
console.log("Done (dirs created).");
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let npxBin;
|
|
42
|
+
try {
|
|
43
|
+
npxBin = execSync("which npx", { encoding: "utf-8" }).trim();
|
|
44
|
+
} catch {
|
|
45
|
+
npxBin = join(dirname(nodeBin), "npx");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const nodeBinDir = dirname(nodeBin);
|
|
49
|
+
|
|
50
|
+
// 3. Write LaunchAgent plist
|
|
51
|
+
const launchAgentsDir = join(home, "Library", "LaunchAgents");
|
|
52
|
+
await mkdir(launchAgentsDir, { recursive: true });
|
|
53
|
+
|
|
54
|
+
const plistPath = join(launchAgentsDir, "com.statechange.ssh-tunnel-manager.plist");
|
|
55
|
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
56
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
57
|
+
<plist version="1.0">
|
|
58
|
+
<dict>
|
|
59
|
+
<key>Label</key>
|
|
60
|
+
<string>com.statechange.ssh-tunnel-manager</string>
|
|
61
|
+
<key>ProgramArguments</key>
|
|
62
|
+
<array>
|
|
63
|
+
<string>${npxBin}</string>
|
|
64
|
+
<string>electron</string>
|
|
65
|
+
<string>${appEntry}</string>
|
|
66
|
+
</array>
|
|
67
|
+
<key>WorkingDirectory</key>
|
|
68
|
+
<string>${packageRoot}</string>
|
|
69
|
+
<key>EnvironmentVariables</key>
|
|
70
|
+
<dict>
|
|
71
|
+
<key>PATH</key>
|
|
72
|
+
<string>${nodeBinDir}:/usr/local/bin:/usr/bin:/bin</string>
|
|
73
|
+
<key>HOME</key>
|
|
74
|
+
<string>${home}</string>
|
|
75
|
+
</dict>
|
|
76
|
+
<key>RunAtLoad</key>
|
|
77
|
+
<true/>
|
|
78
|
+
<key>KeepAlive</key>
|
|
79
|
+
<false/>
|
|
80
|
+
<key>StandardOutPath</key>
|
|
81
|
+
<string>${historyDir}/app-stdout.log</string>
|
|
82
|
+
<key>StandardErrorPath</key>
|
|
83
|
+
<string>${historyDir}/app-stderr.log</string>
|
|
84
|
+
</dict>
|
|
85
|
+
</plist>
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
// Unload existing agent before overwriting (idempotent)
|
|
89
|
+
try {
|
|
90
|
+
execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" });
|
|
91
|
+
} catch {
|
|
92
|
+
// Not loaded — that's fine
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await writeFile(plistPath, plistContent, "utf-8");
|
|
96
|
+
console.log(`Wrote LaunchAgent: ${plistPath}`);
|
|
97
|
+
|
|
98
|
+
// 4. Load the LaunchAgent
|
|
99
|
+
try {
|
|
100
|
+
execSync(`launchctl load "${plistPath}"`, { stdio: "inherit" });
|
|
101
|
+
console.log("LaunchAgent loaded (menu bar app will start).");
|
|
102
|
+
} catch {
|
|
103
|
+
console.warn("Failed to load LaunchAgent — you may need to load it manually:");
|
|
104
|
+
console.warn(` launchctl load "${plistPath}"`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 5. Summary
|
|
108
|
+
console.log("");
|
|
109
|
+
console.log("SSH Tunnel Manager installed:");
|
|
110
|
+
console.log(` CLI command: ssh-tunnels`);
|
|
111
|
+
console.log(` Config file: ${join(baseDir, "config.json")}`);
|
|
112
|
+
console.log(` PID files: ${pidsDir}/`);
|
|
113
|
+
console.log(` Log files: ${historyDir}/`);
|
|
114
|
+
console.log(` LaunchAgent: ${plistPath}`);
|
|
115
|
+
console.log(` Menu bar app: ${appEntry}`);
|
|
116
|
+
console.log("");
|
|
117
|
+
console.log("Get started: ssh-tunnels status");
|