cassian-cli 0.2.1 → 0.3.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.
@@ -32,8 +32,13 @@ function promptChoice(rl, question, choices, fallback) {
32
32
  }
33
33
  function buildYaml(opts) {
34
34
  const volName = opts.name.replace(/[^a-z0-9-]/g, "-") + "-data";
35
- const syncignoreLines = opts.syncignore.map(p => ` - "${p}"`).join("\n");
36
35
  const setupLine = opts.setup ? ` setup: ${opts.setup}\n` : "";
36
+ const noSyncLines = opts.noSync.length
37
+ ? ` no_sync:\n${opts.noSync.map(p => ` - "${p}"`).join("\n")}\n` : "";
38
+ const storageLines = opts.storage.length
39
+ ? ` storage:\n${opts.storage.map(p => ` - "${p}"`).join("\n")}\n` : "";
40
+ const excludeLines = opts.exclude.length
41
+ ? ` exclude:\n${opts.exclude.map(p => ` - "${p}"`).join("\n")}\n` : "";
37
42
  return `name: ${opts.name}
38
43
 
39
44
  gpu:
@@ -52,9 +57,7 @@ resources:
52
57
  shm_size: ${opts.shmSize}
53
58
 
54
59
  workspace:
55
- ${setupLine} syncignore:
56
- ${syncignoreLines}
57
- `;
60
+ ${setupLine}${noSyncLines}${storageLines}${excludeLines}`;
58
61
  }
59
62
  export async function init(options = {}) {
60
63
  if (existsSync("cassian.yaml")) {
@@ -65,9 +68,6 @@ export async function init(options = {}) {
65
68
  // If all required flags are provided, skip the wizard entirely
66
69
  const allFlagsProvided = !!(options.name && options.gpu && options.gpuCount && options.memory && options.disk);
67
70
  if (allFlagsProvided || skipPrompts) {
68
- const syncignore = options.syncignore
69
- ? options.syncignore.split(",").map(s => s.trim()).filter(Boolean)
70
- : ["outputs/**", "*.ckpt", "*.safetensors", "**/.cache/**"];
71
71
  const yaml = buildYaml({
72
72
  name: options.name || dirName,
73
73
  gpuType: options.gpu || "rtx3090",
@@ -76,7 +76,9 @@ export async function init(options = {}) {
76
76
  shmSize: options.shm || "16G",
77
77
  diskSize: options.disk || "50G",
78
78
  setup: options.setup || (existsSync("requirements.txt") ? "pip install -r /workspace/requirements.txt" : ""),
79
- syncignore,
79
+ noSync: ["checkpoints/", "outputs/"],
80
+ storage: [],
81
+ exclude: ["node_modules/", ".venv/", "__pycache__/", "*.pyc", "wandb/"],
80
82
  });
81
83
  writeFileSync("cassian.yaml", yaml);
82
84
  console.log();
@@ -122,13 +124,27 @@ export async function init(options = {}) {
122
124
  const setupRaw = options.setup ?? await prompt(rl, ` Setup command ${dim(`[${defaultSetup || "skip"}]`)}: `, defaultSetup);
123
125
  const setup = setupRaw === "skip" ? "" : setupRaw;
124
126
  console.log();
125
- console.log(dim(" Patterns to exclude from sync (comma-separated, or enter for defaults)."));
126
- const syncignoreRaw = options.syncignore ?? await prompt(rl, ` Syncignore ${dim("[outputs/**, *.ckpt, *.safetensors, **/.cache/**]")}: `, "");
127
- const syncignore = syncignoreRaw
128
- ? syncignoreRaw.split(",").map(s => s.trim()).filter(Boolean)
129
- : ["outputs/**", "*.ckpt", "*.safetensors", "**/.cache/**"];
127
+ console.log(dim(" Folders that stay on the GPU instance but don't sync to your machine"));
128
+ console.log(dim(" (large files like checkpoints, training outputs still persists across sessions)"));
129
+ const noSyncRaw = options.syncignore ?? await prompt(rl, ` No-sync folders ${dim("[checkpoints/, outputs/]")}: `, "");
130
+ const noSync = noSyncRaw
131
+ ? noSyncRaw.split(",").map(s => s.trim()).filter(Boolean)
132
+ : ["checkpoints/", "outputs/"];
133
+ console.log();
134
+ console.log(dim(" Folders streamed from cloud storage (huge datasets, pretrained models)"));
135
+ console.log(dim(" (doesn't use instance disk — reads directly from cloud)"));
136
+ const storageRaw = await prompt(rl, ` Cloud storage folders ${dim("[none]")}: `, "");
137
+ const storage = storageRaw
138
+ ? storageRaw.split(",").map(s => s.trim()).filter(Boolean)
139
+ : [];
140
+ console.log();
141
+ console.log(dim(" Folders to exclude entirely (build artifacts, can be recreated)"));
142
+ const excludeRaw = await prompt(rl, ` Exclude ${dim("[node_modules/, .venv/, __pycache__/, *.pyc, wandb/]")}: `, "");
143
+ const exclude = excludeRaw
144
+ ? excludeRaw.split(",").map(s => s.trim()).filter(Boolean)
145
+ : ["node_modules/", ".venv/", "__pycache__/", "*.pyc", "wandb/"];
130
146
  rl.close();
131
- const yaml = buildYaml({ name, gpuType, gpuCount, memory, shmSize, diskSize, setup, syncignore });
147
+ const yaml = buildYaml({ name, gpuType, gpuCount, memory, shmSize, diskSize, setup, noSync, storage, exclude });
132
148
  writeFileSync("cassian.yaml", yaml);
133
149
  console.log();
134
150
  console.log(` ${green("✓")} Created cassian.yaml`);
@@ -1,6 +1,5 @@
1
1
  import * as tar from "tar";
2
- import { mkdirSync, existsSync, readdirSync, unlinkSync, statSync } from "fs";
3
- import { join, normalize } from "path";
2
+ import { mkdirSync } from "fs";
4
3
  import { Readable } from "stream";
5
4
  import ora from "ora";
6
5
  import { ApiClient } from "../lib/api.js";
@@ -26,55 +25,46 @@ export async function sync() {
26
25
  const tarBuf = await client.pull(instance.id, mountPath);
27
26
  const cwd = process.cwd();
28
27
  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) => {
28
+ // Build skip patterns from all workspace config fields
29
+ const patterns = ["**/.git/**", "**/.DS_Store"];
30
+ if (config.workspace?.syncignore)
31
+ patterns.push(...config.workspace.syncignore);
32
+ if (config.workspace?.no_sync) {
33
+ for (const p of config.workspace.no_sync) {
34
+ patterns.push(p.endsWith("/") ? `${p}**` : `**/${p}/**`);
35
+ }
36
+ }
37
+ if (config.workspace?.storage) {
38
+ for (const p of config.workspace.storage) {
39
+ patterns.push(p.endsWith("/") ? `${p}**` : `**/${p}/**`);
40
+ }
41
+ }
42
+ if (config.workspace?.exclude) {
43
+ for (const p of config.workspace.exclude) {
44
+ if (p.endsWith("/"))
45
+ patterns.push(`${p}**`);
46
+ else if (p.includes("*"))
47
+ patterns.push(`**/${p}`);
48
+ else
49
+ patterns.push(`**/${p}/**`);
50
+ }
51
+ }
52
+ const ignoreRegexes = patterns.map((p) => {
33
53
  const esc = p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\x00").replace(/\*/g, "[^/]*").replace(/\x00/g, ".*");
34
54
  return new RegExp(`(^|/)${esc}($|/)`);
35
55
  });
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
- }
56
+ function shouldSkip(rel) {
57
+ return ignoreRegexes.some((re) => re.test(rel));
75
58
  }
76
- // Extract
77
- await new Promise((res, rej) => Readable.from(tarBuf).pipe(tar.extract({ cwd, strip: 1 })).on("finish", res).on("error", rej));
59
+ // Extract with filtering
60
+ await new Promise((res, rej) => Readable.from(tarBuf).pipe(tar.extract({
61
+ cwd,
62
+ strip: 1,
63
+ filter: (path) => {
64
+ const stripped = path.replace(/^\.\//, "");
65
+ return !shouldSkip(stripped);
66
+ },
67
+ })).on("finish", res).on("error", rej));
78
68
  spinner.succeed("Files synced");
79
69
  }
80
70
  catch (err) {
@@ -128,6 +128,11 @@ export async function up() {
128
128
  shm_size: config.resources?.shm_size || null,
129
129
  cpus: config.resources?.cpus || null,
130
130
  },
131
+ workspace: {
132
+ no_sync: config.workspace?.no_sync || [],
133
+ storage: config.workspace?.storage || [],
134
+ exclude: config.workspace?.exclude || config.workspace?.syncignore || [],
135
+ },
131
136
  });
132
137
  spinner.succeed("Instance ready");
133
138
  // Sync happens during ssh/exec, not during up
package/dist/lib/push.js CHANGED
@@ -1,9 +1,32 @@
1
1
  export async function pushWorkspace(client, instanceId, config) {
2
2
  const tar = await import("tar");
3
3
  const mountPath = config.volumes?.[0]?.mount || "/workspace";
4
- const syncignore = config.workspace?.syncignore ?? [];
5
- const defaultIgnore = ["**/.git/**", "**/node_modules/**", "**/__pycache__/**", "**/*.pyc", "**/.DS_Store", ...syncignore];
6
- const ignoreRegexes = defaultIgnore.map((p) => {
4
+ // Build ignore list from all workspace fields
5
+ const patterns = ["**/.git/**", "**/.DS_Store"];
6
+ if (config.workspace?.syncignore)
7
+ patterns.push(...config.workspace.syncignore);
8
+ if (config.workspace?.no_sync) {
9
+ for (const p of config.workspace.no_sync) {
10
+ patterns.push(p.endsWith("/") ? `${p}**` : `**/${p}/**`);
11
+ }
12
+ }
13
+ if (config.workspace?.storage) {
14
+ for (const p of config.workspace.storage) {
15
+ const name = p.replace(/\/$/, "");
16
+ patterns.push(`${name}`, `${name}/**`);
17
+ }
18
+ }
19
+ if (config.workspace?.exclude) {
20
+ for (const p of config.workspace.exclude) {
21
+ if (p.endsWith("/"))
22
+ patterns.push(`${p}**`);
23
+ else if (p.includes("*"))
24
+ patterns.push(`**/${p}`);
25
+ else
26
+ patterns.push(`**/${p}/**`);
27
+ }
28
+ }
29
+ const ignoreRegexes = patterns.map((p) => {
7
30
  const esc = p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\x00").replace(/\*/g, "[^/]*").replace(/\x00/g, ".*");
8
31
  return new RegExp(`(^|/)${esc}($|/)`);
9
32
  });
@@ -15,13 +15,42 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, statSyn
15
15
  import { resolve, dirname, normalize } from "path";
16
16
  import { getCredentials, isTokenExpired, refreshToken } from "./auth.js";
17
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,
18
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
19
+ function buildIgnorePatterns(config) {
20
+ const patterns = [
21
+ "**/.git/**", "**/.DS_Store",
24
22
  ];
23
+ // Legacy syncignore
24
+ if (config.workspace?.syncignore)
25
+ patterns.push(...config.workspace.syncignore);
26
+ // no_sync: persists to cloud but doesn't sync to laptop
27
+ if (config.workspace?.no_sync) {
28
+ for (const p of config.workspace.no_sync) {
29
+ patterns.push(p.endsWith("/") ? `${p}**` : `**/${p}/**`);
30
+ }
31
+ }
32
+ // storage: cold paths, don't sync to laptop (exclude the name itself + contents)
33
+ if (config.workspace?.storage) {
34
+ for (const p of config.workspace.storage) {
35
+ const name = p.replace(/\/$/, "");
36
+ patterns.push(name, `${name}/**`);
37
+ }
38
+ }
39
+ // exclude: throwaway, don't sync to laptop
40
+ if (config.workspace?.exclude) {
41
+ for (const p of config.workspace.exclude) {
42
+ if (p.endsWith("/")) {
43
+ patterns.push(`${p}**`);
44
+ }
45
+ else if (p.includes("*")) {
46
+ patterns.push(`**/${p}`);
47
+ }
48
+ else {
49
+ patterns.push(`**/${p}/**`);
50
+ }
51
+ }
52
+ }
53
+ return patterns;
25
54
  }
26
55
  function globToRegex(pattern) {
27
56
  const esc = pattern
@@ -41,8 +70,7 @@ async function getToken() {
41
70
  }
42
71
  export function startBidirectionalSync(instanceId, config, agentUrl) {
43
72
  const mountPath = config.volumes?.[0]?.mount ?? "/workspace";
44
- const syncignore = config.workspace?.syncignore ?? [];
45
- const ignoreRegexes = buildIgnorePatterns(syncignore).map(globToRegex);
73
+ const ignoreRegexes = buildIgnorePatterns(config).map(globToRegex);
46
74
  const cwd = process.cwd();
47
75
  let stopped = false;
48
76
  // Track files recently pushed by us so we don't pull-echo them back
package/dist/types.d.ts CHANGED
@@ -24,6 +24,9 @@ export interface CassianConfig {
24
24
  workspace?: {
25
25
  setup?: string;
26
26
  syncignore?: string[];
27
+ no_sync?: string[];
28
+ storage?: string[];
29
+ exclude?: string[];
27
30
  };
28
31
  }
29
32
  export interface DevOverrides {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cassian-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "The Cassian GPU cloud CLI — provision GPUs, sync files, and run workloads from your terminal.",
5
5
  "type": "module",
6
6
  "bin": {