diff --git a/src/sdk/devbox.ts b/src/sdk/devbox.ts index e8338ddb9..09ae2dcb2 100644 --- a/src/sdk/devbox.ts +++ b/src/sdk/devbox.ts @@ -20,6 +20,7 @@ import { PollingOptions } from '../lib/polling'; import { Snapshot } from './snapshot'; import { Execution } from './execution'; import { ExecutionResult } from './execution-result'; +import { uuidv7 } from 'uuidv7'; // Re-export Execution and ExecutionResult for Devbox namespace export { Execution } from './execution'; @@ -257,6 +258,128 @@ export class DevboxCmdOps { } } +/** + * Named shell operations for a devbox. + * Provides methods for executing commands in a persistent, stateful shell session. + * + * Use {@link Devbox.shell} to create a named shell instance. If you use the same shell name, + * it will re-attach to the existing named shell, preserving its state (environment variables, + * current working directory, etc.). + * + * Named shells are stateful and maintain environment variables and the current working directory (CWD) + * across commands. Commands executed through the same + * named shell instance will execute sequentially - the shell can only run one command at a time with + * automatic queuing. This ensures that environment changes and directory changes from one command + * are preserved for the next command. + * + * @example + * ```typescript + * // Create a named shell + * const shell = devbox.shell('my-session'); + * + * // Commands execute sequentially and share state + * await shell.exec('cd /app'); + * await shell.exec('export MY_VAR=value'); + * await shell.exec('echo $MY_VAR'); // Will output 'value' because env is preserved + * await shell.exec('pwd'); // Will output '/app' because CWD is preserved + * ``` + */ +export class DevboxNamedShell { + /** + * @private + */ + constructor( + private devbox: Devbox, + private shellName: string, + ) {} + + /** + * Execute a command in the named shell and wait for it to complete. + * Optionally provide callbacks to stream logs in real-time. + * + * The command will execute in the persistent shell session, maintaining environment variables + * and the current working directory from previous commands. Commands are queued and execute + * sequentially - only one command runs at a time in the named shell. + * + * When callbacks are provided, this method waits for both the command to complete + * AND all streaming data to be processed before returning. + * + * @example + * ```typescript + * const shell = devbox.shell('my-session'); + * + * // Simple execution + * const result = await shell.exec('ls -la'); + * console.log(await result.stdout()); + * + * // With streaming callbacks + * const result = await shell.exec('npm install', { + * stdout: (line) => process.stdout.write(line), + * stderr: (line) => process.stderr.write(line), + * }); + * + * // Stateful execution - environment and CWD are preserved + * await shell.exec('cd /app'); + * await shell.exec('export NODE_ENV=production'); + * const result = await shell.exec('npm start'); // Runs in /app with NODE_ENV=production + * ``` + * + * @param {string} command - The command to execute + * @param {Omit, 'shell_name'> & ExecuteStreamingCallbacks} [params] - Optional parameters (shell_name is automatically set) + * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options with optional polling configuration + * @returns {Promise} {@link ExecutionResult} with stdout, stderr, and exit status + */ + async exec( + command: string, + params?: Omit, 'shell_name'> & ExecuteStreamingCallbacks, + options?: Core.RequestOptions & { polling?: Partial> }, + ): Promise { + return this.devbox.cmd.exec(command, { ...params, shell_name: this.shellName }, options); + } + + /** + * Execute a command in the named shell asynchronously without waiting for completion. + * Optionally provide callbacks to stream logs in real-time as they are produced. + * + * The command will execute in the persistent shell session, maintaining environment variables + * and the current working directory from previous commands. Commands are queued and execute + * sequentially - only one command runs at a time in the named shell. + * + * Callbacks fire in real-time as logs arrive. When you call execution.result(), + * it will wait for both the command to complete and all streaming to finish. + * + * @example + * ```typescript + * const shell = devbox.shell('my-session'); + * + * const execution = await shell.execAsync('long-running-task.sh', { + * stdout: (line) => console.log(`[LOG] ${line}`), + * }); + * + * // Do other work while command runs... + * // Note: if you call shell.exec() or shell.execAsync() again, it will queue + * // and wait for this command to complete first + * + * const result = await execution.result(); + * if (result.success) { + * console.log('Task completed successfully!'); + * } + * ``` + * + * @param {string} command - The command to execute + * @param {Omit, 'shell_name'> & ExecuteStreamingCallbacks} [params] - Optional parameters (shell_name is automatically set) + * @param {Core.RequestOptions} [options] - Request options + * @returns {Promise} {@link Execution} object for tracking and controlling the command + */ + async execAsync( + command: string, + params?: Omit, 'shell_name'> & ExecuteStreamingCallbacks, + options?: Core.RequestOptions, + ): Promise { + return this.devbox.cmd.execAsync(command, { ...params, shell_name: this.shellName }, options); + } +} + /** * File operations for a devbox. * Provides methods for reading, writing, uploading, and downloading files. @@ -541,6 +664,37 @@ export class Devbox { return this._id; } + /** + * Create a named shell instance for stateful command execution. + * + * Named shells are stateful and maintain environment variables and the current working directory (CWD) + * across commands, just like a real shell on your local computer. Commands executed through the same + * named shell instance will execute sequentially - the shell can only run one command at a time with + * automatic queuing. This ensures that environment changes and directory changes from one command + * are preserved for the next command. + * + * @example + * ```typescript + * // Create a named shell with a custom name + * const shell = devbox.shell('my-session'); + * + * // Create a named shell with an auto-generated UUID name + * const shell2 = devbox.shell(); + * + * // Commands execute sequentially and share state + * await shell.exec('cd /app'); + * await shell.exec('export MY_VAR=value'); + * await shell.exec('echo $MY_VAR'); // Will output 'value' because env is preserved + * await shell.exec('pwd'); // Will output '/app' because CWD is preserved + * ``` + * + * @param {string} [shellName] - The name of the persistent shell session. If not provided, a UUID will be generated automatically. + * @returns {DevboxNamedShell} A {@link DevboxNamedShell} instance for executing commands in the named shell + */ + shell(shellName: string = uuidv7()): DevboxNamedShell { + return new DevboxNamedShell(this, shellName); + } + /** * Start streaming logs with callbacks. * Returns a promise that resolves when all streams complete. @@ -799,5 +953,10 @@ export declare namespace Devbox { * @see {@link ExecuteStreamingCallbacks} */ type ExecuteStreamingCallbacks as ExecuteStreamingCallbacks, + /** + * Named shell operations class for stateful command execution. + * @see {@link DevboxNamedShell} + */ + DevboxNamedShell as NamedShell, }; } diff --git a/tests/smoketests/object-oriented/devbox.test.ts b/tests/smoketests/object-oriented/devbox.test.ts index d5528e59c..7e6909776 100644 --- a/tests/smoketests/object-oriented/devbox.test.ts +++ b/tests/smoketests/object-oriented/devbox.test.ts @@ -501,19 +501,25 @@ describe('smoketest: object-oriented devbox', () => { let taskBCount = 0; // Start both executions at the same time (don't await) - const executionA = devbox.cmd.execAsync('echo "A1" && sleep 0.5 && echo "A2" && sleep 0.5 && echo "A3"', { - stdout: (line) => { - taskALogs.push(line); - taskACount++; + const executionA = devbox.cmd.execAsync( + 'echo "A1" && sleep 0.5 && echo "A2" && sleep 0.5 && echo "A3"', + { + stdout: (line) => { + taskALogs.push(line); + taskACount++; + }, }, - }); - - const executionB = devbox.cmd.execAsync('sleep 0.3 && echo "B1" && sleep 0.5 && echo "B2" && sleep 0.5 && echo "B3"', { - stdout: (line) => { - taskBLogs.push(line); - taskBCount++; + ); + + const executionB = devbox.cmd.execAsync( + 'sleep 0.3 && echo "B1" && sleep 0.5 && echo "B2" && sleep 0.5 && echo "B3"', + { + stdout: (line) => { + taskBLogs.push(line); + taskBCount++; + }, }, - }); + ); // Wait for both to start const [execA, execB] = await Promise.all([executionA, executionB]); @@ -554,4 +560,332 @@ describe('smoketest: object-oriented devbox', () => { expect(taskBCombined).toBe(await resultB.stdout()); }); }); + + describe('named shell - stateful command execution', () => { + let devbox: Devbox; + + beforeAll(async () => { + devbox = await sdk.devbox.create({ + name: uniqueName('sdk-devbox-named-shell'), + launch_parameters: { resource_size_request: 'X_SMALL', keep_alive_time_seconds: 60 * 5 }, + }); + }, THIRTY_SECOND_TIMEOUT); + + afterAll(async () => { + if (devbox) { + await devbox.shutdown(); + } + }); + + test('shell.exec - basic execution', async () => { + expect(devbox).toBeDefined(); + const shell = devbox.shell('test-shell-1'); + const result = await shell.exec('echo "Hello from named shell!"'); + expect(result).toBeDefined(); + expect(result.exitCode).toBe(0); + const output = await result.stdout(); + expect(output).toContain('Hello from named shell!'); + }); + + test('shell.exec - CWD persistence across commands', async () => { + expect(devbox).toBeDefined(); + const shell = devbox.shell('test-shell-2'); + + // Create a directory and change to it + await shell.exec('mkdir -p /tmp/test-shell-dir'); + await shell.exec('cd /tmp/test-shell-dir'); + + // Verify we're in the new directory + const pwdResult = await shell.exec('pwd'); + const pwd = (await pwdResult.stdout()).trim(); + expect(pwd).toBe('/tmp/test-shell-dir'); + + // Create a file in the current directory + await shell.exec('echo "test content" > testfile.txt'); + + // Verify the file exists in the current directory + const lsResult = await shell.exec('ls testfile.txt'); + expect(lsResult.exitCode).toBe(0); + const lsOutput = await lsResult.stdout(); + expect(lsOutput).toContain('testfile.txt'); + }); + + test('shell.exec - environment variable persistence', async () => { + expect(devbox).toBeDefined(); + const shell = devbox.shell('test-shell-3'); + + // Set an environment variable + await shell.exec('export TEST_VAR="test-value-123"'); + + // Verify the variable persists in the next command + const echoResult = await shell.exec('echo $TEST_VAR'); + const output = (await echoResult.stdout()).trim(); + expect(output).toBe('test-value-123'); + + // Set another variable and verify both persist + await shell.exec('export ANOTHER_VAR="another-value"'); + const bothResult = await shell.exec('echo "$TEST_VAR:$ANOTHER_VAR"'); + const bothOutput = (await bothResult.stdout()).trim(); + expect(bothOutput).toBe('test-value-123:another-value'); + }); + + test('shell.exec - combined CWD and environment persistence', async () => { + expect(devbox).toBeDefined(); + const shell = devbox.shell('test-shell-4'); + + // Set environment and change directory + await shell.exec('export PROJECT_DIR="/tmp/my-project"'); + await shell.exec('mkdir -p $PROJECT_DIR'); + await shell.exec('cd $PROJECT_DIR'); + + // Verify both persist + const pwdResult = await shell.exec('pwd'); + const pwd = (await pwdResult.stdout()).trim(); + expect(pwd).toBe('/tmp/my-project'); + + // Create a file using the environment variable + await shell.exec('echo "project file" > $PROJECT_DIR/file.txt'); + + // Verify file exists + const lsResult = await shell.exec('ls file.txt'); + expect(lsResult.exitCode).toBe(0); + }); + + test('shell.execAsync - basic async execution', async () => { + expect(devbox).toBeDefined(); + const shell = devbox.shell('test-shell-5'); + const execution = await shell.execAsync('sleep 1 && echo "Async command completed"'); + expect(execution).toBeDefined(); + expect(execution.executionId).toBeTruthy(); + + // Wait for completion + const result = await execution.result(); + expect(result.exitCode).toBe(0); + const output = await result.stdout(); + expect(output).toContain('Async command completed'); + }); + + test('shell.execAsync - stateful async execution', async () => { + expect(devbox).toBeDefined(); + const shell = devbox.shell('test-shell-6'); + + // Set state in first command + await shell.exec('export ASYNC_VAR="async-value"'); + await shell.exec('cd /tmp'); + + // Start async command that uses the state + const execution = await shell.execAsync('echo "CWD: $(pwd), VAR: $ASYNC_VAR"'); + const result = await execution.result(); + + expect(result.exitCode).toBe(0); + const output = await result.stdout(); + expect(output).toContain('CWD: /tmp'); + expect(output).toContain('VAR: async-value'); + }); + + test('shell.exec - sequential execution (queuing)', async () => { + expect(devbox).toBeDefined(); + const shell = devbox.shell('test-shell-7'); + + // Start multiple commands - they should execute sequentially + const startTime = Date.now(); + await shell.exec('sleep 1 && echo "first"'); + await shell.exec('sleep 1 && echo "second"'); + await shell.exec('sleep 1 && echo "third"'); + const endTime = Date.now(); + + // Verify they took at least 3 seconds (sequential execution) + const duration = endTime - startTime; + expect(duration).toBeGreaterThanOrEqual(2900); // Allow some margin for overhead + + // Verify all commands executed in order + const finalResult = await shell.exec('echo "done"'); + const output = await finalResult.stdout(); + expect(output).toContain('done'); + }); + + test('shell.execAsync - sequential execution with queuing', async () => { + expect(devbox).toBeDefined(); + const shell = devbox.shell('test-shell-8'); + + // Start multiple async commands - they should queue and execute sequentially + const exec1 = shell.execAsync('sleep 1 && echo "async-first"'); + const exec2 = shell.execAsync('sleep 1 && echo "async-second"'); + const exec3 = shell.execAsync('sleep 1 && echo "async-third"'); + + // Wait for all to complete + const [result1, result2, result3] = await Promise.all([ + (await exec1).result(), + (await exec2).result(), + (await exec3).result(), + ]); + + // Verify all completed successfully + expect(result1.exitCode).toBe(0); + expect(result2.exitCode).toBe(0); + expect(result3.exitCode).toBe(0); + + // Verify outputs + expect(await result1.stdout()).toContain('async-first'); + expect(await result2.stdout()).toContain('async-second'); + expect(await result3.stdout()).toContain('async-third'); + }); + + test('shell.exec - with streaming callbacks', async () => { + expect(devbox).toBeDefined(); + const shell = devbox.shell('test-shell-9'); + const stdoutLines: string[] = []; + + const result = await shell.exec('echo "line1" && echo "line2" && echo "line3"', { + stdout: (line) => { + stdoutLines.push(line); + }, + }); + + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + expect(stdoutLines.length).toBeGreaterThan(0); + const stdoutCombined = stdoutLines.join(''); + expect(stdoutCombined).toContain('line1'); + expect(stdoutCombined).toContain('line2'); + expect(stdoutCombined).toContain('line3'); + // Verify streaming captured same data as result + expect(stdoutCombined).toBe(await result.stdout()); + }); + + test('shell.execAsync - with streaming callbacks', async () => { + expect(devbox).toBeDefined(); + const shell = devbox.shell('test-shell-10'); + const stdoutLines: string[] = []; + + const execution = await shell.execAsync('echo "async-line1" && sleep 0.5 && echo "async-line2"', { + stdout: (line) => { + stdoutLines.push(line); + }, + }); + + const result = await execution.result(); + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + + const stdoutCombined = stdoutLines.join(''); + expect(stdoutCombined).toContain('async-line1'); + expect(stdoutCombined).toContain('async-line2'); + // Verify streaming captured same data as result + expect(stdoutCombined).toBe(await result.stdout()); + }); + + test('multiple named shells - independent state', async () => { + expect(devbox).toBeDefined(); + const shell1 = devbox.shell('independent-shell-1'); + const shell2 = devbox.shell('independent-shell-2'); + + // Set different state in each shell + await shell1.exec('export VAR="shell1-value"'); + await shell1.exec('cd /tmp'); + await shell2.exec('export VAR="shell2-value"'); + await shell2.exec('cd /home'); + + // Verify each shell maintains its own state + const result1 = await shell1.exec('echo "$VAR:$(pwd)"'); + const output1 = (await result1.stdout()).trim(); + expect(output1).toContain('shell1-value'); + expect(output1).toContain('/tmp'); + + const result2 = await shell2.exec('echo "$VAR:$(pwd)"'); + const output2 = (await result2.stdout()).trim(); + expect(output2).toContain('shell2-value'); + expect(output2).toContain('/home'); + }); + + test('shell.exec - with additional params passed through', async () => { + expect(devbox).toBeDefined(); + const shell = devbox.shell('test-shell-params'); + + // Test that additional params (like working_dir) are passed through correctly + // Note: shell_name should override any shell_name in params + const result = await shell.exec('pwd', { + working_dir: '/tmp', + }); + + expect(result.exitCode).toBe(0); + const output = (await result.stdout()).trim(); + // Should be in /tmp due to working_dir param + expect(output).toBe('/tmp'); + }); + + test('shell.execAsync - with additional params passed through', async () => { + expect(devbox).toBeDefined(); + const shell = devbox.shell('test-shell-async-params'); + + // Test that additional params are passed through correctly + const execution = await shell.execAsync('pwd', { + working_dir: '/home', + }); + + const result = await execution.result(); + expect(result.exitCode).toBe(0); + const output = (await result.stdout()).trim(); + // Should be in /home due to working_dir param + expect(output).toBe('/home'); + }); + + test('shell.exec - with stderr streaming callback', async () => { + expect(devbox).toBeDefined(); + const shell = devbox.shell('test-shell-stderr'); + const stderrLines: string[] = []; + + const result = await shell.exec('echo "error output" >&2', { + stderr: (line) => { + stderrLines.push(line); + }, + }); + + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + expect(stderrLines.length).toBeGreaterThan(0); + const stderrCombined = stderrLines.join(''); + expect(stderrCombined).toContain('error output'); + // Verify streaming captured same data as result + expect(stderrCombined).toBe(await result.stderr()); + }); + + test('shell.execAsync - with both stdout and stderr streaming callbacks', async () => { + expect(devbox).toBeDefined(); + const shell = devbox.shell('test-shell-both-streams'); + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + + const execution = await shell.execAsync('echo "to stdout" && echo "to stderr" >&2', { + stdout: (line) => stdoutLines.push(line), + stderr: (line) => stderrLines.push(line), + }); + + const result = await execution.result(); + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + + const stdoutCombined = stdoutLines.join(''); + const stderrCombined = stderrLines.join(''); + + expect(stdoutCombined).toContain('to stdout'); + expect(stderrCombined).toContain('to stderr'); + + // Verify streaming captured same data as result + expect(stdoutCombined).toBe(await result.stdout()); + expect(stderrCombined).toBe(await result.stderr()); + }); + + test('shell - auto-generated shell name', async () => { + expect(devbox).toBeDefined(); + // Create shell without providing a name - should auto-generate UUID + const shell = devbox.shell(); + expect(shell).toBeDefined(); + + const result = await shell.exec('echo "test"'); + expect(result.exitCode).toBe(0); + const output = await result.stdout(); + expect(output).toContain('test'); + }); + }); });