@sanlabs/sanbox-cli 0.0.4 → 0.0.9
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/README.md +129 -8
- package/dist/api.js +112 -35
- package/dist/args.js +9 -2
- package/dist/cli.js +620 -138
- package/dist/config.js +24 -14
- package/dist/deviceLogin.js +106 -0
- package/dist/fileAccess.js +16 -0
- package/dist/output.js +3 -1
- package/dist/runs.js +21 -3
- package/dist/ssh.js +129 -0
- package/dist/userSession.js +124 -0
- package/dist/version.js +1 -1
- package/dist/watch.js +2 -2
- package/package.json +3 -1
package/dist/config.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { CliError, commandAction } from "./errors.js";
|
|
4
|
+
import { userSessionStore } from "./userSession.js";
|
|
4
5
|
export const defaultApiUrl = "https://console.sanbox.cloud";
|
|
6
|
+
export const resolveApiUrl = (flags = {}) => String(flags["api-url"] || process.env.SANBOX_API_URL || readLocalConfig().api_url || defaultApiUrl).replace(/\/+$/, "");
|
|
5
7
|
export const readLocalConfig = (cwd = process.cwd()) => {
|
|
6
8
|
const configPath = path.join(cwd, ".sanbox", "config.json");
|
|
7
9
|
try {
|
|
@@ -11,7 +13,6 @@ export const readLocalConfig = (cwd = process.cwd()) => {
|
|
|
11
13
|
const record = parsed;
|
|
12
14
|
return {
|
|
13
15
|
api_url: typeof record.api_url === "string" ? record.api_url : undefined,
|
|
14
|
-
org: typeof record.org === "string" ? record.org : undefined,
|
|
15
16
|
default_template: typeof record.default_template === "string" ? record.default_template : undefined
|
|
16
17
|
};
|
|
17
18
|
}
|
|
@@ -21,22 +22,31 @@ export const readLocalConfig = (cwd = process.cwd()) => {
|
|
|
21
22
|
throw error;
|
|
22
23
|
}
|
|
23
24
|
};
|
|
24
|
-
export const readConfig = (flags = {}
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
export const readConfig = (flags = {}) => {
|
|
26
|
+
const apiUrl = resolveApiUrl(flags);
|
|
27
|
+
const environmentApiKey = process.env.SANBOX_API_KEY || "";
|
|
28
|
+
const savedSession = environmentApiKey ? null : userSessionStore.read(apiUrl);
|
|
29
|
+
if (savedSession && Date.parse(savedSession.expiresAt) <= Date.now())
|
|
30
|
+
userSessionStore.delete(apiUrl);
|
|
31
|
+
const apiKey = environmentApiKey || (savedSession && Date.parse(savedSession.expiresAt) > Date.now() ? savedSession.accessToken : "");
|
|
32
|
+
if (!apiKey) {
|
|
33
|
+
throw new CliError("authentication_required", "Sign in with sanbox login or set SANBOX_API_KEY for automation.", {
|
|
34
|
+
nextActions: [commandAction(["sanbox", "login"], "Sign in with your Sanbox user account.")]
|
|
32
35
|
});
|
|
33
36
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
return { apiUrl, apiKey };
|
|
38
|
+
};
|
|
39
|
+
export const readUserConfig = (flags = {}, sessionStore = userSessionStore) => {
|
|
40
|
+
const apiUrl = resolveApiUrl(flags);
|
|
41
|
+
const session = sessionStore.read(apiUrl);
|
|
42
|
+
if (session && Date.parse(session.expiresAt) <= Date.now())
|
|
43
|
+
sessionStore.delete(apiUrl);
|
|
44
|
+
if (!session || Date.parse(session.expiresAt) <= Date.now()) {
|
|
45
|
+
throw new CliError("user_login_required", "SSH requires a Sanbox user login. Run sanbox login.", {
|
|
46
|
+
nextActions: [commandAction(["sanbox", "login"], "Sign in with the account that owns the computer.")]
|
|
37
47
|
});
|
|
38
48
|
}
|
|
39
|
-
return { apiUrl,
|
|
49
|
+
return { apiUrl, apiKey: session.accessToken };
|
|
40
50
|
};
|
|
41
51
|
export function readTemplateSelection(flags = {}, options = {}) {
|
|
42
52
|
const flagValue = flags.template;
|
|
@@ -52,7 +62,7 @@ export function readTemplateSelection(flags = {}, options = {}) {
|
|
|
52
62
|
return null;
|
|
53
63
|
throw new CliError("template_required", "A template must be selected explicitly.", {
|
|
54
64
|
nextActions: [
|
|
55
|
-
commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the
|
|
65
|
+
commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the API key's organization."),
|
|
56
66
|
commandAction(["sanbox", "run", "<task>", "--template", "<template-id>"], "Run with an explicit template."),
|
|
57
67
|
commandAction(["sanbox", "context", "--json"], "Select a template for this shell and inspect the resolved context.", { SANBOX_TEMPLATE: "<template-id>" })
|
|
58
68
|
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { CliError } from "./errors.js";
|
|
5
|
+
const loginRequestIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
6
|
+
const responseBody = async (response) => {
|
|
7
|
+
const text = await response.text();
|
|
8
|
+
if (!text)
|
|
9
|
+
return {};
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(text);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return { error: text };
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const responseError = (response, body) => new CliError("cli_login_failed", typeof body.error === "string" ? body.error : response.statusText || "Sanbox login failed.", { status: response.status });
|
|
18
|
+
export const openBrowser = (url, platform = process.platform) => {
|
|
19
|
+
const command = platform === "darwin" ? "open" : platform === "win32" ? "rundll32" : "xdg-open";
|
|
20
|
+
const args = platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
21
|
+
try {
|
|
22
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
23
|
+
child.once("error", () => { });
|
|
24
|
+
child.unref();
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
export const loginWithBrowser = async (options) => {
|
|
32
|
+
const fetchImpl = options.fetchImpl || fetch;
|
|
33
|
+
const sleep = options.sleep || ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
34
|
+
const verifier = options.verifier || randomBytes(32).toString("base64url");
|
|
35
|
+
const challenge = createHash("sha256").update(verifier, "utf8").digest("base64url");
|
|
36
|
+
const requestResponse = await fetchImpl(`${options.apiUrl}/v1/cli-auth/requests`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
device_challenge: challenge,
|
|
41
|
+
device_name: options.deviceName || `${os.hostname()} local terminal`
|
|
42
|
+
})
|
|
43
|
+
});
|
|
44
|
+
const requestBody = await responseBody(requestResponse);
|
|
45
|
+
if (!requestResponse.ok)
|
|
46
|
+
throw responseError(requestResponse, requestBody);
|
|
47
|
+
const request = requestBody;
|
|
48
|
+
let verificationUrl;
|
|
49
|
+
try {
|
|
50
|
+
verificationUrl = new URL(request.verification_url);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new CliError("cli_login_invalid", "Sanbox returned an invalid login request.");
|
|
54
|
+
}
|
|
55
|
+
if (!loginRequestIdPattern.test(request.request_id || "") ||
|
|
56
|
+
!request.user_code ||
|
|
57
|
+
verificationUrl.origin !== new URL(options.apiUrl).origin ||
|
|
58
|
+
!Number.isFinite(Date.parse(request.expires_at)) ||
|
|
59
|
+
Date.parse(request.expires_at) <= Date.now()) {
|
|
60
|
+
throw new CliError("cli_login_invalid", "Sanbox returned an invalid login request.");
|
|
61
|
+
}
|
|
62
|
+
const browserOpened = options.launchBrowser ? options.launchBrowser(request.verification_url) : false;
|
|
63
|
+
options.onPrompt?.(request, browserOpened);
|
|
64
|
+
const intervalMs = Math.max(1_000, Math.min(10_000, Number(request.interval_seconds || 2) * 1_000));
|
|
65
|
+
while (Date.now() < Date.parse(request.expires_at)) {
|
|
66
|
+
await sleep(intervalMs);
|
|
67
|
+
const tokenResponse = await fetchImpl(`${options.apiUrl}/v1/cli-auth/requests/${encodeURIComponent(request.request_id)}/token`, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "Content-Type": "application/json" },
|
|
70
|
+
body: JSON.stringify({ device_verifier: verifier })
|
|
71
|
+
});
|
|
72
|
+
const tokenBody = await responseBody(tokenResponse);
|
|
73
|
+
if (tokenResponse.status === 202)
|
|
74
|
+
continue;
|
|
75
|
+
if (!tokenResponse.ok)
|
|
76
|
+
throw responseError(tokenResponse, tokenBody);
|
|
77
|
+
const result = tokenBody;
|
|
78
|
+
if (!result.access_token?.startsWith("sbx_user_") ||
|
|
79
|
+
!result.email ||
|
|
80
|
+
!result.organization?.id ||
|
|
81
|
+
!result.organization.slug ||
|
|
82
|
+
!result.organization.name ||
|
|
83
|
+
!Number.isFinite(Date.parse(result.expires_at)) ||
|
|
84
|
+
Date.parse(result.expires_at) <= Date.now()) {
|
|
85
|
+
throw new CliError("cli_login_invalid", "Sanbox returned an invalid user session.");
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
accessToken: result.access_token,
|
|
89
|
+
apiUrl: options.apiUrl,
|
|
90
|
+
email: result.email,
|
|
91
|
+
organization: result.organization,
|
|
92
|
+
expiresAt: result.expires_at
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
throw new CliError("cli_login_expired", "Sanbox login expired. Run sanbox login again.");
|
|
96
|
+
};
|
|
97
|
+
export const revokeUserSession = async (apiUrl, accessToken, fetchImpl = fetch) => {
|
|
98
|
+
const response = await fetchImpl(`${apiUrl}/v1/cli-auth/session`, {
|
|
99
|
+
method: "DELETE",
|
|
100
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
101
|
+
});
|
|
102
|
+
if (response.ok || response.status === 401)
|
|
103
|
+
return;
|
|
104
|
+
const body = await responseBody(response);
|
|
105
|
+
throw responseError(response, body);
|
|
106
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
const durationPattern = /^(\d+)(s|m|h|d)$/;
|
|
3
|
+
export const parseFileAccessExpiry = (value) => {
|
|
4
|
+
const normalized = value.trim().toLowerCase();
|
|
5
|
+
const match = durationPattern.exec(normalized);
|
|
6
|
+
if (!match) {
|
|
7
|
+
throw new CliError("invalid_file_access_expiry", "--expires must be a duration such as 15m, 1h, or 1d.");
|
|
8
|
+
}
|
|
9
|
+
const amount = Number(match[1]);
|
|
10
|
+
const multiplier = { s: 1, m: 60, h: 3600, d: 86400 }[match[2]];
|
|
11
|
+
const seconds = amount * multiplier;
|
|
12
|
+
if (!Number.isSafeInteger(seconds) || seconds < 60 || seconds > 7 * 86400) {
|
|
13
|
+
throw new CliError("invalid_file_access_expiry", "--expires must be between 60s and 7d.");
|
|
14
|
+
}
|
|
15
|
+
return seconds;
|
|
16
|
+
};
|
package/dist/output.js
CHANGED
|
@@ -77,7 +77,9 @@ export const summarizeRun = (payload) => {
|
|
|
77
77
|
const selection = [
|
|
78
78
|
templateId ? `template=${templateId}` : "",
|
|
79
79
|
run.provider_id ? `provider=${run.provider_id}` : "",
|
|
80
|
-
run.model_id ? `model=${run.model_id}` : ""
|
|
80
|
+
run.model_id ? `model=${run.model_id}` : "",
|
|
81
|
+
run.sandbox_state ? `sandbox=${run.sandbox_state}` : "",
|
|
82
|
+
run.snapshot_generation ? `snapshot=${run.snapshot_generation}` : ""
|
|
81
83
|
].filter(Boolean).join(" ");
|
|
82
84
|
return `${run.id} ${run.status}${selection ? ` ${selection}` : ""}${run.exit_code === null ? "" : ` exit=${run.exit_code}`}${run.error ? ` error=${run.error}` : ""}`;
|
|
83
85
|
};
|
package/dist/runs.js
CHANGED
|
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { buildInputBundle } from "./inputs.js";
|
|
5
|
-
const terminalStatuses = new Set(["completed", "failed", "canceled"
|
|
5
|
+
const terminalStatuses = new Set(["completed", "failed", "canceled"]);
|
|
6
6
|
export const isTerminalRun = (run) => terminalStatuses.has(run.status);
|
|
7
7
|
export const waitForRun = async (client, runId, options = {}) => {
|
|
8
8
|
const pollIntervalMs = options.pollIntervalMs ?? 2000;
|
|
@@ -16,6 +16,25 @@ export const waitForRun = async (client, runId, options = {}) => {
|
|
|
16
16
|
}
|
|
17
17
|
return payload;
|
|
18
18
|
};
|
|
19
|
+
export const waitForSandboxState = async (client, runId, target, options = {}) => {
|
|
20
|
+
const pollIntervalMs = options.pollIntervalMs ?? 2000;
|
|
21
|
+
const deadline = Date.now() + (options.timeoutSeconds ?? 1800) * 1000;
|
|
22
|
+
let payload = await client.getRun(runId);
|
|
23
|
+
while (payload.run.sandbox_state !== target) {
|
|
24
|
+
if (payload.run.sandbox_state === "error" || payload.run.sandbox_state === "deleted") {
|
|
25
|
+
throw new Error(`Sandbox entered ${payload.run.sandbox_state} while waiting for ${target}.`);
|
|
26
|
+
}
|
|
27
|
+
if (target === "running" && payload.run.sandbox_state === "paused") {
|
|
28
|
+
throw new Error("Sandbox returned to paused before the manual lease became ready.");
|
|
29
|
+
}
|
|
30
|
+
if (Date.now() > deadline) {
|
|
31
|
+
throw new Error(`Timed out waiting for sandbox ${runId} to become ${target}.`);
|
|
32
|
+
}
|
|
33
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
34
|
+
payload = await client.getRun(runId);
|
|
35
|
+
}
|
|
36
|
+
return payload;
|
|
37
|
+
};
|
|
19
38
|
export const createRun = async (client, options) => {
|
|
20
39
|
let inputCollectionId;
|
|
21
40
|
if (options.inputs.length > 0) {
|
|
@@ -31,8 +50,7 @@ export const createRun = async (client, options) => {
|
|
|
31
50
|
external_run_id: options.externalRunId,
|
|
32
51
|
workload_id: options.templateId,
|
|
33
52
|
instruction: options.instruction,
|
|
34
|
-
...(inputCollectionId ? { input_collection_id: inputCollectionId } : {})
|
|
35
|
-
retention_ttl_seconds: options.retentionTtlSeconds
|
|
53
|
+
...(inputCollectionId ? { input_collection_id: inputCollectionId } : {})
|
|
36
54
|
});
|
|
37
55
|
};
|
|
38
56
|
export const readTasks = async (tasksPath) => {
|
package/dist/ssh.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { WebSocket } from "ws";
|
|
6
|
+
import { CliError } from "./errors.js";
|
|
7
|
+
const waitForCommand = (command, args, environment = process.env) => new Promise((resolve, reject) => {
|
|
8
|
+
const child = spawn(command, args, { stdio: "inherit", env: environment });
|
|
9
|
+
child.once("error", reject);
|
|
10
|
+
child.once("exit", (code, signal) => {
|
|
11
|
+
if (signal) {
|
|
12
|
+
resolve(128 + (os.constants.signals[signal] || 0));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
resolve(code ?? 1);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
export const sshArguments = (session, privateKeyPath, knownHostsPath, proxyCommand = "sanbox ssh-proxy") => [
|
|
19
|
+
"-F", os.devNull,
|
|
20
|
+
"-o", `ProxyCommand=${proxyCommand}`,
|
|
21
|
+
"-o", `IdentityFile=${privateKeyPath}`,
|
|
22
|
+
"-o", "IdentitiesOnly=yes",
|
|
23
|
+
"-o", `UserKnownHostsFile=${knownHostsPath}`,
|
|
24
|
+
"-o", "StrictHostKeyChecking=yes",
|
|
25
|
+
"-o", `HostKeyAlias=${session.host_alias}`,
|
|
26
|
+
"-o", "PasswordAuthentication=no",
|
|
27
|
+
"-o", "KbdInteractiveAuthentication=no",
|
|
28
|
+
"-o", "ClearAllForwardings=yes",
|
|
29
|
+
`${session.username}@${session.host_alias}`
|
|
30
|
+
];
|
|
31
|
+
export const sshProxyEnvironment = (apiUrl, websocketUrl, environment = process.env) => {
|
|
32
|
+
const childEnvironment = { ...environment };
|
|
33
|
+
delete childEnvironment.SANBOX_API_KEY;
|
|
34
|
+
childEnvironment.SANBOX_SSH_WEBSOCKET_URL = websocketUrl;
|
|
35
|
+
childEnvironment.SANBOX_SSH_ORIGIN = new URL(apiUrl).origin;
|
|
36
|
+
return childEnvironment;
|
|
37
|
+
};
|
|
38
|
+
export const openSSH = async (client, runId) => {
|
|
39
|
+
const temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "sanbox-ssh-"));
|
|
40
|
+
const privateKeyPath = path.join(temporaryDirectory, "id_ed25519");
|
|
41
|
+
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
42
|
+
const knownHostsPath = path.join(temporaryDirectory, "known_hosts");
|
|
43
|
+
try {
|
|
44
|
+
let keygenExit;
|
|
45
|
+
try {
|
|
46
|
+
keygenExit = await waitForCommand("ssh-keygen", [
|
|
47
|
+
"-q", "-t", "ed25519", "-N", "", "-C", "sanbox-ephemeral", "-f", privateKeyPath
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new CliError("ssh_client_unavailable", "OpenSSH ssh-keygen is required for sanbox ssh.");
|
|
52
|
+
}
|
|
53
|
+
if (keygenExit !== 0) {
|
|
54
|
+
throw new CliError("ssh_keygen_failed", "Could not generate the ephemeral SSH key.");
|
|
55
|
+
}
|
|
56
|
+
const publicKey = (await fs.readFile(publicKeyPath, "utf8")).trim();
|
|
57
|
+
const session = await client.createSSHSession(runId, publicKey);
|
|
58
|
+
await fs.writeFile(knownHostsPath, `${session.host_alias} ${session.host_public_key}\n`, { mode: 0o600 });
|
|
59
|
+
const environment = sshProxyEnvironment(client.config.apiUrl, session.websocket_url);
|
|
60
|
+
const proxyCommand = process.env.SANBOX_SSH_PROXY_COMMAND || "sanbox ssh-proxy";
|
|
61
|
+
try {
|
|
62
|
+
return await waitForCommand("ssh", sshArguments(session, privateKeyPath, knownHostsPath, proxyCommand), environment);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
throw new CliError("ssh_client_unavailable", "The OpenSSH client is required for sanbox ssh.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
export const runSSHProxy = async () => {
|
|
73
|
+
const url = process.env.SANBOX_SSH_WEBSOCKET_URL;
|
|
74
|
+
if (!url)
|
|
75
|
+
throw new CliError("ssh_session_required", "SANBOX_SSH_WEBSOCKET_URL is required by the SSH proxy.");
|
|
76
|
+
const origin = process.env.SANBOX_SSH_ORIGIN;
|
|
77
|
+
process.stdin.pause();
|
|
78
|
+
const socket = new WebSocket(url, origin ? { origin } : undefined);
|
|
79
|
+
socket.binaryType = "nodebuffer";
|
|
80
|
+
return await new Promise((resolve, reject) => {
|
|
81
|
+
let completed = false;
|
|
82
|
+
const finish = (code) => {
|
|
83
|
+
if (completed)
|
|
84
|
+
return;
|
|
85
|
+
completed = true;
|
|
86
|
+
process.stdin.pause();
|
|
87
|
+
resolve(code);
|
|
88
|
+
};
|
|
89
|
+
const closeSocket = () => {
|
|
90
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
91
|
+
socket.close(1011, "SSH proxy failed");
|
|
92
|
+
else if (socket.readyState === WebSocket.CONNECTING)
|
|
93
|
+
socket.terminate();
|
|
94
|
+
};
|
|
95
|
+
socket.once("open", () => {
|
|
96
|
+
process.stdin.on("data", (data) => {
|
|
97
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
98
|
+
socket.send(data, { binary: true });
|
|
99
|
+
});
|
|
100
|
+
process.stdin.once("end", () => {
|
|
101
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
102
|
+
socket.send(JSON.stringify({ type: "close" }));
|
|
103
|
+
});
|
|
104
|
+
process.stdin.resume();
|
|
105
|
+
});
|
|
106
|
+
socket.on("message", (data, isBinary) => {
|
|
107
|
+
if (isBinary) {
|
|
108
|
+
process.stdout.write(Buffer.isBuffer(data) ? data : Buffer.from(data));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const message = JSON.parse(String(data));
|
|
113
|
+
if (message.type === "error") {
|
|
114
|
+
process.stderr.write(`${typeof message.message === "string" ? message.message : "SSH connection failed."}\n`);
|
|
115
|
+
closeSocket();
|
|
116
|
+
finish(1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Ignore unknown control messages. SSH bytes are always binary.
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
socket.once("close", (code) => finish(code === 1000 ? 0 : 1));
|
|
124
|
+
socket.once("error", (error) => {
|
|
125
|
+
closeSocket();
|
|
126
|
+
reject(error);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CliError, commandAction } from "./errors.js";
|
|
6
|
+
export const defaultCredentialsPath = path.join(os.homedir(), ".sanbox", "credentials.json");
|
|
7
|
+
const emptyCredentials = () => ({ version: 1, sessions: {} });
|
|
8
|
+
const originFor = (apiUrl) => new URL(apiUrl).origin;
|
|
9
|
+
const invalidCredentials = (credentialsPath) => new CliError("credential_store_invalid", `The saved Sanbox user sessions in ${credentialsPath} are invalid. Remove the file, then run sanbox login.`);
|
|
10
|
+
const unavailable = (credentialsPath) => new CliError("credential_store_unavailable", `The Sanbox CLI could not access ${credentialsPath}.`, {
|
|
11
|
+
nextActions: [commandAction(["sanbox", "login"], "Make sure your user account can read and write the ~/.sanbox directory, then sign in again.")]
|
|
12
|
+
});
|
|
13
|
+
const parseSession = (value) => {
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
15
|
+
return null;
|
|
16
|
+
const record = value;
|
|
17
|
+
const organization = record.organization;
|
|
18
|
+
if (typeof record.accessToken !== "string" || !record.accessToken.startsWith("sbx_user_") ||
|
|
19
|
+
typeof record.apiUrl !== "string" || typeof record.email !== "string" ||
|
|
20
|
+
typeof record.expiresAt !== "string" || !organization || typeof organization !== "object" ||
|
|
21
|
+
Array.isArray(organization))
|
|
22
|
+
return null;
|
|
23
|
+
const org = organization;
|
|
24
|
+
if (typeof org.id !== "string" || typeof org.slug !== "string" || typeof org.name !== "string")
|
|
25
|
+
return null;
|
|
26
|
+
return {
|
|
27
|
+
accessToken: record.accessToken,
|
|
28
|
+
apiUrl: record.apiUrl,
|
|
29
|
+
email: record.email,
|
|
30
|
+
expiresAt: record.expiresAt,
|
|
31
|
+
organization: { id: org.id, slug: org.slug, name: org.name }
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
const parseCredentials = (value, credentialsPath) => {
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(value);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw invalidCredentials(credentialsPath);
|
|
41
|
+
}
|
|
42
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
43
|
+
throw invalidCredentials(credentialsPath);
|
|
44
|
+
}
|
|
45
|
+
const record = parsed;
|
|
46
|
+
if (record.version !== 1 || !record.sessions || typeof record.sessions !== "object" || Array.isArray(record.sessions)) {
|
|
47
|
+
throw invalidCredentials(credentialsPath);
|
|
48
|
+
}
|
|
49
|
+
const sessions = {};
|
|
50
|
+
for (const [origin, value] of Object.entries(record.sessions)) {
|
|
51
|
+
const session = parseSession(value);
|
|
52
|
+
if (!session || originFor(session.apiUrl) !== origin)
|
|
53
|
+
throw invalidCredentials(credentialsPath);
|
|
54
|
+
sessions[origin] = session;
|
|
55
|
+
}
|
|
56
|
+
return { version: 1, sessions };
|
|
57
|
+
};
|
|
58
|
+
const readCredentials = (credentialsPath) => {
|
|
59
|
+
try {
|
|
60
|
+
return parseCredentials(fs.readFileSync(credentialsPath, "utf8"), credentialsPath);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error.code === "ENOENT")
|
|
64
|
+
return emptyCredentials();
|
|
65
|
+
if (error instanceof CliError)
|
|
66
|
+
throw error;
|
|
67
|
+
throw unavailable(credentialsPath);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const writeCredentials = (credentialsPath, credentials) => {
|
|
71
|
+
const directory = path.dirname(credentialsPath);
|
|
72
|
+
const temporaryPath = path.join(directory, `.${path.basename(credentialsPath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
73
|
+
try {
|
|
74
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
75
|
+
if (process.platform !== "win32")
|
|
76
|
+
fs.chmodSync(directory, 0o700);
|
|
77
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(credentials, null, 2)}\n`, {
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
flag: "wx",
|
|
80
|
+
mode: 0o600
|
|
81
|
+
});
|
|
82
|
+
fs.renameSync(temporaryPath, credentialsPath);
|
|
83
|
+
if (process.platform !== "win32")
|
|
84
|
+
fs.chmodSync(credentialsPath, 0o600);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
try {
|
|
88
|
+
fs.unlinkSync(temporaryPath);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// The temporary file may not have been created.
|
|
92
|
+
}
|
|
93
|
+
throw unavailable(credentialsPath);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
export const createUserSessionStore = (credentialsPath = defaultCredentialsPath) => ({
|
|
97
|
+
read(apiUrl) {
|
|
98
|
+
return readCredentials(credentialsPath).sessions[originFor(apiUrl)] || null;
|
|
99
|
+
},
|
|
100
|
+
write(session) {
|
|
101
|
+
const credentials = readCredentials(credentialsPath);
|
|
102
|
+
credentials.sessions[originFor(session.apiUrl)] = session;
|
|
103
|
+
writeCredentials(credentialsPath, credentials);
|
|
104
|
+
},
|
|
105
|
+
delete(apiUrl) {
|
|
106
|
+
const credentials = readCredentials(credentialsPath);
|
|
107
|
+
const origin = originFor(apiUrl);
|
|
108
|
+
if (!credentials.sessions[origin])
|
|
109
|
+
return;
|
|
110
|
+
delete credentials.sessions[origin];
|
|
111
|
+
if (Object.keys(credentials.sessions).length === 0) {
|
|
112
|
+
try {
|
|
113
|
+
fs.unlinkSync(credentialsPath);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error.code !== "ENOENT")
|
|
117
|
+
throw unavailable(credentialsPath);
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
writeCredentials(credentialsPath, credentials);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
export const userSessionStore = createUserSessionStore();
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "0.0.
|
|
1
|
+
export const version = "0.0.9";
|
package/dist/watch.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SanboxApiError } from "./api.js";
|
|
2
|
-
const terminalStatuses = new Set(["completed", "failed", "canceled"
|
|
3
|
-
const terminalEventKinds = new Set(["run.completed", "run.failed", "run.canceled"
|
|
2
|
+
const terminalStatuses = new Set(["completed", "failed", "canceled"]);
|
|
3
|
+
const terminalEventKinds = new Set(["run.completed", "run.failed", "run.canceled"]);
|
|
4
4
|
const retryableNetworkCodes = new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "EAI_AGAIN", "ENETUNREACH"]);
|
|
5
5
|
export class WatchInterruptedError extends Error {
|
|
6
6
|
constructor() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanlabs/sanbox-cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -29,10 +29,12 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"fast-glob": "^3.3.3",
|
|
31
31
|
"ignore": "^7.0.5",
|
|
32
|
+
"ws": "^8.21.3",
|
|
32
33
|
"yazl": "^3.3.1"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"@types/node": "^25.6.0",
|
|
37
|
+
"@types/ws": "^8.18.1",
|
|
36
38
|
"@types/yazl": "^3.3.0",
|
|
37
39
|
"tsx": "^4.21.0",
|
|
38
40
|
"typescript": "^6.0.3"
|