onedeploy-cli 0.1.10 → 0.1.11

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/browser.js CHANGED
@@ -9,12 +9,7 @@ export const openBrowser = async (url) => {
9
9
  case "linux": {
10
10
  if (isRoot && sudoUser) {
11
11
  // Run as the original user to avoid browser sandbox issues
12
- await promisify(execFile)("sudo", [
13
- "-u",
14
- sudoUser,
15
- "xdg-open",
16
- url,
17
- ]);
12
+ await promisify(execFile)("sudo", ["-u", sudoUser, "xdg-open", url]);
18
13
  }
19
14
  else {
20
15
  await promisify(execFile)("xdg-open", [url]);
package/dist/index.js CHANGED
@@ -1,7 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
  import prompts from "prompts";
3
3
  import { loadConfig, saveConfig } from "./config.js";
4
- import { cleanupNebula, connectNebula, installNebula, isNebulaRunning, } from "./nebula.js";
4
+ import { cleanupNebula, getSession, installNebula, isNebulaRunning, startNebula, stopNebula, } from "./nebula.js";
5
+ import { deleteAllTunnels } from "./tunnel.js";
6
+ import { tunnelMenu } from "./tunnel-menu.js";
7
+ // Track active connection for cleanup on signals
8
+ let activeConnection = null;
9
+ const cleanup = async () => {
10
+ if (activeConnection) {
11
+ console.log("\nCleaning up...");
12
+ try {
13
+ await deleteAllTunnels(activeConnection.instance, activeConnection.session);
14
+ }
15
+ catch {
16
+ // ignore if already disconnected
17
+ }
18
+ activeConnection.stopRefresh();
19
+ stopNebula(activeConnection.nebulaProcess);
20
+ activeConnection = null;
21
+ }
22
+ await cleanupNebula();
23
+ };
24
+ const handleSignal = () => {
25
+ if (!isNebulaRunning) {
26
+ process.exit();
27
+ }
28
+ // Perform cleanup and exit
29
+ cleanup().finally(() => process.exit());
30
+ };
5
31
  if (process.argv.includes("--version")) {
6
32
  console.log("onedeploy-cli version 0.1.4");
7
33
  process.exit();
@@ -109,9 +135,30 @@ const mainMenu = async () => {
109
135
  else {
110
136
  urlToConnect = selection;
111
137
  }
112
- // Connect first
113
138
  const instance = { url: urlToConnect, apiKey: "" };
114
- await connectNebula(instance);
139
+ // 1. Authenticate
140
+ const session = await getSession(instance);
141
+ // 2. Start Nebula (non-blocking)
142
+ const { nebulaProcess, stopRefresh } = await startNebula(instance, session);
143
+ // Track connection for signal handlers
144
+ activeConnection = { instance, session, nebulaProcess, stopRefresh };
145
+ // 3. Enter tunnel management loop
146
+ try {
147
+ await tunnelMenu(instance, session);
148
+ }
149
+ finally {
150
+ // 4. Cleanup on exit
151
+ console.log("Cleaning up tunnels...");
152
+ try {
153
+ await deleteAllTunnels(instance, session);
154
+ }
155
+ catch {
156
+ // ignore if already disconnected
157
+ }
158
+ stopRefresh();
159
+ stopNebula(nebulaProcess);
160
+ activeConnection = null;
161
+ }
115
162
  // Only save if connection was successful (didn't throw)
116
163
  await addInstance(urlToConnect);
117
164
  return true;
@@ -120,8 +167,11 @@ process.on("SIGINT", () => {
120
167
  if (!isNebulaRunning) {
121
168
  process.exit();
122
169
  }
123
- // ignore SIGINTs to continue after Nebula is disconnected
170
+ // When Nebula is running, let prompts handle SIGINT (returns undefined)
171
+ // which triggers the finally block in mainMenu
124
172
  });
173
+ process.on("SIGTERM", handleSignal);
174
+ process.on("SIGHUP", handleSignal);
125
175
  await installNebula();
126
176
  try {
127
177
  while (true) {
@@ -132,5 +182,5 @@ try {
132
182
  }
133
183
  }
134
184
  finally {
135
- await cleanupNebula();
185
+ await cleanup();
136
186
  }
package/dist/nebula.js CHANGED
@@ -107,6 +107,10 @@ export const getNebulaCert = async (instance, session) => {
107
107
  `${tmpDir}/nebula-debug.crt`,
108
108
  ]);
109
109
  const cert = JSON.parse(stdout);
110
+ // Extract Nebula IP from certificate (remove CIDR suffix)
111
+ if (cert.details.networks && cert.details.networks.length > 0) {
112
+ nebulaIp = cert.details.networks[0].split("/")[0];
113
+ }
110
114
  // in case our local time isn't correct, wait for the certificate to become valid
111
115
  const validFromDate = new Date(cert.details.notBefore);
112
116
  if (validFromDate.toString() === "Invalid Date") {
@@ -119,7 +123,8 @@ export const getNebulaCert = async (instance, session) => {
119
123
  }
120
124
  };
121
125
  export let isNebulaRunning = false;
122
- const getSession = (instance) => {
126
+ export let nebulaIp = null;
127
+ export const getSession = (instance) => {
123
128
  return new Promise((resolve) => {
124
129
  const getServerAddr = () => {
125
130
  const addr = server.address();
@@ -165,17 +170,21 @@ const getSession = (instance) => {
165
170
  server.listen();
166
171
  });
167
172
  };
168
- export const connectNebula = async (instance) => {
169
- const session = await getSession(instance);
173
+ export const startNebula = async (instance, session) => {
170
174
  await getNebulaCert(instance, session);
171
175
  isNebulaRunning = true;
172
- const nebula = spawn(`${nebulaDir}/nebula`, ["-config", `${tmpDir}/nebula.yml`], { stdio: "inherit" });
176
+ const nebula = spawn(`${nebulaDir}/nebula`, ["-config", `${tmpDir}/nebula.yml`], { stdio: "ignore" });
173
177
  const interval = setInterval(async () => {
174
178
  // refresh Nebula cert every 10 minutes (lifetime is 15 minutes)
175
179
  await getNebulaCert(instance, session);
176
180
  nebula.kill("SIGHUP");
177
181
  }, 10 * 60 * 1000);
178
- await new Promise((resolve) => nebula.on("exit", resolve));
182
+ return {
183
+ nebulaProcess: nebula,
184
+ stopRefresh: () => clearInterval(interval),
185
+ };
186
+ };
187
+ export const stopNebula = (nebulaProcess) => {
188
+ nebulaProcess.kill("SIGTERM");
179
189
  isNebulaRunning = false;
180
- clearInterval(interval);
181
190
  };
package/dist/proxy.js ADDED
@@ -0,0 +1,69 @@
1
+ import { createServer, Socket } from "node:net";
2
+ // Track active proxies by port
3
+ const activeProxies = new Map();
4
+ const connectToLocalhost = (port, clientSocket) => {
5
+ const targetSocket = new Socket();
6
+ const tryConnect = (host, fallback) => {
7
+ targetSocket.connect(port, host, () => {
8
+ clientSocket.pipe(targetSocket);
9
+ targetSocket.pipe(clientSocket);
10
+ });
11
+ targetSocket.once("error", (err) => {
12
+ if (err.code === "ECONNREFUSED" && fallback) {
13
+ // Try fallback address
14
+ targetSocket.removeAllListeners();
15
+ const fallbackSocket = new Socket();
16
+ fallbackSocket.connect(port, fallback, () => {
17
+ clientSocket.pipe(fallbackSocket);
18
+ fallbackSocket.pipe(clientSocket);
19
+ });
20
+ fallbackSocket.on("error", () => clientSocket.destroy());
21
+ clientSocket.on("error", () => fallbackSocket.destroy());
22
+ clientSocket.on("close", () => fallbackSocket.destroy());
23
+ fallbackSocket.on("close", () => clientSocket.destroy());
24
+ }
25
+ else {
26
+ clientSocket.destroy();
27
+ }
28
+ });
29
+ };
30
+ tryConnect("127.0.0.1", "::1");
31
+ clientSocket.on("error", () => targetSocket.destroy());
32
+ clientSocket.on("close", () => targetSocket.destroy());
33
+ targetSocket.on("close", () => clientSocket.destroy());
34
+ };
35
+ export const startProxy = (port, bindIp) => {
36
+ return new Promise((resolve) => {
37
+ const server = createServer((clientSocket) => {
38
+ connectToLocalhost(port, clientSocket);
39
+ });
40
+ server.on("error", (err) => {
41
+ if (err.code === "EADDRINUSE") {
42
+ // App already binds to this IP, no proxy needed
43
+ resolve(false);
44
+ }
45
+ else {
46
+ // Other error, log and continue without proxy
47
+ console.error(`Proxy error on port ${port}: ${err.message}`);
48
+ resolve(false);
49
+ }
50
+ });
51
+ server.listen(port, bindIp, () => {
52
+ activeProxies.set(port, server);
53
+ resolve(true);
54
+ });
55
+ });
56
+ };
57
+ export const stopProxy = (port) => {
58
+ const server = activeProxies.get(port);
59
+ if (server) {
60
+ server.close();
61
+ activeProxies.delete(port);
62
+ }
63
+ };
64
+ export const stopAllProxies = () => {
65
+ for (const [port, server] of activeProxies) {
66
+ server.close();
67
+ activeProxies.delete(port);
68
+ }
69
+ };
@@ -0,0 +1,169 @@
1
+ import prompts from "prompts";
2
+ import { createTunnel, deleteAllTunnels, deleteTunnel, getActiveTunnels, validateName, validatePort, } from "./tunnel.js";
3
+ const MENU_CREATE = "create";
4
+ const MENU_DELETE = "delete";
5
+ const MENU_DELETE_ALL = "delete_all";
6
+ const MENU_DISCONNECT = "disconnect";
7
+ const displayActiveTunnels = () => {
8
+ const tunnels = getActiveTunnels();
9
+ console.log("\n--- Active Tunnels ---");
10
+ if (tunnels.length === 0) {
11
+ console.log(" (no active tunnels)");
12
+ }
13
+ else {
14
+ for (const tunnel of tunnels) {
15
+ const nameDisplay = tunnel.name ? ` (${tunnel.name})` : "";
16
+ console.log(` https://${tunnel.hostname}${nameDisplay} → localhost:${tunnel.port}`);
17
+ }
18
+ }
19
+ console.log("");
20
+ };
21
+ const createTunnelPrompt = async (instance, session) => {
22
+ const { port } = await prompts({
23
+ type: "number",
24
+ name: "port",
25
+ message: "Enter port number",
26
+ validate: (value) => {
27
+ const error = validatePort(value);
28
+ return error ?? true;
29
+ },
30
+ });
31
+ if (port === undefined) {
32
+ return;
33
+ }
34
+ const { name } = await prompts({
35
+ type: "text",
36
+ name: "name",
37
+ message: "Enter tunnel name (optional, press Enter to skip)",
38
+ validate: (value) => {
39
+ if (!value)
40
+ return true;
41
+ const error = validateName(value);
42
+ return error ?? true;
43
+ },
44
+ });
45
+ if (name === undefined) {
46
+ return;
47
+ }
48
+ try {
49
+ await createTunnel(instance, session, port, name || undefined);
50
+ }
51
+ catch (error) {
52
+ if (error instanceof Error) {
53
+ console.error(`\nFailed to create tunnel: ${error.message}`);
54
+ }
55
+ else {
56
+ console.error("\nFailed to create tunnel");
57
+ }
58
+ }
59
+ };
60
+ const deleteTunnelPrompt = async (instance, session) => {
61
+ const tunnels = getActiveTunnels();
62
+ if (tunnels.length === 0) {
63
+ console.log("\nNo tunnels to delete.");
64
+ return;
65
+ }
66
+ const { tunnelId } = await prompts({
67
+ type: "select",
68
+ name: "tunnelId",
69
+ message: "Select tunnel to delete",
70
+ choices: tunnels.map((tunnel) => {
71
+ const nameDisplay = tunnel.name ? ` (${tunnel.name})` : "";
72
+ return {
73
+ title: `https://${tunnel.hostname}${nameDisplay} → localhost:${tunnel.port}`,
74
+ value: tunnel.id,
75
+ };
76
+ }),
77
+ });
78
+ if (tunnelId === undefined) {
79
+ return;
80
+ }
81
+ const { confirm } = await prompts({
82
+ type: "confirm",
83
+ name: "confirm",
84
+ message: "Are you sure you want to delete this tunnel?",
85
+ initial: false,
86
+ });
87
+ if (confirm !== true) {
88
+ return;
89
+ }
90
+ try {
91
+ await deleteTunnel(instance, session, tunnelId);
92
+ console.log("\nTunnel deleted.");
93
+ }
94
+ catch (error) {
95
+ if (error instanceof Error) {
96
+ console.error(`\nFailed to delete tunnel: ${error.message}`);
97
+ }
98
+ else {
99
+ console.error("\nFailed to delete tunnel");
100
+ }
101
+ }
102
+ };
103
+ const deleteAllTunnelsPrompt = async (instance, session) => {
104
+ const tunnels = getActiveTunnels();
105
+ if (tunnels.length === 0) {
106
+ console.log("\nNo tunnels to delete.");
107
+ return;
108
+ }
109
+ const { confirm } = await prompts({
110
+ type: "confirm",
111
+ name: "confirm",
112
+ message: `Are you sure you want to delete all ${tunnels.length} tunnel(s)?`,
113
+ initial: false,
114
+ });
115
+ if (confirm !== true) {
116
+ return;
117
+ }
118
+ try {
119
+ await deleteAllTunnels(instance, session);
120
+ console.log("\nAll tunnels deleted.");
121
+ }
122
+ catch (error) {
123
+ if (error instanceof Error) {
124
+ console.error(`\nFailed to delete tunnels: ${error.message}`);
125
+ }
126
+ else {
127
+ console.error("\nFailed to delete tunnels");
128
+ }
129
+ }
130
+ };
131
+ export const tunnelMenu = async (instance, session) => {
132
+ while (true) {
133
+ displayActiveTunnels();
134
+ const tunnels = getActiveTunnels();
135
+ const { action } = await prompts({
136
+ type: "select",
137
+ name: "action",
138
+ message: "Tunnel Management",
139
+ choices: [
140
+ { title: "Create new tunnel", value: MENU_CREATE },
141
+ {
142
+ title: "Delete a tunnel",
143
+ value: MENU_DELETE,
144
+ disabled: tunnels.length === 0,
145
+ },
146
+ {
147
+ title: "Delete all tunnels",
148
+ value: MENU_DELETE_ALL,
149
+ disabled: tunnels.length === 0,
150
+ },
151
+ { title: "Disconnect from Nebula", value: MENU_DISCONNECT },
152
+ ],
153
+ });
154
+ if (action === undefined || action === MENU_DISCONNECT) {
155
+ return;
156
+ }
157
+ switch (action) {
158
+ case MENU_CREATE:
159
+ await createTunnelPrompt(instance, session);
160
+ break;
161
+ case MENU_DELETE:
162
+ await deleteTunnelPrompt(instance, session);
163
+ break;
164
+ case MENU_DELETE_ALL:
165
+ await deleteAllTunnelsPrompt(instance, session);
166
+ break;
167
+ }
168
+ }
169
+ };
package/dist/tunnel.js ADDED
@@ -0,0 +1,76 @@
1
+ import { nebulaIp } from "./nebula.js";
2
+ import { startProxy, stopAllProxies, stopProxy } from "./proxy.js";
3
+ // In-memory list of active tunnels
4
+ let activeTunnels = [];
5
+ export const getActiveTunnels = () => {
6
+ return [...activeTunnels];
7
+ };
8
+ export const createTunnel = async (instance, session, port, name) => {
9
+ const body = { port };
10
+ if (name) {
11
+ body.name = name;
12
+ }
13
+ const response = await fetch(`${instance.url}/api/tunnels`, {
14
+ method: "POST",
15
+ headers: {
16
+ Authorization: `Bearer ${session}`,
17
+ "Content-Type": "application/json",
18
+ },
19
+ body: JSON.stringify(body),
20
+ });
21
+ if (!response.ok) {
22
+ const errorText = await response.text();
23
+ throw new Error(`Failed to create tunnel: ${response.status} ${errorText}`);
24
+ }
25
+ const tunnel = (await response.json());
26
+ tunnel.port = port; // Ensure port is set even if API doesn't return it
27
+ activeTunnels.push(tunnel);
28
+ // Try to start IPv6→IPv4 proxy on the Nebula interface
29
+ if (nebulaIp) {
30
+ await startProxy(port, nebulaIp);
31
+ }
32
+ return tunnel;
33
+ };
34
+ export const deleteTunnel = async (instance, session, id) => {
35
+ const response = await fetch(`${instance.url}/api/tunnels/${id}`, {
36
+ method: "DELETE",
37
+ headers: {
38
+ Authorization: `Bearer ${session}`,
39
+ },
40
+ });
41
+ if (!response.ok && response.status !== 404) {
42
+ const errorText = await response.text();
43
+ throw new Error(`Failed to delete tunnel: ${response.status} ${errorText}`);
44
+ }
45
+ const tunnel = activeTunnels.find((t) => t.id === id);
46
+ if (tunnel) {
47
+ stopProxy(tunnel.port);
48
+ }
49
+ activeTunnels = activeTunnels.filter((t) => t.id !== id);
50
+ };
51
+ export const deleteAllTunnels = async (instance, session) => {
52
+ const response = await fetch(`${instance.url}/api/tunnels`, {
53
+ method: "DELETE",
54
+ headers: {
55
+ Authorization: `Bearer ${session}`,
56
+ },
57
+ });
58
+ if (!response.ok && response.status !== 404) {
59
+ const errorText = await response.text();
60
+ throw new Error(`Failed to delete all tunnels: ${response.status} ${errorText}`);
61
+ }
62
+ stopAllProxies();
63
+ activeTunnels = [];
64
+ };
65
+ export const validatePort = (port) => {
66
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
67
+ return "Port must be an integer between 1 and 65535";
68
+ }
69
+ return null;
70
+ };
71
+ export const validateName = (name) => {
72
+ if (name && !/^[a-z0-9-]+$/.test(name)) {
73
+ return "Name must only contain lowercase letters, numbers, and hyphens";
74
+ }
75
+ return null;
76
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onedeploy-cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "CLI tool for connecting to OneDeploy via Nebula",
5
5
  "keywords": [
6
6
  "onedeploy",
@@ -32,9 +32,10 @@
32
32
  "fix": "biome check src/* --fix"
33
33
  },
34
34
  "devDependencies": {
35
- "@biomejs/biome": "^2.3.10",
36
- "@types/node": "^25.0.3",
35
+ "@biomejs/biome": "^2.3.13",
36
+ "@types/node": "^25.2.0",
37
37
  "@types/prompts": "^2.4.9",
38
+ "@typescript/native-preview": "^7.0.0-dev.20260203.1",
38
39
  "typescript": "^5.9.3"
39
40
  },
40
41
  "dependencies": {
package/src/index.ts CHANGED
@@ -1,12 +1,49 @@
1
1
  #!/usr/bin/env node
2
+ import type { ChildProcess } from "node:child_process";
2
3
  import prompts from "prompts";
4
+ import type { Instance } from "./config.js";
3
5
  import { loadConfig, saveConfig } from "./config.js";
4
6
  import {
5
7
  cleanupNebula,
6
- connectNebula,
8
+ getSession,
7
9
  installNebula,
8
10
  isNebulaRunning,
11
+ startNebula,
12
+ stopNebula,
9
13
  } from "./nebula.js";
14
+ import { deleteAllTunnels } from "./tunnel.js";
15
+ import { tunnelMenu } from "./tunnel-menu.js";
16
+
17
+ // Track active connection for cleanup on signals
18
+ let activeConnection: {
19
+ instance: Instance;
20
+ session: string;
21
+ nebulaProcess: ChildProcess;
22
+ stopRefresh: () => void;
23
+ } | null = null;
24
+
25
+ const cleanup = async () => {
26
+ if (activeConnection) {
27
+ console.log("\nCleaning up...");
28
+ try {
29
+ await deleteAllTunnels(activeConnection.instance, activeConnection.session);
30
+ } catch {
31
+ // ignore if already disconnected
32
+ }
33
+ activeConnection.stopRefresh();
34
+ stopNebula(activeConnection.nebulaProcess);
35
+ activeConnection = null;
36
+ }
37
+ await cleanupNebula();
38
+ };
39
+
40
+ const handleSignal = () => {
41
+ if (!isNebulaRunning) {
42
+ process.exit();
43
+ }
44
+ // Perform cleanup and exit
45
+ cleanup().finally(() => process.exit());
46
+ };
10
47
 
11
48
  if (process.argv.includes("--version")) {
12
49
  console.log("onedeploy-cli version 0.1.4");
@@ -127,9 +164,33 @@ const mainMenu = async (): Promise<boolean> => {
127
164
  urlToConnect = selection;
128
165
  }
129
166
 
130
- // Connect first
131
167
  const instance = { url: urlToConnect, apiKey: "" };
132
- await connectNebula(instance);
168
+
169
+ // 1. Authenticate
170
+ const session = await getSession(instance);
171
+
172
+ // 2. Start Nebula (non-blocking)
173
+ const { nebulaProcess, stopRefresh } = await startNebula(instance, session);
174
+
175
+ // Track connection for signal handlers
176
+ activeConnection = { instance, session, nebulaProcess, stopRefresh };
177
+
178
+ // 3. Enter tunnel management loop
179
+ try {
180
+ await tunnelMenu(instance, session);
181
+ } finally {
182
+ // 4. Cleanup on exit
183
+ console.log("Cleaning up tunnels...");
184
+ try {
185
+ await deleteAllTunnels(instance, session);
186
+ } catch {
187
+ // ignore if already disconnected
188
+ }
189
+
190
+ stopRefresh();
191
+ stopNebula(nebulaProcess);
192
+ activeConnection = null;
193
+ }
133
194
 
134
195
  // Only save if connection was successful (didn't throw)
135
196
  await addInstance(urlToConnect);
@@ -141,8 +202,12 @@ process.on("SIGINT", () => {
141
202
  if (!isNebulaRunning) {
142
203
  process.exit();
143
204
  }
144
- // ignore SIGINTs to continue after Nebula is disconnected
205
+ // When Nebula is running, let prompts handle SIGINT (returns undefined)
206
+ // which triggers the finally block in mainMenu
145
207
  });
208
+ process.on("SIGTERM", handleSignal);
209
+ process.on("SIGHUP", handleSignal);
210
+
146
211
  await installNebula();
147
212
  try {
148
213
  while (true) {
@@ -152,5 +217,5 @@ try {
152
217
  }
153
218
  }
154
219
  } finally {
155
- await cleanupNebula();
220
+ await cleanup();
156
221
  }