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,178 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import ora from "ora";
|
|
5
|
+
import { ApiClient } from "../lib/api.js";
|
|
6
|
+
import { getCredentials } from "../lib/auth.js";
|
|
7
|
+
import { loadConfig, loadDevOverrides, findSshPublicKey, parseSizeToGb } from "../lib/config.js";
|
|
8
|
+
import { info, header, green } from "../lib/output.js";
|
|
9
|
+
import { fatal, handleError } from "../lib/errors.js";
|
|
10
|
+
function runQuiet(cmd, timeout = 10000) {
|
|
11
|
+
try {
|
|
12
|
+
return execSync(cmd, { encoding: "utf-8", timeout, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function syncAgentCode(vm, syncPath) {
|
|
19
|
+
const agentDir = resolve(syncPath);
|
|
20
|
+
if (!existsSync(agentDir))
|
|
21
|
+
return;
|
|
22
|
+
const syncignore = resolve(agentDir, ".syncignore");
|
|
23
|
+
const excludeFlag = existsSync(syncignore) ? `--exclude-from="${syncignore}"` : "";
|
|
24
|
+
const rsyncSsh = vm.password
|
|
25
|
+
? `sshpass -p '${vm.password}' ssh -o StrictHostKeyChecking=no -p ${vm.port}`
|
|
26
|
+
: `ssh -o StrictHostKeyChecking=no -p ${vm.port}`;
|
|
27
|
+
const sshPrefix = vm.password
|
|
28
|
+
? `sshpass -p '${vm.password}' ssh -o StrictHostKeyChecking=no -p ${vm.port} ${vm.user}@${vm.host}`
|
|
29
|
+
: `ssh -o StrictHostKeyChecking=no -p ${vm.port} ${vm.user}@${vm.host}`;
|
|
30
|
+
const sudoPrefix = vm.password ? `echo '${vm.password}' | sudo -S` : "sudo";
|
|
31
|
+
runQuiet(`rsync -az --delete ${excludeFlag} -e "${rsyncSsh}" "${agentDir}/" ${vm.user}@${vm.host}:/tmp/cassian-agent-sync/`, 30000);
|
|
32
|
+
runQuiet(`${sshPrefix} "${sudoPrefix} bash -c 'rsync -a --delete /tmp/cassian-agent-sync/ /opt/cassian/agent/ && systemctl restart cassian-agent'" 2>/dev/null`, 30000);
|
|
33
|
+
}
|
|
34
|
+
function cleanOrphans(vm) {
|
|
35
|
+
const sshPrefix = vm.password
|
|
36
|
+
? `sshpass -p '${vm.password}' ssh -o StrictHostKeyChecking=no -p ${vm.port} ${vm.user}@${vm.host}`
|
|
37
|
+
: `ssh -o StrictHostKeyChecking=no -p ${vm.port} ${vm.user}@${vm.host}`;
|
|
38
|
+
const sudoPrefix = vm.password ? `echo '${vm.password}' | sudo -S` : "sudo";
|
|
39
|
+
runQuiet(`${sshPrefix} "${sudoPrefix} bash -c 'docker ps -q --filter name=cassian- | xargs -r docker stop 2>/dev/null; docker ps -aq --filter name=cassian- | xargs -r docker rm 2>/dev/null; for dm in \\$(ls /dev/mapper/cassian-vol-* 2>/dev/null); do name=\\$(basename \\$dm); umount /dev/mapper/\\$name 2>/dev/null; cryptsetup close \\$name 2>/dev/null; done'" 2>/dev/null`, 30000);
|
|
40
|
+
}
|
|
41
|
+
export async function up() {
|
|
42
|
+
let config;
|
|
43
|
+
try {
|
|
44
|
+
config = loadConfig();
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
fatal(e.message, "Create a cassian.yaml in this directory. Run: cassian init");
|
|
48
|
+
}
|
|
49
|
+
const dev = loadDevOverrides();
|
|
50
|
+
const creds = getCredentials();
|
|
51
|
+
if (!creds)
|
|
52
|
+
fatal("You're not logged in.", "Run: cassian login");
|
|
53
|
+
console.log();
|
|
54
|
+
info("Instance", config.name);
|
|
55
|
+
info("GPUs", `${config.gpu.count}x ${config.gpu.type || "any"}`);
|
|
56
|
+
if (config.volumes?.length) {
|
|
57
|
+
info("Storage", config.volumes.map((v) => `${v.size} at ${v.mount}`).join(", "));
|
|
58
|
+
}
|
|
59
|
+
console.log();
|
|
60
|
+
const vm = dev.vm;
|
|
61
|
+
if (vm) {
|
|
62
|
+
const s = ora("Preparing...").start();
|
|
63
|
+
cleanOrphans(vm);
|
|
64
|
+
if (vm.sync_path)
|
|
65
|
+
syncAgentCode(vm, vm.sync_path);
|
|
66
|
+
s.succeed("Ready");
|
|
67
|
+
}
|
|
68
|
+
// Verify connectivity — no technical details exposed
|
|
69
|
+
const connectSpinner = ora("Connecting to Cassian...").start();
|
|
70
|
+
const agentUrl = creds.agent_url;
|
|
71
|
+
let reachable = false;
|
|
72
|
+
for (let i = 0; i < 10; i++) {
|
|
73
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
74
|
+
if (runQuiet(`curl -sf ${agentUrl}/v1/health`, 5000)) {
|
|
75
|
+
reachable = true;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!reachable) {
|
|
80
|
+
connectSpinner.fail("");
|
|
81
|
+
fatal("Could not connect to Cassian.", "Check your internet connection and try again. If this keeps happening, reach out at trycassian.com.");
|
|
82
|
+
}
|
|
83
|
+
connectSpinner.succeed("Connected");
|
|
84
|
+
// SSH key — invisible to user
|
|
85
|
+
let sshPubKey;
|
|
86
|
+
try {
|
|
87
|
+
sshPubKey = readFileSync(findSshPublicKey(dev.ssh_public_key), "utf-8").trim();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
fatal("No SSH key found.", "Generate one with: ssh-keygen -t ed25519");
|
|
91
|
+
}
|
|
92
|
+
const spinner = ora("Starting instance...").start();
|
|
93
|
+
try {
|
|
94
|
+
const client = new ApiClient();
|
|
95
|
+
const { instances } = await client.get("/v1/instances");
|
|
96
|
+
const existing = instances.find((i) => i.name === config.name && i.status === "running");
|
|
97
|
+
if (existing) {
|
|
98
|
+
spinner.text = "Stopping previous instance...";
|
|
99
|
+
await client.delete(`/v1/instances/${existing.id}`);
|
|
100
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
101
|
+
spinner.text = "Starting instance...";
|
|
102
|
+
}
|
|
103
|
+
const instance = await client.post("/v1/instances", {
|
|
104
|
+
name: config.name,
|
|
105
|
+
gpu_count: config.gpu.count,
|
|
106
|
+
image: config.image || "default",
|
|
107
|
+
ssh_public_key: sshPubKey,
|
|
108
|
+
volumes: (config.volumes || []).map((v) => ({
|
|
109
|
+
name: v.name,
|
|
110
|
+
size_gb: parseSizeToGb(v.size),
|
|
111
|
+
mount: v.mount,
|
|
112
|
+
})),
|
|
113
|
+
resources: {
|
|
114
|
+
memory: config.resources?.memory || null,
|
|
115
|
+
shm_size: config.resources?.shm_size || null,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
spinner.succeed("Instance ready");
|
|
119
|
+
// Push workspace (incremental tar via agent API)
|
|
120
|
+
const mountPath = config.volumes?.[0]?.mount || "/workspace";
|
|
121
|
+
const wsSpinner = ora("Syncing files...").start();
|
|
122
|
+
try {
|
|
123
|
+
const tar = await import("tar");
|
|
124
|
+
const syncignore = config.workspace?.syncignore ?? [];
|
|
125
|
+
const defaultIgnore = ["**/.git/**", "**/node_modules/**", "**/__pycache__/**", "**/*.pyc", "**/.DS_Store", ...syncignore];
|
|
126
|
+
const ignoreRegexes = defaultIgnore.map((p) => {
|
|
127
|
+
const esc = p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\x00").replace(/\*/g, "[^/]*").replace(/\x00/g, ".*");
|
|
128
|
+
return new RegExp(`(^|/)${esc}($|/)`);
|
|
129
|
+
});
|
|
130
|
+
const chunks = [];
|
|
131
|
+
await new Promise((res, rej) => {
|
|
132
|
+
const pack = tar.create({ cwd: process.cwd(), filter: (p) => {
|
|
133
|
+
const rel = p.replace(/^\.\//, "");
|
|
134
|
+
return !ignoreRegexes.some((re) => re.test(rel));
|
|
135
|
+
} }, ["."]);
|
|
136
|
+
pack.on("data", (c) => chunks.push(c));
|
|
137
|
+
pack.on("end", res);
|
|
138
|
+
pack.on("error", rej);
|
|
139
|
+
});
|
|
140
|
+
await client.push(instance.id, Buffer.concat(chunks), mountPath);
|
|
141
|
+
wsSpinner.succeed("Files synced");
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
wsSpinner.warn("File sync skipped — you can sync manually once connected");
|
|
145
|
+
}
|
|
146
|
+
// Setup command
|
|
147
|
+
if (config.workspace?.setup) {
|
|
148
|
+
const setupSpinner = ora("Running setup...").start();
|
|
149
|
+
try {
|
|
150
|
+
const result = await client.post(`/v1/instances/${instance.id}/exec`, { command: config.workspace.setup });
|
|
151
|
+
if (result.exit_code === 0) {
|
|
152
|
+
setupSpinner.succeed("Setup complete");
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
setupSpinner.warn("Setup finished with errors — check your setup command in cassian.yaml");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
setupSpinner.warn("Setup skipped");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
console.log();
|
|
163
|
+
header("Your instance is ready");
|
|
164
|
+
info("Name", instance.name);
|
|
165
|
+
info("GPUs", `${instance.gpu_count}x ${instance.gpu_devices.split(",").length > 1 ? "GPUs" : "GPU"}`);
|
|
166
|
+
if (instance.volumes.length > 0) {
|
|
167
|
+
info("Storage", instance.volumes.map((v) => `${v.size_gb}G at ${v.mount}`).join(", "));
|
|
168
|
+
}
|
|
169
|
+
console.log();
|
|
170
|
+
console.log(` ${green("cassian ssh")} — open a terminal`);
|
|
171
|
+
console.log(` ${green("cassian down")} — stop the instance`);
|
|
172
|
+
console.log();
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
spinner.fail("Could not start instance");
|
|
176
|
+
handleError(err, "instance");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import ora from "ora";
|
|
2
|
+
import { createInterface } from "readline";
|
|
3
|
+
import { ApiClient } from "../lib/api.js";
|
|
4
|
+
import { header, dim, red, bold, green } from "../lib/output.js";
|
|
5
|
+
import { fatal, handleError } from "../lib/errors.js";
|
|
6
|
+
export async function volumeCreate(name, size) {
|
|
7
|
+
const sizeGb = parseSizeArg(size);
|
|
8
|
+
const spinner = ora(`Creating volume '${name}'...`).start();
|
|
9
|
+
try {
|
|
10
|
+
const client = new ApiClient();
|
|
11
|
+
await client.post("/v1/volumes", { name, size_gb: sizeGb });
|
|
12
|
+
spinner.succeed(`Volume '${name}' created (${sizeGb}G, encrypted)`);
|
|
13
|
+
console.log();
|
|
14
|
+
console.log(dim(" Add to cassian.yaml:"));
|
|
15
|
+
console.log(dim(` volumes:`));
|
|
16
|
+
console.log(dim(` - name: ${name}`));
|
|
17
|
+
console.log(dim(` size: ${size}`));
|
|
18
|
+
console.log(dim(` mount: /data`));
|
|
19
|
+
console.log();
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
spinner.fail("Failed to create volume");
|
|
23
|
+
handleError(err);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function volumeList() {
|
|
27
|
+
try {
|
|
28
|
+
const client = new ApiClient();
|
|
29
|
+
const { volumes } = await client.get("/v1/volumes");
|
|
30
|
+
console.log();
|
|
31
|
+
header("Storage");
|
|
32
|
+
console.log();
|
|
33
|
+
if (volumes.length === 0) {
|
|
34
|
+
console.log(dim(" No volumes yet."));
|
|
35
|
+
console.log(dim(" Create one: cassian volume create --name my-data --size 50G"));
|
|
36
|
+
console.log();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const vol of volumes) {
|
|
40
|
+
const status = vol.attached_instance_id ? green("attached") : dim("available");
|
|
41
|
+
const age = timeSince(vol.created_at);
|
|
42
|
+
console.log(` ${bold(vol.name)} ${vol.size_gb}G ${status} ${dim(age)}`);
|
|
43
|
+
}
|
|
44
|
+
console.log();
|
|
45
|
+
console.log(dim(` cassian volume delete --name <name> — permanently destroy a volume`));
|
|
46
|
+
console.log();
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
handleError(err);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export async function volumeDelete(name) {
|
|
53
|
+
const client = new ApiClient();
|
|
54
|
+
const { volumes } = await client.get("/v1/volumes");
|
|
55
|
+
const vol = volumes.find((v) => v.name === name);
|
|
56
|
+
if (!vol) {
|
|
57
|
+
fatal(`Volume '${name}' not found.`);
|
|
58
|
+
}
|
|
59
|
+
if (vol.attached_instance_id) {
|
|
60
|
+
fatal(`'${name}' is attached to a running instance.`, "Run: cassian down");
|
|
61
|
+
}
|
|
62
|
+
console.log();
|
|
63
|
+
console.log(red(` ⚠ '${name}' (${vol.size_gb}G) will be permanently destroyed.`));
|
|
64
|
+
console.log(red(` All data will be gone. This cannot be undone.`));
|
|
65
|
+
console.log();
|
|
66
|
+
const confirmed = await confirm(` Type '${name}' to confirm: `, name);
|
|
67
|
+
if (!confirmed) {
|
|
68
|
+
console.log(dim("\n Cancelled.\n"));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const spinner = ora("Destroying volume...").start();
|
|
72
|
+
try {
|
|
73
|
+
await client.delete(`/v1/volumes/${vol.id}`);
|
|
74
|
+
spinner.succeed(`'${name}' deleted.`);
|
|
75
|
+
console.log();
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
spinner.fail("Failed to delete volume");
|
|
79
|
+
handleError(err);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function parseSizeArg(size) {
|
|
83
|
+
const match = size.match(/^(\d+)\s*(G|GB|T|TB)$/i);
|
|
84
|
+
if (!match)
|
|
85
|
+
fatal(`Invalid size: '${size}'. Use a format like 50G or 1T.`);
|
|
86
|
+
const num = parseInt(match[1]);
|
|
87
|
+
return match[2].toUpperCase().startsWith("T") ? num * 1024 : num;
|
|
88
|
+
}
|
|
89
|
+
function confirm(prompt, expected) {
|
|
90
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
91
|
+
return new Promise((resolve) => {
|
|
92
|
+
rl.question(prompt, (answer) => {
|
|
93
|
+
rl.close();
|
|
94
|
+
resolve(answer.trim() === expected);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function timeSince(iso) {
|
|
99
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
100
|
+
const mins = Math.floor(diff / 60000);
|
|
101
|
+
if (mins < 60)
|
|
102
|
+
return `${mins}m ago`;
|
|
103
|
+
const hrs = Math.floor(mins / 60);
|
|
104
|
+
if (hrs < 24)
|
|
105
|
+
return `${hrs}h ago`;
|
|
106
|
+
return `${Math.floor(hrs / 24)}d ago`;
|
|
107
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { handleError } from "./lib/errors.js";
|
|
3
|
+
// Catch any unhandled promise rejections and print clean errors
|
|
4
|
+
process.on("unhandledRejection", (reason) => handleError(reason));
|
|
5
|
+
process.on("uncaughtException", (err) => handleError(err));
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import { login } from "./commands/login.js";
|
|
8
|
+
import { init } from "./commands/init.js";
|
|
9
|
+
import { logout } from "./commands/logout.js";
|
|
10
|
+
import { up } from "./commands/up.js";
|
|
11
|
+
import { down } from "./commands/down.js";
|
|
12
|
+
import { ssh } from "./commands/ssh.js";
|
|
13
|
+
import { status } from "./commands/status.js";
|
|
14
|
+
import { logs } from "./commands/logs.js";
|
|
15
|
+
import { volumeCreate, volumeList, volumeDelete } from "./commands/volume.js";
|
|
16
|
+
import { sync } from "./commands/sync.js";
|
|
17
|
+
const program = new Command();
|
|
18
|
+
program
|
|
19
|
+
.name("cassian")
|
|
20
|
+
.description("Cassian GPU Cloud CLI")
|
|
21
|
+
.version("0.1.0");
|
|
22
|
+
program
|
|
23
|
+
.command("login")
|
|
24
|
+
.description("Authenticate with Cassian via browser")
|
|
25
|
+
.action(login);
|
|
26
|
+
program
|
|
27
|
+
.command("logout")
|
|
28
|
+
.description("Clear stored credentials")
|
|
29
|
+
.action(logout);
|
|
30
|
+
program
|
|
31
|
+
.command("up")
|
|
32
|
+
.description("Provision GPU instance from cassian.yaml")
|
|
33
|
+
.action(up);
|
|
34
|
+
program
|
|
35
|
+
.command("down [name]")
|
|
36
|
+
.description("Stop an instance (uses cassian.yaml name if not specified)")
|
|
37
|
+
.action(down);
|
|
38
|
+
program
|
|
39
|
+
.command("ssh")
|
|
40
|
+
.description("SSH into the running instance")
|
|
41
|
+
.action(ssh);
|
|
42
|
+
program
|
|
43
|
+
.command("sync")
|
|
44
|
+
.description("Pull output files from the remote instance to local")
|
|
45
|
+
.action(sync);
|
|
46
|
+
program
|
|
47
|
+
.command("status")
|
|
48
|
+
.description("Show instance and agent status")
|
|
49
|
+
.action(status);
|
|
50
|
+
program
|
|
51
|
+
.command("logs")
|
|
52
|
+
.description("View activity log")
|
|
53
|
+
.option("--event <event>", "Filter by event type (e.g., volume, instance)")
|
|
54
|
+
.option("--limit <n>", "Number of entries to show", "50")
|
|
55
|
+
.action(logs);
|
|
56
|
+
const volume = program
|
|
57
|
+
.command("volume")
|
|
58
|
+
.description("Manage persistent encrypted volumes");
|
|
59
|
+
volume
|
|
60
|
+
.command("create")
|
|
61
|
+
.description("Create a new encrypted volume")
|
|
62
|
+
.requiredOption("--name <name>", "Volume name")
|
|
63
|
+
.requiredOption("--size <size>", "Volume size (e.g., 200G, 1T)")
|
|
64
|
+
.action((opts) => volumeCreate(opts.name, opts.size));
|
|
65
|
+
volume
|
|
66
|
+
.command("list")
|
|
67
|
+
.description("List all volumes")
|
|
68
|
+
.action(volumeList);
|
|
69
|
+
volume
|
|
70
|
+
.command("delete")
|
|
71
|
+
.description("Delete a volume (irreversible)")
|
|
72
|
+
.requiredOption("--name <name>", "Volume name to delete")
|
|
73
|
+
.action((opts) => volumeDelete(opts.name));
|
|
74
|
+
program
|
|
75
|
+
.command("init")
|
|
76
|
+
.description("Set up a new Cassian project interactively")
|
|
77
|
+
.action(init);
|
|
78
|
+
program.parse();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare class ApiClient {
|
|
2
|
+
private agentUrl;
|
|
3
|
+
private credentials;
|
|
4
|
+
constructor();
|
|
5
|
+
private getToken;
|
|
6
|
+
request<T>(method: string, path: string, body?: unknown): Promise<T>;
|
|
7
|
+
get<T>(path: string): Promise<T>;
|
|
8
|
+
post<T>(path: string, body: unknown): Promise<T>;
|
|
9
|
+
delete<T>(path: string): Promise<T>;
|
|
10
|
+
push(instanceId: string, tarBuf: Buffer, dest: string): Promise<void>;
|
|
11
|
+
pull(instanceId: string, path: string): Promise<Buffer>;
|
|
12
|
+
checksum(instanceId: string, path: string): Promise<string>;
|
|
13
|
+
}
|
|
14
|
+
export declare class ApiError extends Error {
|
|
15
|
+
status: number;
|
|
16
|
+
detail: string;
|
|
17
|
+
constructor(status: number, detail: string);
|
|
18
|
+
}
|
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getCredentials, isTokenExpired, refreshToken } from "./auth.js";
|
|
2
|
+
export class ApiClient {
|
|
3
|
+
agentUrl;
|
|
4
|
+
credentials;
|
|
5
|
+
constructor() {
|
|
6
|
+
const creds = getCredentials();
|
|
7
|
+
if (!creds)
|
|
8
|
+
throw new Error("Not logged in. Run 'cassian login' first.");
|
|
9
|
+
this.credentials = creds;
|
|
10
|
+
this.agentUrl = creds.agent_url;
|
|
11
|
+
}
|
|
12
|
+
async getToken() {
|
|
13
|
+
if (isTokenExpired(this.credentials)) {
|
|
14
|
+
const configPath = `${process.env.HOME}/.cassian/config.json`;
|
|
15
|
+
const { readFileSync, existsSync } = await import("fs");
|
|
16
|
+
let supabaseUrl = "";
|
|
17
|
+
if (existsSync(configPath)) {
|
|
18
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
19
|
+
supabaseUrl = config.supabase_url || "";
|
|
20
|
+
}
|
|
21
|
+
if (!supabaseUrl)
|
|
22
|
+
throw new Error("Session expired. Run 'cassian login' again.");
|
|
23
|
+
this.credentials = await refreshToken(this.credentials, supabaseUrl);
|
|
24
|
+
}
|
|
25
|
+
return this.credentials.access_token;
|
|
26
|
+
}
|
|
27
|
+
async request(method, path, body) {
|
|
28
|
+
const token = await this.getToken();
|
|
29
|
+
const resp = await fetch(`${this.agentUrl}${path}`, {
|
|
30
|
+
method,
|
|
31
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
32
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
33
|
+
});
|
|
34
|
+
if (!resp.ok) {
|
|
35
|
+
const errBody = await resp.json().catch(() => ({}));
|
|
36
|
+
throw new ApiError(resp.status, errBody.detail || resp.statusText);
|
|
37
|
+
}
|
|
38
|
+
return resp.json();
|
|
39
|
+
}
|
|
40
|
+
async get(path) { return this.request("GET", path); }
|
|
41
|
+
async post(path, body) { return this.request("POST", path, body); }
|
|
42
|
+
async delete(path) { return this.request("DELETE", path); }
|
|
43
|
+
async push(instanceId, tarBuf, dest) {
|
|
44
|
+
const token = await this.getToken();
|
|
45
|
+
const formData = new FormData();
|
|
46
|
+
formData.append("archive", new Blob([tarBuf.buffer], { type: "application/x-tar" }), "workspace.tar");
|
|
47
|
+
formData.append("dest", dest);
|
|
48
|
+
const resp = await fetch(`${this.agentUrl}/v1/instances/${instanceId}/push`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
51
|
+
body: formData,
|
|
52
|
+
});
|
|
53
|
+
if (!resp.ok) {
|
|
54
|
+
const errBody = await resp.json().catch(() => ({}));
|
|
55
|
+
throw new ApiError(resp.status, errBody.detail || resp.statusText);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async pull(instanceId, path) {
|
|
59
|
+
const token = await this.getToken();
|
|
60
|
+
const resp = await fetch(`${this.agentUrl}/v1/instances/${instanceId}/pull?path=${encodeURIComponent(path)}`, {
|
|
61
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
62
|
+
});
|
|
63
|
+
if (!resp.ok) {
|
|
64
|
+
const errBody = await resp.json().catch(() => ({}));
|
|
65
|
+
throw new ApiError(resp.status, errBody.detail || resp.statusText);
|
|
66
|
+
}
|
|
67
|
+
return Buffer.from(await resp.arrayBuffer());
|
|
68
|
+
}
|
|
69
|
+
async checksum(instanceId, path) {
|
|
70
|
+
const token = await this.getToken();
|
|
71
|
+
const resp = await fetch(`${this.agentUrl}/v1/instances/${instanceId}/checksum?path=${encodeURIComponent(path)}`, {
|
|
72
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
73
|
+
});
|
|
74
|
+
if (!resp.ok)
|
|
75
|
+
return "";
|
|
76
|
+
const data = await resp.json();
|
|
77
|
+
return data.checksum;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export class ApiError extends Error {
|
|
81
|
+
status;
|
|
82
|
+
detail;
|
|
83
|
+
constructor(status, detail) {
|
|
84
|
+
super(`[${status}] ${detail}`);
|
|
85
|
+
this.status = status;
|
|
86
|
+
this.detail = detail;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Credentials } from "../types.js";
|
|
2
|
+
export declare function getCredentials(): Credentials | null;
|
|
3
|
+
export declare function saveCredentials(creds: Credentials): void;
|
|
4
|
+
export declare function clearCredentials(): void;
|
|
5
|
+
export declare function isTokenExpired(creds: Credentials): boolean;
|
|
6
|
+
export declare function refreshToken(creds: Credentials, supabaseUrl: string): Promise<Credentials>;
|
|
7
|
+
export declare function getAgentUrl(): string;
|
package/dist/lib/auth.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
const CREDENTIALS_DIR = join(homedir(), ".cassian");
|
|
5
|
+
const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials.json");
|
|
6
|
+
export function getCredentials() {
|
|
7
|
+
if (!existsSync(CREDENTIALS_FILE))
|
|
8
|
+
return null;
|
|
9
|
+
try {
|
|
10
|
+
const data = readFileSync(CREDENTIALS_FILE, "utf-8");
|
|
11
|
+
return JSON.parse(data);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function saveCredentials(creds) {
|
|
18
|
+
mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
|
|
19
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
20
|
+
}
|
|
21
|
+
export function clearCredentials() {
|
|
22
|
+
if (existsSync(CREDENTIALS_FILE)) {
|
|
23
|
+
writeFileSync(CREDENTIALS_FILE, "");
|
|
24
|
+
const { unlinkSync } = require("fs");
|
|
25
|
+
unlinkSync(CREDENTIALS_FILE);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function isTokenExpired(creds) {
|
|
29
|
+
return Date.now() / 1000 > creds.expires_at - 60; // 60s buffer
|
|
30
|
+
}
|
|
31
|
+
export async function refreshToken(creds, supabaseUrl) {
|
|
32
|
+
const resp = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json", apikey: getAnonKey() },
|
|
35
|
+
body: JSON.stringify({ refresh_token: creds.refresh_token }),
|
|
36
|
+
});
|
|
37
|
+
if (!resp.ok) {
|
|
38
|
+
throw new Error("Token refresh failed. Run 'cassian login' to re-authenticate.");
|
|
39
|
+
}
|
|
40
|
+
const data = await resp.json();
|
|
41
|
+
const updated = {
|
|
42
|
+
...creds,
|
|
43
|
+
access_token: data.access_token,
|
|
44
|
+
refresh_token: data.refresh_token,
|
|
45
|
+
expires_at: Math.floor(Date.now() / 1000) + data.expires_in,
|
|
46
|
+
};
|
|
47
|
+
saveCredentials(updated);
|
|
48
|
+
return updated;
|
|
49
|
+
}
|
|
50
|
+
export function getAgentUrl() {
|
|
51
|
+
const creds = getCredentials();
|
|
52
|
+
if (!creds)
|
|
53
|
+
throw new Error("Not logged in. Run 'cassian login' first.");
|
|
54
|
+
return creds.agent_url;
|
|
55
|
+
}
|
|
56
|
+
function getAnonKey() {
|
|
57
|
+
const configPath = join(CREDENTIALS_DIR, "config.json");
|
|
58
|
+
if (existsSync(configPath)) {
|
|
59
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
60
|
+
return config.supabase_anon_key || "";
|
|
61
|
+
}
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CassianConfig, DevOverrides } from "../types.js";
|
|
2
|
+
export declare function loadConfig(path?: string): CassianConfig;
|
|
3
|
+
export declare function loadDevOverrides(): DevOverrides;
|
|
4
|
+
export declare function findSshPublicKey(override?: string): string;
|
|
5
|
+
export declare function parseSizeToGb(size: string): number;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
export function loadConfig(path) {
|
|
6
|
+
const configPath = path || findConfigFile();
|
|
7
|
+
if (!configPath) {
|
|
8
|
+
throw new Error("No cassian.yaml found. Run 'cassian init' to create one.");
|
|
9
|
+
}
|
|
10
|
+
const config = parse(readFileSync(configPath, "utf-8"));
|
|
11
|
+
validate(config);
|
|
12
|
+
return config;
|
|
13
|
+
}
|
|
14
|
+
// Dev overrides live in ~/.cassian/dev.yaml — vm credentials, sync paths, etc.
|
|
15
|
+
// Never part of cassian.yaml. Users never see or touch this.
|
|
16
|
+
export function loadDevOverrides() {
|
|
17
|
+
const p = resolve(homedir(), ".cassian", "dev.yaml");
|
|
18
|
+
if (!existsSync(p))
|
|
19
|
+
return {};
|
|
20
|
+
return parse(readFileSync(p, "utf-8"));
|
|
21
|
+
}
|
|
22
|
+
export function findSshPublicKey(override) {
|
|
23
|
+
if (override)
|
|
24
|
+
return resolve(override.replace("~", homedir()));
|
|
25
|
+
const sshDir = resolve(homedir(), ".ssh");
|
|
26
|
+
if (existsSync(sshDir)) {
|
|
27
|
+
for (const name of ["id_ed25519.pub", "id_rsa.pub", "id_ecdsa.pub"]) {
|
|
28
|
+
const p = resolve(sshDir, name);
|
|
29
|
+
if (existsSync(p))
|
|
30
|
+
return p;
|
|
31
|
+
}
|
|
32
|
+
const any = readdirSync(sshDir).find((f) => f.endsWith(".pub"));
|
|
33
|
+
if (any)
|
|
34
|
+
return resolve(sshDir, any);
|
|
35
|
+
}
|
|
36
|
+
throw new Error("No SSH public key found. Run: ssh-keygen -t ed25519");
|
|
37
|
+
}
|
|
38
|
+
function findConfigFile() {
|
|
39
|
+
for (const name of ["cassian.yaml", "cassian.yml"]) {
|
|
40
|
+
const p = resolve(process.cwd(), name);
|
|
41
|
+
if (existsSync(p))
|
|
42
|
+
return p;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function validate(config) {
|
|
47
|
+
if (!config.name)
|
|
48
|
+
throw new Error("cassian.yaml: 'name' is required");
|
|
49
|
+
if (!config.gpu?.count)
|
|
50
|
+
throw new Error("cassian.yaml: 'gpu.count' is required");
|
|
51
|
+
if (config.gpu.count < 1 || config.gpu.count > 8) {
|
|
52
|
+
throw new Error("cassian.yaml: 'gpu.count' must be between 1 and 8");
|
|
53
|
+
}
|
|
54
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(config.name)) {
|
|
55
|
+
throw new Error("cassian.yaml: 'name' must be lowercase alphanumeric with hyphens");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function parseSizeToGb(size) {
|
|
59
|
+
const match = size.match(/^(\d+)\s*(G|GB|T|TB)$/i);
|
|
60
|
+
if (!match)
|
|
61
|
+
throw new Error(`Invalid size format: ${size}. Use '200G' or '1T'.`);
|
|
62
|
+
const num = parseInt(match[1]);
|
|
63
|
+
return match[2].toUpperCase().startsWith("T") ? num * 1024 : num;
|
|
64
|
+
}
|