talos-deploy 0.0.3
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/api/client.js +28 -0
- package/dist/api/progress.js +49 -0
- package/dist/commands/auth.js +156 -0
- package/dist/commands/ssh-proxy.js +67 -0
- package/dist/commands/up.js +120 -0
- package/dist/config/index.js +66 -0
- package/dist/config/profiles.js +53 -0
- package/dist/index.js +69 -0
- package/dist/lib/ssh.js +185 -0
- package/package.json +37 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { loadConfig, getPortalUrl } from "../config/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Authenticated API client.
|
|
4
|
+
* Loads token from config and attaches Bearer header.
|
|
5
|
+
*/
|
|
6
|
+
export async function api(path, options = {}) {
|
|
7
|
+
const config = loadConfig();
|
|
8
|
+
if (!config) {
|
|
9
|
+
console.error("Not logged in. Run: tt login");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
const portalUrl = getPortalUrl();
|
|
13
|
+
const headers = {
|
|
14
|
+
Authorization: `Bearer ${config.token}`,
|
|
15
|
+
};
|
|
16
|
+
if (options.body) {
|
|
17
|
+
headers["Content-Type"] = "application/json";
|
|
18
|
+
}
|
|
19
|
+
const resp = await fetch(`${portalUrl}${path}`, {
|
|
20
|
+
...options,
|
|
21
|
+
headers,
|
|
22
|
+
});
|
|
23
|
+
if (resp.status === 401) {
|
|
24
|
+
console.error("Session expired. Run: tt login");
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
return resp;
|
|
28
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { EventSource } from "eventsource";
|
|
2
|
+
import { loadConfig, getPortalUrl } from "../config/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Subscribe to sandbox operation progress via SSE.
|
|
5
|
+
*
|
|
6
|
+
* @param sandboxId - The sandbox ID
|
|
7
|
+
* @param operationId - The operation ID returned from POST /api/sandboxes
|
|
8
|
+
* @param onProgress - Callback for each progress event
|
|
9
|
+
* @param onDone - Callback when operation completes
|
|
10
|
+
* @returns A cleanup function to close the SSE connection
|
|
11
|
+
*/
|
|
12
|
+
export function streamProgress(sandboxId, operationId, onProgress, onDone) {
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
const portalUrl = getPortalUrl();
|
|
15
|
+
const url = `${portalUrl}/api/sandboxes/${sandboxId}/progress?operationId=${operationId}`;
|
|
16
|
+
const token = config?.token ?? "";
|
|
17
|
+
const es = new EventSource(url, {
|
|
18
|
+
// Use custom fetch to inject Authorization header
|
|
19
|
+
fetch: async (inputUrl, init) => {
|
|
20
|
+
return globalThis.fetch(inputUrl, {
|
|
21
|
+
...init,
|
|
22
|
+
headers: {
|
|
23
|
+
...init.headers,
|
|
24
|
+
Authorization: `Bearer ${token}`,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
es.addEventListener("progress", (e) => {
|
|
30
|
+
try {
|
|
31
|
+
const event = JSON.parse(e.data);
|
|
32
|
+
onProgress(event);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
console.error("Failed to parse SSE progress event:", err);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
es.addEventListener("done", () => {
|
|
39
|
+
es.close();
|
|
40
|
+
onDone();
|
|
41
|
+
});
|
|
42
|
+
es.addEventListener("error", () => {
|
|
43
|
+
// EventSource auto-reconnects. If the operation is done,
|
|
44
|
+
// the server will replay events and send "done" on reconnect.
|
|
45
|
+
});
|
|
46
|
+
return () => {
|
|
47
|
+
es.close();
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { loadConfig, saveConfig, clearConfig, getPortalUrl } from "../config/index.js";
|
|
5
|
+
// ── auth login ──────────────────────────────────────────────
|
|
6
|
+
export async function authLoginCommand() {
|
|
7
|
+
const config = loadConfig();
|
|
8
|
+
const portalUrl = getPortalUrl();
|
|
9
|
+
// Already logged in?
|
|
10
|
+
if (config?.token) {
|
|
11
|
+
try {
|
|
12
|
+
const resp = await fetch(`${portalUrl}/api/auth/status`, {
|
|
13
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
14
|
+
});
|
|
15
|
+
if (resp.ok) {
|
|
16
|
+
const data = (await resp.json());
|
|
17
|
+
if (data.user?.email) {
|
|
18
|
+
console.log(`Already logged in as ${data.user.email}`);
|
|
19
|
+
console.log(`To switch accounts, run: tt auth login --force`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Token invalid, proceed to fresh login
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Fresh login via browser authorization
|
|
29
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
30
|
+
const callbackServer = await startCallbackServer(state);
|
|
31
|
+
const authUrl = `${portalUrl}/auth/cli?port=${callbackServer.port}&state=${state}`;
|
|
32
|
+
console.log("Opening browser for authorization...");
|
|
33
|
+
console.log(`If browser doesn't open, visit: ${authUrl}`);
|
|
34
|
+
openBrowser(authUrl);
|
|
35
|
+
try {
|
|
36
|
+
const token = await callbackServer.tokenPromise;
|
|
37
|
+
// Fetch user info
|
|
38
|
+
try {
|
|
39
|
+
const resp = await fetch(`${portalUrl}/api/auth/status`, {
|
|
40
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
41
|
+
});
|
|
42
|
+
if (resp.ok) {
|
|
43
|
+
const data = (await resp.json());
|
|
44
|
+
saveConfig({
|
|
45
|
+
token,
|
|
46
|
+
serverUrl: portalUrl,
|
|
47
|
+
email: data.user?.email ?? "",
|
|
48
|
+
userId: data.user?.userId ?? 0,
|
|
49
|
+
});
|
|
50
|
+
console.log(`Logged in as ${data.user?.email ?? "unknown"}`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
saveConfig({ token, serverUrl: portalUrl });
|
|
54
|
+
console.log("Logged in successfully!");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
saveConfig({ token, serverUrl: portalUrl });
|
|
59
|
+
console.log("Logged in successfully!");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.error(err.message);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// ── auth status ─────────────────────────────────────────────
|
|
68
|
+
export async function authStatusCommand() {
|
|
69
|
+
const config = loadConfig();
|
|
70
|
+
const portalUrl = getPortalUrl();
|
|
71
|
+
if (!config?.token) {
|
|
72
|
+
console.log("Not logged in.");
|
|
73
|
+
console.log("Run: tt auth login");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const resp = await fetch(`${portalUrl}/api/auth/status`, {
|
|
78
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
79
|
+
});
|
|
80
|
+
if (resp.ok) {
|
|
81
|
+
const data = (await resp.json());
|
|
82
|
+
console.log(`${data.user.email} (${data.user.role})`);
|
|
83
|
+
console.log(`Server: ${portalUrl}`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
console.log("Session expired. Run: tt auth login");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
console.log(`Config: ${config.email || "unknown user"}`);
|
|
91
|
+
console.log(`Server: ${portalUrl} (unreachable)`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// ── auth logout ─────────────────────────────────────────────
|
|
95
|
+
export function authLogoutCommand() {
|
|
96
|
+
const config = loadConfig();
|
|
97
|
+
if (!config?.token) {
|
|
98
|
+
console.log("Not logged in.");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
clearConfig();
|
|
102
|
+
console.log(`Logged out${config.email ? ` (${config.email})` : ""}.`);
|
|
103
|
+
}
|
|
104
|
+
function startCallbackServer(expectedState) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const server = http.createServer();
|
|
107
|
+
server.listen(0, "127.0.0.1", () => {
|
|
108
|
+
const addr = server.address();
|
|
109
|
+
if (!addr || typeof addr === "string") {
|
|
110
|
+
reject(new Error("Failed to bind local server"));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const port = addr.port;
|
|
114
|
+
const tokenPromise = new Promise((resolveToken, rejectToken) => {
|
|
115
|
+
const timeout = setTimeout(() => {
|
|
116
|
+
server.close();
|
|
117
|
+
rejectToken(new Error("Login timed out (120s). Please try again."));
|
|
118
|
+
}, 120_000);
|
|
119
|
+
server.on("request", (req, httpRes) => {
|
|
120
|
+
httpRes.setHeader("Access-Control-Allow-Origin", "*");
|
|
121
|
+
if (req.method === "OPTIONS") {
|
|
122
|
+
httpRes.writeHead(204);
|
|
123
|
+
httpRes.end();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
127
|
+
const reqState = url.searchParams.get("state");
|
|
128
|
+
const reqToken = url.searchParams.get("token");
|
|
129
|
+
if (reqState === expectedState && reqToken) {
|
|
130
|
+
clearTimeout(timeout);
|
|
131
|
+
httpRes.writeHead(200, { "Content-Type": "text/html" });
|
|
132
|
+
httpRes.end("<html><body><h2>Authorized!</h2><p>You can close this tab.</p></body></html>");
|
|
133
|
+
server.close();
|
|
134
|
+
resolveToken(reqToken);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
httpRes.writeHead(400, { "Content-Type": "text/plain" });
|
|
138
|
+
httpRes.end("Invalid callback");
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
resolve({ port, tokenPromise });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function openBrowser(url) {
|
|
147
|
+
const openCmd = process.platform === "darwin" ? "open"
|
|
148
|
+
: process.platform === "win32" ? "start"
|
|
149
|
+
: "xdg-open";
|
|
150
|
+
try {
|
|
151
|
+
execSync(`${openCmd} "${url}"`, { stdio: "ignore" });
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Browser may not be available
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { loadConfig as defaultLoadConfig, getPortalUrl } from "../config/index.js";
|
|
2
|
+
import { api } from "../api/client.js";
|
|
3
|
+
import WS from "ws";
|
|
4
|
+
const defaultDeps = () => ({
|
|
5
|
+
loadConfig: defaultLoadConfig,
|
|
6
|
+
apiFetch: api,
|
|
7
|
+
createWebSocket: (url) => new WS(url),
|
|
8
|
+
stdin: process.stdin,
|
|
9
|
+
stdout: process.stdout,
|
|
10
|
+
stderr: process.stderr,
|
|
11
|
+
exit: process.exit,
|
|
12
|
+
});
|
|
13
|
+
export async function sshProxyCommand(project, deps) {
|
|
14
|
+
const { loadConfig, apiFetch, createWebSocket, stdin, stdout, stderr, exit } = { ...defaultDeps(), ...deps };
|
|
15
|
+
const config = loadConfig();
|
|
16
|
+
if (!config?.token) {
|
|
17
|
+
stderr.write("Not logged in. Run: talosd auth login\n");
|
|
18
|
+
exit(1);
|
|
19
|
+
}
|
|
20
|
+
const portalUrl = config.serverUrl || getPortalUrl();
|
|
21
|
+
// Find sandbox
|
|
22
|
+
const resp = await apiFetch(`/api/sandboxes?project=${encodeURIComponent(project)}`);
|
|
23
|
+
const data = await resp.json();
|
|
24
|
+
const sandboxes = data.sandboxes ?? [];
|
|
25
|
+
const sandbox = sandboxes[0];
|
|
26
|
+
if (!sandbox) {
|
|
27
|
+
stderr.write(`No sandbox found for project "${project}". Run: talosd up --project ${project}\n`);
|
|
28
|
+
exit(1);
|
|
29
|
+
}
|
|
30
|
+
// Wake if sleeping
|
|
31
|
+
if (sandbox.status === "sleeping") {
|
|
32
|
+
stderr.write("Sandbox is sleeping, waking...\n");
|
|
33
|
+
await apiFetch(`/api/sandboxes/${sandbox.id}/wake`, { method: "POST" });
|
|
34
|
+
}
|
|
35
|
+
const wsBaseUrl = portalUrl.replace(/^http/, "ws");
|
|
36
|
+
const wsUrl = `${wsBaseUrl}/api/sandboxes/${sandbox.id}/ssh?token=${encodeURIComponent(config.token)}`;
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const ws = createWebSocket(wsUrl);
|
|
39
|
+
const pendingData = [];
|
|
40
|
+
stdin.on("data", (chunk) => {
|
|
41
|
+
if (ws.readyState === 1 /* OPEN */) {
|
|
42
|
+
ws.send(chunk);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
pendingData.push(chunk);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
stdin.on("end", () => {
|
|
49
|
+
ws.close();
|
|
50
|
+
});
|
|
51
|
+
ws.on("open", () => {
|
|
52
|
+
while (pendingData.length > 0) {
|
|
53
|
+
ws.send(pendingData.shift());
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
ws.on("message", (msg) => {
|
|
57
|
+
stdout.write(msg);
|
|
58
|
+
});
|
|
59
|
+
ws.on("close", (_code, _reason) => {
|
|
60
|
+
resolve();
|
|
61
|
+
});
|
|
62
|
+
ws.on("error", (err) => {
|
|
63
|
+
stderr.write(`Connection error: ${err.message}\n`);
|
|
64
|
+
resolve();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { ensureSshKey, uploadSshKey, updateSshConfig, sshIntoSandbox, resolveTalosBinaryPath } from "../lib/ssh.js";
|
|
2
|
+
import { api } from "../api/client.js";
|
|
3
|
+
import { streamProgress } from "../api/progress.js";
|
|
4
|
+
// ANSI escape codes for terminal output
|
|
5
|
+
const GREEN = "\x1b[32m";
|
|
6
|
+
const RED = "\x1b[31m";
|
|
7
|
+
const YELLOW = "\x1b[33m";
|
|
8
|
+
const GRAY = "\x1b[90m";
|
|
9
|
+
const BOLD = "\x1b[1m";
|
|
10
|
+
const RESET = "\x1b[0m";
|
|
11
|
+
const CLEAR_LINE = "\r\x1b[2K";
|
|
12
|
+
// Friendly step names for display
|
|
13
|
+
const STEP_LABELS = {
|
|
14
|
+
create_claim: "Creating sandbox",
|
|
15
|
+
wake: "Waking sandbox",
|
|
16
|
+
wait_ready: "Waiting for sandbox pod",
|
|
17
|
+
inject_env: "Injecting environment",
|
|
18
|
+
activate: "Activating sandbox",
|
|
19
|
+
ready: "Ready for connection",
|
|
20
|
+
error: "Error",
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Up command — create or wake a sandbox with progress display, then SSH into it.
|
|
24
|
+
*/
|
|
25
|
+
export async function upCommand(opts) {
|
|
26
|
+
console.log(`\n${BOLD}Talos — Starting your environment${RESET}\n`);
|
|
27
|
+
// Step 1: SSH key
|
|
28
|
+
process.stdout.write(" Checking SSH key...");
|
|
29
|
+
ensureSshKey();
|
|
30
|
+
process.stdout.write(`${CLEAR_LINE} ${GREEN}✓${RESET} SSH key ready\n`);
|
|
31
|
+
// Step 2: Upload SSH key
|
|
32
|
+
process.stdout.write(" Uploading SSH key...");
|
|
33
|
+
await uploadSshKey();
|
|
34
|
+
process.stdout.write(`${CLEAR_LINE} ${GREEN}✓${RESET} SSH key uploaded\n`);
|
|
35
|
+
// Step 3: Create or wake sandbox
|
|
36
|
+
process.stdout.write(" Creating sandbox...");
|
|
37
|
+
const resp = await api("/api/sandboxes", {
|
|
38
|
+
method: "POST",
|
|
39
|
+
body: JSON.stringify({ project: opts.project }),
|
|
40
|
+
});
|
|
41
|
+
const data = (await resp.json());
|
|
42
|
+
if (!resp.ok) {
|
|
43
|
+
process.stdout.write(`${CLEAR_LINE} ${RED}✗${RESET} ${data.error || "Failed to create sandbox"}\n`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
const sandbox = data.sandbox;
|
|
47
|
+
// If sandbox is already active (no operation needed)
|
|
48
|
+
if (!data.operationId || sandbox.status === "active") {
|
|
49
|
+
process.stdout.write(`${CLEAR_LINE} ${GREEN}✓${RESET} Sandbox ready (${sandbox.sandboxclaim_name})\n`);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// Stream progress via SSE
|
|
53
|
+
process.stdout.write(`${CLEAR_LINE} ${YELLOW}⠋${RESET} Sandbox ${sandbox.status === "sleeping" ? "waking" : "creating"}...\n`);
|
|
54
|
+
await showProgress(sandbox.id, data.operationId);
|
|
55
|
+
}
|
|
56
|
+
// Step 4: Write SSH config with ProxyCommand
|
|
57
|
+
const binaryPath = resolveTalosBinaryPath();
|
|
58
|
+
updateSshConfig(opts.project, binaryPath);
|
|
59
|
+
process.stdout.write(`${CLEAR_LINE} ${GREEN}✓${RESET} SSH config updated (host: tt-${opts.project})\n`);
|
|
60
|
+
// Step 5: SSH via host alias
|
|
61
|
+
console.log(`\n ${BOLD}Connecting via SSH...${RESET}\n`);
|
|
62
|
+
await sshIntoSandbox(opts.project);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Display progress from SSE stream with step-by-step updates.
|
|
66
|
+
*/
|
|
67
|
+
function showProgress(sandboxId, operationId) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const stepStates = new Map();
|
|
70
|
+
const render = () => {
|
|
71
|
+
// Move cursor up to rewrite progress lines
|
|
72
|
+
const lines = [];
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
// Sort by step number and render
|
|
75
|
+
const sorted = Array.from(stepStates.values()).sort((a, b) => a.step - b.step);
|
|
76
|
+
for (const evt of sorted) {
|
|
77
|
+
if (seen.has(evt.stepName))
|
|
78
|
+
continue;
|
|
79
|
+
seen.add(evt.stepName);
|
|
80
|
+
const label = STEP_LABELS[evt.stepName] || evt.stepName;
|
|
81
|
+
const indent = " ";
|
|
82
|
+
if (evt.status === "completed") {
|
|
83
|
+
lines.push(`${indent}${GREEN} ├ ✓${RESET} ${label}`);
|
|
84
|
+
}
|
|
85
|
+
else if (evt.status === "in_progress") {
|
|
86
|
+
lines.push(`${indent}${YELLOW}⠋${RESET} ${label}... ${GRAY}${evt.message || ""}${RESET}`);
|
|
87
|
+
}
|
|
88
|
+
else if (evt.status === "failed") {
|
|
89
|
+
lines.push(`${indent}${RED} ├ ✗${RESET} ${label}: ${evt.error || evt.message || "failed"}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Write all lines
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
process.stdout.write(`${CLEAR_LINE}${line}\n`);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const cleanup = streamProgress(sandboxId, operationId, (event) => {
|
|
98
|
+
stepStates.set(event.stepName, event);
|
|
99
|
+
// If this is a failed event, reject
|
|
100
|
+
if (event.status === "failed" && event.stepName === "error") {
|
|
101
|
+
cleanup();
|
|
102
|
+
console.error(`\n ${RED}Error: ${event.message}${RESET}`);
|
|
103
|
+
reject(new Error(event.message));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Re-render for in_progress events (live updating)
|
|
107
|
+
if (event.status === "in_progress") {
|
|
108
|
+
// Just print the message inline for simple display
|
|
109
|
+
const label = STEP_LABELS[event.stepName] || event.stepName;
|
|
110
|
+
process.stdout.write(`${CLEAR_LINE} ${YELLOW}⠋${RESET} ${label}... ${GRAY}${event.message || ""}${RESET}`);
|
|
111
|
+
}
|
|
112
|
+
else if (event.status === "completed") {
|
|
113
|
+
const label = STEP_LABELS[event.stepName] || event.stepName;
|
|
114
|
+
process.stdout.write(`${CLEAR_LINE} ${GREEN}✓${RESET} ${label}\n`);
|
|
115
|
+
}
|
|
116
|
+
}, () => {
|
|
117
|
+
resolve();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import dotenv from "dotenv";
|
|
5
|
+
export const CONFIG_DIR = path.join(os.homedir(), ".talos");
|
|
6
|
+
export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
7
|
+
// Load .env from current directory or project root
|
|
8
|
+
dotenv.config({ path: path.resolve(process.cwd(), ".env") }) ||
|
|
9
|
+
dotenv.config({ path: path.resolve(process.cwd(), "../..", ".env") });
|
|
10
|
+
// ── Config loading ─────────────────────────────────────
|
|
11
|
+
export function loadConfig() {
|
|
12
|
+
if (!fs.existsSync(CONFIG_FILE))
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
16
|
+
// v1 → treat as legacy (no email/userId), still usable for token
|
|
17
|
+
if (raw.token && !raw.version) {
|
|
18
|
+
return { version: 2, token: raw.token, email: "", userId: 0, serverUrl: "" };
|
|
19
|
+
}
|
|
20
|
+
return raw;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function saveConfig(config) {
|
|
27
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
28
|
+
const existing = loadConfig();
|
|
29
|
+
const merged = {
|
|
30
|
+
version: 2,
|
|
31
|
+
token: config.token,
|
|
32
|
+
email: config.email ?? existing?.email ?? "",
|
|
33
|
+
userId: config.userId ?? existing?.userId ?? 0,
|
|
34
|
+
serverUrl: config.serverUrl ?? existing?.serverUrl ?? "",
|
|
35
|
+
expiresAt: config.expiresAt ?? existing?.expiresAt,
|
|
36
|
+
};
|
|
37
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
|
|
38
|
+
}
|
|
39
|
+
export function clearConfig() {
|
|
40
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
41
|
+
fs.unlinkSync(CONFIG_FILE);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// ── Server URL resolution ──────────────────────────────
|
|
45
|
+
export function getPortalUrl() {
|
|
46
|
+
// Priority: env var > config file > code default
|
|
47
|
+
if (process.env.TALOS_SERVER_URL) {
|
|
48
|
+
return process.env.TALOS_SERVER_URL;
|
|
49
|
+
}
|
|
50
|
+
const config = loadConfig();
|
|
51
|
+
if (config?.serverUrl) {
|
|
52
|
+
return config.serverUrl;
|
|
53
|
+
}
|
|
54
|
+
if (process.env.ENV === "PRODUCTION") {
|
|
55
|
+
if (!process.env.PORTAL_DOMAIN) {
|
|
56
|
+
console.error("Please set PORTAL_DOMAIN for production");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
return `https://${process.env.PORTAL_DOMAIN}`;
|
|
60
|
+
}
|
|
61
|
+
return `http://localhost:${process.env.PORTAL_PORT || "3080"}`;
|
|
62
|
+
}
|
|
63
|
+
// ── SSH key path ───────────────────────────────────────
|
|
64
|
+
export function getSshKeyPath() {
|
|
65
|
+
return path.join(os.homedir(), ".ssh/id_ed25519");
|
|
66
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deployment profiles determine how the CLI interacts with the server
|
|
3
|
+
* and sandbox infrastructure.
|
|
4
|
+
*/
|
|
5
|
+
export const PROFILES = {
|
|
6
|
+
mock: {
|
|
7
|
+
name: "mock",
|
|
8
|
+
sandboxMode: "mock",
|
|
9
|
+
requiresKubectl: false,
|
|
10
|
+
sshAvailable: false,
|
|
11
|
+
},
|
|
12
|
+
"local-k3d": {
|
|
13
|
+
name: "local-k3d",
|
|
14
|
+
sandboxMode: "k8s",
|
|
15
|
+
requiresKubectl: true,
|
|
16
|
+
sshAvailable: true,
|
|
17
|
+
},
|
|
18
|
+
"remote-k3d": {
|
|
19
|
+
name: "remote-k3d",
|
|
20
|
+
sandboxMode: "k8s",
|
|
21
|
+
requiresKubectl: false,
|
|
22
|
+
sshAvailable: true,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Detect the active deployment profile.
|
|
27
|
+
* Priority: TALOS_PROFILE env > SANDBOX_MODE env > auto-detect
|
|
28
|
+
*/
|
|
29
|
+
export function detectProfile() {
|
|
30
|
+
const profileName = process.env.TALOS_PROFILE;
|
|
31
|
+
if (profileName && PROFILES[profileName]) {
|
|
32
|
+
return PROFILES[profileName];
|
|
33
|
+
}
|
|
34
|
+
const sandboxMode = process.env.SANDBOX_MODE;
|
|
35
|
+
if (sandboxMode === "mock")
|
|
36
|
+
return PROFILES.mock;
|
|
37
|
+
if (sandboxMode === "k8s")
|
|
38
|
+
return PROFILES["local-k3d"];
|
|
39
|
+
// Auto-detect: check if kubectl has a current context
|
|
40
|
+
try {
|
|
41
|
+
const { execSync } = require("node:child_process");
|
|
42
|
+
const ctx = execSync("kubectl config current-context 2>/dev/null", {
|
|
43
|
+
encoding: "utf-8",
|
|
44
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
45
|
+
}).trim();
|
|
46
|
+
if (ctx)
|
|
47
|
+
return PROFILES["local-k3d"];
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// kubectl not available or no context
|
|
51
|
+
}
|
|
52
|
+
return PROFILES.mock;
|
|
53
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { authLoginCommand, authStatusCommand, authLogoutCommand } from "./commands/auth.js";
|
|
4
|
+
import { upCommand } from "./commands/up.js";
|
|
5
|
+
import { sshProxyCommand } from "./commands/ssh-proxy.js";
|
|
6
|
+
const program = new Command();
|
|
7
|
+
program
|
|
8
|
+
.name("talosd")
|
|
9
|
+
.description("Talos Deploy CLI — sandbox environments for Claude Code")
|
|
10
|
+
.version("0.1.0");
|
|
11
|
+
// ── auth ──────────────────────────────────────────────────
|
|
12
|
+
const authCmd = program
|
|
13
|
+
.command("auth")
|
|
14
|
+
.description("Manage authentication")
|
|
15
|
+
.action(async () => {
|
|
16
|
+
// Default: show status (like `gh auth`)
|
|
17
|
+
await authStatusCommand();
|
|
18
|
+
});
|
|
19
|
+
authCmd
|
|
20
|
+
.command("login")
|
|
21
|
+
.description("Login via browser")
|
|
22
|
+
.option("--force", "Force re-login even if already authenticated")
|
|
23
|
+
.action(async (opts) => {
|
|
24
|
+
if (opts.force)
|
|
25
|
+
clearConfigForced();
|
|
26
|
+
await authLoginCommand();
|
|
27
|
+
});
|
|
28
|
+
authCmd
|
|
29
|
+
.command("status")
|
|
30
|
+
.description("Show current auth status")
|
|
31
|
+
.action(async () => {
|
|
32
|
+
await authStatusCommand();
|
|
33
|
+
});
|
|
34
|
+
authCmd
|
|
35
|
+
.command("logout")
|
|
36
|
+
.description("Logout")
|
|
37
|
+
.action(() => {
|
|
38
|
+
authLogoutCommand();
|
|
39
|
+
});
|
|
40
|
+
// Backward compat: `talosd login` → `talosd auth login`
|
|
41
|
+
program
|
|
42
|
+
.command("login")
|
|
43
|
+
.description("Login via browser (alias for auth login)")
|
|
44
|
+
.action(async () => {
|
|
45
|
+
await authLoginCommand();
|
|
46
|
+
});
|
|
47
|
+
// ── up ────────────────────────────────────────────────────
|
|
48
|
+
program
|
|
49
|
+
.command("up")
|
|
50
|
+
.description("Create or wake your sandbox and connect via SSH")
|
|
51
|
+
.option("-p, --project <name>", "Project name", "default")
|
|
52
|
+
.action(async (opts) => {
|
|
53
|
+
await upCommand({ project: opts.project });
|
|
54
|
+
});
|
|
55
|
+
// ── ssh-proxy ─────────────────────────────────────────────
|
|
56
|
+
// Used as SSH ProxyCommand: SSH client spawns this to relay traffic via WebSocket
|
|
57
|
+
program
|
|
58
|
+
.command("ssh-proxy")
|
|
59
|
+
.description("SSH ProxyCommand relay — connect stdin/stdout to sandbox via WebSocket")
|
|
60
|
+
.requiredOption("--project <name>", "Project name")
|
|
61
|
+
.action(async (opts) => {
|
|
62
|
+
await sshProxyCommand(opts.project);
|
|
63
|
+
});
|
|
64
|
+
program.parse();
|
|
65
|
+
// Helper for --force
|
|
66
|
+
function clearConfigForced() {
|
|
67
|
+
const { clearConfig } = require("./config/index.js");
|
|
68
|
+
clearConfig();
|
|
69
|
+
}
|
package/dist/lib/ssh.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import net from "net";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import { execSync, spawn } from "child_process";
|
|
6
|
+
import WebSocket from "ws";
|
|
7
|
+
import { getSshKeyPath, loadConfig, getPortalUrl } from "../config/index.js";
|
|
8
|
+
import { api } from "../api/client.js";
|
|
9
|
+
// ── SSH key management ─────────────────────────────────
|
|
10
|
+
export function ensureSshKey() {
|
|
11
|
+
const keyPath = getSshKeyPath();
|
|
12
|
+
if (!fs.existsSync(keyPath)) {
|
|
13
|
+
console.log("No SSH key found, generating one...");
|
|
14
|
+
execSync(`ssh-keygen -t ed25519 -f ${keyPath} -N ""`, { stdio: "pipe" });
|
|
15
|
+
console.log("SSH key generated.");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function uploadSshKey() {
|
|
19
|
+
const pubKeyPath = getSshKeyPath() + ".pub";
|
|
20
|
+
const pubKey = fs.readFileSync(pubKeyPath, "utf-8").trim();
|
|
21
|
+
const resp = await api("/api/ssh-keys", {
|
|
22
|
+
method: "POST",
|
|
23
|
+
body: JSON.stringify({ public_key: pubKey, name: "default" }),
|
|
24
|
+
});
|
|
25
|
+
return resp.ok;
|
|
26
|
+
}
|
|
27
|
+
// ── SSH relay via WebSocket ─────────────────────────────
|
|
28
|
+
/**
|
|
29
|
+
* Establish an SSH relay through the Portal's WebSocket endpoint.
|
|
30
|
+
* Creates a local TCP server; when SSH connects, data is piped through
|
|
31
|
+
* WebSocket → Portal → Sandbox Manager → pod SSH.
|
|
32
|
+
*/
|
|
33
|
+
export function establishSshRelay(sandboxId) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const config = loadConfig();
|
|
36
|
+
const portalUrl = getPortalUrl();
|
|
37
|
+
// Build WebSocket URL with auth token
|
|
38
|
+
const wsBaseUrl = portalUrl.replace(/^http/, "ws");
|
|
39
|
+
const tokenParam = config?.token ? `?token=${encodeURIComponent(config.token)}` : "";
|
|
40
|
+
const wsUrl = `${wsBaseUrl}/api/sandboxes/${sandboxId}/ssh${tokenParam}`;
|
|
41
|
+
const server = net.createServer();
|
|
42
|
+
server.listen(0, "127.0.0.1", () => {
|
|
43
|
+
const addr = server.address();
|
|
44
|
+
if (!addr || typeof addr === "string") {
|
|
45
|
+
reject(new Error("Failed to bind local server"));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const port = addr.port;
|
|
49
|
+
server.on("connection", (tcpSocket) => {
|
|
50
|
+
// Connect WebSocket for this SSH session
|
|
51
|
+
const ws = new WebSocket(wsUrl);
|
|
52
|
+
let closed = false;
|
|
53
|
+
// Buffer TCP data until WebSocket is open
|
|
54
|
+
const pendingData = [];
|
|
55
|
+
const cleanupSocket = () => {
|
|
56
|
+
if (closed)
|
|
57
|
+
return;
|
|
58
|
+
closed = true;
|
|
59
|
+
try {
|
|
60
|
+
ws.close();
|
|
61
|
+
}
|
|
62
|
+
catch { }
|
|
63
|
+
try {
|
|
64
|
+
tcpSocket.end();
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
};
|
|
68
|
+
// Start listening for TCP data immediately (SSH sends handshake right away)
|
|
69
|
+
tcpSocket.on("data", (data) => {
|
|
70
|
+
if (closed)
|
|
71
|
+
return;
|
|
72
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
73
|
+
ws.send(data);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
pendingData.push(data);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
// WebSocket → TCP
|
|
80
|
+
ws.on("message", (msg) => {
|
|
81
|
+
if (!closed) {
|
|
82
|
+
tcpSocket.write(msg);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
ws.on("open", () => {
|
|
86
|
+
// Flush any data buffered while connecting
|
|
87
|
+
while (pendingData.length > 0) {
|
|
88
|
+
const d = pendingData.shift();
|
|
89
|
+
ws.send(d);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
ws.on("error", (err) => {
|
|
93
|
+
console.error(`WebSocket error: ${err.message}`);
|
|
94
|
+
cleanupSocket();
|
|
95
|
+
});
|
|
96
|
+
ws.on("close", () => cleanupSocket());
|
|
97
|
+
tcpSocket.on("close", () => cleanupSocket());
|
|
98
|
+
tcpSocket.on("error", () => cleanupSocket());
|
|
99
|
+
});
|
|
100
|
+
resolve({
|
|
101
|
+
port,
|
|
102
|
+
cleanup: () => {
|
|
103
|
+
server.close();
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
server.on("error", (err) => reject(err));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// ── Legacy: kubectl port-forward (kept as fallback) ────
|
|
111
|
+
export function establishPortForward(podName, namespace) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const { spawn } = require("child_process");
|
|
114
|
+
const pf = spawn("kubectl", ["port-forward", podName, "0:22", "-n", namespace], {
|
|
115
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
116
|
+
});
|
|
117
|
+
const timeout = setTimeout(() => {
|
|
118
|
+
pf.kill();
|
|
119
|
+
reject(new Error("port-forward timeout (15s)"));
|
|
120
|
+
}, 15_000);
|
|
121
|
+
const onPort = (data) => {
|
|
122
|
+
const match = data.toString().match(/Forwarding from\s+\d+\.\d+\.\d+\.\d+:(\d+)/);
|
|
123
|
+
if (match) {
|
|
124
|
+
clearTimeout(timeout);
|
|
125
|
+
const port = parseInt(match[1], 10);
|
|
126
|
+
resolve({
|
|
127
|
+
port,
|
|
128
|
+
cleanup: () => {
|
|
129
|
+
pf.kill();
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
pf.stdout.on("data", onPort);
|
|
135
|
+
pf.stderr.on("data", onPort);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// ── Binary path resolution ─────────────────────────────
|
|
139
|
+
export function resolveTalosBinaryPath(execFn = execSync, argv = process.argv) {
|
|
140
|
+
try {
|
|
141
|
+
return execFn("which talosd").toString().trim();
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return path.resolve(argv[1]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// ── SSH config update ──────────────────────────────────
|
|
148
|
+
export function updateSshConfig(project, binaryPath, sshConfigPath = path.join(os.homedir(), ".ssh/config")) {
|
|
149
|
+
let content = "";
|
|
150
|
+
if (fs.existsSync(sshConfigPath)) {
|
|
151
|
+
content = fs.readFileSync(sshConfigPath, "utf-8");
|
|
152
|
+
}
|
|
153
|
+
const hostAlias = `tt-${project}`;
|
|
154
|
+
const block = `\nHost ${hostAlias}\n` +
|
|
155
|
+
` User coder\n` +
|
|
156
|
+
` StrictHostKeyChecking no\n` +
|
|
157
|
+
` UserKnownHostsFile /dev/null\n` +
|
|
158
|
+
` IdentityFile ~/.ssh/id_ed25519\n` +
|
|
159
|
+
` ProxyCommand ${binaryPath} ssh-proxy --project ${project}\n`;
|
|
160
|
+
const regex = new RegExp(`\n?Host ${hostAlias}\n(?: .*\n)*`);
|
|
161
|
+
if (regex.test(content)) {
|
|
162
|
+
content = content.replace(regex, block);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
content += block;
|
|
166
|
+
}
|
|
167
|
+
fs.writeFileSync(sshConfigPath, content);
|
|
168
|
+
}
|
|
169
|
+
// ── SSH session ────────────────────────────────────────
|
|
170
|
+
export function sshIntoSandbox(project, spawnFn = spawn) {
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
const ssh = spawnFn("ssh", [`tt-${project}`], { stdio: "inherit" });
|
|
173
|
+
ssh.on("close", (code) => {
|
|
174
|
+
if (code && code !== 0) {
|
|
175
|
+
reject(new Error(`SSH exited with code ${code}`));
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
resolve();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
ssh.on("error", (err) => {
|
|
182
|
+
reject(err);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "talos-deploy",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Talos Deploy CLI — sandbox environments for Claude Code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"talosd": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "tsx src/index.ts",
|
|
14
|
+
"build": "tsc && chmod +x dist/index.js",
|
|
15
|
+
"prepublishOnly": "npm run build",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/qiaolei1973/talos-deploy"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"commander": "^12.0.0",
|
|
26
|
+
"dotenv": "^17.4.2",
|
|
27
|
+
"eventsource": "^4.1.0",
|
|
28
|
+
"ws": "^8.21.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^25.9.1",
|
|
32
|
+
"@types/ws": "^8.18.1",
|
|
33
|
+
"tsx": "^4.0.0",
|
|
34
|
+
"typescript": "^5.5.0",
|
|
35
|
+
"vitest": "^3.2.6"
|
|
36
|
+
}
|
|
37
|
+
}
|