onedeploy-cli 0.1.8 → 0.1.10

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,46 @@
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", [
13
+ "-u",
14
+ sudoUser,
15
+ "xdg-open",
16
+ url,
17
+ ]);
18
+ }
19
+ else {
20
+ await promisify(execFile)("xdg-open", [url]);
21
+ }
22
+ break;
23
+ }
24
+ case "darwin": {
25
+ if (isRoot && sudoUser) {
26
+ // Run as the original user to avoid browser issues
27
+ await promisify(execFile)("sudo", ["-u", sudoUser, "open", url]);
28
+ }
29
+ else {
30
+ await promisify(execFile)("open", [url]);
31
+ }
32
+ break;
33
+ }
34
+ case "win32":
35
+ await promisify(execFile)("cmd", ["/c", "start", '""', url]);
36
+ break;
37
+ default:
38
+ console.error(`Unsupported platform: ${process.platform}.`);
39
+ console.error("Please open the URL manually.");
40
+ break;
41
+ }
42
+ }
43
+ catch {
44
+ console.error("Could not open browser automatically. Please open the URL manually.");
45
+ }
46
+ };
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
@@ -7,21 +7,33 @@ if (process.argv.includes("--version")) {
7
7
  process.exit();
8
8
  }
9
9
  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) {
10
+ const NEW_URL_VALUE = "__NEW_URL__";
11
+ const normalizeUrl = (url) => {
12
+ const trimmedUrl = url.trim();
13
+ // Convert http:// to https://
14
+ if (trimmedUrl.startsWith("http://")) {
15
+ return trimmedUrl.replace("http://", "https://");
16
+ }
17
+ // Add https:// if no protocol specified
18
+ if (!trimmedUrl.startsWith("https://")) {
19
+ return `https://${trimmedUrl}`;
20
+ }
21
+ return trimmedUrl;
22
+ };
23
+ const addInstance = async (url) => {
24
+ // Check for duplicates
25
+ if (config.instances.some((instance) => instance.url === url)) {
19
26
  return;
20
27
  }
21
- config.instances.push({ url: result.url });
28
+ config.instances.push({ url });
22
29
  await saveConfig(config);
23
30
  };
31
+ const REMOVE_URL_VALUE = "__REMOVE_URL__";
24
32
  const removeConnection = async () => {
33
+ if (config.instances.length === 0) {
34
+ console.log("No connections to remove.");
35
+ return;
36
+ }
25
37
  const { connection } = await prompts([
26
38
  {
27
39
  type: "select",
@@ -50,53 +62,58 @@ const removeConnection = async () => {
50
62
  }
51
63
  };
52
64
  const mainMenu = async () => {
53
- const { action } = await prompts([
65
+ const choices = [
66
+ ...config.instances.map((instance) => ({
67
+ title: instance.url,
68
+ value: instance.url,
69
+ })),
70
+ {
71
+ title: "Enter new URL",
72
+ value: NEW_URL_VALUE,
73
+ },
74
+ {
75
+ title: "Remove a URL",
76
+ value: REMOVE_URL_VALUE,
77
+ disabled: config.instances.length === 0,
78
+ },
79
+ ];
80
+ const { selection } = await prompts([
54
81
  {
55
82
  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
- ],
83
+ name: "selection",
84
+ message: "Select oneDeploy instance",
85
+ choices,
73
86
  },
74
87
  ]);
75
- if (action === "connect") {
88
+ if (selection === undefined) {
89
+ return false;
90
+ }
91
+ if (selection === REMOVE_URL_VALUE) {
92
+ await removeConnection();
93
+ return true;
94
+ }
95
+ let urlToConnect;
96
+ if (selection === NEW_URL_VALUE) {
76
97
  const result = await prompts([
77
98
  {
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
- })),
99
+ type: "text",
100
+ name: "url",
101
+ message: "Enter oneDeploy URL",
85
102
  },
86
103
  ]);
87
- if (result.connection !== undefined) {
88
- await connectNebula(result.connection);
104
+ if (result.url === undefined) {
105
+ return true;
89
106
  }
90
- }
91
- else if (action === "setup") {
92
- await setupConnection();
93
- }
94
- else if (action === "remove") {
95
- await removeConnection();
107
+ urlToConnect = normalizeUrl(result.url);
96
108
  }
97
109
  else {
98
- return false;
110
+ urlToConnect = selection;
99
111
  }
112
+ // Connect first
113
+ const instance = { url: urlToConnect, apiKey: "" };
114
+ await connectNebula(instance);
115
+ // Only save if connection was successful (didn't throw)
116
+ await addInstance(urlToConnect);
100
117
  return true;
101
118
  };
102
119
  process.on("SIGINT", () => {
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,27 +101,25 @@ 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
  ]);
109
+ const cert = JSON.parse(stdout);
76
110
  // in case our local time isn't correct, wait for the certificate to become valid
77
- const validFrom = stdout.match(/Not before: ([\d-]+ [\d:]+ \+\d+)/);
78
- if (validFrom === null) {
79
- throw new Error("Invalid Nebula certificate: Missing 'Not before' field.");
111
+ const validFromDate = new Date(cert.details.notBefore);
112
+ if (validFromDate.toString() === "Invalid Date") {
113
+ throw new Error("Nebula certificate has invalid 'notBefore' date");
80
114
  }
81
- const validFromDate = new Date(validFrom[1]);
82
115
  if (validFromDate > new Date()) {
83
116
  const waitTime = validFromDate.getTime() - Date.now();
84
117
  console.log(`Your system clock is running slow! Waiting ${Math.ceil(waitTime / 1000)} seconds for Nebula certificate to become valid...`);
85
118
  await new Promise((resolve) => setTimeout(resolve, waitTime));
86
119
  }
87
- process.stdout.write(stdout);
88
120
  };
89
121
  export let isNebulaRunning = false;
90
122
  const getSession = (instance) => {
91
- const verificationCode = randomBytes(8).toString("base64url");
92
123
  return new Promise((resolve) => {
93
124
  const getServerAddr = () => {
94
125
  const addr = server.address();
@@ -106,8 +137,10 @@ const getSession = (instance) => {
106
137
  throw new Error("Missing token in callback URL");
107
138
  }
108
139
  // authentication complete
109
- res.writeHead(200);
110
- res.write("Authentication successful! You can close this window.");
140
+ res.writeHead(200, "OK", {
141
+ "content-type": "text/html",
142
+ });
143
+ res.write(successPage);
111
144
  res.end();
112
145
  server.close();
113
146
  resolve(token);
@@ -115,17 +148,19 @@ const getSession = (instance) => {
115
148
  else {
116
149
  const target = `${getServerAddr()}/authenticated`;
117
150
  res.writeHead(302, {
118
- location: `${instance.url}/auth/nebula?redirect=${encodeURIComponent(target)}&code=${encodeURIComponent(verificationCode)}`,
151
+ location: `${instance.url}/auth/nebula?redirect=${encodeURIComponent(target)}`,
119
152
  });
120
153
  res.end();
121
154
  }
122
155
  });
123
- server.on("listening", () => {
156
+ server.on("listening", async () => {
124
157
  const addr = server.address();
125
158
  if (addr === null || typeof addr === "string") {
126
159
  throw new Error("Unexpected address type");
127
160
  }
128
- console.log(`Visit ${getServerAddr()} to authenticate. Client verification code: ${verificationCode}`);
161
+ const authUrl = getServerAddr();
162
+ console.log(`Visit ${authUrl} to authenticate (opened in browser)`);
163
+ await openBrowser(authUrl);
129
164
  });
130
165
  server.listen();
131
166
  });
@@ -134,7 +169,7 @@ export const connectNebula = async (instance) => {
134
169
  const session = await getSession(instance);
135
170
  await getNebulaCert(instance, session);
136
171
  isNebulaRunning = true;
137
- const nebula = spawn(`${tmpDir}/nebula`, ["-config", `${tmpDir}/nebula.yml`], { stdio: "inherit" });
172
+ const nebula = spawn(`${nebulaDir}/nebula`, ["-config", `${tmpDir}/nebula.yml`], { stdio: "inherit" });
138
173
  const interval = setInterval(async () => {
139
174
  // refresh Nebula cert every 10 minutes (lifetime is 15 minutes)
140
175
  await getNebulaCert(instance, session);
@@ -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
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onedeploy-cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "CLI tool for connecting to OneDeploy via Nebula",
5
5
  "keywords": [
6
6
  "onedeploy",
@@ -32,14 +32,13 @@
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.10",
36
+ "@types/node": "^25.0.3",
37
37
  "@types/prompts": "^2.4.9",
38
38
  "typescript": "^5.9.3"
39
39
  },
40
40
  "dependencies": {
41
41
  "prompts": "^2.4.2",
42
- "yaml": "^2.8.2",
43
- "yedra": "^0.19.0"
42
+ "yaml": "^2.8.2"
44
43
  }
45
44
  }
package/src/index.ts CHANGED
@@ -15,22 +15,37 @@ if (process.argv.includes("--version")) {
15
15
 
16
16
  const config = await loadConfig();
17
17
 
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) {
18
+ const NEW_URL_VALUE = "__NEW_URL__";
19
+
20
+ const normalizeUrl = (url: string): string => {
21
+ const trimmedUrl = url.trim();
22
+ // Convert http:// to https://
23
+ if (trimmedUrl.startsWith("http://")) {
24
+ return trimmedUrl.replace("http://", "https://");
25
+ }
26
+ // Add https:// if no protocol specified
27
+ if (!trimmedUrl.startsWith("https://")) {
28
+ return `https://${trimmedUrl}`;
29
+ }
30
+ return trimmedUrl;
31
+ };
32
+
33
+ const addInstance = async (url: string) => {
34
+ // Check for duplicates
35
+ if (config.instances.some((instance) => instance.url === url)) {
27
36
  return;
28
37
  }
29
- config.instances.push({ url: result.url });
38
+ config.instances.push({ url });
30
39
  await saveConfig(config);
31
40
  };
32
41
 
42
+ const REMOVE_URL_VALUE = "__REMOVE_URL__";
43
+
33
44
  const removeConnection = async () => {
45
+ if (config.instances.length === 0) {
46
+ console.log("No connections to remove.");
47
+ return;
48
+ }
34
49
  const { connection } = await prompts([
35
50
  {
36
51
  type: "select",
@@ -60,50 +75,65 @@ const removeConnection = async () => {
60
75
  };
61
76
 
62
77
  const mainMenu = async (): Promise<boolean> => {
63
- const { action } = await prompts([
78
+ const choices = [
79
+ ...config.instances.map((instance) => ({
80
+ title: instance.url,
81
+ value: instance.url,
82
+ })),
83
+ {
84
+ title: "Enter new URL",
85
+ value: NEW_URL_VALUE,
86
+ },
87
+ {
88
+ title: "Remove a URL",
89
+ value: REMOVE_URL_VALUE,
90
+ disabled: config.instances.length === 0,
91
+ },
92
+ ];
93
+
94
+ const { selection } = await prompts([
64
95
  {
65
96
  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
- ],
97
+ name: "selection",
98
+ message: "Select oneDeploy instance",
99
+ choices,
83
100
  },
84
101
  ]);
85
- if (action === "connect") {
102
+
103
+ if (selection === undefined) {
104
+ return false;
105
+ }
106
+
107
+ if (selection === REMOVE_URL_VALUE) {
108
+ await removeConnection();
109
+ return true;
110
+ }
111
+
112
+ let urlToConnect: string;
113
+
114
+ if (selection === NEW_URL_VALUE) {
86
115
  const result = await prompts([
87
116
  {
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
- })),
117
+ type: "text",
118
+ name: "url",
119
+ message: "Enter oneDeploy URL",
95
120
  },
96
121
  ]);
97
- if (result.connection !== undefined) {
98
- await connectNebula(result.connection);
122
+ if (result.url === undefined) {
123
+ return true;
99
124
  }
100
- } else if (action === "setup") {
101
- await setupConnection();
102
- } else if (action === "remove") {
103
- await removeConnection();
125
+ urlToConnect = normalizeUrl(result.url);
104
126
  } else {
105
- return false;
127
+ urlToConnect = selection;
106
128
  }
129
+
130
+ // Connect first
131
+ const instance = { url: urlToConnect, apiKey: "" };
132
+ await connectNebula(instance);
133
+
134
+ // Only save if connection was successful (didn't throw)
135
+ await addInstance(urlToConnect);
136
+
107
137
  return true;
108
138
  };
109
139