Skip to content
Closed
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)


Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codesandbox/sdk",
"version": "2.3.0",
"version": "2.4.0",
"description": "The CodeSandbox SDK",
"author": "CodeSandbox",
"license": "MIT",
Expand Down
94 changes: 58 additions & 36 deletions pint-openapi-bundled.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

{
"openapi": "3.0.3",
"info": {
Expand Down Expand Up @@ -715,12 +716,6 @@
"schema": {
"$ref": "#/components/schemas/ExecListResponse"
}
},
"text/event-stream": {
"schema": {
"type": "string",
"description": "Server-Sent Events stream of exec updates"
}
}
}
},
Expand Down Expand Up @@ -1638,14 +1633,62 @@
}
}
},
"/api/v1/ports/stream": {
"/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": "ListPortsSSE",
"operationId": "StreamPortsList",
"security": [
{
"bearerAuth": []
Expand All @@ -1655,32 +1698,6 @@
"200": {
"description": "Open ports retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PortsListResponse"
},
"examples": {
"default": {
"summary": "Example ports response",
"value": {
"ports": [
{
"port": 8080,
"address": "0.0.0.0"
},
{
"port": 3000,
"address": "127.0.0.1"
},
{
"port": 5432,
"address": "::1"
}
]
}
}
}
},
"text/event-stream": {
"schema": {
"type": "string",
Expand Down Expand Up @@ -1915,14 +1932,19 @@
"pid": {
"type": "integer",
"description": "Process ID of the exec"
},
"interactive": {
"type": "boolean",
"description": "Whether the exec is interactive"
}
},
"required": [
"id",
"command",
"args",
"status",
"pid"
"pid",
"interactive"
]
},
"ExecListResponse": {
Expand Down Expand Up @@ -2327,4 +2349,4 @@
}
}
}
}
}
42 changes: 26 additions & 16 deletions src/Sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) => {
Expand All @@ -144,31 +151,34 @@ export class Sandbox {
return `export ${key}='${safe}'`;
})
.join("\n");
const cmd = [
`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;
Expand Down
44 changes: 42 additions & 2 deletions src/SandboxClient/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -106,7 +133,10 @@ export class SandboxCommands {
? `source $HOME/.private/.env 2>/dev/null || true && env ${Object.entries(
passedEnv
)
.map(([key, value]) => `${key}=${value}`)
.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}'`;

Expand Down Expand Up @@ -242,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;
}
Expand Down Expand Up @@ -276,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();
}
Expand Down Expand Up @@ -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
);
}
);
}
Expand Down
30 changes: 15 additions & 15 deletions src/bin/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
});
Expand Down