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.
@@ -0,0 +1,58 @@
1
+ import { ApiError } from "./api.js";
2
+ import { red, dim } from "./output.js";
3
+ const DEBUG = !!process.env.DEBUG;
4
+ export function fatal(message, hint) {
5
+ console.error();
6
+ console.error(red(" ✗ ") + message);
7
+ if (hint)
8
+ console.error(dim(` ${hint}`));
9
+ console.error();
10
+ process.exit(1);
11
+ }
12
+ export function handleError(err, context) {
13
+ if (DEBUG) {
14
+ console.error();
15
+ console.error(err);
16
+ console.error();
17
+ process.exit(1);
18
+ }
19
+ if (err instanceof ApiError) {
20
+ return translateApiError(err);
21
+ }
22
+ const msg = err instanceof Error ? err.message : String(err);
23
+ if (msg.includes("EPIPE") || msg.includes("ERR_STREAM_WRITE_AFTER_END")) {
24
+ process.exit(0);
25
+ }
26
+ if (msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND") || msg.includes("ETIMEDOUT")) {
27
+ fatal("Could not connect to Cassian.", "Check your internet connection and try again.");
28
+ }
29
+ if (msg.toLowerCase().includes("not logged in")) {
30
+ fatal("You're not logged in.", "Run: cassian login");
31
+ }
32
+ if (msg.toLowerCase().includes("session expired") || msg.toLowerCase().includes("expired")) {
33
+ fatal("Your session has expired.", "Run: cassian login");
34
+ }
35
+ fatal(context ? `${context}: ${msg}` : msg);
36
+ }
37
+ function translateApiError(err) {
38
+ switch (err.status) {
39
+ case 401:
40
+ case 403:
41
+ fatal("Your session has expired.", "Run: cassian login");
42
+ case 404:
43
+ fatal("Instance not found.", "Run: cassian up");
44
+ case 409:
45
+ fatal("An instance with this name already exists.", "Run: cassian down, then try again.");
46
+ case 422:
47
+ fatal(`Invalid configuration: ${err.detail}`);
48
+ case 429:
49
+ fatal("Too many requests.", "Wait a moment and try again.");
50
+ case 500:
51
+ case 502:
52
+ case 503:
53
+ case 504:
54
+ fatal("Something went wrong on our end.", "Try again in a moment. Still broken? Reach out at trycassian.com.");
55
+ default:
56
+ fatal(err.detail ?? "Something went wrong.");
57
+ }
58
+ }
@@ -0,0 +1,11 @@
1
+ export declare const green: import("chalk").ChalkInstance;
2
+ export declare const dim: import("chalk").ChalkInstance;
3
+ export declare const bold: import("chalk").ChalkInstance;
4
+ export declare const red: import("chalk").ChalkInstance;
5
+ export declare const yellow: import("chalk").ChalkInstance;
6
+ export declare function header(text: string): void;
7
+ export declare function success(text: string): void;
8
+ export declare function error(text: string): void;
9
+ export declare function warn(text: string): void;
10
+ export declare function info(label: string, value: string): void;
11
+ export declare function table(rows: Array<Record<string, string>>, columns: string[]): void;
@@ -0,0 +1,37 @@
1
+ import chalk from "chalk";
2
+ export const green = chalk.hex("#4ade80");
3
+ export const dim = chalk.dim;
4
+ export const bold = chalk.bold;
5
+ export const red = chalk.red;
6
+ export const yellow = chalk.yellow;
7
+ export function header(text) {
8
+ console.log(bold.white(text));
9
+ }
10
+ export function success(text) {
11
+ console.log(green("✓") + " " + text);
12
+ }
13
+ export function error(text) {
14
+ console.error(red("✗") + " " + text);
15
+ }
16
+ export function warn(text) {
17
+ console.log(yellow("!") + " " + text);
18
+ }
19
+ export function info(label, value) {
20
+ console.log(` ${dim(label.padEnd(12))}${value}`);
21
+ }
22
+ export function table(rows, columns) {
23
+ if (rows.length === 0) {
24
+ console.log(dim(" (none)"));
25
+ return;
26
+ }
27
+ const widths = {};
28
+ for (const col of columns) {
29
+ widths[col] = Math.max(col.length, ...rows.map((r) => (r[col] || "").length));
30
+ }
31
+ const headerLine = columns.map((c) => c.toUpperCase().padEnd(widths[c])).join(" ");
32
+ console.log(dim(` ${headerLine}`));
33
+ for (const row of rows) {
34
+ const line = columns.map((c) => (row[c] || "").padEnd(widths[c])).join(" ");
35
+ console.log(` ${line}`);
36
+ }
37
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Bidirectional workspace sync — HTTP polling based.
3
+ *
4
+ * Push (local → container):
5
+ * chokidar watches CWD. On any change, POST /file with the file content.
6
+ *
7
+ * Pull (container → local):
8
+ * Poll GET /changes?since=<seq> every 2s. For each changed path,
9
+ * GET /file?path=<abs> and write locally. Deletes applied immediately.
10
+ *
11
+ * No long-lived connections = no Cloudflare idle timeout.
12
+ */
13
+ import type { CassianConfig } from "../types.js";
14
+ export declare function startBidirectionalSync(instanceId: string, config: CassianConfig, agentUrl: string): () => void;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Bidirectional workspace sync — HTTP polling based.
3
+ *
4
+ * Push (local → container):
5
+ * chokidar watches CWD. On any change, POST /file with the file content.
6
+ *
7
+ * Pull (container → local):
8
+ * Poll GET /changes?since=<seq> every 2s. For each changed path,
9
+ * GET /file?path=<abs> and write locally. Deletes applied immediately.
10
+ *
11
+ * No long-lived connections = no Cloudflare idle timeout.
12
+ */
13
+ import chokidar from "chokidar";
14
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, statSync, } from "fs";
15
+ import { resolve, dirname, normalize } from "path";
16
+ import { getCredentials, isTokenExpired, refreshToken } from "./auth.js";
17
+ const POLL_INTERVAL_MS = 2000;
18
+ const MAX_FILE_SIZE = 512 * 1024; // 512KB — skip large files (weights, checkpoints)
19
+ function buildIgnorePatterns(syncignore) {
20
+ return [
21
+ "**/.git/**", "**/node_modules/**", "**/__pycache__/**",
22
+ "**/*.pyc", "**/.DS_Store",
23
+ ...syncignore,
24
+ ];
25
+ }
26
+ function globToRegex(pattern) {
27
+ const esc = pattern
28
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
29
+ .replace(/\*\*/g, "\x00").replace(/\*/g, "[^/]*").replace(/\x00/g, ".*");
30
+ return new RegExp(`(^|/)${esc}($|/)`);
31
+ }
32
+ async function getToken() {
33
+ const creds = getCredentials();
34
+ if (!creds)
35
+ throw new Error("Not logged in");
36
+ if (isTokenExpired(creds)) {
37
+ const { readFileSync: rf, existsSync: ef } = await import("fs");
38
+ const cfgPath = `${process.env.HOME}/.cassian/config.json`;
39
+ let supabaseUrl = "";
40
+ if (ef(cfgPath))
41
+ supabaseUrl = JSON.parse(rf(cfgPath, "utf-8")).supabase_url || "";
42
+ if (!supabaseUrl)
43
+ throw new Error("Session expired");
44
+ const refreshed = await refreshToken(creds, supabaseUrl);
45
+ return refreshed.access_token;
46
+ }
47
+ return creds.access_token;
48
+ }
49
+ export function startBidirectionalSync(instanceId, config, agentUrl) {
50
+ const mountPath = config.volumes?.[0]?.mount ?? "/workspace";
51
+ const syncignore = config.workspace?.syncignore ?? [];
52
+ const ignoreRegexes = buildIgnorePatterns(syncignore).map(globToRegex);
53
+ const cwd = process.cwd();
54
+ let stopped = false;
55
+ // Track files recently pushed by us so we don't pull-echo them back
56
+ const recentlyPushed = new Set();
57
+ // ── Push: local → container ──────────────────────────────────────────────
58
+ const pushFile = async (rel) => {
59
+ if (stopped)
60
+ return;
61
+ const abs = resolve(cwd, rel);
62
+ if (!existsSync(abs))
63
+ return;
64
+ try {
65
+ const st = statSync(abs);
66
+ if (st.isDirectory() || st.size > MAX_FILE_SIZE)
67
+ return;
68
+ }
69
+ catch {
70
+ return;
71
+ }
72
+ recentlyPushed.add(rel);
73
+ setTimeout(() => recentlyPushed.delete(rel), 3000);
74
+ try {
75
+ const token = await getToken();
76
+ const content = readFileSync(abs);
77
+ const formData = new FormData();
78
+ formData.append("file", new Blob([content]), rel);
79
+ formData.append("path", `${mountPath}/${rel}`);
80
+ await fetch(`${agentUrl}/v1/instances/${instanceId}/file`, {
81
+ method: "POST",
82
+ headers: { Authorization: `Bearer ${token}` },
83
+ body: formData,
84
+ });
85
+ }
86
+ catch { /* silent — next chokidar event will retry */ }
87
+ };
88
+ const deleteFile = async (rel) => {
89
+ if (stopped)
90
+ return;
91
+ try {
92
+ const token = await getToken();
93
+ await fetch(`${agentUrl}/v1/instances/${instanceId}/exec`, {
94
+ method: "POST",
95
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
96
+ body: JSON.stringify({ command: `rm -f "${mountPath}/${rel}"` }),
97
+ });
98
+ }
99
+ catch { /* silent */ }
100
+ };
101
+ const watcher = chokidar.watch(".", {
102
+ cwd,
103
+ ignoreInitial: true,
104
+ persistent: true,
105
+ ignored: (p) => ignoreRegexes.some((re) => re.test(p.replace(/^\.\//, ""))),
106
+ awaitWriteFinish: { stabilityThreshold: 400, pollInterval: 100 },
107
+ });
108
+ watcher
109
+ .on("add", (f) => pushFile(f))
110
+ .on("change", (f) => pushFile(f))
111
+ .on("unlink", (f) => deleteFile(f));
112
+ // ── Start watch session on agent ─────────────────────────────────────────
113
+ (async () => {
114
+ try {
115
+ const token = await getToken();
116
+ await fetch(`${agentUrl}/v1/instances/${instanceId}/watch`, {
117
+ method: "POST",
118
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
119
+ body: JSON.stringify({ path: mountPath }),
120
+ });
121
+ }
122
+ catch { /* agent may already be watching */ }
123
+ })();
124
+ // ── Pull: poll for remote changes ─────────────────────────────────────────
125
+ let seq = 0;
126
+ let polling = false;
127
+ const poll = async () => {
128
+ if (stopped || polling)
129
+ return;
130
+ polling = true;
131
+ try {
132
+ const token = await getToken();
133
+ const resp = await fetch(`${agentUrl}/v1/instances/${instanceId}/changes?since=${seq}&path=${encodeURIComponent(mountPath)}`, { headers: { Authorization: `Bearer ${token}` } });
134
+ if (!resp.ok)
135
+ return;
136
+ const data = await resp.json();
137
+ if (data.changes.length === 0) {
138
+ seq = data.seq;
139
+ return;
140
+ }
141
+ for (const change of data.changes) {
142
+ const rel = change.path;
143
+ if (recentlyPushed.has(rel))
144
+ continue; // we just pushed this, skip echo
145
+ const norm = normalize(rel);
146
+ if (norm.startsWith("..") || norm.startsWith("/"))
147
+ continue; // path traversal guard
148
+ if (ignoreRegexes.some((re) => re.test(rel)))
149
+ continue;
150
+ const localPath = resolve(cwd, rel);
151
+ if (change.deleted) {
152
+ try {
153
+ unlinkSync(localPath);
154
+ }
155
+ catch { /* already gone */ }
156
+ }
157
+ else {
158
+ // Fetch the file content
159
+ const absPath = `${mountPath}/${rel}`;
160
+ const fileResp = await fetch(`${agentUrl}/v1/instances/${instanceId}/file?path=${encodeURIComponent(absPath)}`, { headers: { Authorization: `Bearer ${token}` } });
161
+ if (fileResp.ok) {
162
+ const buf = Buffer.from(await fileResp.arrayBuffer());
163
+ mkdirSync(dirname(localPath), { recursive: true });
164
+ writeFileSync(localPath, buf);
165
+ }
166
+ }
167
+ if (change.seq > seq)
168
+ seq = change.seq;
169
+ }
170
+ seq = data.seq;
171
+ }
172
+ catch { /* network hiccup, retry next poll */ }
173
+ finally {
174
+ polling = false;
175
+ }
176
+ };
177
+ const interval = setInterval(poll, POLL_INTERVAL_MS);
178
+ return () => {
179
+ stopped = true;
180
+ watcher.close();
181
+ clearInterval(interval);
182
+ };
183
+ }
@@ -0,0 +1,89 @@
1
+ export interface Credentials {
2
+ access_token: string;
3
+ refresh_token: string;
4
+ expires_at: number;
5
+ user_email: string;
6
+ agent_url: string;
7
+ }
8
+ export interface CassianConfig {
9
+ name: string;
10
+ gpu: {
11
+ count: number;
12
+ type?: string;
13
+ };
14
+ image?: string;
15
+ volumes?: Array<{
16
+ name: string;
17
+ size: string;
18
+ mount: string;
19
+ }>;
20
+ resources?: {
21
+ memory?: string;
22
+ shm_size?: string;
23
+ };
24
+ workspace?: {
25
+ setup?: string;
26
+ syncignore?: string[];
27
+ };
28
+ }
29
+ export interface DevOverrides {
30
+ vm?: {
31
+ host: string;
32
+ port: number;
33
+ user: string;
34
+ password?: string;
35
+ sync_path?: string;
36
+ };
37
+ ssh_public_key?: string;
38
+ }
39
+ export interface SshInfo {
40
+ host: string;
41
+ port: number;
42
+ user: string;
43
+ command: string;
44
+ }
45
+ export interface SyncInfo {
46
+ host: string;
47
+ port: number;
48
+ user: string;
49
+ path: string;
50
+ }
51
+ export interface VolumeStatus {
52
+ name: string;
53
+ size_gb: number;
54
+ mount: string;
55
+ status: string;
56
+ }
57
+ export interface InstanceResponse {
58
+ id: string;
59
+ name: string;
60
+ status: string;
61
+ gpu_count: number;
62
+ gpu_devices: string;
63
+ image: string;
64
+ ssh: SshInfo | null;
65
+ sync: SyncInfo | null;
66
+ volumes: VolumeStatus[];
67
+ created_at: string;
68
+ }
69
+ export interface VolumeResponse {
70
+ id: string;
71
+ name: string;
72
+ size_gb: number;
73
+ status: string;
74
+ attached_instance_id: string | null;
75
+ created_at: string;
76
+ }
77
+ export interface HealthResponse {
78
+ status: string;
79
+ version: string;
80
+ docker: string;
81
+ gpus: number;
82
+ uptime_seconds: number;
83
+ }
84
+ export interface GpuInfo {
85
+ index: number;
86
+ name: string;
87
+ memory_mb: number;
88
+ allocated_to: string | null;
89
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "cassian-cli",
3
+ "version": "0.1.0",
4
+ "description": "The Cassian GPU cloud CLI — provision GPUs, sync files, and run workloads from your terminal.",
5
+ "type": "module",
6
+ "bin": {
7
+ "cassian": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "dev": "tsx src/index.ts",
15
+ "start": "node dist/index.js",
16
+ "prepublishOnly": "npm run build"
17
+ },
18
+ "keywords": [
19
+ "gpu",
20
+ "cloud",
21
+ "ml",
22
+ "ai",
23
+ "cassian"
24
+ ],
25
+ "homepage": "https://trycassian.com",
26
+ "license": "MIT",
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "dependencies": {
31
+ "@types/tar": "^6.1.13",
32
+ "chalk": "^5.3.0",
33
+ "chokidar": "^5.0.0",
34
+ "commander": "^12.0.0",
35
+ "glob": "^13.0.6",
36
+ "open": "^10.0.0",
37
+ "ora": "^8.0.0",
38
+ "tar": "^7.5.15",
39
+ "ws": "^8.20.1",
40
+ "yaml": "^2.4.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/chokidar": "^1.7.5",
44
+ "@types/node": "^20",
45
+ "@types/ws": "^8.18.1",
46
+ "tsx": "^4.7.0",
47
+ "typescript": "^5.4.0"
48
+ }
49
+ }