onedeploy-cli 0.1.9 → 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/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # OneDeploy CLI
1
+ # oneDeploy CLI
2
2
 
3
- This CLI allows you to connect to a OneDeploy cluster via
3
+ This CLI allows you to connect to a oneDeploy cluster via
4
4
  [Nebula](https://github.com/slackhq/nebula).
5
5
 
6
6
  ## Getting Started
@@ -17,12 +17,11 @@ or from an administrator prompt on Windows:
17
17
  npx onedeploy-cli
18
18
  ```
19
19
 
20
- Then, you need to create an API key on your OneDeploy dashboard. You can
21
- `Setup new connection` to enter your dashboard URL and API key. Afterwards, you
22
- can `Connect` to the cluster you added. Please note that connections are stored
23
- per computer (for the root user), not individually per user.
20
+ Then, you need to create an API key on your oneDeploy dashboard. You can
21
+ `Enter new URL` to enter your oneDeploy URL. After the first connection, this
22
+ URL will be remembered. You will need to authenticate using your passkey.
24
23
 
25
24
  ## License
26
25
 
27
- The OneDeploy CLI licensed under MIT, see [here](./LICENSE). It uses the Nebula
26
+ The oneDeploy CLI licensed under MIT, see [here](./LICENSE). It uses the Nebula
28
27
  overlay network, which is also MIT licensed.
@@ -0,0 +1,41 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ export const openBrowser = async (url) => {
4
+ try {
5
+ // If running as root via sudo, run browser as the original user
6
+ const isRoot = process.getuid?.() === 0;
7
+ const sudoUser = process.env.SUDO_USER;
8
+ switch (process.platform) {
9
+ case "linux": {
10
+ if (isRoot && sudoUser) {
11
+ // Run as the original user to avoid browser sandbox issues
12
+ await promisify(execFile)("sudo", ["-u", sudoUser, "xdg-open", url]);
13
+ }
14
+ else {
15
+ await promisify(execFile)("xdg-open", [url]);
16
+ }
17
+ break;
18
+ }
19
+ case "darwin": {
20
+ if (isRoot && sudoUser) {
21
+ // Run as the original user to avoid browser issues
22
+ await promisify(execFile)("sudo", ["-u", sudoUser, "open", url]);
23
+ }
24
+ else {
25
+ await promisify(execFile)("open", [url]);
26
+ }
27
+ break;
28
+ }
29
+ case "win32":
30
+ await promisify(execFile)("cmd", ["/c", "start", '""', url]);
31
+ break;
32
+ default:
33
+ console.error(`Unsupported platform: ${process.platform}.`);
34
+ console.error("Please open the URL manually.");
35
+ break;
36
+ }
37
+ }
38
+ catch {
39
+ console.error("Could not open browser automatically. Please open the URL manually.");
40
+ }
41
+ };
package/dist/config.js CHANGED
@@ -1,19 +1,11 @@
1
1
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { y } from "yedra";
4
- const configDir = `${homedir()}/.config/onedeploy`;
3
+ export const configDir = `${homedir()}/.config/onedeploy`;
5
4
  await mkdir(configDir, { recursive: true });
6
- const Instance = y.object({
7
- url: y.string(),
8
- });
9
- const Config = y.object({
10
- instances: Instance.array(),
11
- });
12
5
  export const loadConfig = async () => {
13
6
  try {
14
7
  const result = await readFile(`${configDir}/config.json`, "utf-8");
15
- const config = JSON.parse(result);
16
- return Config.parse(config);
8
+ return JSON.parse(result);
17
9
  }
18
10
  catch (_error) {
19
11
  // no config yet
package/dist/index.js CHANGED
@@ -1,27 +1,65 @@
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();
8
34
  }
9
35
  const config = await loadConfig();
10
- const setupConnection = async () => {
11
- const result = await prompts([
12
- {
13
- type: "text",
14
- name: "url",
15
- message: "Enter OneDeploy URL",
16
- },
17
- ]);
18
- if (result.url === undefined) {
36
+ const NEW_URL_VALUE = "__NEW_URL__";
37
+ const normalizeUrl = (url) => {
38
+ const trimmedUrl = url.trim();
39
+ // Convert http:// to https://
40
+ if (trimmedUrl.startsWith("http://")) {
41
+ return trimmedUrl.replace("http://", "https://");
42
+ }
43
+ // Add https:// if no protocol specified
44
+ if (!trimmedUrl.startsWith("https://")) {
45
+ return `https://${trimmedUrl}`;
46
+ }
47
+ return trimmedUrl;
48
+ };
49
+ const addInstance = async (url) => {
50
+ // Check for duplicates
51
+ if (config.instances.some((instance) => instance.url === url)) {
19
52
  return;
20
53
  }
21
- config.instances.push({ url: result.url });
54
+ config.instances.push({ url });
22
55
  await saveConfig(config);
23
56
  };
57
+ const REMOVE_URL_VALUE = "__REMOVE_URL__";
24
58
  const removeConnection = async () => {
59
+ if (config.instances.length === 0) {
60
+ console.log("No connections to remove.");
61
+ return;
62
+ }
25
63
  const { connection } = await prompts([
26
64
  {
27
65
  type: "select",
@@ -50,61 +88,90 @@ const removeConnection = async () => {
50
88
  }
51
89
  };
52
90
  const mainMenu = async () => {
53
- const { action } = await prompts([
91
+ const choices = [
92
+ ...config.instances.map((instance) => ({
93
+ title: instance.url,
94
+ value: instance.url,
95
+ })),
96
+ {
97
+ title: "Enter new URL",
98
+ value: NEW_URL_VALUE,
99
+ },
100
+ {
101
+ title: "Remove a URL",
102
+ value: REMOVE_URL_VALUE,
103
+ disabled: config.instances.length === 0,
104
+ },
105
+ ];
106
+ const { selection } = await prompts([
54
107
  {
55
108
  type: "select",
56
- name: "action",
57
- message: "What do you want to do?",
58
- choices: [
59
- {
60
- title: "Connect",
61
- value: "connect",
62
- disabled: config.instances.length === 0,
63
- },
64
- {
65
- title: "Setup new connection",
66
- value: "setup",
67
- },
68
- {
69
- title: "Remove connection",
70
- value: "remove",
71
- },
72
- ],
109
+ name: "selection",
110
+ message: "Select oneDeploy instance",
111
+ choices,
73
112
  },
74
113
  ]);
75
- if (action === "connect") {
114
+ if (selection === undefined) {
115
+ return false;
116
+ }
117
+ if (selection === REMOVE_URL_VALUE) {
118
+ await removeConnection();
119
+ return true;
120
+ }
121
+ let urlToConnect;
122
+ if (selection === NEW_URL_VALUE) {
76
123
  const result = await prompts([
77
124
  {
78
- type: "select",
79
- name: "connection",
80
- message: "Select OneDeploy instance",
81
- choices: config.instances.map((instance) => ({
82
- title: instance.url,
83
- value: instance,
84
- })),
125
+ type: "text",
126
+ name: "url",
127
+ message: "Enter oneDeploy URL",
85
128
  },
86
129
  ]);
87
- if (result.connection !== undefined) {
88
- await connectNebula(result.connection);
130
+ if (result.url === undefined) {
131
+ return true;
89
132
  }
133
+ urlToConnect = normalizeUrl(result.url);
90
134
  }
91
- else if (action === "setup") {
92
- await setupConnection();
135
+ else {
136
+ urlToConnect = selection;
93
137
  }
94
- else if (action === "remove") {
95
- await removeConnection();
138
+ const instance = { url: urlToConnect, apiKey: "" };
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);
96
148
  }
97
- else {
98
- return false;
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;
99
161
  }
162
+ // Only save if connection was successful (didn't throw)
163
+ await addInstance(urlToConnect);
100
164
  return true;
101
165
  };
102
166
  process.on("SIGINT", () => {
103
167
  if (!isNebulaRunning) {
104
168
  process.exit();
105
169
  }
106
- // 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
107
172
  });
173
+ process.on("SIGTERM", handleSignal);
174
+ process.on("SIGHUP", handleSignal);
108
175
  await installNebula();
109
176
  try {
110
177
  while (true) {
@@ -115,5 +182,5 @@ try {
115
182
  }
116
183
  }
117
184
  finally {
118
- await cleanupNebula();
185
+ await cleanup();
119
186
  }
package/dist/nebula.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
- import { randomBytes, randomUUID } from "node:crypto";
3
- import { chmod, mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { access, chmod, mkdir, readFile, rm, writeFile, } from "node:fs/promises";
4
4
  import { createServer } from "node:http";
5
5
  import { tmpdir } from "node:os";
6
6
  import path from "node:path";
7
7
  import { Readable } from "node:stream";
8
8
  import { promisify } from "node:util";
9
9
  import { parse } from "yaml";
10
+ import { openBrowser } from "./browser.js";
11
+ import { configDir } from "./config.js";
12
+ import { successPage } from "./success.js";
10
13
  const getNebulaPackageName = () => {
11
14
  switch (process.platform) {
12
15
  case "linux":
@@ -33,22 +36,52 @@ const getNebulaPackageName = () => {
33
36
  const nebulaVersion = "v1.10.0";
34
37
  const packageName = getNebulaPackageName();
35
38
  const nebulaUrl = `https://github.com/slackhq/nebula/releases/download/${nebulaVersion}/${packageName}`;
39
+ const nebulaDir = path.join(configDir, "nebula");
36
40
  const tmpDir = path.join(tmpdir(), `onedeploy-${randomUUID()}`);
37
41
  await mkdir(tmpDir, { recursive: true });
38
- export const installNebula = async () => {
39
- console.log(`Downloading Nebula to ${tmpDir}/${packageName}...`);
42
+ const isCacheValid = async () => {
43
+ try {
44
+ const versionFile = path.join(nebulaDir, "version");
45
+ const cachedVersion = await readFile(versionFile, "utf-8");
46
+ if (cachedVersion.trim() !== nebulaVersion) {
47
+ // Version mismatch - delete old version
48
+ await rm(nebulaDir, { recursive: true, force: true });
49
+ return false;
50
+ }
51
+ // Check if binaries exist
52
+ await access(path.join(nebulaDir, "nebula"));
53
+ await access(path.join(nebulaDir, "nebula-cert"));
54
+ return true;
55
+ }
56
+ catch {
57
+ // If any error occurs (file doesn't exist, etc.), invalidate cache
58
+ await rm(nebulaDir, { recursive: true, force: true });
59
+ return false;
60
+ }
61
+ };
62
+ const downloadNebula = async () => {
63
+ console.log(`Downloading Nebula ${nebulaVersion}...`);
40
64
  const file = await fetch(nebulaUrl);
41
65
  if (file.body === null) {
42
66
  throw new Error("Could not download Nebula executable.");
43
67
  }
44
- await writeFile(`${tmpDir}/${packageName}`, Readable.from(file.body));
45
- console.log(`Extracting Nebula to ${tmpDir}/nebula...`);
46
- await promisify(execFile)("tar", [
47
- "-xf",
48
- `${tmpDir}/${packageName}`,
49
- "-C",
50
- tmpDir,
51
- ]);
68
+ const downloadPath = path.join(tmpDir, packageName);
69
+ await writeFile(downloadPath, Readable.from(file.body));
70
+ console.log(`Extracting Nebula...`);
71
+ // Extract directly to nebulaDir
72
+ await mkdir(nebulaDir, { recursive: true });
73
+ await promisify(execFile)("tar", ["-xf", downloadPath, "-C", nebulaDir]);
74
+ // Store version info
75
+ await writeFile(path.join(nebulaDir, "version"), nebulaVersion);
76
+ // Clean up downloaded archive
77
+ await rm(downloadPath, { force: true });
78
+ console.log(`Stored Nebula binaries to ${nebulaDir}`);
79
+ };
80
+ export const installNebula = async () => {
81
+ const cached = await isCacheValid();
82
+ if (!cached) {
83
+ await downloadNebula();
84
+ }
52
85
  };
53
86
  export const cleanupNebula = async () => {
54
87
  await rm(tmpDir, { recursive: true, force: true });
@@ -68,12 +101,16 @@ export const getNebulaCert = async (instance, session) => {
68
101
  console.log(`Wrote Nebula configuration to ${tmpDir}/nebula.yml`);
69
102
  const parsedConfig = parse(nebulaConfig);
70
103
  await writeFile(`${tmpDir}/nebula-debug.crt`, parsedConfig.pki.cert);
71
- const { stdout } = await promisify(execFile)(`${tmpDir}/nebula-cert`, [
104
+ const { stdout } = await promisify(execFile)(`${nebulaDir}/nebula-cert`, [
72
105
  "print",
73
106
  "-path",
74
107
  `${tmpDir}/nebula-debug.crt`,
75
108
  ]);
76
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
+ }
77
114
  // in case our local time isn't correct, wait for the certificate to become valid
78
115
  const validFromDate = new Date(cert.details.notBefore);
79
116
  if (validFromDate.toString() === "Invalid Date") {
@@ -86,8 +123,8 @@ export const getNebulaCert = async (instance, session) => {
86
123
  }
87
124
  };
88
125
  export let isNebulaRunning = false;
89
- const getSession = (instance) => {
90
- const verificationCode = randomBytes(8).toString("base64url");
126
+ export let nebulaIp = null;
127
+ export const getSession = (instance) => {
91
128
  return new Promise((resolve) => {
92
129
  const getServerAddr = () => {
93
130
  const addr = server.address();
@@ -105,8 +142,10 @@ const getSession = (instance) => {
105
142
  throw new Error("Missing token in callback URL");
106
143
  }
107
144
  // authentication complete
108
- res.writeHead(200);
109
- res.write("Authentication successful! You can close this window.");
145
+ res.writeHead(200, "OK", {
146
+ "content-type": "text/html",
147
+ });
148
+ res.write(successPage);
110
149
  res.end();
111
150
  server.close();
112
151
  resolve(token);
@@ -114,32 +153,38 @@ const getSession = (instance) => {
114
153
  else {
115
154
  const target = `${getServerAddr()}/authenticated`;
116
155
  res.writeHead(302, {
117
- location: `${instance.url}/auth/nebula?redirect=${encodeURIComponent(target)}&code=${encodeURIComponent(verificationCode)}`,
156
+ location: `${instance.url}/auth/nebula?redirect=${encodeURIComponent(target)}`,
118
157
  });
119
158
  res.end();
120
159
  }
121
160
  });
122
- server.on("listening", () => {
161
+ server.on("listening", async () => {
123
162
  const addr = server.address();
124
163
  if (addr === null || typeof addr === "string") {
125
164
  throw new Error("Unexpected address type");
126
165
  }
127
- console.log(`Visit ${getServerAddr()} to authenticate. Client verification code: ${verificationCode}`);
166
+ const authUrl = getServerAddr();
167
+ console.log(`Visit ${authUrl} to authenticate (opened in browser)`);
168
+ await openBrowser(authUrl);
128
169
  });
129
170
  server.listen();
130
171
  });
131
172
  };
132
- export const connectNebula = async (instance) => {
133
- const session = await getSession(instance);
173
+ export const startNebula = async (instance, session) => {
134
174
  await getNebulaCert(instance, session);
135
175
  isNebulaRunning = true;
136
- const nebula = spawn(`${tmpDir}/nebula`, ["-config", `${tmpDir}/nebula.yml`], { stdio: "inherit" });
176
+ const nebula = spawn(`${nebulaDir}/nebula`, ["-config", `${tmpDir}/nebula.yml`], { stdio: "ignore" });
137
177
  const interval = setInterval(async () => {
138
178
  // refresh Nebula cert every 10 minutes (lifetime is 15 minutes)
139
179
  await getNebulaCert(instance, session);
140
180
  nebula.kill("SIGHUP");
141
181
  }, 10 * 60 * 1000);
142
- 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");
143
189
  isNebulaRunning = false;
144
- clearInterval(interval);
145
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,201 @@
1
+ export const successPage = `<!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>oneDeploy</title>
8
+ <link rel="icon" type="image/svg+xml"
9
+ href="data:image/svg+xml,%3Csvg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='14' cy='27' r='14' fill='%2322C55E'/%3E%3Ccircle cx='38' cy='10' r='10' fill='%234ADE80'/%3E%3Ccircle cx='40' cy='40' r='8' fill='%2316A34A'/%3E%3Cline x1='26' y1='19' x2='30' y2='14' stroke='%2386EFAC' stroke-width='3' stroke-linecap='round'/%3E%3Cline x1='26' y1='36' x2='34' y2='38' stroke='%2386EFAC' stroke-width='3' stroke-linecap='round'/%3E%3Cpath d='M9 20V34L21 27L9 20Z' fill='white'/%3E%3C/svg%3E">
10
+ <style>
11
+ * {
12
+ margin: 0;
13
+ padding: 0;
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ body {
18
+ font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
19
+ margin: 0;
20
+ padding: 0;
21
+ }
22
+
23
+ .page-container {
24
+ min-height: 100vh;
25
+ background-color: #0b0b0f;
26
+ color: #f3f4f6;
27
+ }
28
+
29
+ .navbar {
30
+ padding: 1rem;
31
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
32
+ background-color: #0e0e14;
33
+ }
34
+
35
+ .logo-container {
36
+ display: flex;
37
+ align-items: center;
38
+ gap: 0.75rem;
39
+ }
40
+
41
+ .logo-container img {
42
+ width: 2.5rem;
43
+ height: 2.5rem;
44
+ }
45
+
46
+ .logo-text {
47
+ font-size: 1.5rem;
48
+ font-weight: 700;
49
+ letter-spacing: -0.025em;
50
+ line-height: 1.2;
51
+ }
52
+
53
+ @media (min-width: 768px) {
54
+ .logo-text {
55
+ font-size: 1.875rem;
56
+ }
57
+ }
58
+
59
+ .logo-one {
60
+ color: #4ade80;
61
+ }
62
+
63
+ .logo-deploy {
64
+ color: #f3f4f6;
65
+ }
66
+
67
+ .content-wrapper {
68
+ padding: 1rem;
69
+ }
70
+
71
+ .container {
72
+ min-height: 80vh;
73
+ display: flex;
74
+ align-items: center;
75
+ justify-content: center;
76
+ padding-left: 1rem;
77
+ padding-right: 1rem;
78
+ }
79
+
80
+ .card {
81
+ width: 100%;
82
+ max-width: 28rem;
83
+ background-color: #14141c;
84
+ border: 1px solid rgba(255, 255, 255, 0.05);
85
+ border-radius: 1rem;
86
+ padding: 2rem;
87
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
88
+ }
89
+
90
+ .header {
91
+ text-align: center;
92
+ margin-bottom: 2rem;
93
+ }
94
+
95
+ .icon-container {
96
+ display: inline-flex;
97
+ align-items: center;
98
+ justify-content: center;
99
+ width: 4rem;
100
+ height: 4rem;
101
+ border-radius: 1rem;
102
+ background-color: rgba(139, 92, 246, 0.1);
103
+ color: #a78bfa;
104
+ margin-bottom: 1.5rem;
105
+ border: 1px solid rgba(255, 255, 255, 0.05);
106
+ }
107
+
108
+ .icon-container svg {
109
+ width: 2rem;
110
+ height: 2rem;
111
+ }
112
+
113
+ h1 {
114
+ font-size: 1.875rem;
115
+ font-weight: 700;
116
+ color: #f3f4f6;
117
+ letter-spacing: -0.025em;
118
+ margin: 0 0 0.5rem 0;
119
+ line-height: 2.25rem;
120
+ text-align: center;
121
+ }
122
+
123
+ .description {
124
+ color: #9ca3af;
125
+ font-size: 1rem;
126
+ line-height: 1.5rem;
127
+ text-align: center;
128
+ margin: 0;
129
+ }
130
+
131
+ .status-container {
132
+ min-height: 60px;
133
+ display: flex;
134
+ align-items: center;
135
+ justify-content: center;
136
+ }
137
+
138
+ .success-box {
139
+ width: 100%;
140
+ display: flex;
141
+ align-items: center;
142
+ justify-content: center;
143
+ padding-top: 0.75rem;
144
+ padding-bottom: 0.75rem;
145
+ font-size: 1rem;
146
+ font-weight: 500;
147
+ background-color: rgba(34, 197, 94, 0.1);
148
+ border: 1px solid rgba(34, 197, 94, 0.2);
149
+ border-radius: 0.5rem;
150
+ color: #4ade80;
151
+ }
152
+
153
+ .success-box svg {
154
+ width: 1.25rem;
155
+ height: 1.25rem;
156
+ }
157
+ </style>
158
+ </head>
159
+
160
+ <body>
161
+ <div class="page-container">
162
+ <div class="navbar">
163
+ <div class="logo-container">
164
+ <img alt="oneDeploy Logo"
165
+ src="data:image/svg+xml,%3Csvg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='14' cy='27' r='14' fill='%2322C55E'/%3E%3Ccircle cx='38' cy='10' r='10' fill='%234ADE80'/%3E%3Ccircle cx='40' cy='40' r='8' fill='%2316A34A'/%3E%3Cline x1='26' y1='19' x2='30' y2='14' stroke='%2386EFAC' stroke-width='3' stroke-linecap='round'/%3E%3Cline x1='26' y1='36' x2='34' y2='38' stroke='%2386EFAC' stroke-width='3' stroke-linecap='round'/%3E%3Cpath d='M9 20V34L21 27L9 20Z' fill='white'/%3E%3C/svg%3E">
166
+ <div class="logo-text">
167
+ <span class="logo-one">one</span><span class="logo-deploy">Deploy</span>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ <div class="content-wrapper">
172
+ <div class="container">
173
+ <div class="card">
174
+ <div class="header">
175
+ <div class="icon-container">
176
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
177
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
178
+ <circle cx="12" cy="12" r="10" />
179
+ <line x1="2" y1="12" x2="22" y2="12" />
180
+ <path
181
+ d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
182
+ </svg>
183
+ </div>
184
+ <h1>Network Access Request</h1>
185
+ <p class="description">oneDeploy CLI is requesting access to the oneDeploy network</p>
186
+ </div>
187
+
188
+ <div class="status-container">
189
+ <div class="success-box">
190
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
191
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
192
+ <path d="M20 6 9 17l-5-5" />
193
+ </svg>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ </div>
198
+ </div>
199
+ </div>
200
+ </body>
201
+ `;
@@ -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.9",
3
+ "version": "0.1.11",
4
4
  "description": "CLI tool for connecting to OneDeploy via Nebula",
5
5
  "keywords": [
6
6
  "onedeploy",
@@ -32,14 +32,14 @@
32
32
  "fix": "biome check src/* --fix"
33
33
  },
34
34
  "devDependencies": {
35
- "@biomejs/biome": "^2.3.8",
36
- "@types/node": "^25.0.1",
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": {
41
42
  "prompts": "^2.4.2",
42
- "yaml": "^2.8.2",
43
- "yedra": "^0.19.0"
43
+ "yaml": "^2.8.2"
44
44
  }
45
45
  }
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");
@@ -15,22 +52,37 @@ if (process.argv.includes("--version")) {
15
52
 
16
53
  const config = await loadConfig();
17
54
 
18
- const setupConnection = async () => {
19
- const result = await prompts([
20
- {
21
- type: "text",
22
- name: "url",
23
- message: "Enter OneDeploy URL",
24
- },
25
- ]);
26
- if (result.url === undefined) {
55
+ const NEW_URL_VALUE = "__NEW_URL__";
56
+
57
+ const normalizeUrl = (url: string): string => {
58
+ const trimmedUrl = url.trim();
59
+ // Convert http:// to https://
60
+ if (trimmedUrl.startsWith("http://")) {
61
+ return trimmedUrl.replace("http://", "https://");
62
+ }
63
+ // Add https:// if no protocol specified
64
+ if (!trimmedUrl.startsWith("https://")) {
65
+ return `https://${trimmedUrl}`;
66
+ }
67
+ return trimmedUrl;
68
+ };
69
+
70
+ const addInstance = async (url: string) => {
71
+ // Check for duplicates
72
+ if (config.instances.some((instance) => instance.url === url)) {
27
73
  return;
28
74
  }
29
- config.instances.push({ url: result.url });
75
+ config.instances.push({ url });
30
76
  await saveConfig(config);
31
77
  };
32
78
 
79
+ const REMOVE_URL_VALUE = "__REMOVE_URL__";
80
+
33
81
  const removeConnection = async () => {
82
+ if (config.instances.length === 0) {
83
+ console.log("No connections to remove.");
84
+ return;
85
+ }
34
86
  const { connection } = await prompts([
35
87
  {
36
88
  type: "select",
@@ -60,50 +112,89 @@ const removeConnection = async () => {
60
112
  };
61
113
 
62
114
  const mainMenu = async (): Promise<boolean> => {
63
- const { action } = await prompts([
115
+ const choices = [
116
+ ...config.instances.map((instance) => ({
117
+ title: instance.url,
118
+ value: instance.url,
119
+ })),
120
+ {
121
+ title: "Enter new URL",
122
+ value: NEW_URL_VALUE,
123
+ },
124
+ {
125
+ title: "Remove a URL",
126
+ value: REMOVE_URL_VALUE,
127
+ disabled: config.instances.length === 0,
128
+ },
129
+ ];
130
+
131
+ const { selection } = await prompts([
64
132
  {
65
133
  type: "select",
66
- name: "action",
67
- message: "What do you want to do?",
68
- choices: [
69
- {
70
- title: "Connect",
71
- value: "connect",
72
- disabled: config.instances.length === 0,
73
- },
74
- {
75
- title: "Setup new connection",
76
- value: "setup",
77
- },
78
- {
79
- title: "Remove connection",
80
- value: "remove",
81
- },
82
- ],
134
+ name: "selection",
135
+ message: "Select oneDeploy instance",
136
+ choices,
83
137
  },
84
138
  ]);
85
- if (action === "connect") {
139
+
140
+ if (selection === undefined) {
141
+ return false;
142
+ }
143
+
144
+ if (selection === REMOVE_URL_VALUE) {
145
+ await removeConnection();
146
+ return true;
147
+ }
148
+
149
+ let urlToConnect: string;
150
+
151
+ if (selection === NEW_URL_VALUE) {
86
152
  const result = await prompts([
87
153
  {
88
- type: "select",
89
- name: "connection",
90
- message: "Select OneDeploy instance",
91
- choices: config.instances.map((instance) => ({
92
- title: instance.url,
93
- value: instance,
94
- })),
154
+ type: "text",
155
+ name: "url",
156
+ message: "Enter oneDeploy URL",
95
157
  },
96
158
  ]);
97
- if (result.connection !== undefined) {
98
- await connectNebula(result.connection);
159
+ if (result.url === undefined) {
160
+ return true;
99
161
  }
100
- } else if (action === "setup") {
101
- await setupConnection();
102
- } else if (action === "remove") {
103
- await removeConnection();
162
+ urlToConnect = normalizeUrl(result.url);
104
163
  } else {
105
- return false;
164
+ urlToConnect = selection;
106
165
  }
166
+
167
+ const instance = { url: urlToConnect, apiKey: "" };
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
+ }
194
+
195
+ // Only save if connection was successful (didn't throw)
196
+ await addInstance(urlToConnect);
197
+
107
198
  return true;
108
199
  };
109
200
 
@@ -111,8 +202,12 @@ process.on("SIGINT", () => {
111
202
  if (!isNebulaRunning) {
112
203
  process.exit();
113
204
  }
114
- // 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
115
207
  });
208
+ process.on("SIGTERM", handleSignal);
209
+ process.on("SIGHUP", handleSignal);
210
+
116
211
  await installNebula();
117
212
  try {
118
213
  while (true) {
@@ -122,5 +217,5 @@ try {
122
217
  }
123
218
  }
124
219
  } finally {
125
- await cleanupNebula();
220
+ await cleanup();
126
221
  }