cassian-cli 0.1.5 → 0.1.7

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.
@@ -1 +1,4 @@
1
- export declare function exec(args: string[]): Promise<void>;
1
+ export declare function exec(args: string[], opts?: {
2
+ timeout?: string;
3
+ noSync?: boolean;
4
+ }): Promise<void>;
@@ -1,11 +1,13 @@
1
1
  import { ApiClient } from "../lib/api.js";
2
2
  import { loadConfig } from "../lib/config.js";
3
+ import { pushWorkspace } from "../lib/push.js";
3
4
  import { fatal, handleError } from "../lib/errors.js";
4
- export async function exec(args) {
5
+ export async function exec(args, opts = {}) {
5
6
  if (args.length === 0) {
6
7
  fatal("No command specified.", "Usage: cassian exec <command>");
7
8
  }
8
9
  const command = args.join(" ");
10
+ const timeout = parseInt(opts.timeout || "600");
9
11
  let config;
10
12
  try {
11
13
  config = loadConfig();
@@ -20,9 +22,17 @@ export async function exec(args) {
20
22
  if (!instance) {
21
23
  fatal(`${config.name} is not running.`, "Run: cassian up");
22
24
  }
25
+ if (!opts.noSync) {
26
+ try {
27
+ await pushWorkspace(client, instance.id, config);
28
+ }
29
+ catch { /* sync best-effort — don't block exec */ }
30
+ }
31
+ const mountPath = config.volumes?.[0]?.mount || "/workspace";
23
32
  const result = await client.post(`/v1/instances/${instance.id}/exec`, {
24
33
  command,
25
- timeout: 60,
34
+ timeout,
35
+ workdir: mountPath,
26
36
  });
27
37
  if (result.stdout)
28
38
  process.stdout.write(result.stdout);
@@ -1,84 +1,177 @@
1
1
  import { createServer } from "http";
2
+ import { createInterface } from "readline";
2
3
  import open from "open";
3
4
  import { saveCredentials } from "../lib/auth.js";
4
5
  import { success, dim } from "../lib/output.js";
5
6
  import { fatal } from "../lib/errors.js";
6
7
  import { PLATFORM_URL } from "../lib/constants.js";
7
- const CALLBACK_PORT = 9876;
8
+ const PORT_RANGE = [9876, 9877, 9878, 9879, 9880];
8
9
  export async function login() {
9
- const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
10
- const platformUrl = PLATFORM_URL;
10
+ const port = await findOpenPort();
11
+ const callbackUrl = port ? `http://localhost:${port}/callback` : null;
12
+ const authUrl = callbackUrl
13
+ ? `${PLATFORM_URL}?cli_redirect=${encodeURIComponent(callbackUrl)}`
14
+ : `${PLATFORM_URL}?cli_mode=token`;
15
+ const allowedOrigin = new URL(PLATFORM_URL).origin;
11
16
  console.log();
12
- console.log(" Opening Cassian in your browser...");
17
+ console.log(dim(" Login URL:"));
18
+ console.log(` ${authUrl}`);
13
19
  console.log();
14
- const authUrl = `${platformUrl}?cli_redirect=${encodeURIComponent(callbackUrl)}`;
15
- // Derive the allowed CORS origin from platformUrl (e.g. https://trycassian.com)
16
- const allowedOrigin = new URL(platformUrl).origin;
17
- return new Promise((resolve, reject) => {
18
- const server = createServer((req, res) => {
19
- const origin = req.headers["origin"] || "";
20
- // Only accept requests from the platform — block anything else on this port
21
- if (origin && origin !== allowedOrigin) {
22
- res.writeHead(403);
23
- res.end();
24
- return;
25
- }
26
- res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
27
- res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
28
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
29
- if (req.method === "OPTIONS") {
30
- res.writeHead(204);
20
+ // If we got a port, try the automatic callback flow
21
+ if (port && callbackUrl) {
22
+ console.log(dim(" Press Enter to open browser. Or paste the token from the page below."));
23
+ console.log();
24
+ return new Promise((resolve) => {
25
+ let done = false;
26
+ const server = createServer((req, res) => {
27
+ const origin = req.headers["origin"] || "";
28
+ if (origin && origin !== allowedOrigin) {
29
+ res.writeHead(403);
30
+ res.end();
31
+ return;
32
+ }
33
+ res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
34
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
35
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
36
+ if (req.method === "OPTIONS") {
37
+ res.writeHead(204);
38
+ res.end();
39
+ return;
40
+ }
41
+ const url = new URL(req.url || "/", `http://localhost:${port}`);
42
+ if (url.pathname === "/callback" && req.method === "POST") {
43
+ const accessToken = url.searchParams.get("access_token");
44
+ const refreshToken = url.searchParams.get("refresh_token");
45
+ const expiresIn = parseInt(url.searchParams.get("expires_in") || "3600");
46
+ if (!accessToken || !refreshToken) {
47
+ res.writeHead(400);
48
+ res.end();
49
+ return;
50
+ }
51
+ finishLogin(accessToken, refreshToken, expiresIn);
52
+ res.writeHead(200);
53
+ res.end("ok");
54
+ return;
55
+ }
56
+ res.writeHead(404);
31
57
  res.end();
32
- return;
33
- }
34
- const url = new URL(req.url || "/", `http://localhost:${CALLBACK_PORT}`);
35
- // Platform posts tokens directly to /callback?access_token=...&refresh_token=...
36
- if (url.pathname === "/callback" && req.method === "POST") {
37
- const params = url.searchParams;
38
- const accessToken = params.get("access_token");
39
- const refreshToken = params.get("refresh_token");
40
- const expiresIn = parseInt(params.get("expires_in") || "3600");
41
- if (!accessToken || !refreshToken) {
42
- res.writeHead(400);
43
- res.end("Missing tokens");
58
+ });
59
+ // Also accept pasted token from stdin
60
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
61
+ rl.on("line", (line) => {
62
+ const trimmed = line.trim();
63
+ if (!trimmed) {
64
+ // Enter with no input = open browser
65
+ open(authUrl).catch(() => { });
44
66
  return;
45
67
  }
46
- // Decode JWT to get email
68
+ // User pasted a token
69
+ if (handlePastedToken(trimmed)) {
70
+ rl.close();
71
+ server.close();
72
+ }
73
+ });
74
+ server.listen(port, "127.0.0.1");
75
+ server.on("error", () => {
76
+ // Port taken — fall back to paste-only
77
+ console.log(dim(" Could not bind port. Paste your token below:"));
78
+ });
79
+ const timeout = setTimeout(() => {
80
+ if (!done) {
81
+ server.close();
82
+ rl.close();
83
+ fatal("Login timed out.", "Run: cassian login");
84
+ }
85
+ }, 300000);
86
+ server.on("close", () => { if (!done)
87
+ clearTimeout(timeout); });
88
+ function finishLogin(accessToken, refreshToken, expiresIn) {
89
+ if (done)
90
+ return;
91
+ done = true;
92
+ clearTimeout(timeout);
47
93
  const payload = JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url").toString());
48
94
  const email = payload.email || "unknown";
49
- const creds = {
95
+ saveCredentials({
50
96
  access_token: accessToken,
51
97
  refresh_token: refreshToken,
52
98
  expires_at: Math.floor(Date.now() / 1000) + expiresIn,
53
99
  user_email: email,
54
- };
55
- saveCredentials(creds);
56
- res.writeHead(200);
57
- res.end("ok");
100
+ });
58
101
  console.log();
59
102
  success(`Logged in as ${email}`);
60
103
  console.log();
104
+ rl.close();
61
105
  server.close();
62
106
  resolve();
63
- return;
64
107
  }
65
- res.writeHead(404);
66
- res.end();
108
+ function handlePastedToken(token) {
109
+ if (done)
110
+ return false;
111
+ try {
112
+ // Format: access_token:::refresh_token
113
+ let accessToken;
114
+ let refreshToken;
115
+ if (token.includes(":::")) {
116
+ [accessToken, refreshToken] = token.split(":::");
117
+ }
118
+ else {
119
+ fatal("Invalid token format.", "Copy the full token from the login page.");
120
+ return false;
121
+ }
122
+ const parts = accessToken.split(".");
123
+ if (parts.length !== 3)
124
+ throw new Error("bad jwt");
125
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
126
+ const expiresIn = (payload.exp || Math.floor(Date.now() / 1000) + 3600) - Math.floor(Date.now() / 1000);
127
+ finishLogin(accessToken, refreshToken, expiresIn);
128
+ return true;
129
+ }
130
+ catch {
131
+ console.log(dim(" Invalid token. Try again or wait for browser callback."));
132
+ return false;
133
+ }
134
+ }
67
135
  });
68
- server.listen(CALLBACK_PORT, "127.0.0.1", () => {
69
- open(authUrl).catch(() => {
70
- console.log(dim(" Browser didn't open? Visit:"));
71
- console.log(` ${authUrl}`);
72
- console.log();
73
- });
136
+ }
137
+ else {
138
+ // No port available — paste only
139
+ console.log(dim(" After logging in, copy the token from the page and paste it here:"));
140
+ console.log();
141
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
142
+ const token = await new Promise((res) => {
143
+ rl.question(" Token: ", (answer) => { rl.close(); res(answer.trim()); });
74
144
  });
75
- server.on("error", () => {
76
- fatal("Could not start login. Try again.");
145
+ if (!token || !token.includes(":::")) {
146
+ fatal("Invalid token.", "Copy the full token from the login page.");
147
+ }
148
+ const [accessToken, refreshToken] = token.split(":::");
149
+ const payload = JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url").toString());
150
+ const email = payload.email || "unknown";
151
+ saveCredentials({
152
+ access_token: accessToken,
153
+ refresh_token: refreshToken,
154
+ expires_at: payload.exp || Math.floor(Date.now() / 1000) + 3600,
155
+ user_email: email,
77
156
  });
78
- const timeout = setTimeout(() => {
79
- server.close();
80
- fatal("Login timed out.", "Run: cassian login");
81
- }, 120000);
82
- server.on("close", () => clearTimeout(timeout));
157
+ console.log();
158
+ success(`Logged in as ${email}`);
159
+ console.log();
160
+ }
161
+ }
162
+ function findOpenPort() {
163
+ return new Promise((resolve) => {
164
+ let idx = 0;
165
+ function tryNext() {
166
+ if (idx >= PORT_RANGE.length) {
167
+ resolve(null);
168
+ return;
169
+ }
170
+ const port = PORT_RANGE[idx++];
171
+ const srv = createServer();
172
+ srv.listen(port, "127.0.0.1", () => { srv.close(() => resolve(port)); });
173
+ srv.on("error", () => tryNext());
174
+ }
175
+ tryNext();
83
176
  });
84
177
  }
package/dist/index.js CHANGED
@@ -42,9 +42,10 @@ program
42
42
  .action(ssh);
43
43
  program
44
44
  .command("exec <command...>")
45
- .description("Run a command on the instance and return output")
46
- .option("--timeout <seconds>", "Command timeout in seconds", "60")
47
- .action((command, opts) => exec(command));
45
+ .description("Sync files and run a command on the instance")
46
+ .option("--timeout <seconds>", "Command timeout in seconds", "600")
47
+ .option("--no-sync", "Skip file sync before executing")
48
+ .action((command, opts) => exec(command, { timeout: opts.timeout, noSync: !opts.sync }));
48
49
  program
49
50
  .command("sync")
50
51
  .description("Sync workspace from instance to local")
@@ -0,0 +1,3 @@
1
+ import { ApiClient } from "./api.js";
2
+ import type { CassianConfig } from "../types.js";
3
+ export declare function pushWorkspace(client: ApiClient, instanceId: string, config: CassianConfig): Promise<void>;
@@ -0,0 +1,21 @@
1
+ export async function pushWorkspace(client, instanceId, config) {
2
+ const tar = await import("tar");
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) => {
7
+ const esc = p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\x00").replace(/\*/g, "[^/]*").replace(/\x00/g, ".*");
8
+ return new RegExp(`(^|/)${esc}($|/)`);
9
+ });
10
+ const chunks = [];
11
+ await new Promise((res, rej) => {
12
+ const pack = tar.create({ cwd: process.cwd(), filter: (p) => {
13
+ const rel = p.replace(/^\.\//, "");
14
+ return !ignoreRegexes.some((re) => re.test(rel));
15
+ } }, ["."]);
16
+ pack.on("data", (c) => chunks.push(c));
17
+ pack.on("end", res);
18
+ pack.on("error", rej);
19
+ });
20
+ await client.push(instanceId, Buffer.concat(chunks), mountPath);
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cassian-cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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": {