diff --git a/CHANGELOG.md b/CHANGELOG.md index f251ca3..4be7423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [2.4.0](https://github.com/codesandbox/codesandbox-sdk/compare/v2.3.0...v2.4.0) (2025-10-16) + + +### Features + +* batch writes in template build ([#205](https://github.com/codesandbox/codesandbox-sdk/issues/205)) ([9b2f3f6](https://github.com/codesandbox/codesandbox-sdk/commit/9b2f3f66c4a0b3fa7c9b2f29c456875bea807ead)) +* command error with exit code ([#203](https://github.com/codesandbox/codesandbox-sdk/issues/203)) ([652c2ef](https://github.com/codesandbox/codesandbox-sdk/commit/652c2efb900ce36081f0a1dca6919b288508116b)) + + +### Bug Fixes + +* batch session initialization commands ([#207](https://github.com/codesandbox/codesandbox-sdk/issues/207)) ([363faca](https://github.com/codesandbox/codesandbox-sdk/commit/363faca904324d3daecbde8990555dfa3a5eb577)) +* handle spacing in env variables of commands ([#202](https://github.com/codesandbox/codesandbox-sdk/issues/202)) ([da5a772](https://github.com/codesandbox/codesandbox-sdk/commit/da5a7724d0d264c8088747c137a45f3cb6c534d6)) + ## [2.3.0](https://github.com/codesandbox/codesandbox-sdk/compare/v2.2.1...v2.3.0) (2025-09-29) diff --git a/package-lock.json b/package-lock.json index 05cc710..6e8592c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codesandbox/sdk", - "version": "2.3.0", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codesandbox/sdk", - "version": "2.3.0", + "version": "2.4.0", "license": "MIT", "dependencies": { "@hey-api/client-fetch": "^0.13.1", diff --git a/package.json b/package.json index 617338b..d6d2695 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codesandbox/sdk", - "version": "2.3.0", + "version": "2.4.0", "description": "The CodeSandbox SDK", "author": "CodeSandbox", "license": "MIT", diff --git a/pint-openapi-bundled.json b/pint-openapi-bundled.json index faeb1a5..2ec70d7 100644 --- a/pint-openapi-bundled.json +++ b/pint-openapi-bundled.json @@ -1,3 +1,4 @@ + { "openapi": "3.0.3", "info": { @@ -1587,6 +1588,61 @@ } }, "/api/v1/stream/execs": { + "get": { + "summary": "List all execs", + "tags": [ + "execs" + ], + "description": "Returns a list of all active execs using SSE.", + "operationId": "StreamExecsList", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Execs retrieved successfully", + "content": { + "text/event-stream": { + "schema": { + "type": "string", + "description": "Server-Sent Events stream of exec updates" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "default": { + "description": "Unexpected Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/stream/ports": { + "get": { + "summary": "List open ports using Server-Sent Events (SSE)", + "tags": [ + "ports" + ], + "description": "Lists all open TCP ports on the system AND LISTEN TO THE CHANGES, excluding ignored ports configured in the server.", + "operationId": "StreamPortsList", "get": { "summary": "List all execs", "tags": ["execs"], @@ -1860,7 +1916,14 @@ "description": "Whether the exec is interactive" } }, - "required": ["id", "command", "args", "status", "pid", "interactive"] + "required": [ + "id", + "command", + "args", + "status", + "pid", + "interactive" + ] }, "ExecListResponse": { "type": "object", diff --git a/src/Sandbox.ts b/src/Sandbox.ts index bf9e8fb..88e5869 100644 --- a/src/Sandbox.ts +++ b/src/Sandbox.ts @@ -136,6 +136,13 @@ export class Sandbox { this.tracer ); + const commands: string[] = []; + + // Ensure .private directory exists if env or git is configured + if (customSession.env || customSession.git) { + commands.push(`mkdir -p "$HOME/.private"`); + } + if (customSession.env) { const envStrings = Object.entries(customSession.env) .map(([key, value]) => { @@ -144,32 +151,35 @@ export class Sandbox { return `export ${key}='${safe}'`; }) .join("\n"); - const cmd = [ - `mkdir -p "$HOME/.private"`, - `cat << 'EOF' > "$HOME/.private/.env"`, - envStrings, - `EOF`, - ].join("\n"); - await client.commands.run(cmd); + commands.push( + [ + `cat << 'EOF' > "$HOME/.private/.env"`, + envStrings, + `EOF`, + ].join("\n") + ); + } if (customSession.git) { - await Promise.all([ - client.commands.run( - `echo "https://${customSession.git.username || "x-access-token"}:${ - customSession.git.accessToken - }@${customSession.git.provider}" > $HOME/.private/.gitcredentials` - ), - client.commands.run( - `echo "[user] + commands.push( + `echo "https://${customSession.git.username || "x-access-token"}:${ + customSession.git.accessToken + }@${customSession.git.provider}" > $HOME/.private/.gitcredentials` + ); + commands.push( + `echo "[user] name = ${customSession.git.name || customSession.id} email = ${customSession.git.email} [init] defaultBranch = main [credential] helper = store --file ~/.private/.gitcredentials" > $HOME/.gitconfig` - ), - ]); + ); + } + + if (commands.length > 0) { + await client.commands.run(commands.join("\n")); } return client; diff --git a/src/SandboxClient/commands.ts b/src/SandboxClient/commands.ts index 56552e8..c9dce51 100644 --- a/src/SandboxClient/commands.ts +++ b/src/SandboxClient/commands.ts @@ -1,6 +1,6 @@ import { Disposable, DisposableStore } from "../utils/disposable"; import { Emitter } from "../utils/event"; -import { IAgentClient } from "../agent-client-interface"; +import { IAgentClient } from "../AgentClient/agent-client-interface"; import * as protocol from "../pitcher-protocol"; import { Barrier } from "../utils/barrier"; import { Tracer, SpanStatusCode } from "@opentelemetry/api"; @@ -26,6 +26,33 @@ export type CommandStatus = | "KILLED" | "RESTARTING"; +/** + * Error thrown when a command fails with a non-zero exit code. + */ +export class CommandError extends Error { + /** + * The exit code returned by the command. + */ + exitCode: number; + + /** + * The output produced by the command. + */ + output: string; + + constructor(message: string, exitCode: number, output: string) { + super(message); + this.name = "CommandError"; + this.exitCode = exitCode; + this.output = output; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, CommandError); + } + } +} + const DEFAULT_SHELL_SIZE = { cols: 128, rows: 24 }; // This can not be called Commands due to React Native @@ -91,33 +118,39 @@ export class SandboxCommands { "command.name": opts?.name || "", }, async () => { + const disposableStore = new DisposableStore(); + const onOutput = new Emitter(); + disposableStore.add(onOutput); + command = Array.isArray(command) ? command.join(" && ") : command; const passedEnv = Object.assign(opts?.env ?? {}); - // Build bash args array - const args = ["-c", "source $HOME/.private/.env 2>/dev/null || true"]; + const escapedCommand = command.replace(/'/g, "'\\''"); - if (Object.keys(passedEnv).length) { - Object.entries(passedEnv).forEach(([key, value]) => { - args.push("&&", "env", `${key}=${value}`); - }); - } + // TODO: use a new shell API that natively supports cwd & env + let commandWithEnv = Object.keys(passedEnv).length + ? `source $HOME/.private/.env 2>/dev/null || true && env ${Object.entries( + passedEnv + ) + .map(([key, value]) => { + const escapedValue = String(value).replace(/'/g, "'\\''"); + return `${key}='${escapedValue}'`; + }) + .join(" ")} bash -c '${escapedCommand}'` + : `source $HOME/.private/.env 2>/dev/null || true && bash -c '${escapedCommand}'`; if (opts?.cwd) { - args.push("&&", "cd", opts.cwd); + commandWithEnv = `cd ${opts.cwd} && ${commandWithEnv}`; } - args.push("&&", command); - - const shell = await this.agentClient.shells.create({ - command: "bash", - args, - projectPath: this.agentClient.workspacePath, - size: opts?.dimensions ?? DEFAULT_SHELL_SIZE, - type: opts?.asGlobalSession ? "COMMAND" : "TERMINAL", - isSystemShell: true, - }); + const shell = await this.agentClient.shells.create( + this.agentClient.workspacePath, + opts?.dimensions ?? DEFAULT_SHELL_SIZE, + commandWithEnv, + opts?.asGlobalSession ? "COMMAND" : "TERMINAL", + true + ); if (shell.status === "ERROR" || shell.status === "KILLED") { throw new Error(`Failed to create shell: ${shell.buffer.join("\n")}`); @@ -183,8 +216,7 @@ export class SandboxCommands { return shells .filter( - (shell): shell is protocol.shell.CommandShellDTO => - shell.shellType === "TERMINAL" && isCommandShell(shell) + (shell) => shell.shellType === "TERMINAL" && isCommandShell(shell) ) .map( (shell) => @@ -240,6 +272,11 @@ export class Command { */ #status: CommandStatus = "RUNNING"; + /** + * The exit code of the command, available after it completes. + */ + private exitCode?: number; + get status(): CommandStatus { return this.#status; } @@ -274,6 +311,7 @@ export class Command { this.disposable.addDisposable( agentClient.shells.onShellExited(({ shellId, exitCode }) => { if (shellId === this.shell.shellId) { + this.exitCode = exitCode; this.status = exitCode === 0 ? "FINISHED" : "ERROR"; this.barrier.open(); } @@ -295,8 +333,6 @@ export class Command { return; } - console.log("GOTZ OUT"); - this.onOutputEmitter.fire(out); this.output.push(out); @@ -390,7 +426,11 @@ export class Command { return cleaned; } - throw new Error(`Command ERROR: ${cleaned}`); + throw new CommandError( + `Command failed with exit code ${this.exitCode ?? "unknown"}`, + this.exitCode ?? 1, + cleaned + ); } ); } @@ -434,4 +474,4 @@ export class Command { } ); } -} +} \ No newline at end of file diff --git a/src/bin/commands/build.ts b/src/bin/commands/build.ts index ca59ffe..9b0fab3 100644 --- a/src/bin/commands/build.ts +++ b/src/bin/commands/build.ts @@ -306,23 +306,23 @@ export const buildCommand: yargs.CommandModule< updateSpinnerMessage(index, "Writing files to sandbox...") ); - await Promise.all( - filePaths.map((filePath) => - retryWithDelay( - async () => { + // Use batch write for more efficient file uploads + await retryWithDelay( + async () => { + const files = await Promise.all( + filePaths.map(async (filePath) => { const fullPath = path.join(argv.directory, filePath); const content = await fs.readFile(fullPath); - const dirname = path.dirname(filePath); - await session.fs.mkdir(dirname, true); - await session.fs.writeFile(filePath, content, { - create: true, - overwrite: true, - }); - }, - 3, - 200 - ) - ) + return { + path: filePath, + content, + }; + }) + ); + await session.fs.batchWrite(files); + }, + 3, + 200 ).catch((error) => { throw new Error(`Failed to write files to sandbox: ${error}`); });