@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.
@@ -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");