cassian-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/down.d.ts +1 -0
- package/dist/commands/down.js +43 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +138 -0
- package/dist/commands/login.d.ts +1 -0
- package/dist/commands/login.js +96 -0
- package/dist/commands/logout.d.ts +1 -0
- package/dist/commands/logout.js +6 -0
- package/dist/commands/logs.d.ts +4 -0
- package/dist/commands/logs.js +60 -0
- package/dist/commands/ssh.d.ts +1 -0
- package/dist/commands/ssh.js +123 -0
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +63 -0
- package/dist/commands/sync.d.ts +1 -0
- package/dist/commands/sync.js +84 -0
- package/dist/commands/up.d.ts +1 -0
- package/dist/commands/up.js +178 -0
- package/dist/commands/volume.d.ts +3 -0
- package/dist/commands/volume.js +107 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +78 -0
- package/dist/lib/api.d.ts +18 -0
- package/dist/lib/api.js +88 -0
- package/dist/lib/auth.d.ts +7 -0
- package/dist/lib/auth.js +63 -0
- package/dist/lib/config.d.ts +5 -0
- package/dist/lib/config.js +64 -0
- package/dist/lib/errors.d.ts +2 -0
- package/dist/lib/errors.js +58 -0
- package/dist/lib/output.d.ts +11 -0
- package/dist/lib/output.js +37 -0
- package/dist/lib/watcher.d.ts +14 -0
- package/dist/lib/watcher.js +183 -0
- package/dist/types.d.ts +89 -0
- package/dist/types.js +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function down(nameArg?: string): Promise<void>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import ora from "ora";
|
|
2
|
+
import { ApiClient } from "../lib/api.js";
|
|
3
|
+
import { loadConfig } from "../lib/config.js";
|
|
4
|
+
import { success, dim } from "../lib/output.js";
|
|
5
|
+
import { fatal, handleError } from "../lib/errors.js";
|
|
6
|
+
export async function down(nameArg) {
|
|
7
|
+
// Accept an explicit name arg, or fall back to cassian.yaml
|
|
8
|
+
let name;
|
|
9
|
+
if (nameArg) {
|
|
10
|
+
name = nameArg;
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
try {
|
|
14
|
+
name = loadConfig().name;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
fatal("No instance name given.", "Run: cassian down <name> or cd to your project and run: cassian down");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const spinner = ora(`Stopping ${name}...`).start();
|
|
21
|
+
try {
|
|
22
|
+
const client = new ApiClient();
|
|
23
|
+
const { instances } = await client.get("/v1/instances");
|
|
24
|
+
const instance = instances.find((i) => i.name === name && i.status === "running");
|
|
25
|
+
if (!instance) {
|
|
26
|
+
spinner.stop();
|
|
27
|
+
console.log();
|
|
28
|
+
success(`${name} is not running.`);
|
|
29
|
+
console.log(dim(" Run: cassian status to see all instances"));
|
|
30
|
+
console.log();
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
await client.delete(`/v1/instances/${instance.id}`);
|
|
34
|
+
spinner.succeed(`${name} stopped`);
|
|
35
|
+
console.log();
|
|
36
|
+
success("Your work is saved. Run cassian up to start again.");
|
|
37
|
+
console.log();
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
spinner.fail("Could not stop instance");
|
|
41
|
+
handleError(err);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function init(): Promise<void>;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createInterface } from "readline";
|
|
2
|
+
import { writeFileSync, existsSync } from "fs";
|
|
3
|
+
import { green, dim, bold } from "../lib/output.js";
|
|
4
|
+
import { fatal } from "../lib/errors.js";
|
|
5
|
+
function prompt(rl, question, fallback) {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
rl.question(question, (answer) => {
|
|
8
|
+
resolve(answer.trim() || fallback);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function promptChoice(rl, question, choices, fallback) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
console.log(question);
|
|
15
|
+
choices.forEach((c, i) => {
|
|
16
|
+
const marker = c.value === fallback ? green("›") : " ";
|
|
17
|
+
const hint = c.hint ? dim(` — ${c.hint}`) : "";
|
|
18
|
+
console.log(` ${marker} ${i + 1}) ${c.label}${hint}`);
|
|
19
|
+
});
|
|
20
|
+
rl.question(dim(` [1-${choices.length}, default ${choices.findIndex(c => c.value === fallback) + 1}]: `), (answer) => {
|
|
21
|
+
const idx = parseInt(answer.trim()) - 1;
|
|
22
|
+
if (idx >= 0 && idx < choices.length)
|
|
23
|
+
resolve(choices[idx].value);
|
|
24
|
+
else
|
|
25
|
+
resolve(fallback);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export async function init() {
|
|
30
|
+
if (existsSync("cassian.yaml")) {
|
|
31
|
+
fatal("cassian.yaml already exists in this directory.");
|
|
32
|
+
}
|
|
33
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
34
|
+
console.log();
|
|
35
|
+
console.log(bold(" Let's set up your Cassian project."));
|
|
36
|
+
console.log(dim(" Press enter to accept defaults.\n"));
|
|
37
|
+
// Instance name
|
|
38
|
+
const dirName = process.cwd().split("/").pop().toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
39
|
+
const name = await prompt(rl, ` Instance name ${dim(`[${dirName}]`)}: `, dirName);
|
|
40
|
+
// GPU type
|
|
41
|
+
console.log();
|
|
42
|
+
const gpuType = await promptChoice(rl, " GPU type:", [
|
|
43
|
+
{ label: "H100 SXM", value: "h100-sxm", hint: "80GB — best for large training runs" },
|
|
44
|
+
{ label: "H100 PCIe", value: "h100-pcie", hint: "80GB" },
|
|
45
|
+
{ label: "A100 SXM", value: "a100-sxm", hint: "80GB — reliable workhorse" },
|
|
46
|
+
{ label: "A100 PCIe", value: "a100-pcie", hint: "80GB" },
|
|
47
|
+
{ label: "L40S", value: "l40s", hint: "48GB — great for inference" },
|
|
48
|
+
{ label: "RTX 4090", value: "rtx4090", hint: "24GB — fast, affordable" },
|
|
49
|
+
{ label: "RTX 3090", value: "rtx3090", hint: "24GB" },
|
|
50
|
+
], "rtx3090");
|
|
51
|
+
// GPU count
|
|
52
|
+
console.log();
|
|
53
|
+
const gpuCountStr = await promptChoice(rl, " Number of GPUs:", [
|
|
54
|
+
{ label: "1", value: "1" },
|
|
55
|
+
{ label: "2", value: "2" },
|
|
56
|
+
{ label: "4", value: "4" },
|
|
57
|
+
{ label: "8", value: "8" },
|
|
58
|
+
], "1");
|
|
59
|
+
const gpuCount = parseInt(gpuCountStr);
|
|
60
|
+
// RAM
|
|
61
|
+
console.log();
|
|
62
|
+
const memory = await promptChoice(rl, " RAM:", [
|
|
63
|
+
{ label: "16 GB", value: "16G" },
|
|
64
|
+
{ label: "32 GB", value: "32G", hint: "recommended" },
|
|
65
|
+
{ label: "64 GB", value: "64G" },
|
|
66
|
+
{ label: "128 GB", value: "128G" },
|
|
67
|
+
{ label: "256 GB", value: "256G" },
|
|
68
|
+
], "32G");
|
|
69
|
+
// Shared memory (for PyTorch DataLoader)
|
|
70
|
+
console.log();
|
|
71
|
+
const shmSize = await promptChoice(rl, " Shared memory (for PyTorch DataLoader):", [
|
|
72
|
+
{ label: "4 GB", value: "4G" },
|
|
73
|
+
{ label: "8 GB", value: "8G" },
|
|
74
|
+
{ label: "16 GB", value: "16G", hint: "recommended" },
|
|
75
|
+
{ label: "32 GB", value: "32G" },
|
|
76
|
+
], "16G");
|
|
77
|
+
// Disk size
|
|
78
|
+
console.log();
|
|
79
|
+
const diskSize = await promptChoice(rl, " Workspace disk size:", [
|
|
80
|
+
{ label: "20 GB", value: "20G" },
|
|
81
|
+
{ label: "50 GB", value: "50G", hint: "recommended" },
|
|
82
|
+
{ label: "100 GB", value: "100G" },
|
|
83
|
+
{ label: "200 GB", value: "200G" },
|
|
84
|
+
{ label: "500 GB", value: "500G", hint: "for large datasets / checkpoints" },
|
|
85
|
+
{ label: "1 TB", value: "1T" },
|
|
86
|
+
], "50G");
|
|
87
|
+
// Setup command
|
|
88
|
+
console.log();
|
|
89
|
+
const hasReqs = existsSync("requirements.txt");
|
|
90
|
+
const hasPyproject = existsSync("pyproject.toml");
|
|
91
|
+
let defaultSetup = "";
|
|
92
|
+
if (hasReqs)
|
|
93
|
+
defaultSetup = "pip install -r /workspace/requirements.txt";
|
|
94
|
+
else if (hasPyproject)
|
|
95
|
+
defaultSetup = "pip install -e /workspace";
|
|
96
|
+
const setupRaw = await prompt(rl, ` Setup command ${dim(`[${defaultSetup || "skip"}]`)}: `, defaultSetup);
|
|
97
|
+
const setup = setupRaw === "skip" ? "" : setupRaw;
|
|
98
|
+
// Syncignore — what not to sync
|
|
99
|
+
console.log();
|
|
100
|
+
console.log(dim(" Patterns to exclude from sync (like .gitignore)."));
|
|
101
|
+
console.log(dim(" Separate with commas, or press enter for defaults."));
|
|
102
|
+
const syncignoreRaw = await prompt(rl, ` Syncignore ${dim("[outputs/**, *.ckpt, *.safetensors, **/.cache/**]")}: `, "");
|
|
103
|
+
const customIgnore = syncignoreRaw
|
|
104
|
+
? syncignoreRaw.split(",").map(s => s.trim()).filter(Boolean)
|
|
105
|
+
: ["outputs/**", "*.ckpt", "*.safetensors", "**/.cache/**"];
|
|
106
|
+
rl.close();
|
|
107
|
+
// Build yaml
|
|
108
|
+
const volName = name.replace(/[^a-z0-9-]/g, "-") + "-data";
|
|
109
|
+
const syncignoreLines = customIgnore.map(p => ` - "${p}"`).join("\n");
|
|
110
|
+
const setupLine = setup ? ` setup: ${setup}\n` : "";
|
|
111
|
+
const yaml = `name: ${name}
|
|
112
|
+
|
|
113
|
+
gpu:
|
|
114
|
+
count: ${gpuCount}
|
|
115
|
+
type: ${gpuType}
|
|
116
|
+
|
|
117
|
+
image: default
|
|
118
|
+
|
|
119
|
+
volumes:
|
|
120
|
+
- name: ${volName}
|
|
121
|
+
size: ${diskSize}
|
|
122
|
+
mount: /workspace
|
|
123
|
+
|
|
124
|
+
resources:
|
|
125
|
+
memory: ${memory}
|
|
126
|
+
shm_size: ${shmSize}
|
|
127
|
+
|
|
128
|
+
workspace:
|
|
129
|
+
${setupLine} syncignore:
|
|
130
|
+
${syncignoreLines}
|
|
131
|
+
`;
|
|
132
|
+
writeFileSync("cassian.yaml", yaml);
|
|
133
|
+
console.log();
|
|
134
|
+
console.log(` ${green("✓")} Created cassian.yaml`);
|
|
135
|
+
console.log();
|
|
136
|
+
console.log(` Run ${green("cassian up")} to start your instance.`);
|
|
137
|
+
console.log();
|
|
138
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function login(): Promise<void>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createServer } from "http";
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import open from "open";
|
|
6
|
+
import { saveCredentials } from "../lib/auth.js";
|
|
7
|
+
import { success, dim } from "../lib/output.js";
|
|
8
|
+
import { fatal } from "../lib/errors.js";
|
|
9
|
+
const CASSIAN_DIR = join(homedir(), ".cassian");
|
|
10
|
+
const CONFIG_FILE = join(CASSIAN_DIR, "config.json");
|
|
11
|
+
const CALLBACK_PORT = 9876;
|
|
12
|
+
function getCliConfig() {
|
|
13
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
14
|
+
fatal("Cassian CLI is not configured.", "Visit trycassian.com to get started.");
|
|
15
|
+
}
|
|
16
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
17
|
+
}
|
|
18
|
+
export async function login() {
|
|
19
|
+
const config = getCliConfig();
|
|
20
|
+
const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
|
|
21
|
+
const platformUrl = config.platform_url || "https://platform.trycassian.com";
|
|
22
|
+
console.log();
|
|
23
|
+
console.log(" Opening Cassian in your browser...");
|
|
24
|
+
console.log();
|
|
25
|
+
const authUrl = `${platformUrl}?cli_redirect=${encodeURIComponent(callbackUrl)}`;
|
|
26
|
+
// Derive the allowed CORS origin from platformUrl (e.g. https://trycassian.com)
|
|
27
|
+
const allowedOrigin = new URL(platformUrl).origin;
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const server = createServer((req, res) => {
|
|
30
|
+
const origin = req.headers["origin"] || "";
|
|
31
|
+
// Only accept requests from the platform — block anything else on this port
|
|
32
|
+
if (origin && origin !== allowedOrigin) {
|
|
33
|
+
res.writeHead(403);
|
|
34
|
+
res.end();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
|
|
38
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
39
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
40
|
+
if (req.method === "OPTIONS") {
|
|
41
|
+
res.writeHead(204);
|
|
42
|
+
res.end();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const url = new URL(req.url || "/", `http://localhost:${CALLBACK_PORT}`);
|
|
46
|
+
// Platform posts tokens directly to /callback?access_token=...&refresh_token=...
|
|
47
|
+
if (url.pathname === "/callback" && req.method === "POST") {
|
|
48
|
+
const params = url.searchParams;
|
|
49
|
+
const accessToken = params.get("access_token");
|
|
50
|
+
const refreshToken = params.get("refresh_token");
|
|
51
|
+
const expiresIn = parseInt(params.get("expires_in") || "3600");
|
|
52
|
+
if (!accessToken || !refreshToken) {
|
|
53
|
+
res.writeHead(400);
|
|
54
|
+
res.end("Missing tokens");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Decode JWT to get email
|
|
58
|
+
const payload = JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url").toString());
|
|
59
|
+
const email = payload.email || "unknown";
|
|
60
|
+
const creds = {
|
|
61
|
+
access_token: accessToken,
|
|
62
|
+
refresh_token: refreshToken,
|
|
63
|
+
expires_at: Math.floor(Date.now() / 1000) + expiresIn,
|
|
64
|
+
user_email: email,
|
|
65
|
+
agent_url: config.agent_url,
|
|
66
|
+
};
|
|
67
|
+
saveCredentials(creds);
|
|
68
|
+
res.writeHead(200);
|
|
69
|
+
res.end("ok");
|
|
70
|
+
console.log();
|
|
71
|
+
success(`Logged in as ${email}`);
|
|
72
|
+
console.log();
|
|
73
|
+
server.close();
|
|
74
|
+
resolve();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
res.writeHead(404);
|
|
78
|
+
res.end();
|
|
79
|
+
});
|
|
80
|
+
server.listen(CALLBACK_PORT, "127.0.0.1", () => {
|
|
81
|
+
open(authUrl).catch(() => {
|
|
82
|
+
console.log(dim(" Browser didn't open? Visit:"));
|
|
83
|
+
console.log(` ${authUrl}`);
|
|
84
|
+
console.log();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
server.on("error", () => {
|
|
88
|
+
fatal("Could not start login. Try again.");
|
|
89
|
+
});
|
|
90
|
+
const timeout = setTimeout(() => {
|
|
91
|
+
server.close();
|
|
92
|
+
fatal("Login timed out.", "Run: cassian login");
|
|
93
|
+
}, 120000);
|
|
94
|
+
server.on("close", () => clearTimeout(timeout));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function logout(): void;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ApiClient } from "../lib/api.js";
|
|
2
|
+
import { header, dim } from "../lib/output.js";
|
|
3
|
+
export async function logs(options) {
|
|
4
|
+
const client = new ApiClient();
|
|
5
|
+
const params = new URLSearchParams();
|
|
6
|
+
if (options.event)
|
|
7
|
+
params.set("event", options.event);
|
|
8
|
+
if (options.limit)
|
|
9
|
+
params.set("limit", options.limit);
|
|
10
|
+
const queryString = params.toString() ? `?${params.toString()}` : "";
|
|
11
|
+
const { logs: entries } = await client.get(`/v1/logs${queryString}`);
|
|
12
|
+
header("Activity Log:");
|
|
13
|
+
console.log();
|
|
14
|
+
if (entries.length === 0) {
|
|
15
|
+
console.log(dim(" No activity yet."));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const colWidths = { ts: 23, event: 20 };
|
|
19
|
+
console.log(dim(` ${"TIMESTAMP".padEnd(colWidths.ts)} ${"EVENT".padEnd(colWidths.event)} DETAILS`));
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const ts = new Date(entry.ts).toISOString().replace("T", " ").slice(0, 19) + " UTC";
|
|
22
|
+
const details = formatDetails(entry.event, entry.data);
|
|
23
|
+
console.log(` ${ts} ${entry.event.padEnd(colWidths.event)} ${details}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function formatDetails(event, data) {
|
|
27
|
+
switch (event) {
|
|
28
|
+
case "instance.create":
|
|
29
|
+
return `${data.name} (${data.gpu_count}x GPU)`;
|
|
30
|
+
case "instance.ready":
|
|
31
|
+
return `ssh port ${data.ssh_port} (${data.duration_ms || "?"}ms)`;
|
|
32
|
+
case "instance.stop":
|
|
33
|
+
return `${data.name || data.instance_id} (uptime: ${formatUptime(data.uptime_seconds)})`;
|
|
34
|
+
case "volume.create":
|
|
35
|
+
return `${data.name} (${data.size_gb}G)`;
|
|
36
|
+
case "volume.mount":
|
|
37
|
+
return `${data.name} → ${data.mount}`;
|
|
38
|
+
case "volume.unmount":
|
|
39
|
+
return `${data.name}`;
|
|
40
|
+
case "volume.delete":
|
|
41
|
+
return `${data.name} (${data.size_gb}G) securely erased`;
|
|
42
|
+
case "gpu.allocate":
|
|
43
|
+
return `devices ${data.gpu_devices}`;
|
|
44
|
+
case "gpu.release":
|
|
45
|
+
return `devices ${data.gpu_devices}`;
|
|
46
|
+
default:
|
|
47
|
+
return JSON.stringify(data);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function formatUptime(seconds) {
|
|
51
|
+
if (!seconds)
|
|
52
|
+
return "unknown";
|
|
53
|
+
if (seconds < 60)
|
|
54
|
+
return `${seconds}s`;
|
|
55
|
+
if (seconds < 3600)
|
|
56
|
+
return `${Math.floor(seconds / 60)}m`;
|
|
57
|
+
const h = Math.floor(seconds / 3600);
|
|
58
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
59
|
+
return `${h}h${m}m`;
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function ssh(): Promise<void>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { ApiClient } from "../lib/api.js";
|
|
3
|
+
import { getCredentials, isTokenExpired, refreshToken } from "../lib/auth.js";
|
|
4
|
+
import { loadConfig } from "../lib/config.js";
|
|
5
|
+
import { dim } from "../lib/output.js";
|
|
6
|
+
import { fatal, handleError } from "../lib/errors.js";
|
|
7
|
+
import { startBidirectionalSync } from "../lib/watcher.js";
|
|
8
|
+
export async function ssh() {
|
|
9
|
+
let config;
|
|
10
|
+
try {
|
|
11
|
+
config = loadConfig();
|
|
12
|
+
}
|
|
13
|
+
catch (e) {
|
|
14
|
+
fatal(e.message);
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const client = new ApiClient();
|
|
18
|
+
const { instances } = await client.get("/v1/instances");
|
|
19
|
+
const instance = instances.find((i) => i.name === config.name && i.status === "running");
|
|
20
|
+
if (!instance) {
|
|
21
|
+
fatal(`${config.name} is not running.`, "Run: cassian up");
|
|
22
|
+
}
|
|
23
|
+
const creds = getCredentials();
|
|
24
|
+
const stopSync = startBidirectionalSync(instance.id, config, creds?.agent_url ?? "");
|
|
25
|
+
const connected = await connectWebSocket(instance, stopSync);
|
|
26
|
+
if (!connected) {
|
|
27
|
+
stopSync();
|
|
28
|
+
fatal("Could not connect to instance.", "Try again or run: cassian down && cassian up");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (err.code === "ERR_USE_AFTER_CLOSE")
|
|
33
|
+
return;
|
|
34
|
+
handleError(err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function getToken() {
|
|
38
|
+
const creds = getCredentials();
|
|
39
|
+
if (!creds)
|
|
40
|
+
throw new Error("Not logged in. Run 'cassian login' first.");
|
|
41
|
+
if (isTokenExpired(creds)) {
|
|
42
|
+
const { readFileSync, existsSync } = await import("fs");
|
|
43
|
+
const configPath = `${process.env.HOME}/.cassian/config.json`;
|
|
44
|
+
let supabaseUrl = "";
|
|
45
|
+
if (existsSync(configPath)) {
|
|
46
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
47
|
+
supabaseUrl = config.supabase_url || "";
|
|
48
|
+
}
|
|
49
|
+
if (!supabaseUrl)
|
|
50
|
+
throw new Error("Session expired. Run 'cassian login' again.");
|
|
51
|
+
const refreshed = await refreshToken(creds, supabaseUrl);
|
|
52
|
+
return refreshed.access_token;
|
|
53
|
+
}
|
|
54
|
+
return creds.access_token;
|
|
55
|
+
}
|
|
56
|
+
async function connectWebSocket(instance, stopSync) {
|
|
57
|
+
const creds = getCredentials();
|
|
58
|
+
if (!creds)
|
|
59
|
+
return false;
|
|
60
|
+
const token = await getToken();
|
|
61
|
+
const agentUrl = creds.agent_url.replace(/^http/, "ws");
|
|
62
|
+
const wsUrl = `${agentUrl}/v1/instances/${instance.id}/terminal?token=${encodeURIComponent(token)}`;
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const ws = new WebSocket(wsUrl);
|
|
65
|
+
let connected = false;
|
|
66
|
+
const timeout = setTimeout(() => {
|
|
67
|
+
if (!connected) {
|
|
68
|
+
ws.terminate();
|
|
69
|
+
resolve(false);
|
|
70
|
+
}
|
|
71
|
+
}, 5000);
|
|
72
|
+
ws.on("open", () => {
|
|
73
|
+
connected = true;
|
|
74
|
+
clearTimeout(timeout);
|
|
75
|
+
console.log(dim(` Connected to ${instance.name}. Type 'exit' to disconnect.`));
|
|
76
|
+
console.log(dim(` Tip: for long-running jobs use nohup — e.g. nohup python train.py &`));
|
|
77
|
+
console.log();
|
|
78
|
+
if (process.stdin.isTTY)
|
|
79
|
+
process.stdin.setRawMode(true);
|
|
80
|
+
process.stdin.resume();
|
|
81
|
+
const sendResize = () => {
|
|
82
|
+
const { columns, rows } = process.stdout;
|
|
83
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
84
|
+
ws.send(JSON.stringify({ type: "resize", cols: columns, rows: rows }));
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
sendResize();
|
|
88
|
+
process.stdout.on("resize", sendResize);
|
|
89
|
+
process.stdin.on("data", (data) => {
|
|
90
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
91
|
+
ws.send(data);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
ws.on("message", (data) => {
|
|
95
|
+
process.stdout.write(data);
|
|
96
|
+
});
|
|
97
|
+
ws.on("close", () => {
|
|
98
|
+
stopSync();
|
|
99
|
+
if (process.stdin.isTTY)
|
|
100
|
+
process.stdin.setRawMode(false);
|
|
101
|
+
process.stdin.pause();
|
|
102
|
+
process.exit(0);
|
|
103
|
+
});
|
|
104
|
+
ws.on("error", () => {
|
|
105
|
+
stopSync();
|
|
106
|
+
if (process.stdin.isTTY)
|
|
107
|
+
process.stdin.setRawMode(false);
|
|
108
|
+
process.stdin.pause();
|
|
109
|
+
process.exit(1);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
ws.on("error", () => {
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
if (!connected)
|
|
115
|
+
resolve(false);
|
|
116
|
+
});
|
|
117
|
+
ws.on("unexpected-response", () => {
|
|
118
|
+
clearTimeout(timeout);
|
|
119
|
+
if (!connected)
|
|
120
|
+
resolve(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function status(): Promise<void>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ApiClient } from "../lib/api.js";
|
|
2
|
+
import { header, dim, green, bold } from "../lib/output.js";
|
|
3
|
+
import { handleError } from "../lib/errors.js";
|
|
4
|
+
export async function status() {
|
|
5
|
+
const client = new ApiClient();
|
|
6
|
+
try {
|
|
7
|
+
const [{ instances }, { volumes }] = await Promise.all([
|
|
8
|
+
client.get("/v1/instances"),
|
|
9
|
+
client.get("/v1/volumes"),
|
|
10
|
+
]);
|
|
11
|
+
console.log();
|
|
12
|
+
// Instances
|
|
13
|
+
header("Instances");
|
|
14
|
+
console.log();
|
|
15
|
+
if (instances.length === 0) {
|
|
16
|
+
console.log(dim(" No running instances."));
|
|
17
|
+
console.log(dim(" Run: cassian up"));
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
for (const inst of instances) {
|
|
21
|
+
const gpus = `${inst.gpu_count}x ${inst.gpu_devices ? `(GPU ${inst.gpu_devices})` : "GPU"}`;
|
|
22
|
+
const vols = inst.volumes.map((v) => `${v.name} → ${v.mount}`).join(", ") || "—";
|
|
23
|
+
const age = timeSince(inst.created_at);
|
|
24
|
+
console.log(` ${bold(inst.name)} ${green("● running")} ${dim(age)}`);
|
|
25
|
+
console.log(` ${dim("GPUs")} ${gpus}`);
|
|
26
|
+
console.log(` ${dim("Storage")} ${vols}`);
|
|
27
|
+
console.log();
|
|
28
|
+
}
|
|
29
|
+
console.log(dim(` cassian ssh — connect`));
|
|
30
|
+
console.log(dim(` cassian down — stop instance`));
|
|
31
|
+
}
|
|
32
|
+
console.log();
|
|
33
|
+
// Volumes
|
|
34
|
+
header("Storage");
|
|
35
|
+
console.log();
|
|
36
|
+
if (volumes.length === 0) {
|
|
37
|
+
console.log(dim(" No volumes yet."));
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
for (const vol of volumes) {
|
|
41
|
+
const attached = vol.attached_instance_id ? green("attached") : dim("available");
|
|
42
|
+
const age = timeSince(vol.created_at);
|
|
43
|
+
console.log(` ${bold(vol.name)} ${vol.size_gb}G ${attached} ${dim(age)}`);
|
|
44
|
+
}
|
|
45
|
+
console.log();
|
|
46
|
+
console.log(dim(` cassian volume delete --name <name> — destroy a volume`));
|
|
47
|
+
}
|
|
48
|
+
console.log();
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
handleError(err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function timeSince(iso) {
|
|
55
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
56
|
+
const mins = Math.floor(diff / 60000);
|
|
57
|
+
if (mins < 60)
|
|
58
|
+
return `${mins}m ago`;
|
|
59
|
+
const hrs = Math.floor(mins / 60);
|
|
60
|
+
if (hrs < 24)
|
|
61
|
+
return `${hrs}h ago`;
|
|
62
|
+
return `${Math.floor(hrs / 24)}d ago`;
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function sync(): Promise<void>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as tar from "tar";
|
|
2
|
+
import { mkdirSync, existsSync, readdirSync, unlinkSync, statSync } from "fs";
|
|
3
|
+
import { join, normalize } from "path";
|
|
4
|
+
import { Readable } from "stream";
|
|
5
|
+
import ora from "ora";
|
|
6
|
+
import { ApiClient } from "../lib/api.js";
|
|
7
|
+
import { loadConfig } from "../lib/config.js";
|
|
8
|
+
import { fatal, handleError } from "../lib/errors.js";
|
|
9
|
+
export async function sync() {
|
|
10
|
+
let config;
|
|
11
|
+
try {
|
|
12
|
+
config = loadConfig();
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
fatal(e.message);
|
|
16
|
+
}
|
|
17
|
+
const client = new ApiClient();
|
|
18
|
+
const mountPath = config.volumes?.[0]?.mount ?? "/workspace";
|
|
19
|
+
const { instances } = await client.get("/v1/instances");
|
|
20
|
+
const instance = instances.find((i) => i.name === config.name && i.status === "running");
|
|
21
|
+
if (!instance) {
|
|
22
|
+
fatal(`${config.name} is not running.`, "Run: cassian up");
|
|
23
|
+
}
|
|
24
|
+
const spinner = ora("Syncing files from instance...").start();
|
|
25
|
+
try {
|
|
26
|
+
const tarBuf = await client.pull(instance.id, mountPath);
|
|
27
|
+
const cwd = process.cwd();
|
|
28
|
+
mkdirSync(cwd, { recursive: true });
|
|
29
|
+
// Build ignored set (same as push — only reconcile within sync scope)
|
|
30
|
+
const syncignore = config.workspace?.syncignore ?? [];
|
|
31
|
+
const defaultIgnore = ["**/.git/**", "**/node_modules/**", "**/__pycache__/**", "**/*.pyc", "**/.DS_Store", ...syncignore];
|
|
32
|
+
const ignoreRegexes = defaultIgnore.map((p) => {
|
|
33
|
+
const esc = p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\x00").replace(/\*/g, "[^/]*").replace(/\x00/g, ".*");
|
|
34
|
+
return new RegExp(`(^|/)${esc}($|/)`);
|
|
35
|
+
});
|
|
36
|
+
// Collect remote file set from tar
|
|
37
|
+
const remoteFiles = new Set();
|
|
38
|
+
await new Promise((res) => {
|
|
39
|
+
const parser = new tar.Parser({
|
|
40
|
+
onentry: (entry) => {
|
|
41
|
+
const stripped = entry.path.replace(/^[^/]+\//, "");
|
|
42
|
+
const norm = normalize(stripped);
|
|
43
|
+
if (stripped && !norm.startsWith("..") && !norm.startsWith("/"))
|
|
44
|
+
remoteFiles.add(norm);
|
|
45
|
+
entry.resume();
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
Readable.from(tarBuf).pipe(parser)
|
|
49
|
+
.on("finish", res).on("error", res);
|
|
50
|
+
});
|
|
51
|
+
// Delete local synced files not in remote
|
|
52
|
+
function walk(dir) {
|
|
53
|
+
if (!existsSync(dir))
|
|
54
|
+
return [];
|
|
55
|
+
return readdirSync(dir).flatMap((e) => {
|
|
56
|
+
const full = join(dir, e);
|
|
57
|
+
const rel = full.slice(cwd.length + 1);
|
|
58
|
+
if (ignoreRegexes.some((re) => re.test(rel)))
|
|
59
|
+
return [];
|
|
60
|
+
try {
|
|
61
|
+
return statSync(full).isDirectory() ? walk(full) : [rel];
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
for (const f of walk(cwd)) {
|
|
69
|
+
if (!remoteFiles.has(normalize(f))) {
|
|
70
|
+
try {
|
|
71
|
+
unlinkSync(join(cwd, f));
|
|
72
|
+
}
|
|
73
|
+
catch { /* skip */ }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Extract
|
|
77
|
+
await new Promise((res, rej) => Readable.from(tarBuf).pipe(tar.extract({ cwd, strip: 1 })).on("finish", res).on("error", rej));
|
|
78
|
+
spinner.succeed("Files synced");
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
spinner.fail("Sync failed");
|
|
82
|
+
handleError(err);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function up(): Promise<void>;
|