diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5781917 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# API KEYS +export CSB_API_KEY=... # Production +# export CSB_API_KEY=... # Stream +# export CSB_API_KEY=csb_v1_devbox # Devbox Local + +# BASE URLS +export CSB_BASE_URL=https://api.codesandbox.io # Production +# export CSB_BASE_URL=https://api.codesandbox.stream # Stream +# export CSB_BASE_URL=https://api.codesandbox.dev # Devbox Local + + +# TEMPLATES +export CSB_TEMPLATE_ID=... # Production (Pitcher) +# export CSB_TEMPLATE_ID=... # Production (Pint) +# export CSB_TEMPLATE_ID=... # Stream (Pitcher) +# export CSB_TEMPLATE_ID=... # Stream (Pint) +# export CSB_TEMPLATE_ID=... # Devbox Local (Pint) + + diff --git a/.gitignore b/.gitignore index 68e2089..4bd75d5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ # Generated stuff dist test.ts -test-template ### macOS ### *.DS_Store diff --git a/README.md b/README.md index ab84dd4..10ac2df 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,29 @@ const output = await client.commands.run("echo 'Hello World'"); console.log(output); // Hello World ``` +## Running tests + +### All tests + +- Run all tests with `npm run test` +- Run specific test file `npm run test -- filesystem` + +### E2E production + +- Run e2e tests with `npm run test:e2e` +- Run specific test file `npm run test -- filesystem` + +### E2E local + +- Clone the sandbox templates repo (https://github.com/codesandbox/sandbox-templates) +- Create the `.env` based on example and populate it +- Run `source .env` to export the env variables +- Build template for Pitcher `./dist/bin/codesandbox.mjs build ./test-template-pitcher` +- Build template for Pint `./dist/bin/codesandbox.mjs build ./test-template-pint --beta` +- Update `.env` with template id and `source .env` it again +- Run e2e tests with `npm run test:e2e` +- Run specific test file `npm run test -- filesystem` + ## Efficient Sandbox Retrieval When you need to retrieve metadata for specific sandboxes by their IDs, you can use the efficient retrieval methods instead of listing and filtering all sandboxes: diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 7272249..0000000 --- a/TODO.md +++ /dev/null @@ -1,112 +0,0 @@ -## QUESTIONS - -- Should Snapshot Tags work like NPM? - - - Create Sandbox with no wakeup config - - Write files - - Wait for condition - - Hibernate - - New endpoint to tag it - - Sandbox is tagged (Check if hibernated?) - - BIG QUESTION: Should we force prevent starting the Sandbox? What about TanStack - - - New endpoint to create an alias to any tag - - - Change endpoint for Sandbox creation to allow tags/aliases as id - - - What is Tag / Alias format? - -## USER QUESTIONS - -## TODO - -- Highlight snapshot building in docs -- https://github.com/codesandbox/codesandbox-applications/pull/4645 -- Publish browser-static-server - -# 1 New API - -```ts -const sdk = new CodeSandbox(apiToken); - -const sandbox = await sdk.sandbox.resume(id); -const sandbox = await sdk.sandbox.create(SandboxOptions & StartOptions); - -sandbox.isUpToDate; -sandbox.bootupType; -sandbox.cluster; -sandbox.connect(); -sandbox.createBrowserSession(); -sandbox.createRestClient(); -sandbox.updateTier(); -sandbox.updateHibernationTimeout(); - -sdk.sandbox.shutdown(id); -sdk.sandbox.previewTokens.create(id); - -const session = await sandbox.createBrowserSession(); -const client = sandbox.connect(); -const client = sandbox.createRestClient(); -``` - -# 2 Git clone support - -```ts -// Factory.ai -// Create base template -// const sbx = await sdk.sandbox.create(); -// sbx.git.clone(); -// -> /project/sandbox -// git set-remote origin ... -// git pull - -// /project/sandbox/.git -> /persisted/.git - -/** - * sdk.sandbox.create({ source: { - * type: 'git', - * url: 'https://github.com/sandbox-git/sandbox-git.git', - * branch: 'main', - * gitAccessToken: '...' - * } }) - * 1. create sandbox - * 2. ... - * 3. clone - * - * // Source = Dropbox - * // Source = Zip - * - * - * // API create zip - * - * sandbox.create({ - * source: { - * type: 'zip', - * url: 'https://example.com/my-zip-file.zip' - * } - * }); - */ - -await sandbox.git.clone({ - url: "https://github.com/sandbox-git/sandbox-git.git", - branch: "main", -}); - -// rm -rf /project/sandbox/* -// - -await sandbox.git.pull(); -await sandbox.git.checkout("main"); - -// -``` - -# 3 Snapshot Tagging - -```ts -sdk.sandbox.create({ - files: {}, -}); -``` - -# Export types properly diff --git a/openapi-git-spec.json b/openapi-git-spec.json deleted file mode 100644 index 3a8d660..0000000 --- a/openapi-git-spec.json +++ /dev/null @@ -1,1348 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Git API", - "description": "API for interacting with Git version control in sandboxes", - "version": "1.0.0" - }, - "paths": { - "/git/status": { - "post": { - "summary": "Get git status", - "description": "Retrieve the current git status of the repository", - "operationId": "gitStatus", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitStatus" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving git status", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/remotes": { - "post": { - "summary": "Get git remotes", - "description": "Retrieve the remote repositories configured for the git repository", - "operationId": "gitRemotes", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitRemotes" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving git remotes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/targetDiff": { - "post": { - "summary": "Get target diff", - "description": "Retrieve the difference between the current branch and a target branch", - "operationId": "gitTargetDiff", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "branch": { - "type": "string", - "description": "Target branch name" - } - }, - "required": ["branch"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitTargetDiff" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving target diff", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/pull": { - "post": { - "summary": "Pull changes", - "description": "Pull changes from the remote repository", - "operationId": "gitPull", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "branch": { - "type": "string", - "description": "Branch to pull from" - }, - "force": { - "type": "boolean", - "description": "Force pull" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error pulling changes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/discard": { - "post": { - "summary": "Discard changes", - "description": "Discard changes to specified paths or all changes", - "operationId": "gitDiscard", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Paths to discard changes for" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Paths that were discarded" - } - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error discarding changes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/commit": { - "post": { - "summary": "Commit changes", - "description": "Commit changes to the local repository", - "operationId": "gitCommit", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Paths to commit" - }, - "message": { - "type": "string", - "description": "Commit message" - }, - "push": { - "type": "boolean", - "description": "Whether to push after committing" - } - }, - "required": ["message"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "shellId": { - "type": "string", - "description": "ID of the shell process" - } - }, - "required": ["shellId"] - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error committing changes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/push": { - "post": { - "summary": "Push changes", - "description": "Push changes to the remote repository", - "operationId": "gitPush", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error pushing changes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/pushToRemote": { - "post": { - "summary": "Push to remote", - "description": "Push changes to a specific remote repository and branch", - "operationId": "gitPushToRemote", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "URL of the remote repository" - }, - "branch": { - "type": "string", - "description": "Branch to push to" - }, - "squashAllCommits": { - "type": "boolean", - "description": "Whether to squash all commits before pushing" - } - }, - "required": ["url", "branch"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error pushing to remote", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/renameBranch": { - "post": { - "summary": "Rename branch", - "description": "Rename a branch in the local repository", - "operationId": "gitRenameBranch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "oldBranch": { - "type": "string", - "description": "Name of the branch to rename" - }, - "newBranch": { - "type": "string", - "description": "New name for the branch" - } - }, - "required": ["oldBranch", "newBranch"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error renaming branch", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/remoteContent": { - "post": { - "summary": "Get remote content", - "description": "Retrieve the content of a file from a remote branch or commit", - "operationId": "gitRemoteContent", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GitRemoteParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "Content of the file" - } - }, - "required": ["content"] - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving remote content", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/diffStatus": { - "post": { - "summary": "Get diff status", - "description": "Retrieve the status of changes between two references", - "operationId": "gitDiffStatus", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GitDiffStatusParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitDiffStatusResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving diff status", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/resetLocalWithRemote": { - "post": { - "summary": "Reset local with remote", - "description": "Reset the local repository to match the remote", - "operationId": "gitResetLocalWithRemote", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error resetting local with remote", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/checkoutInitialBranch": { - "post": { - "summary": "Checkout initial branch", - "description": "Checkout the initial branch of the repository", - "operationId": "gitCheckoutInitialBranch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error checking out initial branch", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/transposeLines": { - "post": { - "summary": "Transpose lines", - "description": "Map line numbers between different git commits", - "operationId": "gitTransposeLines", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "sha": { - "type": "string", - "description": "Commit SHA" - }, - "path": { - "type": "string", - "description": "File path" - }, - "line": { - "type": "number", - "description": "Line number" - } - }, - "required": ["sha", "path", "line"] - } - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path" - }, - "line": { - "type": "number", - "description": "Line number" - } - }, - "required": ["path", "line"], - "nullable": true - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error transposing lines", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "CommonError": { - "type": "object", - "properties": { - "code": { - "type": "number", - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "data": { - "type": "object", - "description": "Additional error data", - "nullable": true - } - }, - "required": ["code", "message"] - }, - "GitStatusShortFormat": { - "type": "string", - "enum": ["", "M", "A", "D", "R", "C", "U", "?"], - "description": "Git status short format codes" - }, - "GitItem": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path" - }, - "index": { - "$ref": "#/components/schemas/GitStatusShortFormat" - }, - "workingTree": { - "$ref": "#/components/schemas/GitStatusShortFormat" - }, - "isStaged": { - "type": "boolean", - "description": "Whether the file is staged" - }, - "isConflicted": { - "type": "boolean", - "description": "Whether the file has conflicts" - }, - "fileId": { - "type": "string", - "description": "File ID" - } - }, - "required": ["path", "index", "workingTree", "isStaged", "isConflicted"] - }, - "GitChangedFiles": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/GitItem" - }, - "description": "Map of file IDs to GitItems" - }, - "GitBranchProperties": { - "type": "object", - "properties": { - "head": { - "type": "string", - "nullable": true, - "description": "Head commit" - }, - "branch": { - "type": "string", - "nullable": true, - "description": "Branch name" - }, - "ahead": { - "type": "number", - "description": "Number of commits ahead" - }, - "behind": { - "type": "number", - "description": "Number of commits behind" - }, - "safe": { - "type": "boolean", - "description": "Whether the branch is safe to use" - } - }, - "required": ["ahead", "behind", "safe"] - }, - "GitCommit": { - "type": "object", - "properties": { - "hash": { - "type": "string", - "description": "Commit hash" - }, - "date": { - "type": "string", - "description": "Commit date" - }, - "message": { - "type": "string", - "description": "Commit message" - }, - "author": { - "type": "string", - "description": "Commit author" - } - }, - "required": ["hash", "date", "message", "author"] - }, - "GitStatus": { - "type": "object", - "properties": { - "changedFiles": { - "$ref": "#/components/schemas/GitChangedFiles" - }, - "deletedFiles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitItem" - } - }, - "conflicts": { - "type": "boolean", - "description": "Whether there are remote conflicts" - }, - "localChanges": { - "type": "boolean", - "description": "Whether there are local changes" - }, - "remote": { - "$ref": "#/components/schemas/GitBranchProperties" - }, - "target": { - "$ref": "#/components/schemas/GitBranchProperties" - }, - "head": { - "type": "string", - "description": "Current HEAD commit" - }, - "commits": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitCommit" - } - }, - "branch": { - "type": "string", - "nullable": true, - "description": "Current branch name" - }, - "isMerging": { - "type": "boolean", - "description": "Whether a merge is in progress" - } - }, - "required": [ - "changedFiles", - "deletedFiles", - "conflicts", - "localChanges", - "remote", - "target", - "commits", - "branch", - "isMerging" - ] - }, - "GitTargetDiff": { - "type": "object", - "properties": { - "ahead": { - "type": "number", - "description": "Number of commits ahead of target" - }, - "behind": { - "type": "number", - "description": "Number of commits behind target" - }, - "commits": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitCommit" - } - } - }, - "required": ["ahead", "behind", "commits"] - }, - "GitRemotes": { - "type": "object", - "properties": { - "origin": { - "type": "string", - "description": "Origin remote URL" - }, - "upstream": { - "type": "string", - "description": "Upstream remote URL" - } - }, - "required": ["origin", "upstream"] - }, - "GitRemoteParams": { - "type": "object", - "properties": { - "reference": { - "type": "string", - "description": "Branch or commit hash" - }, - "path": { - "type": "string", - "description": "File path" - } - }, - "required": ["reference", "path"] - }, - "GitDiffStatusParams": { - "type": "object", - "properties": { - "base": { - "type": "string", - "description": "Base reference for diffing" - }, - "head": { - "type": "string", - "description": "Head reference for diffing" - } - }, - "required": ["base", "head"] - }, - "GitDiffStatusItem": { - "type": "object", - "properties": { - "status": { - "$ref": "#/components/schemas/GitStatusShortFormat" - }, - "path": { - "type": "string", - "description": "File path" - }, - "oldPath": { - "type": "string", - "description": "Original file path (for renames)" - }, - "hunks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "original": { - "type": "object", - "properties": { - "start": { - "type": "number" - }, - "end": { - "type": "number" - } - }, - "required": ["start", "end"] - }, - "modified": { - "type": "object", - "properties": { - "start": { - "type": "number" - }, - "end": { - "type": "number" - } - }, - "required": ["start", "end"] - } - }, - "required": ["original", "modified"] - } - } - }, - "required": ["status", "path", "hunks"] - }, - "GitDiffStatusResult": { - "type": "object", - "properties": { - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitDiffStatusItem" - } - } - }, - "required": ["files"] - } - } - } -} diff --git a/openapi-git.json b/openapi-git.json deleted file mode 100644 index e69de29..0000000 diff --git a/openapi-port.json b/openapi-port.json deleted file mode 100644 index 176f301..0000000 --- a/openapi-port.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Port API", - "description": "API for managing sandbox port operations", - "version": "1.0.0" - }, - "paths": { - "/port/list": { - "post": { - "summary": "List ports", - "description": "Retrieve a list of available ports and their URLs", - "operationId": "portList", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "list": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Port" - }, - "description": "List of available ports" - } - }, - "required": ["list"] - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error listing ports", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "CommonError": { - "type": "object", - "properties": { - "code": { - "type": "number", - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "data": { - "type": "object", - "description": "Additional error data", - "nullable": true - } - }, - "required": ["code", "message"] - }, - "Port": { - "type": "object", - "properties": { - "port": { - "type": "number", - "description": "Port number" - }, - "url": { - "type": "string", - "description": "URL to access the service on this port" - } - }, - "required": ["port", "url"] - } - } - } -} diff --git a/openapi-sandbox-container.json b/openapi-sandbox-container.json deleted file mode 100644 index f272b37..0000000 --- a/openapi-sandbox-container.json +++ /dev/null @@ -1,179 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sandbox Container API", - "description": "API for managing sandbox container operations", - "version": "1.0.0" - }, - "paths": { - "/container/setup": { - "post": { - "summary": "Setup container", - "description": "Set up a new container based on a template", - "operationId": "containerSetup", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "templateId": { - "type": "string", - "description": "Identifier of the template to use" - }, - "templateArgs": { - "type": "object", - "description": "Arguments for the template", - "additionalProperties": { - "type": "string" - } - }, - "features": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Feature identifier" - }, - "options": { - "type": "object", - "description": "Options for the feature", - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["id", "options"] - }, - "nullable": true - } - }, - "required": ["templateId", "templateArgs"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/TaskDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error setting up container", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "ProtocolError": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "data": { - "type": "object", - "description": "Additional error data", - "nullable": true - } - }, - "required": ["code", "message"] - }, - "TaskDTO": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Task identifier" - }, - "status": { - "type": "string", - "description": "Task status" - }, - "progress": { - "type": "number", - "description": "Task progress (0-100)" - } - }, - "required": ["id", "status", "progress"] - } - } - } -} diff --git a/openapi-sandbox-fs.json b/openapi-sandbox-fs.json deleted file mode 100644 index 57c3c6c..0000000 --- a/openapi-sandbox-fs.json +++ /dev/null @@ -1,2005 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sandbox Rest FS API", - "description": "FS API for interacting with sandbox", - "version": "1.0.0" - }, - "paths": { - "/fs/writeFile": { - "post": { - "summary": "Write to a file", - "description": "Write content to a file at the specified path", - "operationId": "writeFile", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WriteFileRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": {} - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error writing file", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/read": { - "post": { - "summary": "Read file system", - "description": "Retrieve the latest snapshot of the server's MemoryFS file and children list", - "operationId": "fsRead", - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/FSReadResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error reading file system", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/DefaultError" - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/operation": { - "post": { - "summary": "Perform file system operation", - "description": "Send a tree operation reflecting filesystem operations", - "operationId": "fsOperation", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSOperationRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/FSOperationResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error performing operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/DefaultError" - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/search": { - "post": { - "summary": "Search files", - "description": "Search for content in files", - "operationId": "fsSearch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSSearchParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SearchResult" - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error searching files", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/DefaultError" - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/streamingSearch": { - "post": { - "summary": "Start streaming search", - "description": "Start a streaming search for content in files", - "operationId": "fsStreamingSearch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSStreamingSearchParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "searchId": { - "type": "string", - "description": "ID of the search operation" - } - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error starting streaming search", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/DefaultError" - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/cancelStreamingSearch": { - "post": { - "summary": "Cancel streaming search", - "description": "Cancel an ongoing streaming search", - "operationId": "fsCancelStreamingSearch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "searchId": { - "type": "string", - "description": "ID of the search to cancel" - } - }, - "required": ["searchId"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "searchId": { - "type": "string", - "description": "ID of the cancelled search" - } - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error cancelling search", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/DefaultError" - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/pathSearch": { - "post": { - "summary": "Search file paths", - "description": "Search for file paths matching a pattern", - "operationId": "fsPathSearch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PathSearchParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/PathSearchResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error searching paths", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/DefaultError" - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/upload": { - "post": { - "summary": "Upload file", - "description": "Upload a file to the specified parent directory", - "operationId": "fsUpload", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parentId": { - "type": "string", - "description": "ID of the parent directory" - }, - "filename": { - "type": "string", - "description": "Name of the file to create" - }, - "content": { - "type": "string", - "format": "binary", - "description": "File content as binary data" - } - }, - "required": ["parentId", "filename", "content"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "fileId": { - "type": "string", - "description": "ID of the created file" - } - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error uploading file", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/InvalidIdError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/download": { - "post": { - "summary": "Download files", - "description": "Download files at a specified path as a zip", - "operationId": "fsDownload", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to download" - }, - "excludes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Glob patterns of files/folders to exclude from the download" - } - }, - "required": ["path"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "downloadUrl": { - "type": "string", - "description": "URL to download the files from" - } - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error creating download", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/DefaultError" - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/readFile": { - "post": { - "summary": "Read file content", - "description": "Read the content of a file at the specified path", - "operationId": "fsReadFile", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSReadFileParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/FSReadFileResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error reading file", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/readdir": { - "post": { - "summary": "Read directory contents", - "description": "List the contents of a directory at the specified path", - "operationId": "fsReadDir", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSReadDirParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/FSReadDirResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error reading directory", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/stat": { - "post": { - "summary": "Get file/directory stats", - "description": "Get stats for a file or directory at the specified path", - "operationId": "fsStat", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSStatParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/FSStatResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error getting stats", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/copy": { - "post": { - "summary": "Copy file/directory", - "description": "Copy a file or directory from one location to another", - "operationId": "fsCopy", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSCopyParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": {} - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error copying file/directory", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/rename": { - "post": { - "summary": "Rename file/directory", - "description": "Rename a file or directory (move from one location to another)", - "operationId": "fsRename", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSRenameParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": {} - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error renaming file/directory", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/remove": { - "post": { - "summary": "Remove file/directory", - "description": "Delete a file or directory at the specified path", - "operationId": "fsRemove", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSRemoveParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": {} - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error removing file/directory", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/mkdir": { - "post": { - "summary": "Create directory", - "description": "Create a new directory at the specified path", - "operationId": "fsMkdir", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSMkdirParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": {} - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error creating directory", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/watch": { - "post": { - "summary": "Watch file/directory", - "description": "Watch a file or directory for changes", - "operationId": "fsWatch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSWatchParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/FSWatchResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error watching file/directory", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/unwatch": { - "post": { - "summary": "Stop watching file/directory", - "description": "Stop watching a file or directory for changes", - "operationId": "fsUnwatch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSUnwatchParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": {} - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error unwatching file/directory", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - }, - "required": ["status", "error"] - }, - "DefaultError": { - "type": "object", - "properties": { - "code": { - "$ref": "#/components/schemas/PitcherErrorCode", - "description": "Error code identifying the type of error" - }, - "data": { - "type": "object", - "description": "Additional error details", - "nullable": true - }, - "publicMessage": { - "type": "string", - "description": "Human-readable error message that can be displayed to users", - "nullable": true - } - }, - "required": ["code"] - }, - "RawFsError": { - "type": "object", - "properties": { - "code": { - "type": "number", - "enum": [102], - "description": "RAWFS_ERROR code" - }, - "data": { - "type": "object", - "properties": { - "errno": { - "type": ["number", "null"], - "description": "File system error number, or null if not available" - } - }, - "required": ["errno"] - }, - "publicMessage": { - "type": "string", - "description": "Human-readable error message that can be displayed to users", - "nullable": true - } - }, - "required": ["code", "data"] - }, - "PitcherErrorCode": { - "type": "integer", - "description": "Enumeration of error codes", - "enum": [ - 0, 1, 2, 3, 100, 101, 102, 200, 201, 204, 300, 400, 404, 410, 420, - 430, 440, 450, 460, 470, 500, 600, 601, 602, 704, 800, 801, 802, 803, - 814 - ], - "x-enum-descriptions": [ - "CRITICAL_ERROR", - "FEATURE_UNAVAILABLE", - "NO_ACCESS", - "RATE_LIMIT", - "INVALID_ID", - "INVALID_PATH", - "RAWFS_ERROR", - "SHELL_NOT_ACCESSIBLE", - "SHELL_CLOSED", - "SHELL_NOT_FOUND", - "MODEL_NOT_FOUND", - "GIT_OPERATION_IN_PROGRESS", - "GIT_REMOTE_FILE_NOT_FOUND", - "GIT_FETCH_FAIL", - "GIT_PULL_CONFLICT", - "GIT_RESET_LOCAL_REMOTE_ERROR", - "GIT_PUSH_FAIL", - "GIT_RESET_CHECKOUT_INITIAL_BRANCH_FAIL", - "GIT_PULL_FAIL", - "GIT_TRANSPOSE_LINES_FAIL", - "CHANNEL_NOT_FOUND", - "CONFIG_FILE_ALREADY_EXISTS", - "TASK_NOT_FOUND", - "COMMAND_ALREADY_CONFIGURED", - "COMMAND_NOT_FOUND", - "AI_NOT_AVAILABLE", - "PROMPT_TOO_BIG", - "FAILED_TO_RESPOND", - "AI_TOO_FREQUENT_REQUESTS", - "AI_CHAT_NOT_FOUND" - ] - }, - "WriteFileRequest": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path to write to" - }, - "content": { - "type": "string", - "format": "binary", - "description": "File content as binary data (Uint8Array)" - }, - "create": { - "type": "boolean", - "description": "Whether to create the file if it doesn't exist", - "default": false - }, - "overwrite": { - "type": "boolean", - "description": "Whether to overwrite the file if it exists", - "default": false - } - }, - "required": ["path", "content"] - }, - "FSReadResult": { - "type": "object", - "properties": { - "treeNodes": { - "type": "array", - "items": { - "type": "object", - "description": "JSON representation of a node in the file system" - } - }, - "clock": { - "type": "number", - "description": "Current clock value for the file system" - } - }, - "required": ["treeNodes", "clock"] - }, - "FSOperationRequest": { - "type": "object", - "properties": { - "operation": { - "$ref": "#/components/schemas/FSOperation" - } - }, - "required": ["operation"] - }, - "FSOperation": { - "oneOf": [ - { - "$ref": "#/components/schemas/FSCreateOperation" - }, - { - "$ref": "#/components/schemas/FSDeleteOperation" - }, - { - "$ref": "#/components/schemas/FSMoveOperation" - } - ], - "discriminator": { - "propertyName": "type" - } - }, - "FSCreateOperation": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["create"] - }, - "parentId": { - "type": "string", - "description": "ID of the parent directory" - }, - "newEntry": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "ID of the new entry" - }, - "type": { - "type": "string", - "enum": ["directory", "file"], - "description": "Type of the node" - }, - "name": { - "type": "string", - "description": "Name of the new entry" - } - }, - "required": ["id", "type", "name"] - } - }, - "required": ["type", "parentId", "newEntry"] - }, - "FSDeleteOperation": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["delete"] - }, - "id": { - "type": "string", - "description": "ID of the entry to delete" - } - }, - "required": ["type", "id"] - }, - "FSMoveOperation": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["move"] - }, - "id": { - "type": "string", - "description": "ID of the entry to move" - }, - "parentId": { - "type": "string", - "description": "ID of the new parent directory", - "nullable": true - }, - "name": { - "type": "string", - "description": "New name for the entry", - "nullable": true - } - }, - "required": ["type", "id"] - }, - "FSOperationResult": { - "oneOf": [ - { - "type": "object", - "properties": { - "code": { - "type": "number", - "enum": [0], - "description": "Success code" - }, - "clock": { - "type": "number", - "description": "Current clock value" - } - }, - "required": ["code", "clock"] - }, - { - "type": "object", - "properties": { - "code": { - "type": "number", - "enum": [1], - "description": "Ignored code" - } - }, - "required": ["code"] - } - ], - "discriminator": { - "propertyName": "code" - } - }, - "FSSearchParams": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Text to search for" - }, - "glob": { - "type": "string", - "description": "Glob pattern to filter files", - "nullable": true - }, - "isRegex": { - "type": "boolean", - "description": "Whether to treat the search text as a regular expression", - "nullable": true - }, - "caseSensitivity": { - "type": "string", - "enum": ["smart", "enabled", "disabled"], - "description": "Case sensitivity setting for the search", - "nullable": true - } - }, - "required": ["text"] - }, - "SearchResult": { - "type": "object", - "properties": { - "fileId": { - "type": "string", - "description": "ID of the file containing the match" - }, - "lines": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Text of the line containing the match" - } - }, - "required": ["text"] - }, - "lineNumber": { - "type": "integer", - "description": "Line number of the match" - }, - "absoluteOffset": { - "type": "integer", - "description": "Absolute offset of the match in the file" - }, - "submatches": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SearchSubMatch" - } - } - }, - "required": [ - "fileId", - "lines", - "lineNumber", - "absoluteOffset", - "submatches" - ] - }, - "SearchSubMatch": { - "type": "object", - "properties": { - "match": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Matched text" - } - }, - "required": ["text"] - }, - "start": { - "type": "integer", - "description": "Start position of the match" - }, - "end": { - "type": "integer", - "description": "End position of the match" - } - }, - "required": ["match", "start", "end"] - }, - "FSStreamingSearchParams": { - "type": "object", - "properties": { - "searchId": { - "type": "string", - "description": "ID for the search operation" - }, - "text": { - "type": "string", - "description": "Text to search for" - }, - "glob": { - "type": "string", - "description": "Glob pattern to filter files", - "nullable": true - }, - "isRegex": { - "type": "boolean", - "description": "Whether to treat the search text as a regular expression", - "nullable": true - }, - "caseSensitivity": { - "type": "string", - "enum": ["smart", "enabled", "disabled"], - "description": "Case sensitivity setting for the search", - "nullable": true - }, - "maxResults": { - "type": "integer", - "description": "Maximum number of results to return (default: 10,000)", - "nullable": true - } - }, - "required": ["searchId", "text"] - }, - "PathSearchParams": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Text to search for in file paths" - } - }, - "required": ["text"] - }, - "PathSearchResult": { - "type": "object", - "properties": { - "matches": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PathSearchMatch" - } - } - }, - "required": ["matches"] - }, - "PathSearchMatch": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path that matched the search" - }, - "submatches": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SearchSubMatch" - } - } - }, - "required": ["path", "submatches"] - }, - "InvalidIdError": { - "type": "object", - "properties": { - "code": { - "type": "number", - "enum": [100], - "description": "INVALID_ID error code" - } - }, - "required": ["code"] - }, - "FSReadFileParams": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the file to read" - } - }, - "required": ["path"] - }, - "FSReadFileResult": { - "type": "object", - "properties": { - "content": { - "type": "string", - "format": "binary", - "description": "File content as binary data" - } - }, - "required": ["content"] - }, - "FSReadDirParams": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the directory to read" - } - }, - "required": ["path"] - }, - "FSReadDirResult": { - "type": "object", - "properties": { - "entries": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the entry" - }, - "type": { - "type": "string", - "enum": ["directory", "file"], - "description": "Type of the entry" - }, - "isSymlink": { - "type": "boolean", - "description": "Whether the entry is a symlink" - } - }, - "required": ["name", "type", "isSymlink"] - } - } - }, - "required": ["entries"] - }, - "FSStatParams": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the file or directory to stat" - } - }, - "required": ["path"] - }, - "FSStatResult": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["directory", "file"], - "description": "Type of the entry" - }, - "isSymlink": { - "type": "boolean", - "description": "Whether the entry is a symlink" - }, - "size": { - "type": "integer", - "description": "Size of the file in bytes" - }, - "mtime": { - "type": "integer", - "description": "Last modified time" - }, - "ctime": { - "type": "integer", - "description": "Creation time" - }, - "atime": { - "type": "integer", - "description": "Last accessed time" - } - }, - "required": ["type", "isSymlink", "size", "mtime", "ctime", "atime"] - }, - "FSCopyParams": { - "type": "object", - "properties": { - "from": { - "type": "string", - "description": "Path to copy from" - }, - "to": { - "type": "string", - "description": "Path to copy to" - }, - "recursive": { - "type": "boolean", - "description": "Whether to copy directories recursively", - "nullable": true - }, - "overwrite": { - "type": "boolean", - "description": "Whether to overwrite existing files", - "nullable": true - } - }, - "required": ["from", "to"] - }, - "FSRenameParams": { - "type": "object", - "properties": { - "from": { - "type": "string", - "description": "Path to rename from" - }, - "to": { - "type": "string", - "description": "Path to rename to" - }, - "overwrite": { - "type": "boolean", - "description": "Whether to overwrite existing files", - "nullable": true - } - }, - "required": ["from", "to"] - }, - "FSRemoveParams": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to remove" - }, - "recursive": { - "type": "boolean", - "description": "Whether to remove directories recursively", - "nullable": true - } - }, - "required": ["path"] - }, - "FSMkdirParams": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to create directory at" - }, - "recursive": { - "type": "boolean", - "description": "Whether to create parent directories if they don't exist", - "nullable": true - } - }, - "required": ["path"] - }, - "FSWatchParams": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to watch" - }, - "recursive": { - "type": "boolean", - "description": "Whether to watch directories recursively", - "nullable": true - }, - "excludes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Glob patterns to exclude from watching", - "nullable": true - } - }, - "required": ["path"] - }, - "FSWatchResult": { - "type": "object", - "properties": { - "watchId": { - "type": "string", - "description": "ID of the watch" - } - }, - "required": ["watchId"] - }, - "FSUnwatchParams": { - "type": "object", - "properties": { - "watchId": { - "type": "string", - "description": "ID of the watch to stop" - } - }, - "required": ["watchId"] - } - } - } -} diff --git a/openapi-sandbox-git.json b/openapi-sandbox-git.json deleted file mode 100644 index 827a6e9..0000000 --- a/openapi-sandbox-git.json +++ /dev/null @@ -1,1369 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sandbox Git API", - "description": "API for managing git operations in CodeSandbox", - "version": "1.0.0" - }, - "paths": { - "/git/status": { - "post": { - "summary": "Get git status", - "description": "Retrieve current git status including changed files, branch information, and commits", - "operationId": "gitStatus", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitStatus" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving git status", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/remotes": { - "post": { - "summary": "Get git remotes", - "description": "Retrieve git remote information", - "operationId": "gitRemotes", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitRemotes" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving git remotes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/targetDiff": { - "post": { - "summary": "Get git target diff", - "description": "Retrieve diff between current branch and target branch", - "operationId": "gitTargetDiff", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "branch": { - "type": "string", - "description": "Branch to compare against" - } - }, - "required": ["branch"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitTargetDiff" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving git target diff", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/pull": { - "post": { - "summary": "Pull from remote", - "description": "Pull changes from remote repository", - "operationId": "gitPull", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "branch": { - "type": "string", - "description": "Branch to pull from" - }, - "force": { - "type": "boolean", - "description": "Force pull even if there are conflicts" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error pulling from remote", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/discard": { - "post": { - "summary": "Discard changes", - "description": "Discard local changes for specified paths", - "operationId": "gitDiscard", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Paths of files to discard changes" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error discarding changes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/commit": { - "post": { - "summary": "Commit changes", - "description": "Commit changes to the repository", - "operationId": "gitCommit", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Paths of files to commit" - }, - "message": { - "type": "string", - "description": "Commit message" - }, - "push": { - "type": "boolean", - "description": "Whether to push the commit immediately" - } - }, - "required": ["message"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "shellId": { - "type": "string", - "description": "ID of the shell process" - } - }, - "required": ["shellId"] - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error committing changes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/push": { - "post": { - "summary": "Push changes", - "description": "Push local commits to remote repository", - "operationId": "gitPush", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error pushing changes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/pushToRemote": { - "post": { - "summary": "Push to remote", - "description": "Push to a specific remote repository", - "operationId": "gitPushToRemote", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "URL of the remote repository" - }, - "branch": { - "type": "string", - "description": "Branch to push to" - }, - "squashAllCommits": { - "type": "boolean", - "description": "Whether to squash all commits into one" - } - }, - "required": ["url", "branch"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error pushing to remote", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/renameBranch": { - "post": { - "summary": "Rename branch", - "description": "Rename a git branch", - "operationId": "gitRenameBranch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "oldBranch": { - "type": "string", - "description": "Current branch name" - }, - "newBranch": { - "type": "string", - "description": "New branch name" - } - }, - "required": ["oldBranch", "newBranch"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error renaming branch", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/remoteContent": { - "post": { - "summary": "Get remote content", - "description": "Retrieve content from a remote repository", - "operationId": "gitRemoteContent", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GitRemoteParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "Content of the file" - } - }, - "required": ["content"] - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving remote content", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/diffStatus": { - "post": { - "summary": "Get diff status", - "description": "Retrieve diff status between two git references", - "operationId": "gitDiffStatus", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GitDiffStatusParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitDiffStatusResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving diff status", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/resetLocalWithRemote": { - "post": { - "summary": "Reset local with remote", - "description": "Reset local repository to match the remote state", - "operationId": "gitResetLocalWithRemote", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error resetting local with remote", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/checkoutInitialBranch": { - "post": { - "summary": "Checkout initial branch", - "description": "Checkout the initial branch of the repository", - "operationId": "gitCheckoutInitialBranch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error checking out initial branch", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/transposeLines": { - "post": { - "summary": "Transpose lines", - "description": "Transpose line numbers from one git reference to another", - "operationId": "gitTransposeLines", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "sha": { - "type": "string", - "description": "Git commit SHA" - }, - "path": { - "type": "string", - "description": "Path to the file" - }, - "line": { - "type": "number", - "description": "Line number to transpose" - } - }, - "required": ["sha", "path", "line"] - } - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "line": { - "type": "number" - } - }, - "required": ["path", "line"] - }, - { - "type": "null" - } - ] - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error transposing lines", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "CommonError": { - "oneOf": [ - { - "type": "object", - "properties": { - "code": { - "type": "string", - "enum": [ - "GIT_OPERATION_IN_PROGRESS", - "GIT_REMOTE_FILE_NOT_FOUND" - ], - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - } - }, - "required": ["code", "message"] - }, - { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Protocol error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "data": { - "type": "object", - "description": "Additional error data" - } - }, - "required": ["code", "message"] - } - ] - }, - "GitStatusShortFormat": { - "type": "string", - "enum": ["", "M", "A", "D", "R", "C", "U", "?"], - "description": "Git status short format codes" - }, - "GitItem": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path" - }, - "index": { - "$ref": "#/components/schemas/GitStatusShortFormat" - }, - "workingTree": { - "$ref": "#/components/schemas/GitStatusShortFormat" - }, - "isStaged": { - "type": "boolean", - "description": "Whether the file is staged" - }, - "isConflicted": { - "type": "boolean", - "description": "Whether the file has conflicts" - }, - "fileId": { - "type": "string", - "description": "Unique identifier for the file" - } - }, - "required": ["path", "index", "workingTree", "isStaged", "isConflicted"] - }, - "GitChangedFiles": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/GitItem" - }, - "description": "Map of file IDs to Git items" - }, - "GitBranchProperties": { - "type": "object", - "properties": { - "head": { - "type": ["string", "null"], - "description": "Current HEAD reference" - }, - "branch": { - "type": ["string", "null"], - "description": "Current branch name" - }, - "ahead": { - "type": "number", - "description": "Number of commits ahead of the remote" - }, - "behind": { - "type": "number", - "description": "Number of commits behind the remote" - }, - "safe": { - "type": "boolean", - "description": "Whether the branch is safe to operate on" - } - }, - "required": ["ahead", "behind", "safe"] - }, - "GitCommit": { - "type": "object", - "properties": { - "hash": { - "type": "string", - "description": "Commit hash" - }, - "date": { - "type": "string", - "description": "Commit date" - }, - "message": { - "type": "string", - "description": "Commit message" - }, - "author": { - "type": "string", - "description": "Commit author" - } - }, - "required": ["hash", "date", "message", "author"] - }, - "GitStatus": { - "type": "object", - "properties": { - "changedFiles": { - "$ref": "#/components/schemas/GitChangedFiles" - }, - "deletedFiles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitItem" - } - }, - "conflicts": { - "type": "boolean", - "description": "Whether there are remote conflicts" - }, - "localChanges": { - "type": "boolean", - "description": "Whether there are local changes" - }, - "remote": { - "$ref": "#/components/schemas/GitBranchProperties" - }, - "target": { - "$ref": "#/components/schemas/GitBranchProperties" - }, - "head": { - "type": "string", - "description": "Current HEAD reference" - }, - "commits": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitCommit" - } - }, - "branch": { - "type": ["string", "null"], - "description": "Current branch name" - }, - "isMerging": { - "type": "boolean", - "description": "Whether a merge is in progress" - } - }, - "required": [ - "changedFiles", - "deletedFiles", - "conflicts", - "localChanges", - "remote", - "target", - "commits", - "branch", - "isMerging" - ] - }, - "GitTargetDiff": { - "type": "object", - "properties": { - "ahead": { - "type": "number", - "description": "Number of commits ahead of the target" - }, - "behind": { - "type": "number", - "description": "Number of commits behind the target" - }, - "commits": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitCommit" - } - } - }, - "required": ["ahead", "behind", "commits"] - }, - "GitRemotes": { - "type": "object", - "properties": { - "origin": { - "type": "string", - "description": "Origin remote URL" - }, - "upstream": { - "type": "string", - "description": "Upstream remote URL" - } - }, - "required": ["origin", "upstream"] - }, - "GitRemoteParams": { - "type": "object", - "properties": { - "reference": { - "type": "string", - "description": "Branch or commit hash" - }, - "path": { - "type": "string", - "description": "Path to the file" - } - }, - "required": ["reference", "path"] - }, - "GitDiffStatusParams": { - "type": "object", - "properties": { - "base": { - "type": "string", - "description": "Base reference used for diffing" - }, - "head": { - "type": "string", - "description": "Head reference used for diffing" - } - }, - "required": ["base", "head"] - }, - "GitDiffStatusItem": { - "type": "object", - "properties": { - "status": { - "$ref": "#/components/schemas/GitStatusShortFormat" - }, - "path": { - "type": "string", - "description": "Path to the file" - }, - "oldPath": { - "type": "string", - "description": "Original path for renamed files" - }, - "hunks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "original": { - "type": "object", - "properties": { - "start": { - "type": "number" - }, - "end": { - "type": "number" - } - }, - "required": ["start", "end"] - }, - "modified": { - "type": "object", - "properties": { - "start": { - "type": "number" - }, - "end": { - "type": "number" - } - }, - "required": ["start", "end"] - } - }, - "required": ["original", "modified"] - } - } - }, - "required": ["status", "path", "hunks"] - }, - "GitDiffStatusResult": { - "type": "object", - "properties": { - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitDiffStatusItem" - } - } - }, - "required": ["files"] - } - } - } -} diff --git a/openapi-sandbox-setup.json b/openapi-sandbox-setup.json deleted file mode 100644 index bc8b5c4..0000000 --- a/openapi-sandbox-setup.json +++ /dev/null @@ -1,570 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sandbox Setup API", - "description": "API for managing sandbox setup operations", - "version": "1.0.0" - }, - "paths": { - "/setup/get": { - "post": { - "summary": "Get setup progress", - "description": "Retrieve the current setup progress status", - "operationId": "setupGet", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SetupProgress" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving setup progress", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - }, - "/setup/skip": { - "post": { - "summary": "Skip setup step", - "description": "Skip a specific step in the setup process", - "operationId": "setupSkipStep", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "stepIndexToSkip": { - "type": "number", - "description": "Index of the step to skip" - } - }, - "required": ["stepIndexToSkip"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SetupProgress" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error skipping step", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - }, - "/setup/skipAll": { - "post": { - "summary": "Skip all setup steps", - "description": "Skip all remaining steps in the setup process", - "operationId": "setupSkipAll", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "null" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SetupProgress" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error skipping all steps", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - }, - "/setup/disable": { - "post": { - "summary": "Disable setup", - "description": "Disable the setup process", - "operationId": "setupDisable", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "null" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SetupProgress" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error disabling setup", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - }, - "/setup/enable": { - "post": { - "summary": "Enable setup", - "description": "Enable the setup process", - "operationId": "setupEnable", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "null" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SetupProgress" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error enabling setup", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - }, - "/setup/init": { - "post": { - "summary": "Initialize setup", - "description": "Initialize the setup process", - "operationId": "setupInit", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "null" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SetupProgress" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error initializing setup", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - }, - "/setup/setStep": { - "post": { - "summary": "Set current setup step", - "description": "Set the current step in the setup process (used for restarting)", - "operationId": "setupSetStep", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "stepIndex": { - "type": "number", - "description": "Index of the step to set as current" - } - }, - "required": ["stepIndex"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SetupProgress" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error setting current step", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "ProtocolError": { - "type": "object", - "properties": { - "code": { - "type": "number", - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "data": { - "type": "object", - "description": "Additional error data", - "nullable": true - } - }, - "required": ["code", "message"] - }, - "SetupShellStatus": { - "type": "string", - "enum": ["SUCCEEDED", "FAILED", "SKIPPED"], - "description": "Status of a setup shell step" - }, - "Step": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the setup step" - }, - "command": { - "type": "string", - "description": "Command to execute for this step" - }, - "shellId": { - "type": "string", - "description": "ID of the shell executing the command", - "nullable": true - }, - "finishStatus": { - "$ref": "#/components/schemas/SetupShellStatus", - "nullable": true, - "description": "Status of the step after completion" - } - }, - "required": ["name", "command", "shellId", "finishStatus"] - }, - "SetupProgress": { - "type": "object", - "properties": { - "state": { - "type": "string", - "enum": ["IDLE", "IN_PROGRESS", "FINISHED", "STOPPED"], - "description": "Current state of the setup process" - }, - "steps": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Step" - }, - "description": "List of setup steps" - }, - "currentStepIndex": { - "type": "number", - "description": "Index of the current step being executed" - } - }, - "required": ["state", "steps", "currentStepIndex"] - } - } - } -} diff --git a/openapi-sandbox-shell.json b/openapi-sandbox-shell.json deleted file mode 100644 index e362489..0000000 --- a/openapi-sandbox-shell.json +++ /dev/null @@ -1,916 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sandbox Shell API", - "description": "API for managing terminal and command shells in the sandbox", - "version": "1.0.0" - }, - "paths": { - "/shell/create": { - "post": { - "summary": "Create a new shell", - "description": "Creates a new terminal or command shell", - "operationId": "shellCreate", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "Command to execute in the shell" - }, - "cwd": { - "type": "string", - "description": "Working directory for the shell" - }, - "size": { - "$ref": "#/components/schemas/ShellSize", - "description": "Terminal size dimensions" - }, - "type": { - "$ref": "#/components/schemas/ShellProcessType", - "description": "Type of shell to create" - }, - "isSystemShell": { - "type": "boolean", - "description": "Whether this shell is started by the editor itself to run a specific process" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/OpenShellDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error creating shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/in": { - "post": { - "summary": "Send input to shell", - "description": "Sends user input to an active shell", - "operationId": "shellIn", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId", - "description": "ID of the target shell" - }, - "input": { - "type": "string", - "description": "Input to send to the shell" - }, - "size": { - "$ref": "#/components/schemas/ShellSize", - "description": "Current terminal dimensions" - } - }, - "required": ["shellId", "input", "size"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error sending input to shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/list": { - "post": { - "summary": "List all shells", - "description": "Retrieves a list of all available shells", - "operationId": "shellList", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "shells": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShellDTO" - } - } - }, - "required": ["shells"] - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error listing shells", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/open": { - "post": { - "summary": "Open an existing shell", - "description": "Opens an existing shell and retrieves its buffer", - "operationId": "shellOpen", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId", - "description": "ID of the shell to open" - }, - "size": { - "$ref": "#/components/schemas/ShellSize", - "description": "Terminal dimensions" - } - }, - "required": ["shellId", "size"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/OpenShellDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error opening shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/close": { - "post": { - "summary": "Close a shell", - "description": "Closes a shell without terminating the underlying process", - "operationId": "shellClose", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId", - "description": "ID of the shell to close" - } - }, - "required": ["shellId"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error closing shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/restart": { - "post": { - "summary": "Restart a shell", - "description": "Restarts an existing shell process", - "operationId": "shellRestart", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId", - "description": "ID of the shell to restart" - } - }, - "required": ["shellId"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error restarting shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/terminate": { - "post": { - "summary": "Terminate a shell", - "description": "Terminates a shell and its underlying process", - "operationId": "shellTerminate", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId", - "description": "ID of the shell to terminate" - } - }, - "required": ["shellId"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/ShellDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error terminating shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/resize": { - "post": { - "summary": "Resize a shell", - "description": "Updates the dimensions of a shell", - "operationId": "shellResize", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId", - "description": "ID of the shell to resize" - }, - "size": { - "$ref": "#/components/schemas/ShellSize", - "description": "New terminal dimensions" - } - }, - "required": ["shellId", "size"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error resizing shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/rename": { - "post": { - "summary": "Rename a shell", - "description": "Updates the name of a shell", - "operationId": "shellRename", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId", - "description": "ID of the shell to rename" - }, - "name": { - "type": "string", - "description": "New name for the shell" - } - }, - "required": ["shellId", "name"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error renaming shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "ShellId": { - "type": "string", - "description": "Unique identifier for a shell" - }, - "ShellSize": { - "type": "object", - "properties": { - "cols": { - "type": "number", - "description": "Number of columns in the terminal" - }, - "rows": { - "type": "number", - "description": "Number of rows in the terminal" - } - }, - "required": ["cols", "rows"] - }, - "ShellProcessType": { - "type": "string", - "enum": ["TERMINAL", "COMMAND"], - "description": "Type of shell process" - }, - "ShellProcessStatus": { - "type": "string", - "enum": ["RUNNING", "FINISHED", "ERROR", "KILLED", "RESTARTING"], - "description": "Current status of the shell process" - }, - "BaseShellDTO": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId" - }, - "name": { - "type": "string", - "description": "Display name of the shell" - }, - "status": { - "$ref": "#/components/schemas/ShellProcessStatus" - }, - "exitCode": { - "type": "number", - "description": "Exit code of the process if it has finished", - "nullable": true - } - }, - "required": ["shellId", "name", "status"] - }, - "CommandShellDTO": { - "allOf": [ - { - "$ref": "#/components/schemas/BaseShellDTO" - }, - { - "type": "object", - "properties": { - "shellType": { - "type": "string", - "enum": ["COMMAND"], - "description": "Indicates this is a command shell" - }, - "startCommand": { - "type": "string", - "description": "The command that was executed to start this shell" - } - }, - "required": ["shellType", "startCommand"] - } - ] - }, - "TerminalShellDTO": { - "allOf": [ - { - "$ref": "#/components/schemas/BaseShellDTO" - }, - { - "type": "object", - "properties": { - "shellType": { - "type": "string", - "enum": ["TERMINAL"], - "description": "Indicates this is a terminal shell" - }, - "ownerUsername": { - "type": "string", - "description": "Username of the shell owner" - }, - "isSystemShell": { - "type": "boolean", - "description": "Whether this is a system shell" - } - }, - "required": ["shellType", "ownerUsername", "isSystemShell"] - } - ] - }, - "ShellDTO": { - "oneOf": [ - { - "$ref": "#/components/schemas/CommandShellDTO" - }, - { - "$ref": "#/components/schemas/TerminalShellDTO" - } - ], - "discriminator": { - "propertyName": "shellType", - "mapping": { - "COMMAND": "#/components/schemas/CommandShellDTO", - "TERMINAL": "#/components/schemas/TerminalShellDTO" - } - } - }, - "OpenCommandShellDTO": { - "allOf": [ - { - "$ref": "#/components/schemas/CommandShellDTO" - }, - { - "type": "object", - "properties": { - "buffer": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Content buffer of the shell" - } - }, - "required": ["buffer"] - } - ] - }, - "OpenTerminalShellDTO": { - "allOf": [ - { - "$ref": "#/components/schemas/TerminalShellDTO" - }, - { - "type": "object", - "properties": { - "buffer": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Content buffer of the shell" - } - }, - "required": ["buffer"] - } - ] - }, - "OpenShellDTO": { - "oneOf": [ - { - "$ref": "#/components/schemas/OpenCommandShellDTO" - }, - { - "$ref": "#/components/schemas/OpenTerminalShellDTO" - } - ], - "discriminator": { - "propertyName": "shellType", - "mapping": { - "COMMAND": "#/components/schemas/OpenCommandShellDTO", - "TERMINAL": "#/components/schemas/OpenTerminalShellDTO" - } - } - }, - "CommonError": { - "oneOf": [ - { - "type": "object", - "properties": { - "code": { - "type": "string", - "enum": ["SHELL_NOT_ACCESSIBLE"], - "description": "Error code indicating the shell is not accessible" - }, - "message": { - "type": "string", - "description": "Error message" - } - }, - "required": ["code", "message"] - }, - { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Protocol error code" - }, - "message": { - "type": "string", - "description": "Error message" - } - }, - "required": ["code", "message"] - } - ] - } - } - } -} diff --git a/openapi-sandbox-system.json b/openapi-sandbox-system.json deleted file mode 100644 index 501f2f5..0000000 --- a/openapi-sandbox-system.json +++ /dev/null @@ -1,348 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sandbox System API", - "description": "API for managing sandbox system operations", - "version": "1.0.0" - }, - "paths": { - "/system/update": { - "post": { - "summary": "Update system", - "description": "Update the sandbox system", - "operationId": "systemUpdate", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": {} - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error updating system", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/SystemError" - } - } - } - ] - } - } - } - } - } - } - }, - "/system/hibernate": { - "post": { - "summary": "Hibernate system", - "description": "Put the sandbox system into hibernation mode", - "operationId": "systemHibernate", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error hibernating system", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/SystemError" - } - } - } - ] - } - } - } - } - } - } - }, - "/system/metrics": { - "post": { - "summary": "Get system metrics", - "description": "Retrieve current system metrics including CPU, memory and storage usage", - "operationId": "systemMetrics", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SystemMetricsStatus" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving system metrics", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/SystemError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "SystemError": { - "type": "object", - "properties": { - "code": { - "type": "number", - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "data": { - "type": "object", - "description": "Additional error data", - "nullable": true - } - }, - "required": ["code", "message"] - }, - "SystemMetricsStatus": { - "type": "object", - "properties": { - "cpu": { - "type": "object", - "properties": { - "cores": { - "type": "number", - "description": "Number of CPU cores" - }, - "used": { - "type": "number", - "description": "Used CPU resources" - }, - "configured": { - "type": "number", - "description": "Configured CPU resources" - } - }, - "required": ["cores", "used", "configured"] - }, - "memory": { - "type": "object", - "properties": { - "used": { - "type": "number", - "description": "Used memory in bytes" - }, - "total": { - "type": "number", - "description": "Total available memory in bytes" - }, - "configured": { - "type": "number", - "description": "Configured memory limit in bytes" - } - }, - "required": ["used", "total", "configured"] - }, - "storage": { - "type": "object", - "properties": { - "used": { - "type": "number", - "description": "Used storage in bytes" - }, - "total": { - "type": "number", - "description": "Total available storage in bytes" - }, - "configured": { - "type": "number", - "description": "Configured storage limit in bytes" - } - }, - "required": ["used", "total", "configured"] - } - }, - "required": ["cpu", "memory", "storage"] - }, - "InitStatus": { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Status message" - }, - "isError": { - "type": "boolean", - "description": "Whether the status represents an error", - "nullable": true - }, - "progress": { - "type": "number", - "description": "Current progress (0-100)", - "minimum": 0, - "maximum": 100 - }, - "nextProgress": { - "type": "number", - "description": "Next progress target (0-100)", - "minimum": 0, - "maximum": 100 - }, - "stdout": { - "type": "string", - "description": "Standard output from the initialization process", - "nullable": true - } - }, - "required": ["message", "progress", "nextProgress"] - } - } - } -} diff --git a/openapi-sandbox-task.json b/openapi-sandbox-task.json deleted file mode 100644 index 6b5e320..0000000 --- a/openapi-sandbox-task.json +++ /dev/null @@ -1,947 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sandbox Task API", - "description": "API for managing tasks in sandbox", - "version": "1.0.0" - }, - "paths": { - "/task/list": { - "post": { - "summary": "List tasks", - "description": "Retrieve a list of all configured tasks", - "operationId": "taskList", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/TaskListDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving task list", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/run": { - "post": { - "summary": "Run task", - "description": "Start execution of a task by ID", - "operationId": "taskRun", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "taskId": { - "type": "string", - "description": "ID of the task to run" - } - }, - "required": ["taskId"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/TaskDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error running task", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/runCommand": { - "post": { - "summary": "Run command", - "description": "Run a shell command directly, optionally saving it as a task", - "operationId": "taskRunCommand", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "Command to run" - }, - "name": { - "type": "string", - "description": "Optional name for the task", - "nullable": true - }, - "saveToConfig": { - "type": "boolean", - "description": "Whether to save this command as a task in the config", - "nullable": true - } - }, - "required": ["command"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/TaskDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error running command", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/stop": { - "post": { - "summary": "Stop task", - "description": "Stop execution of a running task", - "operationId": "taskStop", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "taskId": { - "type": "string", - "description": "ID of the task to stop" - } - }, - "required": ["taskId"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "oneOf": [ - { - "$ref": "#/components/schemas/TaskDTO" - }, - { - "type": "null", - "description": "Null when stopping an unconfigured task" - } - ] - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error stopping task", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/create": { - "post": { - "summary": "Create task", - "description": "Create a new task configuration", - "operationId": "taskCreate", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "taskFields": { - "$ref": "#/components/schemas/TaskDefinitionDTO" - }, - "startTask": { - "type": "boolean", - "description": "Whether to start the task immediately after creation", - "nullable": true - } - }, - "required": ["taskFields"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/TaskListDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error creating task", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/update": { - "post": { - "summary": "Update task", - "description": "Update an existing task configuration", - "operationId": "taskUpdate", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "taskId": { - "type": "string", - "description": "ID of the task to update" - }, - "taskFields": { - "type": "object", - "description": "Fields to update in the task", - "properties": { - "name": { - "type": "string", - "description": "Name of the task", - "nullable": true - }, - "command": { - "type": "string", - "description": "Command to run", - "nullable": true - }, - "runAtStart": { - "type": "boolean", - "description": "Whether to run the task at sandbox start", - "nullable": true - }, - "preview": { - "type": "object", - "properties": { - "port": { - "type": "number", - "description": "Port to use for previewing the task", - "nullable": true - }, - "pr-link": { - "type": "string", - "enum": ["direct", "redirect", "devtool"], - "description": "Type of PR link to use", - "nullable": true - } - }, - "nullable": true - } - } - } - }, - "required": ["taskId", "taskFields"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/TaskDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error updating task", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/saveToConfig": { - "post": { - "summary": "Save task to config", - "description": "Save a runtime task to the configuration file", - "operationId": "taskSaveToConfig", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "taskId": { - "type": "string", - "description": "ID of the task to save to config" - } - }, - "required": ["taskId"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/TaskDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error saving task to config", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/generateConfig": { - "post": { - "summary": "Generate task config", - "description": "Generate a configuration file from current tasks", - "operationId": "taskGenerateConfig", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error generating config", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/createSetupTasks": { - "post": { - "summary": "Create setup tasks", - "description": "Create tasks that run during sandbox setup", - "operationId": "taskCreateSetupTasks", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "tasks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TaskDefinitionDTO" - }, - "description": "Setup tasks to create" - } - }, - "required": ["tasks"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error creating setup tasks", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "CommonError": { - "type": "object", - "properties": { - "code": { - "type": "number", - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "data": { - "type": "object", - "description": "Additional error data", - "nullable": true - } - }, - "required": ["code"] - }, - "TaskError": { - "oneOf": [ - { - "type": "object", - "properties": { - "code": { - "type": "number", - "enum": [600], - "description": "CONFIG_FILE_ALREADY_EXISTS error code" - }, - "message": { - "type": "string", - "description": "Error message" - } - }, - "required": ["code", "message"] - }, - { - "type": "object", - "properties": { - "code": { - "type": "number", - "enum": [601], - "description": "TASK_NOT_FOUND error code" - }, - "message": { - "type": "string", - "description": "Error message" - } - }, - "required": ["code", "message"] - }, - { - "type": "object", - "properties": { - "code": { - "type": "number", - "enum": [602], - "description": "COMMAND_ALREADY_CONFIGURED error code" - }, - "message": { - "type": "string", - "description": "Error message" - } - }, - "required": ["code", "message"] - }, - { - "$ref": "#/components/schemas/CommonError" - } - ], - "discriminator": { - "propertyName": "code" - } - }, - "TaskDefinitionDTO": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the task" - }, - "command": { - "type": "string", - "description": "Command to run for the task" - }, - "runAtStart": { - "type": "boolean", - "description": "Whether the task should run when the sandbox starts", - "nullable": true - }, - "preview": { - "type": "object", - "properties": { - "port": { - "type": "number", - "description": "Port to preview from this task", - "nullable": true - }, - "pr-link": { - "type": "string", - "enum": ["direct", "redirect", "devtool"], - "description": "Type of PR link to use", - "nullable": true - } - }, - "nullable": true - } - }, - "required": ["name", "command"] - }, - "CommandShellDTO": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "ID of the shell command" - }, - "command": { - "type": "string", - "description": "Command being executed" - }, - "status": { - "type": "string", - "enum": ["initializing", "running", "stopped", "error"], - "description": "Current status of the shell command" - }, - "output": { - "type": "string", - "description": "Current output of the command" - } - }, - "required": ["id", "command", "status", "output"] - }, - "Port": { - "type": "object", - "properties": { - "port": { - "type": "number", - "description": "Port number" - }, - "hostname": { - "type": "string", - "description": "Hostname the port is bound to" - }, - "status": { - "type": "string", - "enum": ["open", "closed"], - "description": "Current status of the port" - }, - "taskId": { - "type": "string", - "description": "ID of the task that opened this port", - "nullable": true - } - }, - "required": ["port", "hostname", "status"] - }, - "TaskDTO": { - "allOf": [ - { - "$ref": "#/components/schemas/TaskDefinitionDTO" - }, - { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique ID of the task" - }, - "unconfigured": { - "type": "boolean", - "description": "Whether this task is unconfigured (not saved in config)", - "nullable": true - }, - "shell": { - "type": "object", - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/CommandShellDTO" - } - ] - }, - "ports": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Port" - }, - "description": "Ports opened by this task" - } - }, - "required": ["id", "shell", "ports"] - } - ] - }, - "TaskListDTO": { - "type": "object", - "properties": { - "tasks": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/TaskDTO" - }, - "description": "Map of task IDs to task objects" - }, - "setupTasks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TaskDefinitionDTO" - }, - "description": "Tasks that run during sandbox setup" - }, - "validationErrors": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Validation errors in the task configuration" - } - }, - "required": ["tasks", "setupTasks", "validationErrors"] - } - } - } -} diff --git a/openapi.json b/openapi.json index 17bd4c6..ee14101 100644 --- a/openapi.json +++ b/openapi.json @@ -117,36 +117,6 @@ "title": "VMAssignTagAliasResponse", "type": "object" }, - "TemplateCreateRequest": { - "properties": { - "description": { - "default": "[Template description]", - "description": "Template description. Maximum 255 characters. Defaults to description of original sandbox.", - "maxLength": 255, - "type": "string" - }, - "forkOf": { - "description": "Short ID of the sandbox to fork.", - "example": "pt_1234567890", - "type": "string" - }, - "tags": { - "default": [], - "description": "Tags to set on the new sandbox, if any. Will not inherit tags from the source sandbox.", - "items": { "type": "string" }, - "type": "array" - }, - "title": { - "default": "[Template title]", - "description": "Template title. Maximum 255 characters. Defaults to title of original sandbox with (forked).", - "maxLength": 255, - "type": "string" - } - }, - "required": ["forkOf"], - "title": "TemplateCreateRequest", - "type": "object" - }, "PreviewToken": { "properties": { "expires_at": { "nullable": true, "type": "string" }, @@ -212,6 +182,66 @@ "title": "PreviewTokenRevokeAllResponse", "type": "object" }, + "TemplateCreateRequestCommon": { + "properties": { + "description": { + "default": "[Template description]", + "description": "Template description. Maximum 255 characters. Defaults to description of original sandbox.", + "maxLength": 255, + "type": "string" + }, + "forkOf": { + "description": "Short ID of the sandbox to fork.", + "example": "pt_1234567890", + "type": "string" + }, + "image": { + "description": "Container image to use as template", + "properties": { + "architecture": { + "description": "The architecture of the image. Required for multi-platform images", + "type": "string" + }, + "name": { + "description": "The image name (for example 'nginx').", + "type": "string" + }, + "registry": { + "default": "docker.io", + "description": "The container registry where the image is stored.", + "type": "string" + }, + "repository": { + "default": "library", + "description": "The repository or namespace where the image is stored.", + "type": "string" + }, + "tag": { + "default": "latest", + "description": "The image tag.", + "type": "string" + } + }, + "required": ["name"], + "type": "object" + }, + "tags": { + "default": [], + "description": "Tags to set on the new sandbox, if any. Will not inherit tags from the source sandbox.", + "items": { "type": "string" }, + "type": "array" + }, + "title": { + "default": "[Template title]", + "description": "Template title. Maximum 255 characters. Defaults to title of original sandbox with (forked).", + "maxLength": 255, + "type": "string" + } + }, + "required": ["forkOf"], + "title": "TemplateCreateRequestCommon", + "type": "object" + }, "Sandbox": { "properties": { "created_at": { "format": "date-time", "type": "string" }, @@ -941,6 +971,7 @@ "reconnect_token": { "type": "string" }, "use_pint": { "type": "boolean" }, "user_workspace_path": { "type": "string" }, + "vm_agent_type": { "type": "string" }, "workspace_path": { "type": "string" } }, "required": [ @@ -955,7 +986,8 @@ "reconnect_token", "use_pint", "user_workspace_path", - "workspace_path" + "workspace_path", + "vm_agent_type" ], "type": "object" } @@ -1555,6 +1587,7 @@ "reconnect_token": { "type": "string" }, "use_pint": { "type": "boolean" }, "user_workspace_path": { "type": "string" }, + "vm_agent_type": { "type": "string" }, "workspace_path": { "type": "string" } }, "required": [ @@ -1569,7 +1602,8 @@ "reconnect_token", "use_pint", "user_workspace_path", - "workspace_path" + "workspace_path", + "vm_agent_type" ], "type": "object" }, @@ -2134,7 +2168,9 @@ "requestBody": { "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/TemplateCreateRequest" } + "schema": { + "$ref": "#/components/schemas/TemplateCreateRequestCommon" + } } }, "description": "Template Create Request", diff --git a/package-lock.json b/package-lock.json index 9ad8d83..62a0277 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codesandbox/sdk", - "version": "2.4.1", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codesandbox/sdk", - "version": "2.4.1", + "version": "2.5.0", "license": "MIT", "dependencies": { "@hey-api/client-fetch": "^0.13.1", @@ -111,6 +111,74 @@ "node": ">=0.1.90" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/darwin-arm64": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", @@ -128,6 +196,346 @@ "node": ">=18" } }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hey-api/client-fetch": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.13.1.tgz", @@ -926,78 +1334,330 @@ "node": ">=14" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.34.0.tgz", - "integrity": "sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA==", - "license": "Apache-2.0", + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.34.0.tgz", + "integrity": "sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.40.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.40.1.tgz", + "integrity": "sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/core": "^1.1.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@opentelemetry/sql-common": { - "version": "0.40.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.40.1.tgz", - "integrity": "sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==", - "license": "Apache-2.0", + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", "optional": true, - "dependencies": { - "@opentelemetry/core": "^1.1.0" - }, + "os": [ + "win32" + ], "engines": { - "node": ">=14" + "node": ">= 10.0.0" }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/watcher": { + "node_modules/@parcel/watcher-win32-ia32": { "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], "dev": true, - "hasInstallScript": true, - "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">= 10.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" } }, - "node_modules/@parcel/watcher-darwin-arm64": { + "node_modules/@parcel/watcher-win32-x64": { "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", "cpu": [ - "arm64" + "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { "node": ">= 10.0.0" @@ -1020,6 +1680,34 @@ "@opentelemetry/api": "^1.8" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.47.0.tgz", + "integrity": "sha512-Weap5hVbZs/yIvUZcFpAmIso8rLmwkO1LesddNjeX28tIhQkAKjRuVgAJ2xpj8wXTny7IZro9aBIgGov0qsL4A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.47.0.tgz", + "integrity": "sha512-XcnlqvG5riTJByKX7bZ1ehe48GiF+eNkdnzV0ziLp85XyJ6tLPfhkXHv3e0h3cpZESTQa8IB+ZHhV/r02+8qKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.47.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.47.0.tgz", @@ -1034,6 +1722,244 @@ "darwin" ] }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.47.0.tgz", + "integrity": "sha512-WaMrgHRbFspYjvycbsbqheBmlsQBLwfZVWv/KFsT212Yz/RjEQ/9KEp1/p0Ef3ZNwbWsylmgf69St66D9NQNHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.47.0.tgz", + "integrity": "sha512-umfYslurvSmAK5MEyOcOGooQ6EBB2pYePQaTVlrOkIfG6uuwu9egYOlxr35lwsp6XG0NzmXW0/5o150LUioMkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.47.0.tgz", + "integrity": "sha512-EFXhIykAl8//4ihOjGNirF89HEUbOB8ev2aiw8ST8wFGwDdIPARh3enDlbp8aFnScl4CDK4DZLQYXaM6qpxzZw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.47.0.tgz", + "integrity": "sha512-EwkC5N61ptruQ9wNkYfLgUWEGh+F3JZSGHkUWhaK2ISAK0d0xmiMKF0trFhRqPQFov5d9DmFiFIhWB5IC79OUA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.47.0.tgz", + "integrity": "sha512-Iz/g1X94vIjppA4H9hN3VEedw4ObC+u+aua2J/VPJnENEJ0GeCAPBN15nJc5pS5M8JPlUhOd3oqhOWX6Un4RHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.47.0.tgz", + "integrity": "sha512-eYEYHYjFo/vb6k1l5uq5+Af9yuo9WaST/z+/8T5gkee+A0Sfx1NIPZtKMEQOLjm/oaeHFGpWaAO97gTPhouIfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.47.0.tgz", + "integrity": "sha512-LX2x0/RszFEmDfjzL6kG/vihD5CkpJ+0K6lcbqX0jAopkkXeY2ZjStngdFMFW+BK7pyrqryJgy6Jt3+oyDxrSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.47.0.tgz", + "integrity": "sha512-0U+56rJmJvqBCwlPFz/BcxkvdiRdNPamBfuFHrOGQtGajSMJ2OqzlvOgwj5vReRQnSA6XMKw/JL1DaBhceil+g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.47.0.tgz", + "integrity": "sha512-2VKOsnNyvS05HFPKtmAWtef+nZyKCot/V3Jh/A5sYMhUvtthNjp6CjakYTtc5xZ8J8Fp5FKrUWGxptVtZ2OzEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.47.0.tgz", + "integrity": "sha512-uY5UP7YZM4DMQiiP9Fl4/7O3UbT2p3uI0qvqLXZSGWBfyYuqi2DYQ48ExylgBN3T8AJork+b+mLGq6VXsxBfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.47.0.tgz", + "integrity": "sha512-qpcN2+/ivq3TcrXtZoHrS9WZplV3Nieh0gvnGb+SFZg7h/YkWsOXINJnjJRWHp9tEur7T8lMnMeQMPS7s9MjUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.47.0.tgz", + "integrity": "sha512-XfuI+o7a2/KA2tBeP+J1CT3siyIQyjpGEL6fFvtUdoHJK1k5iVI3qeGT2i5y6Bb+xQu08AHKBsUGJ2GsOZzXbQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.47.0.tgz", + "integrity": "sha512-ylkLO6G7oUiN28mork3caDmgXHqRuopAxjYDaOqs4CoU9pkfR0R/pGQb2V1x2Zg3tlFj4b/DvxZroxC3xALX6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.47.0.tgz", + "integrity": "sha512-1L72a+ice8xKqJ2afsAVW9EfECOhNMAOC1jH65TgghLaHSFwNzyEdeye+1vRFDNy52OGKip/vajj0ONtX7VpAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.47.0.tgz", + "integrity": "sha512-wluhdd1uNLk/S+ex2Yj62WFw3un2cZo2ZKXy9cOuoti5IhaPXSDSvxT3os+SJ1cjNorE1PwAOfiJU7QUH6n3Zw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.47.0.tgz", + "integrity": "sha512-0SMTA6AeG7u2rfwdkKSo6aZD/obmA7oyhR+4ePwLzlwxNE8sfSI9zmjZXtchvBAZmtkVQNt/lZ6RxSl9wBj4pw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.47.0.tgz", + "integrity": "sha512-mw1/7kAGxLcfzoG7DIKFHvKr2ZUQasKOPCgT2ubkNZPgIDZOJPymqThtRWEeAlXBoipehP4BUFpBAZIrPhFg8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@sentry/core": { "version": "9.29.0", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.29.0.tgz", diff --git a/package.json b/package.json index 5cd880a..68f3139 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codesandbox/sdk", - "version": "2.4.1", + "version": "2.5.0", "description": "The CodeSandbox SDK", "author": "CodeSandbox", "license": "MIT", @@ -43,6 +43,7 @@ "build:cjs:types": "tsc -p ./tsconfig.build-cjs.json --emitDeclarationOnly", "build:esm:types": "tsc -p ./tsconfig.build-esm.json --emitDeclarationOnly", "build-openapi": "rimraf src/api-clients && curl -o openapi.json https://api.codesandbox.io/meta/openapi && npx prettier --write ./openapi.json && node_modules/.bin/openapi-ts -i ./openapi.json -o src/api-clients/client -c @hey-api/client-fetch && npm run build-openapi-pint", + "build-openapi-local": "rimraf src/api-clients && npx prettier --write ./openapi.json && node_modules/.bin/openapi-ts -i ./openapi.json -o src/api-clients/client -c @hey-api/client-fetch && npm run build-openapi-pint", "build-openapi:staging": "rimraf src/api-clients && curl -o openapi.json https://api.codesandbox.stream/meta/openapi && npx prettier --write ./openapi.json && node_modules/.bin/openapi-ts -i ./openapi.json -o src/api-clients/client -c @hey-api/client-fetch && npm run build-openapi-rest", "build-openapi-rest": "npm run build-openapi-rest-fs && npm run build-openapi-rest-task && npm run build-openapi-rest-container && npm run build-openapi-rest-git && npm run build-openapi-rest-setup && npm run build-openapi-rest-shell && npm run build-openapi-rest-system", "build-openapi-rest-container": "node_modules/.bin/openapi-ts -i ./openapi-sandbox-container.json -o src/api-clients/client-rest-container -c @hey-api/client-fetch", @@ -55,12 +56,13 @@ "build-openapi-pint": "node_modules/.bin/openapi-ts -i ./pint-openapi-bundled.json -o src/api-clients/pint -c @hey-api/client-fetch", "clean": "rimraf ./dist", "test": "vitest", - "test:e2e": "vitest run tests/e2e", + "test:e2e": "vitest run --config vitest.e2e.config.ts", "typecheck": "tsc --noEmit", "format": "prettier '**/*.{md,js,jsx,json,ts,tsx}' --write", "postbuild": "rimraf {lib,es}/**/__tests__ {lib,es}/**/*.{spec,test}.{js,d.ts,js.map}", "postversion": "git push && git push --tags", "prepublish": "npm run build", + "benchmark": "vitest run --config vitest.benchmark.config.ts", "demo:install": "cd demo && npm install", "demo:dev": "cd demo && npm run dev", "demo:build": "cd demo && npm run build" diff --git a/pint-openapi-bundled.json b/pint-openapi-bundled.json index a655213..f362e50 100644 --- a/pint-openapi-bundled.json +++ b/pint-openapi-bundled.json @@ -40,7 +40,7 @@ "tags": [ "files" ], - "description": "Creates a new file at the specified path with optional content.", + "description": "Creates a new file at the specified path with binary content from request body.", "operationId": "createFile", "security": [ { @@ -60,11 +60,14 @@ } ], "requestBody": { - "description": "File creation request", + "description": "Raw binary file content", + "required": true, "content": { - "application/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/FileCreateRequest" + "type": "string", + "format": "binary", + "description": "Raw binary file content" } } } @@ -852,12 +855,6 @@ "schema": { "$ref": "#/components/schemas/ExecItem" } - }, - "text/event-stream": { - "schema": { - "type": "string", - "description": "Server-Sent Events stream of exec updates" - } } } }, @@ -1127,10 +1124,8 @@ "content": { "text/event-stream": { "schema": { - "type": "string", - "description": "Server-Sent Events stream of exec updates with same format as ExecStdout" - }, - "example": "data: {\"type\":\"stdout\",\"output\":\"Exec output line 1\\n\", \"sequence\" : 1, \"timestamp\":\"2024-10-01T12:00:00Z\"}\n" + "$ref": "#/components/schemas/ExecStdout" + } } } }, @@ -1734,8 +1729,7 @@ "content": { "text/event-stream": { "schema": { - "type": "string", - "description": "Server-Sent Events stream of exec updates" + "$ref": "#/components/schemas/ExecListResponse" } } } @@ -1782,8 +1776,7 @@ "content": { "text/event-stream": { "schema": { - "type": "string", - "description": "Server-Sent Events stream of ports list updates" + "$ref": "#/components/schemas/PortsListResponse" } } } @@ -1820,6 +1813,115 @@ } } } + }, + "/api/v1/stream/directories/watcher/{path}": { + "get": { + "summary": "Watch directory changes using Server-Sent Events (SSE)", + "tags": [ + "streams", + "files" + ], + "description": "Watch a directory for file system changes and stream events via SSE.", + "operationId": "CreateWatcher", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "description": "Directory path to watch", + "schema": { + "type": "string" + }, + "example": "workspace/src/main.go" + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Whether to watch directories recursively", + "schema": { + "type": "boolean" + }, + "example": true + }, + { + "name": "ignorePatterns", + "in": "query", + "required": false, + "description": "Glob patterns to ignore certain files or directories (can be specified multiple times)", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "example": [ + "*.log", + "temp/*", + "node_modules/*" + ] + } + ], + "responses": { + "200": { + "description": "Directory watcher stream started successfully", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/WatcherEvent" + } + } + } + }, + "400": { + "description": "Bad Request - Path is required or invalid path", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error - Failed to create file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "default": { + "description": "Unexpected Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } } }, "components": { @@ -1868,15 +1970,6 @@ "content" ] }, - "FileCreateRequest": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "File content to create" - } - } - }, "FileOperationResponse": { "type": "object", "properties": { @@ -1908,6 +2001,10 @@ "destination": { "type": "string", "description": "Destination path for move operation" + }, + "recursive": { + "type": "boolean", + "description": "Whether to perform the action recursively for directories" } }, "required": [ @@ -2020,6 +2117,10 @@ "type": "boolean", "description": "Whether the exec is interactive" }, + "pty": { + "type": "boolean", + "description": "Whether the exec is using a pty" + }, "exitCode": { "type": "integer", "description": "Exit code of the process (only present when process has exited)" @@ -2032,6 +2133,7 @@ "status", "pid", "interactive", + "pty", "exitCode" ] }, @@ -2071,6 +2173,21 @@ "interactive": { "type": "boolean", "description": "Whether to start interactive shell session or not (defaults to false)" + }, + "pty": { + "type": "boolean", + "description": "Whether to start pty shell session or not (defaults to false)" + }, + "cwd": { + "type": "string", + "description": "Working directory for the command (defaults to workspace directory if not specified)" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Environment variables to set for the command (key-value pairs)" } }, "required": [ @@ -2105,6 +2222,42 @@ "message" ] }, + "ExecStdout": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Type of the exec output", + "enum": [ + "stdout", + "stderr" + ] + }, + "output": { + "type": "string", + "description": "Data associated with the exec output" + }, + "sequence": { + "type": "integer", + "format": "int32", + "description": "Sequence number of the output message" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Timestamp of when the output was generated" + }, + "exitCode": { + "type": "integer", + "description": "Exit code of the process (only present when process has exited)" + } + }, + "required": [ + "type", + "output", + "sequence" + ] + }, "ExecStdin": { "type": "object", "properties": { @@ -2400,44 +2553,54 @@ "ports" ] }, - "ExecStdout": { + "WatcherEvent": { "type": "object", "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "File paths affected by the event" + }, "type": { "type": "string", - "description": "Type of the exec output", + "description": "Type of file system event", "enum": [ - "stdout", - "stderr" + "ADD", + "REMOVE", + "CHANGE", + "connected", + "error" ] }, - "output": { - "type": "string", - "description": "Data associated with the exec output" - }, - "sequence": { - "type": "integer", - "format": "int32", - "description": "Sequence number of the output message" - }, "timestamp": { "type": "string", "format": "date-time", - "description": "Timestamp of when the output was generated" - }, - "exitCode": { - "type": "integer", - "description": "Exit code of the process (only present when process has exited)" + "description": "Timestamp of when the event occurred" } }, "required": [ + "paths", "type", - "output", - "sequence" + "timestamp" ] }, + "FileCreateRequest": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "File content to create" + } + } + }, "Task": { - "$ref": "#/components/schemas/TaskItem" + "allOf": [ + { + "$ref": "#/components/schemas/TaskItem" + } + ] } } } diff --git a/src/API.ts b/src/API.ts index c1fd419..9539c48 100644 --- a/src/API.ts +++ b/src/API.ts @@ -57,7 +57,6 @@ import type { } from "./api-clients/client"; import { PitcherManagerResponse } from "./types"; - export interface APIOptions { apiKey: string; config?: Config; @@ -346,6 +345,9 @@ export class API { pitcherVersion: handledResponse.pitcher_version, latestPitcherVersion: handledResponse.latest_pitcher_version, pitcherToken: handledResponse.pitcher_token, + pintToken: handledResponse.pint_token, + pintURL: handledResponse.pint_url, + vmAgentType: handledResponse.vm_agent_type, }; } diff --git a/src/AgentClient/index.ts b/src/AgentClient/index.ts index ddfced6..95ba348 100644 --- a/src/AgentClient/index.ts +++ b/src/AgentClient/index.ts @@ -17,12 +17,15 @@ import { IAgentClientSystem, IAgentClientTasks, PickRawFsResult, + SubscribeShellEvent, } from "../agent-client-interface"; import { AgentConnection } from "./AgentConnection"; import { Emitter, Event } from "../utils/event"; import { DEFAULT_SUBSCRIPTIONS, SandboxSession } from "../types"; import { SandboxClient } from "../SandboxClient"; import { InitStatus } from "../pitcher-protocol/messages/system"; +import { IDisposable } from "@xterm/headless"; +import { Disposable } from "../utils/disposable"; // Timeout for detecting a pong response, leading to a forced disconnect // Increased from 15s to 30s to be more tolerant of network latency @@ -31,51 +34,134 @@ let PONG_DETECTION_TIMEOUT = 30_000; // When focusing the app we do a lower timeout to more quickly detect a potential disconnect const FOCUS_PONG_DETECTION_TIMEOUT = 5_000; -class AgentClientShells implements IAgentClientShells { - private onShellExitedEmitter = new Emitter<{ - shellId: string; - exitCode: number; - }>(); - onShellExited = this.onShellExitedEmitter.event; - - private onShellTerminatedEmitter = new Emitter< - shell.ShellTerminateNotification["params"] - >(); - onShellTerminated = this.onShellTerminatedEmitter.event; - - private onShellOutEmitter = new Emitter< - shell.ShellOutNotification["params"] - >(); - onShellOut = this.onShellOutEmitter.event; +type ShellStateChangeEvent = + | { type: "out"; out: string } + | { type: "exit"; exitCode: number }; + +class ShellState { + private listener?: (event: ShellStateChangeEvent) => void; + // The buffer is populated when there is no listener yet, ensuring that we capture + // all output and return it + private buffer: string[] = []; + private exitCode?: number; + getBuffer() { + const bufferString = this.buffer.join("\n"); + this.buffer.length = 0; + return bufferString; + } + getExitCode() { + return this.exitCode; + } + addOut(out: string) { + if (!this.listener) { + this.buffer.push(out); + return; + } - constructor(private agentConnection: AgentConnection) { - agentConnection.onNotification("shell/exit", (params) => { - this.onShellExitedEmitter.fire(params); + this.listener({ + type: "out", + out, }); + } + setExitCode(exitCode: number) { + this.exitCode = exitCode; - agentConnection.onNotification("shell/terminate", (params) => { - this.onShellTerminatedEmitter.fire(params); - }); + if (!this.listener) { + return; + } - agentConnection.onNotification("shell/out", (params) => { - this.onShellOutEmitter.fire(params); + this.listener({ + type: "exit", + exitCode, }); } - create( - projectPath: string, - size: shell.ShellSize, - command?: string, - type?: shell.ShellProcessType, - isSystemShell?: boolean - ): Promise { + onChange(listener: (event: ShellStateChangeEvent) => void) { + this.listener = listener; + + if (this.buffer.length) { + const bufferString = this.buffer.join("\n"); + this.buffer.length = 0; + listener({ + type: "out", + out: bufferString, + }); + } + + return () => { + this.listener = undefined; + }; + } +} + +class AgentClientShells implements IAgentClientShells { + disposeOutputListener: () => void; + disposeExitListener: () => void; + disposeTerminateListener: () => void; + private shellStates: Record = {}; + constructor(private agentConnection: AgentConnection) { + // We use a common listener to keep track of all shell output to avoid race conditions. These + // are then flushed. This does not work with multiple listeners, but you would not use multiple + // listeners for command/terminal output anyways. NOTE! These notifications only appear for created/opened shells + this.disposeOutputListener = agentConnection.onNotification( + "shell/out", + (event) => { + if (!this.shellStates[event.shellId]) { + this.shellStates[event.shellId] = new ShellState(); + } + + this.shellStates[event.shellId].addOut(event.out); + } + ); + this.disposeExitListener = agentConnection.onNotification( + "shell/exit", + (event) => { + if (!this.shellStates[event.shellId]) { + this.shellStates[event.shellId] = new ShellState(); + } + + this.shellStates[event.shellId].setExitCode(event.exitCode); + } + ); + this.disposeTerminateListener = agentConnection.onNotification( + "shell/terminate", + (event) => { + if (!this.shellStates[event.shellId]) { + this.shellStates[event.shellId] = new ShellState(); + } + + this.shellStates[event.shellId].setExitCode(130); + } + ); + } + create({ + command, + args, + size, + type, + isSystemShell, + projectPath, + cwd, + }: { + command: string; + args: string[]; + projectPath: string; + size: shell.ShellSize; + type?: shell.ShellProcessType; + isSystemShell?: boolean; + cwd?: string; + }): Promise { + // Pitcher protocol expects a single command string, so we concatenate command and args + const fullCommand = + args.length > 0 ? `${command} ${args.join(" ")}` : command; + return this.agentConnection.request({ method: "shell/create", params: { - command, + command: fullCommand, size, type, isSystemShell, - cwd: projectPath, + cwd: cwd || projectPath, }, }); } @@ -97,17 +183,108 @@ class AgentClientShells implements IAgentClientShells { return result.shells; } - open( + subscribe( shellId: shell.ShellId, - size: shell.ShellSize - ): Promise { - return this.agentConnection.request({ - method: "shell/open", - params: { - shellId, - size, - }, + listener: (event: SubscribeShellEvent) => void + ): IDisposable { + const disposable = new Disposable(); + + const disposeExit = this.agentConnection.onNotification( + "shell/exit", + (params) => { + if (params.shellId === shellId) { + listener({ type: "exit", exitCode: params.exitCode }); + } + } + ); + + const disposeTerminate = this.agentConnection.onNotification( + "shell/terminate", + (params) => { + if (params.shellId === shellId) { + listener({ type: "terminate" }); + } + } + ); + + disposable.onDidDispose(() => { + disposeExit(); + disposeTerminate(); }); + + return disposable; + } + subscribeOutput( + shellId: shell.ShellId, + size: shell.ShellSize, + listener: (event: { out: string; exitCode?: number }) => void + ): IDisposable { + const disposable = new Disposable(); + + if (!this.shellStates[shellId]) { + this.shellStates[shellId] = new ShellState(); + } + + const shellState = this.shellStates[shellId]; + + if (shellState.getExitCode() !== undefined) { + listener({ + out: shellState.getBuffer(), + exitCode: shellState.getExitCode(), + }); + + return disposable; + } + + const disposeChangeListener = shellState.onChange((event) => { + if (event.type === "out") { + listener({ + out: event.out, + }); + } else { + listener({ + out: shellState.getBuffer(), + exitCode: event.exitCode, + }); + } + }); + + disposable.onWillDispose(disposeChangeListener); + + // If subscribing to existing shell we need to open it to get events + this.agentConnection + .request({ + method: "shell/open", + params: { + shellId, + size, + }, + }) + .then((openShell) => { + listener({ + out: openShell.buffer.join("\n"), + exitCode: openShell.exitCode, + }); + }) + .catch(() => { + // The shell does not exist + }); + + disposable.onDidDispose(() => { + this.agentConnection + .request({ + method: "shell/close", + params: { + shellId, + size, + }, + }) + .catch(() => { + // We do not care + }); + }); + + return disposable; } rename(shellId: shell.ShellId, name: string): Promise { return this.agentConnection.request({ @@ -390,6 +567,7 @@ class AgentClientSystem implements IAgentClientSystem { } export class AgentClient implements IAgentClient { + readonly type = "pitcher" as const; static async create({ session, getSession, @@ -483,6 +661,9 @@ export class AgentClient implements IAgentClient { } } dispose() { + this.shells.disposeOutputListener(); + this.shells.disposeExitListener(); + this.shells.disposeTerminateListener(); this.agentConnection.dispose(); } } diff --git a/src/PintClient/execs.ts b/src/PintClient/execs.ts index eaa8e5b..e09e64d 100644 --- a/src/PintClient/execs.ts +++ b/src/PintClient/execs.ts @@ -1,9 +1,9 @@ import { Client } from "../api-clients/pint/client"; -import { Emitter, EmitterSubscription } from "../utils/event"; import { Disposable } from "../utils/disposable"; import { parseStreamEvent } from "./utils"; import { - IAgentClientShells, + IAgentClientShells, + SubscribeShellEvent, } from "../agent-client-interface"; import { createExec, @@ -27,17 +27,19 @@ import { ShellDTO, ShellProcessStatus, } from "../pitcher-protocol/messages/shell"; +import { IDisposable } from "@xterm/headless"; export class PintShellsClient implements IAgentClientShells { - private openShells: Record = {}; + private execs: ExecItem[] = []; + constructor(private apiClient: Client, private sandboxId: string) {} private subscribeAndEvaluateExecsUpdates( + execId: string, compare: ( nextExec: ExecItem, - prevExec: ExecItem | undefined, + prevExec: ExecItem, prevExecs: ExecItem[] ) => void ) { - let prevExecs: ExecItem[] = []; const abortController = new AbortController(); streamExecsList({ @@ -50,18 +52,23 @@ export class PintShellsClient implements IAgentClientShells { for await (const evt of stream) { const execListResponse = parseStreamEvent(evt); const execs = execListResponse.execs; + const newExec = execs.find((exec) => exec.id === execId); + const currentExec = this.execs.find((exec) => exec.id === execId); - if (prevExecs && execs) { - execs.forEach((exec) => { - const prevExec = prevExecs?.find( - (execItem) => execItem.id === exec.id - ); - - compare(exec, prevExec, prevExecs); - }); + // Removed + if (!newExec && currentExec) { + this.execs.splice(this.execs.indexOf(currentExec), 1); + } + // Added + else if (newExec && !currentExec) { + this.execs.push(newExec); } + // Updated + else if (newExec && currentExec) { + compare(newExec, currentExec, this.execs); - prevExecs = execs || []; + this.execs[this.execs.indexOf(currentExec)] = newExec; + } } }); @@ -69,56 +76,21 @@ export class PintShellsClient implements IAgentClientShells { abortController.abort(); }); } - private onShellExitedEmitter = new EmitterSubscription<{ - shellId: string; - exitCode: number; - }>((fire) => - this.subscribeAndEvaluateExecsUpdates((exec, prevExec) => { - if (!prevExec) { - return; - } - - if (prevExec.status === "RUNNING" && exec.status === "EXITED") { - fire({ - shellId: exec.id, - exitCode: exec.exitCode, - }); - } - }) - ); - onShellExited = this.onShellExitedEmitter.event; - - private onShellOutEmitter = new Emitter<{ - shellId: ShellId; - out: string; - }>(); - onShellOut = this.onShellOutEmitter.event; - private onShellTerminatedEmitter = new EmitterSubscription<{ - shellId: string; - author: string; - }>((fire) => - this.subscribeAndEvaluateExecsUpdates((exec, prevExec) => { - if (!prevExec) { - return; - } - - if (prevExec.status === "RUNNING" && exec.status === "STOPPED") { - fire({ - shellId: exec.id, - author: "", - }); - } - }) - ); - onShellTerminated = this.onShellTerminatedEmitter.event; - constructor(private apiClient: Client, private sandboxId: string) {} private convertExecToShellDTO(exec: ExecItem) { return { isSystemShell: true, name: JSON.stringify({ type: "command", command: exec.command, - name: "", + name: exec.interactive + ? JSON.stringify({ + type: "terminal", + command: exec.command, + }) + : JSON.stringify({ + type: "command", + command: exec.command, + }), }), ownerUsername: "root", shellId: exec.id, @@ -127,22 +99,30 @@ export class PintShellsClient implements IAgentClientShells { status: exec.status as ShellProcessStatus, }; } - async create( - projectPath: string, - size: ShellSize, - command?: string, - type?: ShellProcessType, - isSystemShell?: boolean - ): Promise { - // For Pint, we need to construct args from command - const args = command ? command.split(' ').slice(1) : []; - const baseCommand = command ? command.split(' ')[0] : 'bash'; + async create({ + command, + args, + projectPath, + size, + type, + cwd, + }: { + command: string; + args: string[]; + projectPath: string; + size: ShellSize; + type?: ShellProcessType; + isSystemShell?: boolean; + cwd?: string; + }): Promise { const exec = await createExec({ client: this.apiClient, body: { args, - command: baseCommand, + command, interactive: type === "COMMAND" ? false : true, + // @ts-expect-error - cwd support will be added to Pint API shortly + cwd: cwd || projectPath, }, }); @@ -150,14 +130,65 @@ export class PintShellsClient implements IAgentClientShells { throw new Error(exec.error.message); } - await this.open(exec.data.id, { cols: 200, rows: 80 }); + this.execs.push(exec.data); return { ...this.convertExecToShellDTO(exec.data), buffer: [], }; } - async delete(shellId: ShellId): Promise { + subscribe( + shellId: ShellId, + listener: (event: SubscribeShellEvent) => void + ): IDisposable { + return this.subscribeAndEvaluateExecsUpdates(shellId, (exec, prevExec) => { + if (prevExec.status === "RUNNING" && exec.status === "EXITED") { + listener({ + type: "exit", + exitCode: exec.exitCode, + }); + } + }); + } + subscribeOutput( + shellId: ShellId, + size: ShellSize, + listener: (event: { out: string; exitCode?: number }) => void + ): IDisposable { + const disposable = new Disposable(); + const abortController = new AbortController(); + + getExecOutput({ + client: this.apiClient, + path: { id: shellId }, + query: { lastSequence: 0 }, + signal: abortController.signal, + headers: { + Accept: "text/event-stream", + }, + }).then(async ({ stream }) => { + for await (const evt of stream) { + const data = parseStreamEvent<{ + type: "stdout" | "stderr"; + output: ""; + sequence: number; + timestamp: string; + exitCode?: number; + }>(evt); + + listener({ out: data.output, exitCode: data.exitCode }); + } + }); + + disposable.onDidDispose(() => { + abortController.abort(); + }); + + return disposable; + } + async delete( + shellId: ShellId + ): Promise { try { // First get the exec details before deleting it const exec = await getExec({ @@ -183,12 +214,6 @@ export class PintShellsClient implements IAgentClientShells { }); if (deleteResponse.data) { - // Clean up any open shells reference - if (this.openShells[shellId]) { - this.openShells[shellId].abort(); - delete this.openShells[shellId]; - } - return shellDTO as CommandShellDTO | TerminalShellDTO; } else { return null; @@ -206,53 +231,6 @@ export class PintShellsClient implements IAgentClientShells { execs.data?.execs.map((exec) => this.convertExecToShellDTO(exec)) ?? [] ); } - async open(shellId: ShellId, size: ShellSize): Promise { - const abortController = new AbortController(); - - this.openShells[shellId] = abortController; - - const exec = await getExec({ - client: this.apiClient, - path: { - id: shellId, - }, - }); - - if (!exec.data) { - throw new Error(exec.error.message); - } - - const { stream } = await getExecOutput({ - client: this.apiClient, - path: { id: shellId }, - query: { lastSequence: 0 }, - signal: abortController.signal, - headers: { - Accept: "text/event-stream", - }, - }); - - const buffer: string[] = []; - - for await (const evt of stream) { - const data = parseStreamEvent<{ - type: "stdout" | "stderr"; - output: ""; - sequence: number; - timestamp: string; - }>(evt); - - if (!buffer.length) { - buffer.push(data.output); - break; - } - } - - return { - buffer, - ...this.convertExecToShellDTO(exec.data), - }; - } async rename(shellId: ShellId, name: string): Promise { return null; } @@ -264,7 +242,7 @@ export class PintShellsClient implements IAgentClientShells { id: shellId, }, body: { - status: 'running', + status: "running", }, }); @@ -281,7 +259,7 @@ export class PintShellsClient implements IAgentClientShells { id: shellId, }, body: { - type: 'stdin', + type: "stdin", input: input, }, }); diff --git a/src/PintClient/fs.ts b/src/PintClient/fs.ts index e1392bc..5207ad0 100644 --- a/src/PintClient/fs.ts +++ b/src/PintClient/fs.ts @@ -1,8 +1,8 @@ import { Client } from "../api-clients/pint/client"; -import { - IAgentClientFS, - PickRawFsResult, -} from "../agent-client-interface"; +import { IAgentClientFS, PickRawFsResult } from "../agent-client-interface"; +import { fs } from "../pitcher-protocol"; +import { Disposable } from "../utils/disposable"; +import { parseStreamEvent } from "./utils"; import { createFile, readFile, @@ -12,6 +12,7 @@ import { deleteDirectory, getFileStat, } from "../api-clients/pint"; + export class PintFsClient implements IAgentClientFS { constructor(private apiClient: Client) {} @@ -95,18 +96,16 @@ export class PintFsClient implements IAgentClientFS { create?: boolean, overwrite?: boolean ): Promise> { - try { - // Convert Uint8Array content to string for the API - const decoder = new TextDecoder(); - const contentString = decoder.decode(content); - + try { const response = await createFile({ client: this.apiClient, path: { path: path, }, - body: { - content: contentString, + body: content as unknown as { content: string }, + bodySerializer: (body) => body as unknown as string, + headers: { + "Content-Type": "application/octet-stream", }, }); @@ -132,7 +131,7 @@ export class PintFsClient implements IAgentClientFS { } } - async remove( + async remove( path: string, recursive?: boolean ): Promise> { @@ -253,7 +252,7 @@ export class PintFsClient implements IAgentClientFS { path: from, }, body: { - action: 'copy', + action: "copy", destination: to, }, }); @@ -292,7 +291,7 @@ export class PintFsClient implements IAgentClientFS { path: from, }, body: { - action: 'move', + action: "move", destination: to, }, }); @@ -325,15 +324,101 @@ export class PintFsClient implements IAgentClientFS { readonly recursive?: boolean; readonly excludes?: readonly string[]; }, - onEvent: (watchEvent: any) => void + onEvent: (watchEvent: fs.FSWatchEvent) => void ): Promise< | (PickRawFsResult<"fs/watch"> & { type: "error" }) | { type: "success"; dispose(): void } > { - throw new Error("Not implemented"); + try { + const abortController = new AbortController(); + const config = this.apiClient.getConfig(); + + const url = this.apiClient.buildUrl({ + baseUrl: config.baseUrl as string, + url: "/api/v1/stream/directories/watcher/{path}", + path: { path: path.startsWith("/") ? path.slice(1) : path }, + query: { + recursive: options.recursive, + ignorePatterns: options.excludes ? [...options.excludes] : undefined, + }, + querySerializer: + typeof config.querySerializer === "function" + ? config.querySerializer + : undefined, + }); + + // Make the fetch eagerly so watch() only resolves once the server + // has confirmed the watcher is active (200 OK means ready channel fired). + const _fetch = config.fetch ?? globalThis.fetch; + const response = await _fetch( + new Request(url, { + method: "GET", + headers: config.headers as Headers, + signal: abortController.signal, + }) + ); + + if (!response.ok) { + return { + type: "error", + error: `Failed to establish watcher: ${response.status} ${response.statusText}`, + errno: null, + }; + } + + // SSE connection established — server watcher is now active. + // Process the stream in the background. + let reader: ReadableStreamDefaultReader | null = null; + (async () => { + try { + if (!response.body) return; + reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + const chunks = buffer.split("\n\n"); + buffer = chunks.pop() ?? ""; + for (const chunk of chunks) { + const dataLine = chunk + .split("\n") + .find((l) => l.startsWith("data:")); + if (!dataLine) continue; + try { + const data = JSON.parse(dataLine.replace(/^data:\s*/, "")); + onEvent(parseStreamEvent(data)); + } catch (e) { + console.warn("Failed to parse filesystem watch event:", e); + } + } + } + } catch (error) { + if ((error as Error)?.name !== "AbortError") { + console.error("Filesystem watch stream error:", error); + } + } + })(); + + return { + type: "success", + dispose(): void { + reader?.cancel(); + abortController.abort(); + }, + }; + } catch (error) { + return { + type: "error", + error: error instanceof Error ? error.message : "Unknown error", + errno: null, + }; + } } async download(path?: string): Promise<{ downloadUrl: string }> { throw new Error("Not implemented"); } -} \ No newline at end of file +} diff --git a/src/PintClient/index.ts b/src/PintClient/index.ts index 3431c93..6b6cc91 100644 --- a/src/PintClient/index.ts +++ b/src/PintClient/index.ts @@ -3,9 +3,9 @@ import { Emitter, EmitterSubscription } from "../utils/event"; import { SandboxSession } from "../types"; import { Disposable } from "../utils/disposable"; import { Client, createClient, createConfig } from "../api-clients/pint/client"; -import { PintClientTasks, PintClientSetup, PintClientSystem} from "./tasks"; -import {PintFsClient} from "./fs"; -import {PintShellsClient} from "./execs"; +import { PintClientTasks, PintClientSetup, PintClientSystem } from "./tasks"; +import { PintFsClient } from "./fs"; +import { PintShellsClient } from "./execs"; import { parseStreamEvent } from "./utils"; import { IAgentClient, @@ -69,7 +69,6 @@ class PintPortsClient implements IAgentClientPorts { } } - export class PintClient implements IAgentClient { static async create(session: SandboxSession) { return new PintClient(session); @@ -101,9 +100,9 @@ export class PintClient implements IAgentClient { const apiClient = createClient( createConfig({ - baseUrl: session.pitcherURL, + baseUrl: session.pintURL, headers: { - Authorization: `Bearer ${session.pitcherToken}`, + Authorization: `Bearer ${session.pintToken}`, }, }) ); @@ -120,4 +119,4 @@ export class PintClient implements IAgentClient { async reconnect(): Promise {} async disconnect(): Promise {} dispose(): void {} -} \ No newline at end of file +} diff --git a/src/PintClient/tasks.ts b/src/PintClient/tasks.ts index 2bdeb1a..21729e7 100644 --- a/src/PintClient/tasks.ts +++ b/src/PintClient/tasks.ts @@ -56,7 +56,9 @@ export class PintClientTasks implements IAgentClientTasks { return { tasks: {}, setupTasks: [], - validationErrors: [error instanceof Error ? error.message : "Unknown error"], + validationErrors: [ + error instanceof Error ? error.message : "Unknown error", + ], }; } } @@ -168,28 +170,39 @@ export class PintClientSetup implements IAgentClientSetup { if (response.data) { // Convert API setup tasks to setup progress format - const steps: setup.Step[] = response.data.setupTasks.map((setupTask) => ({ - name: setupTask.name, - command: setupTask.command, - shellId: setupTask.execId || null, - finishStatus: setupTask.status === 'FINISHED' ? 'SUCCEEDED' : - setupTask.status === 'ERROR' ? 'FAILED' : null, - })); + const steps: setup.Step[] = response.data.setupTasks.map( + (setupTask) => ({ + name: setupTask.name, + command: setupTask.command, + shellId: setupTask.execId || null, + finishStatus: + setupTask.status === "FINISHED" + ? "SUCCEEDED" + : setupTask.status === "ERROR" + ? "FAILED" + : null, + }) + ); // Determine overall state based on task statuses - let state: setup.SetupProgress['state'] = 'IDLE'; + let state: setup.SetupProgress["state"] = "IDLE"; let currentStepIndex = 0; - const hasRunningTask = response.data.setupTasks.some(task => task.status === 'RUNNING'); - const allFinished = response.data.setupTasks.every(task => - task.status === 'FINISHED' || task.status === 'ERROR'); + const hasRunningTask = response.data.setupTasks.some( + (task) => task.status === "RUNNING" + ); + const allFinished = response.data.setupTasks.every( + (task) => task.status === "FINISHED" || task.status === "ERROR" + ); if (hasRunningTask) { - state = 'IN_PROGRESS'; + state = "IN_PROGRESS"; // Find the first running task - currentStepIndex = response.data.setupTasks.findIndex(task => task.status === 'RUNNING'); + currentStepIndex = response.data.setupTasks.findIndex( + (task) => task.status === "RUNNING" + ); } else if (allFinished) { - state = 'FINISHED'; + state = "FINISHED"; currentStepIndex = steps.length - 1; } @@ -201,7 +214,7 @@ export class PintClientSetup implements IAgentClientSetup { } else { // Return empty setup progress if no data return { - state: 'IDLE', + state: "IDLE", steps: [], currentStepIndex: 0, }; @@ -209,7 +222,7 @@ export class PintClientSetup implements IAgentClientSetup { } catch (error) { console.error("Failed to get setup progress:", error); return { - state: 'IDLE', + state: "IDLE", steps: [], currentStepIndex: 0, }; @@ -235,4 +248,4 @@ export class PintClientSystem implements IAgentClientSystem { async update(): Promise> { return {}; } -} \ No newline at end of file +} diff --git a/src/Sandbox.ts b/src/Sandbox.ts index cc4ad7f..7cc83be 100644 --- a/src/Sandbox.ts +++ b/src/Sandbox.ts @@ -151,13 +151,13 @@ export class Sandbox { return `export ${key}='${safe}'`; }) .join("\n"); - commands.push( - [ - `cat << 'EOF' > "$HOME/.private/.env"`, - envStrings, - `EOF`, - ].join("\n") - ); + const cmd = [ + `mkdir -p "$HOME/.private"`, + `cat << 'EOF' > "$HOME/.private/.env"`, + envStrings, + `EOF`, + ].join("\n"); + await client.commands.run(cmd); } if (customSession.git) { @@ -188,8 +188,7 @@ export class Sandbox { pitcherManagerResponse: PitcherManagerResponse, customSession?: SessionCreateOptions ): Promise { - // HACK: we currently do not get a flag for pint, but this is a check we can use for now - const isPint = false; + const isPint = pitcherManagerResponse.vmAgentType === "pint"; if (!customSession || !customSession.id) { return { @@ -205,6 +204,9 @@ export class Sandbox { userWorkspacePath: pitcherManagerResponse.userWorkspacePath, workspacePath: pitcherManagerResponse.workspacePath, pitcherVersion: pitcherManagerResponse.pitcherVersion, + pintToken: pitcherManagerResponse.pintToken, + pintURL: pitcherManagerResponse.pintURL, + vmAgentType: pitcherManagerResponse.vmAgentType, }; } @@ -231,6 +233,9 @@ export class Sandbox { userWorkspacePath: handledResponse.user_workspace_path, workspacePath: pitcherManagerResponse.workspacePath, pitcherVersion: pitcherManagerResponse.pitcherVersion, + pintToken: pitcherManagerResponse.pintToken, + pintURL: pitcherManagerResponse.pintURL, + vmAgentType: pitcherManagerResponse.vmAgentType, }; } diff --git a/src/SandboxClient/commands.ts b/src/SandboxClient/commands.ts index 50b842a..e71f410 100644 --- a/src/SandboxClient/commands.ts +++ b/src/SandboxClient/commands.ts @@ -41,7 +41,7 @@ export class CommandError extends Error { output: string; constructor(message: string, exitCode: number, output: string) { - super(message); + super(message + " " + output); this.name = "CommandError"; this.exitCode = exitCode; this.output = output; @@ -104,6 +104,82 @@ export class SandboxCommands { ); } + private async runBackgroundPitcher( + command: string | string[], + opts?: ShellRunOpts + ) { + const disposableStore = new DisposableStore(); + const onOutput = new Emitter(); + disposableStore.add(onOutput); + + command = Array.isArray(command) ? command.join(" && ") : command; + + const passedEnv = Object.assign(opts?.env ?? {}); + + const escapedCommand = command.replace(/'/g, "'\\''"); + + // Build bash args array + const args = ["source $HOME/.private/.env 2>/dev/null || true"]; + + // Add cd command if cwd is specified (Pitcher doesn't support cwd parameter) + if (opts?.cwd) { + args.push("&&", "cd", opts.cwd); + } + + if (Object.keys(passedEnv).length) { + args.push("&&", "env"); + Object.entries(passedEnv).forEach(([key, value]) => { + const escapedValue = String(value).replace(/'/g, "'\\''"); + args.push(`${key}='${escapedValue}'`); + }); + args.push("bash", "-c", `'${escapedCommand}'`); + } else { + args.push("&&", "bash", "-c", `'${escapedCommand}'`); + } + + const shell = await this.agentClient.shells.create({ + projectPath: this.agentClient.workspacePath, + size: opts?.dimensions ?? DEFAULT_SHELL_SIZE, + command: "bash", + args: ["-c", args.join(" ")], + type: opts?.asGlobalSession ? "COMMAND" : "TERMINAL", + isSystemShell: true, + }); + + if (shell.status === "ERROR" || shell.status === "KILLED") { + throw new Error(`Failed to create shell: ${shell.buffer.join("\n")}`); + } + + const details = { + type: "command", + command, + name: opts?.name, + }; + + if (shell.status !== "FINISHED") { + // Only way for us to differentiate between a command and a terminal + this.agentClient.shells + .rename( + shell.shellId, + // We embed some details in the name to properly show the command that was run + // , the name and that it is an actual command + JSON.stringify(details) + ) + .catch(() => { + // It is already done + }); + } + + const cmd = new Command( + this.agentClient, + shell as protocol.shell.CommandShellDTO, + details, + this.tracer + ); + + return cmd; + } + /** * Create and run command in a new shell. Allows you to listen to the output and kill the command. */ @@ -118,39 +194,38 @@ export class SandboxCommands { "command.name": opts?.name || "", }, async () => { - const disposableStore = new DisposableStore(); - const onOutput = new Emitter(); - disposableStore.add(onOutput); + if (this.agentClient.type === "pitcher") { + return this.runBackgroundPitcher(command, opts); + } command = Array.isArray(command) ? command.join(" && ") : command; const passedEnv = Object.assign(opts?.env ?? {}); - const escapedCommand = command.replace(/'/g, "'\\''"); + // Build bash args array + const args = ["source $HOME/.private/.env 2>/dev/null || true"]; - // 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 (Object.keys(passedEnv).length) { + Object.entries(passedEnv).forEach(([key, value]) => { + args.push("&&", "env", `${key}=${value}`); + }); + } + // Add cd command if cwd is specified (Pitcher doesn't support cwd parameter) if (opts?.cwd) { - commandWithEnv = `cd ${opts.cwd} && ${commandWithEnv}`; + args.push("&&", "cd", opts.cwd); } - const shell = await this.agentClient.shells.create( - this.agentClient.workspacePath, - opts?.dimensions ?? DEFAULT_SHELL_SIZE, - commandWithEnv, - opts?.asGlobalSession ? "COMMAND" : "TERMINAL", - true - ); + args.push("&&", command); + + const shell = await this.agentClient.shells.create({ + command: "bash", + args: ["-c", args.join(" ")], + projectPath: this.agentClient.workspacePath, + size: opts?.dimensions ?? DEFAULT_SHELL_SIZE, + type: opts?.asGlobalSession ? "COMMAND" : "TERMINAL", + isSystemShell: true, + }); if (shell.status === "ERROR" || shell.status === "KILLED") { throw new Error(`Failed to create shell: ${shell.buffer.join("\n")}`); @@ -216,7 +291,8 @@ export class SandboxCommands { return shells .filter( - (shell) => shell.shellType === "TERMINAL" && isCommandShell(shell) + (shell): shell is protocol.shell.CommandShellDTO => + shell.shellType === "TERMINAL" && isCommandShell(shell) ) .map( (shell) => @@ -266,6 +342,7 @@ export class Command { private barrier = new Barrier(); private output: string[] = []; + private isSubscribingOutput = false; /** * The status of the command. @@ -308,39 +385,31 @@ export class Command { this.name = details.name; this.tracer = tracer; - 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(); - } - }) - ); - - this.disposable.addDisposable( - agentClient.shells.onShellTerminated(({ shellId }) => { - if (shellId === this.shell.shellId) { - this.status = "KILLED"; - this.barrier.open(); - } - }) - ); - - this.disposable.addDisposable( - this.agentClient.shells.onShellOut(({ shellId, out }) => { - if (shellId !== this.shell.shellId || out.startsWith("[CODESANDBOX]")) { - return; - } - - this.onOutputEmitter.fire(out); + // This only happens on Pitcher, Pint will listen to output from lastSequene=0 + if (shell.buffer) { + this.output = shell.buffer; + } - this.output.push(out); - if (this.output.length > 1000) { - this.output.shift(); - } - }) - ); + if (shell.status === "RUNNING") { + this.disposable.addDisposable( + this.agentClient.shells.subscribeOutput( + this.shell.shellId, + DEFAULT_SHELL_SIZE, + (event) => { + this.output.push(event.out); + if (event.exitCode === 0) { + this.exitCode = event.exitCode; + this.status = event.exitCode === 0 ? "FINISHED" : "ERROR"; + this.barrier.open(); + } else if (typeof event.exitCode === "number") { + this.exitCode = event.exitCode; + this.status = "KILLED"; + this.barrier.open(); + } + } + ) + ); + } } private async withSpan( @@ -389,14 +458,45 @@ export class Command { "command.dimensions.rows": dimensions.rows, }, async () => { - const shell = await this.agentClient.shells.open( - this.shell.shellId, - dimensions + if (this.isSubscribingOutput) { + return this.output.join("\n"); + } + + this.isSubscribingOutput = true; + const barrier = new Barrier(); + + this.disposable.addDisposable( + this.agentClient.shells.subscribeOutput( + this.shell.shellId, + dimensions, + ({ out }) => { + if (barrier.isOpen()) { + this.onOutputEmitter.fire(out); + + this.output.push(out); + if (this.output.length > 1000) { + this.output.shift(); + } + } else { + this.output.push(out); + barrier.open(out); + } + } + ) ); - this.output = shell.buffer; + this.disposable.onDidDispose(() => { + barrier.dispose(); + }); + + const result = await barrier.wait(); + + // This will never really happen + if (result.status === "disposed") { + return ""; + } - return this.output.join("\n"); + return result.value; } ); } @@ -422,12 +522,14 @@ export class Command { "" ); + this.disposable.dispose(); + if (this.status === "FINISHED") { return cleaned; } throw new CommandError( - `Command failed with exit code ${this.exitCode ?? "unknown"}`, + `Command failed with exit code ${this.exitCode ?? "unknown"}.`, this.exitCode ?? 1, cleaned ); diff --git a/src/SandboxClient/filesystem.ts b/src/SandboxClient/filesystem.ts index 68440a5..707b824 100644 --- a/src/SandboxClient/filesystem.ts +++ b/src/SandboxClient/filesystem.ts @@ -184,13 +184,15 @@ export class FileSystem { try { // Extract the zip file using unzip command - const result = await this.agentClient.shells.create( - this.agentClient.workspacePath, - { cols: 128, rows: 24 }, - `cd ${this.agentClient.workspacePath} && unzip -o ${tempZipPath}`, - "COMMAND", - true - ); + const result = await this.agentClient.shells.create({ + projectPath: this.agentClient.workspacePath, + size: { cols: 128, rows: 24 }, + command: "unzip", + args: ["-o", tempZipPath], + type: "COMMAND", + isSystemShell: true, + cwd: this.agentClient.workspacePath, + }); if (result.status === "ERROR" || result.status === "KILLED") { throw new Error( @@ -204,16 +206,17 @@ export class FileSystem { if (result.status === "RUNNING") { // Wait for shell exit event await new Promise((resolve, reject) => { - const disposable = this.agentClient.shells.onShellExited( - ({ shellId, exitCode }) => { - if (shellId === result.shellId) { + const disposable = this.agentClient.shells.subscribe( + result.shellId, + (event) => { + if (event.type === "exit") { disposable.dispose(); - if (exitCode === 0) { + if (event.exitCode === 0) { resolve(); } else { reject( new Error( - `Unzip command failed with exit code ${exitCode}` + `Unzip command failed with exit code ${event.exitCode}` ) ); } diff --git a/src/SandboxClient/index.ts b/src/SandboxClient/index.ts index 4a3159a..3c7637f 100644 --- a/src/SandboxClient/index.ts +++ b/src/SandboxClient/index.ts @@ -45,10 +45,14 @@ export class SandboxClient { if (session.isPint) { const pintClient = await PintClient.create(session); const progress = await pintClient.setup.getProgress(); - return new SandboxClient(pintClient, { - hostToken: session.hostToken, - tracer, - }, progress); + return new SandboxClient( + pintClient, + { + hostToken: session.hostToken, + tracer, + }, + progress + ); } const { client: agentClient, joinResult } = await AgentClient.create({ diff --git a/src/SandboxClient/setup.ts b/src/SandboxClient/setup.ts index 5b5d36e..8ecdf54 100644 --- a/src/SandboxClient/setup.ts +++ b/src/SandboxClient/setup.ts @@ -4,6 +4,7 @@ import { Emitter } from "../utils/event"; import { DEFAULT_SHELL_SIZE } from "./terminals"; import { type IAgentClient } from "../agent-client-interface"; import { Tracer, SpanStatusCode } from "@opentelemetry/api"; +import { Barrier } from "../utils/barrier"; export class Setup { private disposable = new Disposable(); @@ -164,18 +165,6 @@ export class Step { } }) ); - this.disposable.addDisposable( - this.agentClient.shells.onShellOut(({ shellId, out }) => { - if (shellId === this.step.shellId) { - this.onOutputEmitter.fire(out); - - this.output.push(out); - if (this.output.length > 1000) { - this.output.shift(); - } - } - }) - ); } private withSpan( @@ -224,11 +213,31 @@ export class Step { }, async () => { const open = async (shellId: protocol.shell.ShellId) => { - const shell = await this.agentClient.shells.open(shellId, dimensions); + const barrier = new Barrier(); + this.agentClient.shells.subscribeOutput( + shellId, + dimensions, + ({ out }) => { + if (barrier.isOpen()) { + this.onOutputEmitter.fire(out); - this.output = shell.buffer; + this.output.push(out); + if (this.output.length > 1000) { + this.output.shift(); + } + } else { + this.output.push(out); + barrier.open(out); + } + } + ); + const result = await barrier.wait(); + + if (result.status === "disposed") { + return ""; + } - return this.output.join("\n"); + return result.value; }; if (this.step.shellId) { diff --git a/src/SandboxClient/tasks.ts b/src/SandboxClient/tasks.ts index 70d7a8e..2f6928a 100644 --- a/src/SandboxClient/tasks.ts +++ b/src/SandboxClient/tasks.ts @@ -107,6 +107,7 @@ export class Task { output: string[]; dimensions: typeof DEFAULT_SHELL_SIZE; }; + private currentSubscribeOutput?: IDisposable; private onOutputEmitter = this.disposable.addDisposable( new Emitter() ); @@ -174,36 +175,19 @@ export class Task { task.shell && task.shell.shellId !== lastShellId ) { - const openedShell = await this.agentClient.shells.open( + const openedShell = this.openedShell; + this.currentSubscribeOutput?.dispose(); + this.openedShell.shellId = task.shell.shellId; + this.currentSubscribeOutput = this.agentClient.shells.subscribeOutput( task.shell.shellId, - this.openedShell.dimensions + this.openedShell.dimensions, + ({ out }) => { + this.onOutputEmitter.fire("\x1B[2J\x1B[3J\x1B[1;1H"); + openedShell.output.push(out); + this.onOutputEmitter.fire(out); + } ); - - this.openedShell = { - shellId: openedShell.shellId, - output: openedShell.buffer, - dimensions: this.openedShell.dimensions, - }; - - this.onOutputEmitter.fire("\x1B[2J\x1B[3J\x1B[1;1H"); - openedShell.buffer.forEach((out) => this.onOutputEmitter.fire(out)); - } - }) - ); - - this.disposable.addDisposable( - this.agentClient.shells.onShellOut(({ shellId, out }) => { - if ( - !this.shell || - this.shell.shellId !== shellId || - !this.openedShell - ) { - return; } - - // Update output for shell - this.openedShell.output.push(out); - this.onOutputEmitter.fire(out); }) ); } @@ -256,16 +240,24 @@ export class Task { throw new Error("Task is not running"); } - const openedShell = await this.agentClient.shells.open( - this.shell.shellId, - dimensions - ); + if (this.openedShell) { + return this.openedShell.output.join("\n"); + } - this.openedShell = { - shellId: openedShell.shellId, - output: openedShell.buffer, + const openedShell = (this.openedShell = { dimensions, - }; + output: [] as string[], + shellId: this.shell.shellId, + }); + + this.currentSubscribeOutput = this.agentClient.shells.subscribeOutput( + this.shell.shellId, + dimensions, + ({ out }) => { + openedShell.output.push(out); + this.onOutputEmitter.fire(out); + } + ); return this.openedShell.output.join("\n"); } @@ -356,6 +348,7 @@ export class Task { ); } dispose() { + this.currentSubscribeOutput?.dispose(); this.disposable.dispose(); } } diff --git a/src/SandboxClient/terminals.ts b/src/SandboxClient/terminals.ts index 3310b9c..2e40bc0 100644 --- a/src/SandboxClient/terminals.ts +++ b/src/SandboxClient/terminals.ts @@ -4,11 +4,20 @@ import { Emitter } from "../utils/event"; import { isCommandShell, ShellRunOpts } from "./commands"; import { type IAgentClient } from "../agent-client-interface"; import { Tracer, SpanStatusCode } from "@opentelemetry/api"; +import { Barrier } from "../utils/barrier"; export type ShellSize = { cols: number; rows: number }; export const DEFAULT_SHELL_SIZE: ShellSize = { cols: 128, rows: 24 }; +function resolveCwd(workspacePath: string, cwd?: string): string | undefined { + if (!cwd) return undefined; + // Strip leading slash to ensure cwd is always relative to workspace + const relativeCwd = cwd.startsWith("/") ? cwd.slice(1) : cwd; + // Join with workspace path + return `${workspacePath}/${relativeCwd}`.replace(/\/+/g, "/"); +} + export class Terminals { private disposable = new Disposable(); private tracer?: Tracer; @@ -57,6 +66,47 @@ export class Terminals { ); } + private async createPitcherTerminal( + command: "bash" | "zsh" | "fish" | "ksh" | "dash" = "bash", + opts?: ShellRunOpts + ) { + const allEnv = Object.assign(opts?.env ?? {}); + + // Build the command args array + const args = ["source $HOME/.private/.env 2>/dev/null || true"]; + + // Add cd command if cwd is specified (Pitcher doesn't support cwd parameter) + const resolvedCwd = resolveCwd(this.agentClient.workspacePath, opts?.cwd); + if (resolvedCwd && resolvedCwd !== this.agentClient.workspacePath) { + args.push("&&", "cd", resolvedCwd); + } + + if (Object.keys(allEnv).length) { + args.push("&&", "env"); + Object.entries(allEnv).forEach(([key, value]) => { + args.push(`${key}=${value}`); + }); + args.push(command); + } else { + args.push("&&", command); + } + + const shell = await this.agentClient.shells.create({ + projectPath: this.agentClient.workspacePath, + size: opts?.dimensions ?? DEFAULT_SHELL_SIZE, + command: "bash", + args: ["-c", args.join(" ")], + type: "TERMINAL", + isSystemShell: true, + }); + + if (opts?.name) { + this.agentClient.shells.rename(shell.shellId, opts.name); + } + + return new Terminal(shell, this.agentClient, this.tracer); + } + async create( command: "bash" | "zsh" | "fish" | "ksh" | "dash" = "bash", opts?: ShellRunOpts @@ -71,33 +121,31 @@ export class Terminals { hasDimensions: !!opts?.dimensions, }, async () => { - const allEnv = Object.assign(opts?.env ?? {}); - - // TODO: use a new shell API that natively supports cwd & env - let commandWithEnv = Object.keys(allEnv).length - ? `source $HOME/.private/.env 2>/dev/null || true && env ${Object.entries( - allEnv - ) - .map(([key, value]) => `${key}=${value}`) - .join(" ")} ${command}` - : `source $HOME/.private/.env 2>/dev/null || true && ${command}`; - - if (opts?.cwd) { - commandWithEnv = `cd ${opts.cwd} && ${commandWithEnv}`; + if (this.agentClient.type === "pitcher") { + return this.createPitcherTerminal(command, opts); } - const shell = await this.agentClient.shells.create( - this.agentClient.workspacePath, - opts?.dimensions ?? DEFAULT_SHELL_SIZE, - commandWithEnv, - "TERMINAL", - true - ); + const passedEnv = Object.assign(opts?.env ?? {}); - if (opts?.name) { - this.agentClient.shells.rename(shell.shellId, opts.name); + // Build bash args array + const args = ["source $HOME/.private/.env 2>/dev/null || true"]; + + if (Object.keys(passedEnv).length) { + Object.entries(passedEnv).forEach(([key, value]) => { + args.push("&&", "env", `${key}=${value}`); + }); } + const shell = await this.agentClient.shells.create({ + projectPath: this.agentClient.workspacePath, + size: opts?.dimensions ?? DEFAULT_SHELL_SIZE, + command, + args, + type: "TERMINAL", + isSystemShell: true, + cwd: resolveCwd(this.agentClient.workspacePath, opts?.cwd), + }); + return new Terminal(shell, this.agentClient, this.tracer); } ); @@ -143,6 +191,7 @@ export class Terminal { ); public readonly onOutput = this.onOutputEmitter.event; private output = this.shell.buffer || []; + private isSubscribingOutput = false; /** * Gets the ID of the terminal. Can be used to open it again. @@ -164,18 +213,6 @@ export class Terminal { tracer?: Tracer ) { this.tracer = tracer; - this.disposable.addDisposable( - this.agentClient.shells.onShellOut(({ shellId, out }) => { - if (shellId === this.shell.shellId) { - this.onOutputEmitter.fire(out); - - this.output.push(out); - if (this.output.length > 1000) { - this.output.shift(); - } - } - }) - ); } private async withSpan( @@ -223,14 +260,44 @@ export class Terminal { rows: dimensions.rows, }, async () => { - const shell = await this.agentClient.shells.open( - this.shell.shellId, - dimensions + if (this.isSubscribingOutput) { + return this.output.join("\n"); + } + + const barrier = new Barrier(); + + this.disposable.addDisposable( + this.agentClient.shells.subscribeOutput( + this.shell.shellId, + dimensions, + ({ out }) => { + if (barrier.isOpen()) { + this.onOutputEmitter.fire(out); + + this.output.push(out); + if (this.output.length > 1000) { + this.output.shift(); + } + } else { + this.output.push(out); + barrier.open(out); + } + } + ) ); - this.output = shell.buffer; + this.disposable.onDidDispose(() => { + barrier.dispose(); + }); + + const result = await barrier.wait(); + + // This will never really happen + if (result.status === "disposed") { + return ""; + } - return this.output.join("\n"); + return result.value; } ); } diff --git a/src/agent-client-interface.ts b/src/agent-client-interface.ts index a5f9a55..702e602 100644 --- a/src/agent-client-interface.ts +++ b/src/agent-client-interface.ts @@ -1,3 +1,4 @@ +import { IDisposable } from "@xterm/headless"; import { fs, port, @@ -11,26 +12,36 @@ import { } from "./pitcher-protocol"; import { Event } from "./utils/event"; +export type SubscribeShellEvent = + | { + type: "exit"; + exitCode: number; + } + | { + type: "terminate"; + }; + export interface IAgentClientShells { - onShellExited: Event<{ - shellId: string; - exitCode: number; - }>; - onShellTerminated: Event; - onShellOut: Event; - create( - projectPath: string, - size: shell.ShellSize, - command?: string, - type?: shell.ShellProcessType, - isSystemShell?: boolean - ): Promise; + create(options: { + command: string; + args: string[]; + projectPath: string; + size: shell.ShellSize; + type?: shell.ShellProcessType; + isSystemShell?: boolean; + cwd?: string; + }): Promise; rename(shellId: shell.ShellId, name: string): Promise; getShells(): Promise; - open( + subscribe( shellId: shell.ShellId, - size: shell.ShellSize - ): Promise; + listener: (event: SubscribeShellEvent) => void + ): IDisposable; + subscribeOutput( + shellId: shell.ShellId, + size: shell.ShellSize, + listener: (event: { out: string; exitCode?: number }) => void + ): IDisposable; delete( shellId: shell.ShellId ): Promise; @@ -128,6 +139,7 @@ export type IAgentClientState = | "HIBERNATED"; export interface IAgentClient { + type: "pitcher" | "pint"; sandboxId: string; workspacePath: string; isUpToDate: boolean; diff --git a/src/api-clients/client/types.gen.ts b/src/api-clients/client/types.gen.ts index e30ac60..33c88b5 100644 --- a/src/api-clients/client/types.gen.ts +++ b/src/api-clients/client/types.gen.ts @@ -69,28 +69,6 @@ export type VmAssignTagAliasResponse = { }; }; -/** - * TemplateCreateRequest - */ -export type TemplateCreateRequest = { - /** - * Template description. Maximum 255 characters. Defaults to description of original sandbox. - */ - description?: string; - /** - * Short ID of the sandbox to fork. - */ - forkOf: string; - /** - * Tags to set on the new sandbox, if any. Will not inherit tags from the source sandbox. - */ - tags?: Array; - /** - * Template title. Maximum 255 characters. Defaults to title of original sandbox with (forked). - */ - title?: string; -}; - /** * PreviewToken */ @@ -135,6 +113,53 @@ export type PreviewTokenRevokeAllResponse = { }; }; +/** + * TemplateCreateRequestCommon + */ +export type TemplateCreateRequestCommon = { + /** + * Template description. Maximum 255 characters. Defaults to description of original sandbox. + */ + description?: string; + /** + * Short ID of the sandbox to fork. + */ + forkOf: string; + /** + * Container image to use as template + */ + image?: { + /** + * The architecture of the image. Required for multi-platform images + */ + architecture?: string; + /** + * The image name (for example 'nginx'). + */ + name: string; + /** + * The container registry where the image is stored. + */ + registry?: string; + /** + * The repository or namespace where the image is stored. + */ + repository?: string; + /** + * The image tag. + */ + tag?: string; + }; + /** + * Tags to set on the new sandbox, if any. Will not inherit tags from the source sandbox. + */ + tags?: Array; + /** + * Template title. Maximum 255 characters. Defaults to title of original sandbox with (forked). + */ + title?: string; +}; + /** * Sandbox */ @@ -599,6 +624,7 @@ export type VmStartResponse = { reconnect_token: string; use_pint: boolean; user_workspace_path: string; + vm_agent_type: string; workspace_path: string; }; }; @@ -964,6 +990,7 @@ export type SandboxForkResponse = { reconnect_token: string; use_pint: boolean; user_workspace_path: string; + vm_agent_type: string; workspace_path: string; } | null; title: string | null; @@ -1285,7 +1312,7 @@ export type TemplatesCreateData = { /** * Template Create Request */ - body?: TemplateCreateRequest; + body?: TemplateCreateRequestCommon; path?: never; query?: never; url: '/templates'; diff --git a/src/api-clients/pint/sdk.gen.ts b/src/api-clients/pint/sdk.gen.ts index 89545b6..a3b14e9 100644 --- a/src/api-clients/pint/sdk.gen.ts +++ b/src/api-clients/pint/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { ConnectToExecWebSocketData, ConnectToExecWebSocketErrors, ConnectToExecWebSocketResponses, CreateDirectoryData, CreateDirectoryErrors, CreateDirectoryResponses, CreateExecData, CreateExecErrors, CreateExecResponses, CreateFileData, CreateFileErrors, CreateFileResponses, DeleteDirectoryData, DeleteDirectoryErrors, DeleteDirectoryResponses, DeleteExecData, DeleteExecErrors, DeleteExecResponses, DeleteFileData, DeleteFileErrors, DeleteFileResponses, ExecExecStdinData, ExecExecStdinErrors, ExecExecStdinResponses, ExecuteTaskActionData, ExecuteTaskActionErrors, ExecuteTaskActionResponses, GetExecData, GetExecErrors, GetExecOutputData, GetExecOutputErrors, GetExecOutputResponses, GetExecResponses, GetFileStatData, GetFileStatErrors, GetFileStatResponses, GetTaskData, GetTaskErrors, GetTaskResponses, ListDirectoryData, ListDirectoryErrors, ListDirectoryResponses, ListExecsData, ListExecsErrors, ListExecsResponses, ListPortsData, ListPortsErrors, ListPortsResponses, ListSetupTasksData, ListSetupTasksErrors, ListSetupTasksResponses, ListTasksData, ListTasksErrors, ListTasksResponses, PerformFileActionData, PerformFileActionErrors, PerformFileActionResponses, ReadFileData, ReadFileErrors, ReadFileResponses, StreamExecsListData, StreamExecsListErrors, StreamExecsListResponses, StreamPortsListData, StreamPortsListErrors, StreamPortsListResponses, UpdateExecData, UpdateExecErrors, UpdateExecResponses } from './types.gen'; +import type { ConnectToExecWebSocketData, ConnectToExecWebSocketErrors, ConnectToExecWebSocketResponses, CreateDirectoryData, CreateDirectoryErrors, CreateDirectoryResponses, CreateExecData, CreateExecErrors, CreateExecResponses, CreateFileData, CreateFileErrors, CreateFileResponses, CreateWatcherData, CreateWatcherErrors, CreateWatcherResponses, DeleteDirectoryData, DeleteDirectoryErrors, DeleteDirectoryResponses, DeleteExecData, DeleteExecErrors, DeleteExecResponses, DeleteFileData, DeleteFileErrors, DeleteFileResponses, ExecExecStdinData, ExecExecStdinErrors, ExecExecStdinResponses, ExecuteTaskActionData, ExecuteTaskActionErrors, ExecuteTaskActionResponses, GetExecData, GetExecErrors, GetExecOutputData, GetExecOutputErrors, GetExecOutputResponses, GetExecResponses, GetFileStatData, GetFileStatErrors, GetFileStatResponses, GetTaskData, GetTaskErrors, GetTaskResponses, ListDirectoryData, ListDirectoryErrors, ListDirectoryResponses, ListExecsData, ListExecsErrors, ListExecsResponses, ListPortsData, ListPortsErrors, ListPortsResponses, ListSetupTasksData, ListSetupTasksErrors, ListSetupTasksResponses, ListTasksData, ListTasksErrors, ListTasksResponses, PerformFileActionData, PerformFileActionErrors, PerformFileActionResponses, ReadFileData, ReadFileErrors, ReadFileResponses, StreamExecsListData, StreamExecsListErrors, StreamExecsListResponses, StreamPortsListData, StreamPortsListErrors, StreamPortsListResponses, UpdateExecData, UpdateExecErrors, UpdateExecResponses } from './types.gen'; export type Options = Options2 & { /** @@ -442,3 +442,20 @@ export const streamPortsList = (options?: ...options }); }; + +/** + * Watch directory changes using Server-Sent Events (SSE) + * Watch a directory for file system changes and stream events via SSE. + */ +export const createWatcher = (options: Options) => { + return (options.client ?? client).sse.get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/stream/directories/watcher/{path}', + ...options + }); +}; diff --git a/src/api-clients/pint/types.gen.ts b/src/api-clients/pint/types.gen.ts index cc3faa0..8a0363c 100644 --- a/src/api-clients/pint/types.gen.ts +++ b/src/api-clients/pint/types.gen.ts @@ -123,6 +123,10 @@ export type ExecItem = { * Whether the exec is interactive */ interactive: boolean; + /** + * Whether the exec is using a pty + */ + pty: boolean; /** * Exit code of the process (only present when process has exited) */ @@ -153,6 +157,10 @@ export type CreateExecRequest = { * Whether to start interactive shell session or not (defaults to false) */ interactive?: boolean; + /** + * Whether to start pty shell session or not (defaults to false) + */ + pty?: boolean; }; export type UpdateExecRequest = { @@ -169,6 +177,29 @@ export type ExecDeleteResponse = { message: string; }; +export type ExecStdout = { + /** + * Type of the exec output + */ + type: 'stdout' | 'stderr'; + /** + * Data associated with the exec output + */ + output: string; + /** + * Sequence number of the output message + */ + sequence: number; + /** + * Timestamp of when the output was generated + */ + timestamp?: string; + /** + * Exit code of the process (only present when process has exited) + */ + exitCode?: number; +}; + export type ExecStdin = { /** * Type of the exec input @@ -298,29 +329,6 @@ export type PortsListResponse = { ports: Array; }; -export type ExecStdout = { - /** - * Type of the exec output - */ - type: 'stdout' | 'stderr'; - /** - * Data associated with the exec output - */ - output: string; - /** - * Sequence number of the output message - */ - sequence: number; - /** - * Timestamp of when the output was generated - */ - timestamp?: string; - /** - * Exit code of the process (only present when process has exited) - */ - exitCode?: number; -}; - export type Task = TaskItem; export type DeleteFileData = { @@ -1270,3 +1278,54 @@ export type StreamPortsListResponses = { }; export type StreamPortsListResponse = StreamPortsListResponses[keyof StreamPortsListResponses]; + +export type CreateWatcherData = { + body?: never; + path: { + /** + * Directory path to watch + */ + path: string; + }; + query?: { + /** + * Whether to watch directories recursively + */ + recursive?: boolean; + /** + * Glob patterns to ignore certain files or directories (can be specified multiple times) + */ + ignorePatterns?: Array; + }; + url: '/api/v1/stream/directories/watcher/{path}'; +}; + +export type CreateWatcherErrors = { + /** + * Bad Request - Path is required or invalid path + */ + 400: _Error; + /** + * Unauthorized + */ + 401: _Error; + /** + * Internal Server Error - Failed to create file + */ + 500: _Error; + /** + * Unexpected Error + */ + default: _Error; +}; + +export type CreateWatcherError = CreateWatcherErrors[keyof CreateWatcherErrors]; + +export type CreateWatcherResponses = { + /** + * Server-Sent Events stream of directory files updates + */ + 200: string; +}; + +export type CreateWatcherResponse = CreateWatcherResponses[keyof CreateWatcherResponses]; diff --git a/src/bin/commands/build.ts b/src/bin/commands/build.ts index 8f69b92..a698d38 100644 --- a/src/bin/commands/build.ts +++ b/src/bin/commands/build.ts @@ -13,12 +13,23 @@ import { } from "@codesandbox/sdk"; import { VmUpdateSpecsRequest } from "../../api-clients/client"; import { getDefaultTemplateId, retryWithDelay } from "../../utils/api"; -import { getInferredApiKey, getInferredRegistryUrl, isBetaAllowed, isLocalEnvironment } from "../../utils/constants"; +import { + getInferredApiKey, + getInferredRegistryUrl, + isBetaAllowed, + isLocalEnvironment, +} from "../../utils/constants"; import { hashDirectory as getFilePaths } from "../utils/files"; import { mkdir, writeFile } from "fs/promises"; import { sleep } from "../../utils/sleep"; -import { buildDockerImage, prepareDockerBuild, pushDockerImage } from "../utils/docker"; +import { + buildDockerImage, + prepareDockerBuild, + pushDockerImage, + dockerLogin, +} from "../utils/docker"; import { randomUUID } from "crypto"; +import { base32Encode } from "../../utils/encoding"; export type BuildCommandArgs = { directory: string; @@ -182,7 +193,6 @@ export const buildCommand: yargs.CommandModule< }), handler: async (argv) => { - // Beta build process using Docker // This uses the new architecture using bartender and gvisor if (argv.beta && isBetaAllowed()) { @@ -253,7 +263,8 @@ export const buildCommand: yargs.CommandModule< spinner.start( updateSpinnerMessage( index, - `Running setup ${steps.indexOf(step) + 1} / ${steps.length + `Running setup ${steps.indexOf(step) + 1} / ${ + steps.length } - ${step.name}...` ) ); @@ -466,9 +477,9 @@ export const buildCommand: yargs.CommandModule< argv.ci ? String(error) : "Failed, please manually verify at https://codesandbox.io/s/" + - id + - " - " + - String(error) + id + + " - " + + String(error) ) ); @@ -628,7 +639,9 @@ function createAlias(directory: string, alias: string) { * Build a CodeSandbox Template using Docker for use in gvisor-based sandboxes. * @param argv arguments to csb build command */ -export async function betaCodeSandboxBuild(argv: yargs.ArgumentsCamelCase): Promise { +export async function betaCodeSandboxBuild( + argv: yargs.ArgumentsCamelCase +): Promise { let dockerFileCleanupFn: (() => Promise) | undefined; let client: SandboxClient | undefined; @@ -642,8 +655,19 @@ export async function betaCodeSandboxBuild(argv: yargs.ArgumentsCamelCase { - dockerBuildPrepareSpinner.text = `Preparing build environment: (${output})`; - }); + const result = await prepareDockerBuild( + resolvedDirectory, + (output: string) => { + dockerBuildPrepareSpinner.text = `Preparing build environment: (${output})`; + } + ); dockerFileCleanupFn = result.cleanupFn; dockerfilePath = result.dockerfilePath; dockerBuildPrepareSpinner.succeed("Build environment ready."); } catch (error) { - dockerBuildPrepareSpinner.fail(`Failed to prepare build environment: ${(error as Error).message}`); + dockerBuildPrepareSpinner.fail( + `Failed to prepare build environment: ${(error as Error).message}` + ); throw error; } - // Docker Build const dockerBuildSpinner = ora({ stream: process.stdout }); dockerBuildSpinner.start("Building template docker image..."); @@ -690,29 +718,56 @@ export async function betaCodeSandboxBuild(argv: yargs.ArgumentsCamelCase { + await dockerLogin({ + registry: registry, + username: "_token", + password: apiKey, + onOutput: (output: string) => { const cleanOutput = stripAnsiCodes(output); - imagePushSpinner.text = `Pushing template Docker image to CodeSandbox: (${cleanOutput})`; + dockerLoginSpinner.text = `Authenticating with Docker registry: (${cleanOutput})`; }, + }); + dockerLoginSpinner.succeed("Docker registry authentication successful."); + } catch (error) { + dockerLoginSpinner.fail( + `Failed to authenticate with Docker registry: ${ + (error as Error).message + }` ); + throw error; + } + + // Push Docker Image + const imagePushSpinner = ora({ stream: process.stdout }); + imagePushSpinner.start("Pushing template Docker image to CodeSandbox..."); + try { + await pushDockerImage(fullImageName, (output: string) => { + const cleanOutput = stripAnsiCodes(output); + imagePushSpinner.text = `Pushing template Docker image to CodeSandbox: (${cleanOutput})`; + }); } catch (error) { - imagePushSpinner.fail(`Failed to push template Docker image: ${(error as Error).message}`); + imagePushSpinner.fail( + `Failed to push template Docker image: ${(error as Error).message}` + ); throw error; } imagePushSpinner.succeed("Template Docker image pushed to CodeSandbox."); - + const templateCreateSpinner = ora({ stream: process.stdout }); + templateCreateSpinner.start("Creating template with Docker image..."); // Create Template with Docker Image const templateData = await api.createTemplate({ forkOf: argv.fromSandbox || getDefaultTemplateId(api.getClient()), @@ -722,12 +777,13 @@ export async function betaCodeSandboxBuild(argv: yargs.ArgumentsCamelCase 0) { - templateBuildSpinner.text = `Preparing template snapshot: Waiting for ports ${argv.ports.join(', ')} to be ready...`; + templateBuildSpinner.text = `Preparing template snapshot: Waiting for ports ${argv.ports.join( + ", " + )} to be ready...`; await Promise.all( argv.ports.map(async (port) => { - if (!client) throw new Error('Failed to connect to sandbox to wait for ports'); + if (!client) + throw new Error("Failed to connect to sandbox to wait for ports"); const portInfo = await client.ports.waitForPort(port, { - timeoutMs: 10_000, + timeoutMs: 30_000, }); }) ); } else { - templateBuildSpinner.text = `Preparing template snapshot: No ports specified, waiting 10 seconds for tasks to run...`; + templateBuildSpinner.text = `Preparing template snapshot: No ports specified, waiting 0 seconds for tasks to run...`; await sleep(10000); } - templateBuildSpinner.text = "Preparing template snapshot: Sandbox is ready. Creating snapshot..."; - await sdk.sandboxes.hibernate(sandboxId); + templateBuildSpinner.text = + "Preparing template snapshot: Sandbox is ready. Creating snapshot..."; + // TODO: Change back to hibernate once we fix hibernate resume with nydus + // await sdk.sandboxes.hibernate(sandboxId); + await sdk.sandboxes.shutdown(sandboxId); templateBuildSpinner.succeed("Template snapshot created."); - } catch (error) { - templateBuildSpinner.text = "Preparing template snapshot: Failed to create snapshot. Cleaning up..."; + templateBuildSpinner.text = + "Preparing template snapshot: Failed to create snapshot. Cleaning up..."; await sdk.sandboxes.shutdown(sandboxId); - templateBuildSpinner.fail(`Failed to create template reference and example: ${(error as Error).message}`); + templateBuildSpinner.fail( + `Failed to create template reference and example: ${ + (error as Error).message + }` + ); throw error; } @@ -801,9 +869,7 @@ export async function betaCodeSandboxBuild(argv: yargs.ArgumentsCamelCase void; +}; + +export async function dockerLogin(options: DockerLoginOptions): Promise { + const { registry, username, password, onOutput = () => { } } = options; + + await new Promise((resolve, reject) => { + const args = ["login"]; + + if (registry) { + args.push(registry); + } + + args.push("--username", username, "--password-stdin"); + + const loginProcess = spawn("docker", args, { + stdio: ["pipe", "pipe", "pipe"], + }); + + // Write password to stdin + loginProcess.stdin?.write(password); + loginProcess.stdin?.end(); + + let outputBuffer = ""; + + loginProcess.stdout?.on("data", (data) => { + const output = data.toString(); + outputBuffer += output; + const lines = output.trim().split("\n"); + const lastLine = lines[lines.length - 1]; + if (lastLine) { + onOutput(lastLine); + } + }); + + loginProcess.stderr?.on("data", (data) => { + const output = data.toString(); + outputBuffer += output; + const lines = output.trim().split("\n"); + const lastLine = lines[lines.length - 1]; + if (lastLine) { + onOutput(lastLine); + } + }); + + loginProcess.on("close", (code) => { + if (code === 0) { + onOutput(`Docker login successful${registry ? ` to ${registry}` : ""}`); + resolve(); + } else { + reject( + new Error(`Docker login failed with exit code ${code}\n${outputBuffer}`) + ); + } + }); + + loginProcess.on("error", (error) => { + reject(new Error(`Docker login failed: ${error.message}`)); + }); + }); +} + export async function pushDockerImage(imageName: string, onOutput?: (output: string) => void): Promise { onOutput = onOutput || (() => { }); diff --git a/src/types.ts b/src/types.ts index 952d14e..09ecc3f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,9 @@ export interface PitcherManagerResponse { latestPitcherVersion: string; pitcherToken: string; cluster: string; + vmAgentType: string; + pintURL?: string; + pintToken?: string; } export interface SystemMetricsStatus { diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 13a04e8..4f32c18 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -43,14 +43,11 @@ export function getInferredRegistryUrl() { export function isLocalEnvironment(): boolean { const apiHostName = getInferredApiHost(); - return apiHostName === "api.codesandbox.dev" + return apiHostName === "api.codesandbox.dev"; } -const BETA_ALLOWED_HOSTS = [ - "api.codesandbox.dev", - "api.codesandbox.stream", -]; +const BETA_ALLOWED_HOSTS = ["api.codesandbox.dev", "api.codesandbox.stream"]; export function isBetaAllowed(): boolean { const apiHostName = getInferredApiHost(); return BETA_ALLOWED_HOSTS.includes(apiHostName); -} \ No newline at end of file +} diff --git a/src/utils/encoding.ts b/src/utils/encoding.ts new file mode 100644 index 0000000..1c37e9a --- /dev/null +++ b/src/utils/encoding.ts @@ -0,0 +1,54 @@ +/** + * Base32 encoding utilities following RFC 4648 standard + */ + +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/** + * Encodes a string to base32 (RFC 4648) + * @param input - The string to encode + * @param lowercase - Whether to return lowercase encoding (default: true) + * @param removePadding - Whether to remove padding characters (default: true) + * @returns Base32 encoded string + */ +export function base32Encode( + input: string, + lowercase: boolean = true, + removePadding: boolean = true +): string { + const buffer = Buffer.from(input, "utf-8"); + let bits = 0; + let value = 0; + let output = ""; + + for (let i = 0; i < buffer.length; i++) { + value = (value << 8) | buffer[i]; + bits += 8; + + while (bits >= 5) { + output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + + if (bits > 0) { + output += BASE32_ALPHABET[(value << (5 - bits)) & 31]; + } + + // Add padding + while (output.length % 8 !== 0) { + output += "="; + } + + // Remove padding if requested + if (removePadding) { + output = output.replace(/=+$/, ""); + } + + // Convert to lowercase if requested + if (lowercase) { + output = output.toLowerCase(); + } + + return output; +} diff --git a/test-template-pint/.codesandbox/Dockerfile b/test-template-pint/.codesandbox/Dockerfile new file mode 100644 index 0000000..876e82f --- /dev/null +++ b/test-template-pint/.codesandbox/Dockerfile @@ -0,0 +1 @@ +FROM node:22-bookworm \ No newline at end of file diff --git a/test-template-pint/.codesandbox/tasks.json b/test-template-pint/.codesandbox/tasks.json new file mode 100644 index 0000000..b34104d --- /dev/null +++ b/test-template-pint/.codesandbox/tasks.json @@ -0,0 +1,7 @@ +{ + // These tasks will run in order when initializing your CodeSandbox project. + "setupTasks": [], + + // These tasks can be run from CodeSandbox. Running one will open a log in the app. + "tasks": {} +} diff --git a/test-template-pitcher/.codesandbox/Dockerfile b/test-template-pitcher/.codesandbox/Dockerfile new file mode 100644 index 0000000..28d09f4 --- /dev/null +++ b/test-template-pitcher/.codesandbox/Dockerfile @@ -0,0 +1 @@ +FROM ghcr.io/codesandbox/devcontainers/universal:latest diff --git a/test-template-pitcher/.codesandbox/tasks.json b/test-template-pitcher/.codesandbox/tasks.json new file mode 100644 index 0000000..e95b3dc --- /dev/null +++ b/test-template-pitcher/.codesandbox/tasks.json @@ -0,0 +1,7 @@ +{ + // These tasks will run in order when initializing your CodeSandbox project. + "setupTasks": [""], + + // These tasks can be run from CodeSandbox. Running one will open a log in the app. + "tasks": {} +} diff --git a/tests/benchmark/files-and-commands.test.ts b/tests/benchmark/files-and-commands.test.ts new file mode 100644 index 0000000..4e8d7e1 --- /dev/null +++ b/tests/benchmark/files-and-commands.test.ts @@ -0,0 +1,464 @@ +/** + * Files & Commands Benchmark + * + * Measures timing for file operations and command execution inside a sandbox. + * Useful for comparing performance between the old infra (Pitcher) and new infra (Pint). + * + * File operations measured: + * - write_small_file Write a small text file (~1 KB) via writeTextFile + * - write_large_text_file Write a large text file (~10 MB) via writeTextFile + * - write_large_binary_file Write a large binary file (~10 MB) via writeFile + * - read_small_file Read the small file back + * - read_large_file Read the large text file back + * - batch_write_relative Write 50 small files via batchWrite with workspace-relative paths + * - batch_write_absolute Write 50 small files via batchWrite with absolute /tmp paths + * - mkdir Create a nested directory tree + * - readdir List directory contents + * - stat Stat a file + * - copy_file Copy the small file + * - rename_file Rename the copied file + * - remove_file Remove the renamed file + * + * Command operations measured: + * - cmd_echo Simple echo (baseline round-trip latency) + * - cmd_cpu_pi CPU-intensive: compute π digits with python3 + * - cmd_cpu_hash CPU-intensive: sha256 of /dev/urandom (256 MB) + * - cmd_disk_write Disk write: dd 256 MB to a temp file + * - cmd_disk_read Disk read: dd 256 MB from the temp file + * - cmd_find Filesystem traversal: find /usr -type f + * + * Usage: + * CSB_API_KEY= CSB_TEMPLATE_ID= npm run benchmark:files + * CSB_API_KEY= CSB_TEMPLATE_ID= CSB_ITERATIONS=10 npm run benchmark:files + * + * Environment Variables: + * CSB_API_KEY CodeSandbox API key (required) + * CSB_TEMPLATE_ID Template ID to fork from (required) + * CSB_BASE_URL API base URL (default: https://api.codesandbox.io) + * CSB_ITERATIONS Number of benchmark iterations (default: 5) + */ + +import { test } from "vitest"; +import { CodeSandbox, Sandbox } from "../../src/index.js"; +import { SandboxClient, CommandError } from "../../src/SandboxClient/index.js"; +import { + BenchmarkState, + createState, + initSDK, + printReport, + recordSandbox, + recordSandboxError, + timeMs, + tryCleanup, +} from "./utils.js"; + +// --------------------------------------------------------------------------- +// CLI / env argument parsing +// --------------------------------------------------------------------------- + +function parseArgs() { + const templateId = process.env.CSB_TEMPLATE_ID; + const iterations = process.env.CSB_ITERATIONS + ? parseInt(process.env.CSB_ITERATIONS, 10) + : 5; + + if (!templateId) { + throw new Error("CSB_TEMPLATE_ID environment variable is required."); + } + + if (!process.env.CSB_API_KEY) { + throw new Error("CSB_API_KEY environment variable is required."); + } + + return { templateId, iterations }; +} + +// --------------------------------------------------------------------------- +// Operation names +// --------------------------------------------------------------------------- + +const FILE_OPS = [ + "write_small_file", + "write_large_text_file", + "write_large_binary_file", + "read_small_file", + "read_large_file", + "batch_write_relative", + "batch_write_absolute", + "mkdir", + "readdir", + "stat", + "copy_file", + "rename_file", + "remove_file", +] as const; + +const CMD_OPS = [ + "cmd_echo", + "cmd_cpu_pi", + "cmd_cpu_hash", + "cmd_disk_write", + "cmd_disk_read", + "cmd_find", +] as const; + +const ALL_OPS = [...FILE_OPS, ...CMD_OPS] as const; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRandomBytes(size: number): Uint8Array { + const buf = new Uint8Array(size); + let x = 0x12345678; + for (let i = 0; i < size; i++) { + x = (Math.imul(x, 1664525) + 1013904223) >>> 0; + buf[i] = x & 0xff; + } + return buf; +} + +// --------------------------------------------------------------------------- +// Single benchmark iteration +// --------------------------------------------------------------------------- + +async function runIteration( + sdk: CodeSandbox, + state: BenchmarkState, + templateId: string, + index: number +): Promise { + console.log(`\n── Iteration ${index + 1} ──────────────────────────────`); + let sandbox: Sandbox | undefined; + let client: SandboxClient | undefined; + + try { + // ── create sandbox ──────────────────────────────────────────────────────── + console.log(" Creating sandbox..."); + let ms: number; + try { + [sandbox, ms] = await timeMs(() => + sdk.sandboxes.create({ id: templateId, tags: ["benchmark"] }) + ); + console.log(` Created ${(ms / 1000).toFixed(2)}s ✓ (id: ${sandbox.id})`); + } catch (err) { + console.log(` Failed creating sandbox ✗ ${String(err)}`); + return; + } + + const sandboxId = sandbox.id; + const benchDir = `/tmp/benchmark_${index}`; + + // ── connect ─────────────────────────────────────────────────────────────── + console.log(" Connecting..."); + try { + client = await sandbox.connect(); + } catch (err) { + console.log(` Failed connecting ✗ ${String(err)}`); + await tryCleanup(sdk, sandboxId); + return; + } + + const fs = client.fs; + const commands = client.commands; + + // ── mkdir ───────────────────────────────────────────────────────────────── + console.log(" mkdir..."); + try { + [, ms] = await timeMs(() => fs.mkdir(benchDir, true)); + recordSandbox(state, sandboxId, "mkdir", ms); + console.log(` mkdir ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` mkdir ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "mkdir"); + } + + // ── write small file (~1 KB) ────────────────────────────────────────────── + const smallPath = `${benchDir}/small.txt`; + const smallContent = "x".repeat(1024); // 1 KB + console.log(" write_small_file..."); + try { + [, ms] = await timeMs(() => fs.writeTextFile(smallPath, smallContent)); + recordSandbox(state, sandboxId, "write_small_file", ms); + console.log(` write_small_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` write_small_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "write_small_file"); + } + + // ── write large text file (~10 MB via writeTextFile) ──────────────────── + const largePath = `${benchDir}/large.txt`; + const largeTextContent = "x".repeat(10 * 1024 * 1024); // 10 MB + console.log(" write_large_text_file..."); + try { + [, ms] = await timeMs(() => fs.writeTextFile(largePath, largeTextContent)); + recordSandbox(state, sandboxId, "write_large_text_file", ms); + console.log(` write_large_text_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` write_large_text_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "write_large_text_file"); + } + + // ── write large binary file (~10 MB via writeFile) ─────────────────────── + const largeBinPath = `${benchDir}/large.bin`; + const largeBinContent = makeRandomBytes(10 * 1024 * 1024); // 10 MB + console.log(" write_large_binary_file..."); + try { + [, ms] = await timeMs(() => fs.writeFile(largeBinPath, largeBinContent)); + recordSandbox(state, sandboxId, "write_large_binary_file", ms); + console.log(` write_large_binary_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` write_large_binary_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "write_large_binary_file"); + } + + // ── read small file ─────────────────────────────────────────────────────── + console.log(" read_small_file..."); + try { + [, ms] = await timeMs(() => fs.readTextFile(smallPath)); + recordSandbox(state, sandboxId, "read_small_file", ms); + console.log(` read_small_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` read_small_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "read_small_file"); + } + + // ── read large file ─────────────────────────────────────────────────────── + console.log(" read_large_file..."); + try { + [, ms] = await timeMs(() => fs.readFile(largePath)); + recordSandbox(state, sandboxId, "read_large_file", ms); + console.log(` read_large_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` read_large_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "read_large_file"); + } + + // ── batch write relative (50 files, paths relative to workspace) ────────── + console.log(" batch_write_relative..."); + try { + const batchFilesRelative = Array.from({ length: 50 }, (_, i) => ({ + path: `benchmark_${index}/batch/file_${i}.txt`, + content: `batch file ${i}\n`.repeat(20), + })); + [, ms] = await timeMs(() => fs.batchWrite(batchFilesRelative)); + recordSandbox(state, sandboxId, "batch_write_relative", ms); + console.log(` batch_write_relative ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` batch_write_relative ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "batch_write_relative"); + } + + // ── batch write absolute (50 files, absolute paths in /tmp) ─────────────── + console.log(" batch_write_absolute..."); + try { + const batchFilesAbsolute = Array.from({ length: 50 }, (_, i) => ({ + path: `${benchDir}/batch/file_${i}.txt`, + content: `batch file ${i}\n`.repeat(20), + })); + [, ms] = await timeMs(() => fs.batchWrite(batchFilesAbsolute)); + recordSandbox(state, sandboxId, "batch_write_absolute", ms); + console.log(` batch_write_absolute ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` batch_write_absolute ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "batch_write_absolute"); + } + + // ── readdir ─────────────────────────────────────────────────────────────── + console.log(" readdir..."); + try { + [, ms] = await timeMs(() => fs.readdir(benchDir)); + recordSandbox(state, sandboxId, "readdir", ms); + console.log(` readdir ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` readdir ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "readdir"); + } + + // ── stat ────────────────────────────────────────────────────────────────── + console.log(" stat..."); + try { + [, ms] = await timeMs(() => fs.stat(smallPath)); + recordSandbox(state, sandboxId, "stat", ms); + console.log(` stat ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` stat ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "stat"); + } + + // ── copy file ───────────────────────────────────────────────────────────── + const copyPath = `${benchDir}/small_copy.txt`; + console.log(" copy_file..."); + try { + [, ms] = await timeMs(() => fs.copy(smallPath, copyPath, false, true)); + recordSandbox(state, sandboxId, "copy_file", ms); + console.log(` copy_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` copy_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "copy_file"); + } + + // ── rename file ─────────────────────────────────────────────────────────── + const renamedPath = `${benchDir}/small_renamed.txt`; + console.log(" rename_file..."); + try { + [, ms] = await timeMs(() => fs.rename(copyPath, renamedPath, true)); + recordSandbox(state, sandboxId, "rename_file", ms); + console.log(` rename_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` rename_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "rename_file"); + } + + // ── remove file ─────────────────────────────────────────────────────────── + console.log(" remove_file..."); + try { + [, ms] = await timeMs(() => fs.remove(renamedPath)); + recordSandbox(state, sandboxId, "remove_file", ms); + console.log(` remove_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` remove_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "remove_file"); + } + + // ── cmd: echo (baseline latency) ────────────────────────────────────────── + console.log(" cmd_echo..."); + try { + [, ms] = await timeMs(() => commands.run("echo hello")); + recordSandbox(state, sandboxId, "cmd_echo", ms); + console.log(` cmd_echo ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + const detail = err instanceof CommandError ? `exit ${err.exitCode}: ${err.output.trim()}` : String(err); + console.log(` cmd_echo ✗ ${detail}`); + recordSandboxError(state, sandboxId, "cmd_echo"); + } + + // ── cmd: CPU intensive — compute π with python3 (5000 decimal places) ────── + console.log(" cmd_cpu_pi..."); + try { + [, ms] = await timeMs(() => + commands.run( + `python3 -c "from decimal import Decimal, getcontext; getcontext().prec=5000; print(sum(Decimal((-1)**k) / Decimal(2*k+1) for k in range(10000)) * 4)"` + ) + ); + recordSandbox(state, sandboxId, "cmd_cpu_pi", ms); + console.log(` cmd_cpu_pi ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + const detail = err instanceof CommandError ? `exit ${err.exitCode}: ${err.output.trim()}` : String(err); + console.log(` cmd_cpu_pi ✗ ${detail}`); + recordSandboxError(state, sandboxId, "cmd_cpu_pi"); + } + + // ── cmd: CPU intensive — sha256 of 256 MB of random data ────────────────── + console.log(" cmd_cpu_hash..."); + try { + [, ms] = await timeMs(() => + commands.run( + `dd if=/dev/urandom bs=1M count=256 2>/dev/null | sha256sum` + ) + ); + recordSandbox(state, sandboxId, "cmd_cpu_hash", ms); + console.log(` cmd_cpu_hash ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + const detail = err instanceof CommandError ? `exit ${err.exitCode}: ${err.output.trim()}` : String(err); + console.log(` cmd_cpu_hash ✗ ${detail}`); + recordSandboxError(state, sandboxId, "cmd_cpu_hash"); + } + + // ── cmd: disk write — dd 256 MB to temp file ────────────────────────────── + const ddFile = `${client.workspacePath}/dd_test_${index}.bin`; + console.log(" cmd_disk_write..."); + try { + [, ms] = await timeMs(() => + commands.run( + `dd if=/dev/zero of=${ddFile} bs=1M count=256 conv=fdatasync 2>&1` + ) + ); + recordSandbox(state, sandboxId, "cmd_disk_write", ms); + console.log(` cmd_disk_write ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + const detail = err instanceof CommandError ? `exit ${err.exitCode}: ${err.output.trim()}` : String(err); + console.log(` cmd_disk_write ✗ ${detail}`); + recordSandboxError(state, sandboxId, "cmd_disk_write"); + } + + // ── cmd: disk read — dd 256 MB from temp file ───────────────────────────── + console.log(" cmd_disk_read..."); + try { + [, ms] = await timeMs(() => + commands.run( + `dd if=${ddFile} of=/dev/null bs=1M 2>&1` + ) + ); + recordSandbox(state, sandboxId, "cmd_disk_read", ms); + console.log(` cmd_disk_read ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + const detail = err instanceof CommandError ? `exit ${err.exitCode}: ${err.output.trim()}` : String(err); + console.log(` cmd_disk_read ✗ ${detail}`); + recordSandboxError(state, sandboxId, "cmd_disk_read"); + } + + // ── cmd: filesystem traversal — find /usr -type f ───────────────────────── + console.log(" cmd_find..."); + try { + [, ms] = await timeMs(() => + commands.run(`find /usr -type f 2>/dev/null | wc -l`) + ); + recordSandbox(state, sandboxId, "cmd_find", ms); + console.log(` cmd_find ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + const detail = err instanceof CommandError ? `exit ${err.exitCode}: ${err.output.trim()}` : String(err); + console.log(` cmd_find ✗ ${detail}`); + recordSandboxError(state, sandboxId, "cmd_find"); + } + + // ── disconnect & cleanup ────────────────────────────────────────────────── + console.log(" Disconnecting & shutting down (cleanup)..."); + try { + await client.disconnect(); + client.dispose(); + client = undefined; + } catch { + /* best effort */ + } + await tryCleanup(sdk, sandboxId); + sandbox = undefined; + console.log(" Done"); + } finally { + try { + await client?.disconnect(); + client?.dispose(); + } catch { + /* best effort */ + } + if (sandbox) { + await tryCleanup(sdk, sandbox.id); + } + } +} + +// --------------------------------------------------------------------------- +// Vitest test entry point +// --------------------------------------------------------------------------- + +const { templateId, iterations } = parseArgs(); + +// Allow up to 10 minutes per iteration (CPU/disk ops can be slow) +const TIMEOUT_MS = (iterations + 1) * 10 * 60 * 1000; + +test("sandbox files-and-commands benchmark", { timeout: TIMEOUT_MS }, async () => { + const sdk = initSDK(); + const state = createState(); + + const baseUrl = process.env.CSB_BASE_URL ?? "https://api.codesandbox.io"; + console.log("Sandbox Files & Commands Benchmark"); + console.log(` Template: ${templateId}`); + console.log(` Iterations: ${iterations}`); + console.log(` API URL: ${baseUrl}`); + + for (let i = 0; i < iterations; i++) { + await runIteration(sdk, state, templateId, i); + } + + printReport(ALL_OPS, state); +}); diff --git a/tests/benchmark/lifecycle.test.ts b/tests/benchmark/lifecycle.test.ts new file mode 100644 index 0000000..a20924d --- /dev/null +++ b/tests/benchmark/lifecycle.test.ts @@ -0,0 +1,219 @@ +/** + * Sandbox Lifecycle Benchmark + * + * Measures timing for: create, hibernate, resume, shutdown, start (after shutdown) + * Optionally measures time-to-port-ready for: create, resume, start (set CSB_PORT) + * Runs N iterations and reports avg, median, p50, p90, p95, p99 per operation. + * + * Usage: + * CSB_API_KEY= CSB_TEMPLATE_ID= npm run benchmark + * CSB_API_KEY= CSB_TEMPLATE_ID= CSB_ITERATIONS=10 CSB_PORT=3000 npm run benchmark + * + * Environment Variables: + * CSB_API_KEY CodeSandbox API key (required) + * CSB_TEMPLATE_ID Template ID to fork from (required) + * CSB_BASE_URL API base URL (default: https://api.codesandbox.io) + * CSB_ITERATIONS Number of benchmark iterations (default: 5) + * CSB_PORT Port to wait for after create/resume/start (optional) + */ + +import { test } from "vitest"; +import { CodeSandbox, Sandbox } from "../../src/index.js"; +import { + BenchmarkState, + createState, + initSDK, + measurePortReady, + printReport, + recordSandbox, + recordSandboxError, + timeMs, + tryCleanup, +} from "./utils.js"; + +// --------------------------------------------------------------------------- +// CLI / env argument parsing +// --------------------------------------------------------------------------- + +function parseArgs() { + const templateId = process.env.CSB_TEMPLATE_ID; + const iterations = process.env.CSB_ITERATIONS + ? parseInt(process.env.CSB_ITERATIONS, 10) + : 5; + const port = process.env.CSB_PORT + ? parseInt(process.env.CSB_PORT, 10) + : undefined; + + if (!templateId) { + throw new Error("CSB_TEMPLATE_ID environment variable is required."); + } + + if (!process.env.CSB_API_KEY) { + throw new Error("CSB_API_KEY environment variable is required."); + } + + return { templateId, iterations, port }; +} + +// --------------------------------------------------------------------------- +// Operation names +// --------------------------------------------------------------------------- + +const CORE_OPS = [ + "create", + "hibernate", + "resume", + "shutdown", + "start_after_shutdown", +] as const; + +const PORT_OPS = [ + "create_to_port_ready", + "resume_to_port_ready", + "start_after_shutdown_to_port_ready", +] as const; + +// --------------------------------------------------------------------------- +// Single benchmark iteration +// --------------------------------------------------------------------------- + +async function runIteration( + sdk: CodeSandbox, + state: BenchmarkState, + templateId: string, + port: number | undefined, + index: number +): Promise { + console.log(`\n── Iteration ${index + 1} ──────────────────────────────`); + let sandbox: Sandbox | undefined; + + try { + // ── create ──────────────────────────────────────────────────────────────── + console.log(" Creating..."); + let ms: number; + let opStart: number; + try { + opStart = performance.now(); + [sandbox, ms] = await timeMs(() => + sdk.sandboxes.create({ id: templateId, tags: ["benchmark"] }) + ); + recordSandbox(state, sandbox.id, "create", ms); + console.log(` Created ${(ms / 1000).toFixed(2)}s ✓ (id: ${sandbox.id})`); + } catch (err) { + console.log(` Failed creating ✗ ${String(err)}`); + return; + } + + const sandboxId = sandbox.id; + + if (port) { + const portMs = await measurePortReady(sandbox, port, opStart!); + if (portMs !== null) recordSandbox(state, sandboxId, "create_to_port_ready", portMs); + else recordSandboxError(state, sandboxId, "create_to_port_ready"); + } + + // // ── hibernate ───────────────────────────────────────────────────────────── + // console.log(" Hibernating..."); + // try { + // [, ms] = await timeMs(() => sdk.sandboxes.hibernate(sandboxId)); + // recordSandbox(state, sandboxId, "hibernate", ms); + // console.log(` Hibernated ${(ms / 1000).toFixed(2)}s ✓`); + // } catch (err) { + // console.log(` Failed hibernating ✗ ${String(err)}`); + // recordSandboxError(state, sandboxId, "hibernate"); + // await tryCleanup(sdk, sandboxId); + // return; + // } + + // // ── resume ──────────────────────────────────────────────────────────────── + // console.log(" Resuming..."); + // try { + // opStart = performance.now(); + // [sandbox, ms] = await timeMs(() => sdk.sandboxes.resume(sandboxId)); + // recordSandbox(state, sandboxId, "resume", ms); + // console.log(` Resumed ${(ms / 1000).toFixed(2)}s ✓`); + // } catch (err) { + // console.log(` Failed resuming ✗ ${String(err)}`); + // recordSandboxError(state, sandboxId, "resume"); + // await tryCleanup(sdk, sandboxId); + // return; + // } + + // if (port) { + // const portMs = await measurePortReady(sandbox, port, opStart!); + // if (portMs !== null) recordSandbox(state, sandboxId, "resume_to_port_ready", portMs); + // else recordSandboxError(state, sandboxId, "resume_to_port_ready"); + // } + + // ── shutdown ────────────────────────────────────────────────────────────── + console.log(" Shutting down..."); + try { + [, ms] = await timeMs(() => sdk.sandboxes.shutdown(sandboxId)); + recordSandbox(state, sandboxId, "shutdown", ms); + console.log(` Shut down ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` Failed shutting down ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "shutdown"); + await tryCleanup(sdk, sandboxId); + return; + } + + // ── start (after shutdown) ──────────────────────────────────────────────── + console.log(" Starting..."); + try { + opStart = performance.now(); + [sandbox, ms] = await timeMs(() => sdk.sandboxes.resume(sandboxId)); + recordSandbox(state, sandboxId, "start_after_shutdown", ms); + console.log(` Started (after shutdown) ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` Failed starting (after shutdown) ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "start_after_shutdown"); + await tryCleanup(sdk, sandboxId); + return; + } + + if (port) { + const portMs = await measurePortReady(sandbox, port, opStart!); + if (portMs !== null) recordSandbox(state, sandboxId, "start_after_shutdown_to_port_ready", portMs); + else recordSandboxError(state, sandboxId, "start_after_shutdown_to_port_ready"); + } + + // ── final shutdown (unmeasured cleanup) ─────────────────────────────────── + console.log(" Shutting down (cleanup)..."); + await tryCleanup(sdk, sandboxId); + sandbox = undefined; + console.log(" Done"); + } finally { + if (sandbox) { + await tryCleanup(sdk, sandbox.id); + } + } +} + +// --------------------------------------------------------------------------- +// Vitest test entry point +// --------------------------------------------------------------------------- + +const { templateId, iterations, port } = parseArgs(); + +// Allow up to 5 minutes per iteration plus overhead +const TIMEOUT_MS = (iterations + 1) * 5 * 60 * 1000; + +test("sandbox lifecycle benchmark", { timeout: TIMEOUT_MS }, async () => { + const sdk = initSDK(); + const state = createState(); + + const baseUrl = process.env.CSB_BASE_URL ?? "https://api.codesandbox.io"; + console.log("Sandbox Lifecycle Benchmark"); + console.log(` Template: ${templateId}`); + console.log(` Iterations: ${iterations}`); + console.log(` API URL: ${baseUrl}`); + if (port) console.log(` Port: ${port}`); + + for (let i = 0; i < iterations; i++) { + await runIteration(sdk, state, templateId, port, i); + } + + const ops = port ? [...CORE_OPS, ...PORT_OPS] : [...CORE_OPS]; + printReport(ops, state); +}); diff --git a/tests/benchmark/start-from-archive.test.ts b/tests/benchmark/start-from-archive.test.ts new file mode 100644 index 0000000..6e98f97 --- /dev/null +++ b/tests/benchmark/start-from-archive.test.ts @@ -0,0 +1,140 @@ +/** + * Sandbox Start-from-Archive Benchmark + * + * Measures how long it takes to resume a sandbox from archived state and + * optionally wait for a port to be ready. + * + * The sandbox must already be in an archived state before running. Each + * iteration resumes the sandbox, records timings, then re-archives it for + * the next iteration. + * + * Usage: + * CSB_API_KEY= CSB_SANDBOX_ID= CSB_PORT=3000 npm run benchmark -- --project start-from-archive + * + * Environment Variables: + * CSB_API_KEY CodeSandbox API key (required) + * CSB_SANDBOX_ID ID of the archived sandbox (required) + * CSB_PORT Port to wait for after resume (optional) + * CSB_BASE_URL API base URL (default: https://api.codesandbox.io) + * CSB_ITERATIONS Number of benchmark iterations (default: 5) + */ + +import { test } from "vitest"; +import { CodeSandbox, Sandbox } from "../../src/index.js"; +import { + BenchmarkState, + createState, + initSDK, + measurePortReady, + printReport, + recordSandbox, + recordSandboxError, + timeMs, +} from "./utils.js"; + +// --------------------------------------------------------------------------- +// CLI / env argument parsing +// --------------------------------------------------------------------------- + +function parseArgs() { + const sandboxId = process.env.CSB_SANDBOX_ID; + const iterations = process.env.CSB_ITERATIONS + ? parseInt(process.env.CSB_ITERATIONS, 10) + : 5; + const port = process.env.CSB_PORT + ? parseInt(process.env.CSB_PORT, 10) + : undefined; + + if (!sandboxId) { + throw new Error("CSB_SANDBOX_ID environment variable is required."); + } + + if (!process.env.CSB_API_KEY) { + throw new Error("CSB_API_KEY environment variable is required."); + } + + return { sandboxId, iterations, port }; +} + +// --------------------------------------------------------------------------- +// Operation names +// --------------------------------------------------------------------------- + +const CORE_OPS = ["start_from_archive"] as const; +const PORT_OPS = ["start_from_archive_to_port_ready"] as const; + +// --------------------------------------------------------------------------- +// Single benchmark iteration +// --------------------------------------------------------------------------- + +async function runIteration( + sdk: CodeSandbox, + state: BenchmarkState, + sandboxId: string, + port: number | undefined, + index: number +): Promise { + console.log(`\n── Iteration ${index + 1} ──────────────────────────────`); + + // ── resume from archive ─────────────────────────────────────────────────── + console.log(" Resuming from archive..."); + let sandbox: Sandbox; + let opStart: number; + let ms: number; + try { + opStart = performance.now(); + [sandbox, ms] = await timeMs(() => sdk.sandboxes.resume(sandboxId)); + recordSandbox(state, sandboxId, "start_from_archive", ms); + console.log(` Started from archive ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` Failed to start from archive ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "start_from_archive"); + return; + } + + // ── port readiness ──────────────────────────────────────────────────────── + if (port) { + const portMs = await measurePortReady(sandbox, port, opStart); + if (portMs !== null) recordSandbox(state, sandboxId, "start_from_archive_to_port_ready", portMs); + else recordSandboxError(state, sandboxId, "start_from_archive_to_port_ready"); + } + + // ── re-archive for next iteration ───────────────────────────────────────── + if (index < iterations - 1) { + console.log(" Archiving..."); + try { + await sdk.sandboxes.hibernate(sandboxId); + console.log(" Archived ✓"); + } catch (err) { + console.log(` Failed to archive ✗ ${String(err)}`); + } + } +} + +// --------------------------------------------------------------------------- +// Vitest test entry point +// --------------------------------------------------------------------------- + +const { sandboxId, iterations, port } = parseArgs(); + +// Allow up to 5 minutes per iteration plus overhead +const TIMEOUT_MS = (iterations + 1) * 5 * 60 * 1000; + +test("sandbox start-from-archive benchmark", { timeout: TIMEOUT_MS }, async () => { + const sdk = initSDK(); + const state = createState(); + + const baseUrl = process.env.CSB_BASE_URL ?? "https://api.codesandbox.io"; + console.log("Sandbox Start-from-Archive Benchmark"); + console.log(` Sandbox ID: ${sandboxId}`); + console.log(` Iterations: ${iterations}`); + console.log(` API URL: ${baseUrl}`); + if (port) console.log(` Port: ${port}`); + + for (let i = 0; i < iterations; i++) { + await runIteration(sdk, state, sandboxId, port, i); + } + + const ops = port ? [...CORE_OPS, ...PORT_OPS] : [...CORE_OPS]; + printReport(ops, state); +}); diff --git a/tests/benchmark/utils.ts b/tests/benchmark/utils.ts new file mode 100644 index 0000000..45d71a3 --- /dev/null +++ b/tests/benchmark/utils.ts @@ -0,0 +1,234 @@ +import { CodeSandbox, Sandbox } from "../../src/index.js"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; + +// --------------------------------------------------------------------------- +// SDK initialisation +// --------------------------------------------------------------------------- + +export function initSDK(): CodeSandbox { + const baseUrl = process.env.CSB_BASE_URL ?? "https://api.codesandbox.io"; + return new CodeSandbox(process.env.CSB_API_KEY, { baseUrl }); +} + +// --------------------------------------------------------------------------- +// Timing helpers +// --------------------------------------------------------------------------- + +export async function timeMs(fn: () => Promise): Promise<[T, number]> { + const start = performance.now(); + const result = await fn(); + return [result, performance.now() - start]; +} + +// --------------------------------------------------------------------------- +// Statistics +// --------------------------------------------------------------------------- + +export interface Stats { + samples: number; + avg: number; + min: number; + max: number; + median: number; + p50: number; + p90: number; + p95: number; + p99: number; +} + +export function computeStats(values: number[]): Stats { + const sorted = [...values].sort((a, b) => a - b); + const n = sorted.length; + + const pct = (p: number) => { + const rank = Math.ceil((p / 100) * n); + return sorted[Math.min(rank, n) - 1]; + }; + + const avg = values.reduce((sum, v) => sum + v, 0) / n; + + return { + samples: n, + avg, + min: sorted[0], + max: sorted[n - 1], + median: pct(50), + p50: pct(50), + p90: pct(90), + p95: pct(95), + p99: pct(99), + }; +} + +// --------------------------------------------------------------------------- +// Benchmark state +// --------------------------------------------------------------------------- + +export interface SandboxRecord { + id: string; + timings: Record; + errors: string[]; +} + +export interface BenchmarkState { + sandboxes: SandboxRecord[]; +} + +export function createState(): BenchmarkState { + return { sandboxes: [] }; +} + +function getOrCreate(state: BenchmarkState, id: string): SandboxRecord { + let entry = state.sandboxes.find((s) => s.id === id); + if (!entry) { + entry = { id, timings: {}, errors: [] }; + state.sandboxes.push(entry); + } + return entry; +} + +export function recordSandbox( + state: BenchmarkState, + id: string, + op: string, + ms: number +): void { + getOrCreate(state, id).timings[op] = ms; +} + +export function recordSandboxError( + state: BenchmarkState, + id: string, + op: string +): void { + getOrCreate(state, id).errors.push(op); +} + +// --------------------------------------------------------------------------- +// Cleanup helper +// --------------------------------------------------------------------------- + +export async function tryCleanup(sdk: CodeSandbox, sandboxId: string): Promise { + try { + await sdk.sandboxes.shutdown(sandboxId); + } catch { + /* best effort */ + } + try { + await sdk.sandboxes.delete(sandboxId); + } catch { + /* best effort */ + } +} + +// --------------------------------------------------------------------------- +// Port readiness helper +// Measures time from `opStart` until the given port is ready on the sandbox. +// Returns elapsed ms, or null on failure. +// --------------------------------------------------------------------------- + +export async function measurePortReady( + sandbox: Sandbox, + port: number, + opStart: number +): Promise { + let client: SandboxClient | undefined; + try { + client = await sandbox.connect(); + await client.ports.waitForPort(port, { timeoutMs: 120_000 }); + const ms = performance.now() - opStart; + console.log(` Port ${port} ready ${(ms / 1000).toFixed(2)}s ✓`); + return ms; + } catch (err) { + console.log(` Port ${port} not ready ✗ ${String(err)}`); + return null; + } finally { + try { + await client?.disconnect(); + client?.dispose(); + } catch { + /* best effort */ + } + } +} + +// --------------------------------------------------------------------------- +// Report +// --------------------------------------------------------------------------- + +const CYAN = "\x1b[96m"; +const RESET = "\x1b[0m"; + +export function fmtField(key: string, ms: number, valueWidth: number): string { + const raw = `${(ms / 1000).toFixed(2)}s`; + const padded = raw.padEnd(valueWidth); + return `${key}=${CYAN}${padded}${RESET}`; +} + +export function printReport(ops: readonly string[], state: BenchmarkState): void { + const labelWidth = Math.max(...ops.map((o) => o.length)) + 2; + const label = (op: string) => op + ".".repeat(labelWidth - op.length); + + console.log("\n"); + console.log("BENCHMARK RESULTS"); + console.log("─────────────────\n"); + + for (const op of ops) { + const samples = state.sandboxes + .map((s) => s.timings[op]) + .filter((v): v is number => v !== undefined); + const errCount = state.sandboxes.filter((s) => s.errors.includes(op)).length; + + if (samples.length === 0) { + if (errCount > 0) console.log(`${label(op)}: no data errors=${errCount}`); + continue; + } + + const s = computeStats(samples); + + const row = [ + fmtField("avg", s.avg, 8), + fmtField("min", s.min, 8), + fmtField("med", s.median, 8), + fmtField("max", s.max, 8), + fmtField("p(90)", s.p90, 8), + fmtField("p(95)", s.p95, 8), + fmtField("p(99)", s.p99, 8), + `n=${s.samples}`, + ...(errCount > 0 ? [`errors=${errCount}`] : []), + ].join(" "); + + console.log(`${label(op)}: ${row}`); + } + + if (state.sandboxes.length > 0) { + console.log("\nPER-SANDBOX TIMINGS"); + console.log("───────────────────\n"); + + const headers = ["SANDBOX ID", ...ops.map((op) => op.toUpperCase())]; + const rows = state.sandboxes.map(({ id, timings, errors }) => [ + id, + ...ops.map((op) => { + if (timings[op] !== undefined) return `${(timings[op] / 1000).toFixed(2)}s`; + if (errors.includes(op)) return "ERROR"; + return "-"; + }), + ]); + + const colWidths = headers.map((h, i) => + Math.max(h.length, ...rows.map((r) => r[i].length)) + ); + + const sep = " "; + console.log(headers.map((h, i) => h.padEnd(colWidths[i])).join(sep)); + for (const row of rows) { + const line = row.map((val, i) => { + const plain = val.padEnd(colWidths[i]); + if (val === "ERROR") return `\x1b[91m${plain}\x1b[0m`; + if (val === "-") return `\x1b[2m${plain}\x1b[0m`; + return plain; + }); + console.log(line.join(sep)); + } + } +} diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 17c9a87..fda37ae 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -1,19 +1,86 @@ -import { CodeSandbox } from '../../src/index.js'; +import { afterAll, beforeAll } from "vitest"; +import { CodeSandbox, Sandbox } from "../../src/index.js"; /** * Test template ID used across e2e tests */ -export const TEST_TEMPLATE_ID = process.env.CSB_TEST_TEMPLATE_ID ?? ''; +export const TEST_TEMPLATE_ID = process.env.CSB_TEMPLATE_ID; + +if (!TEST_TEMPLATE_ID) { + throw new Error("You have to provide a test template id"); +} + +export function createTest() { + const test = {} as { + sdk: CodeSandbox; + sandbox: Sandbox; + }; + + beforeAll(async () => { + test.sdk = initializeSDK(); + + // Create a sandbox for testing + test.sandbox = await createSandbox(test.sdk); + }); + + afterAll(async () => { + // Shutdown and deletion can take more than 10 seconds, we prevent this using a timeout + await Promise.race([ + new Promise((resolve) => setTimeout(resolve, 9900)), + deleteSandbox(test.sdk, test.sandbox.id), + ]); + }, 10_000); + + return test; +} + +async function deleteSandbox(sdk: CodeSandbox, sandboxId: string) { + try { + await sdk.sandboxes.shutdown(sandboxId!); + await sdk.sandboxes.delete(sandboxId!); + } catch { + // Try to force delete even if shutdown fails + try { + await sdk.sandboxes.delete(sandboxId!); + } catch {} + } +} /** * Initialize SDK with API key from environment */ export function initializeSDK(): CodeSandbox { - const apiKey = process.env.CSB_API_KEY; - if (!apiKey) { - throw new Error('CSB_API_KEY environment variable is required for e2e tests'); + if (process.env.CSB_BASE_URL) { + return new CodeSandbox(process.env.CSB_API_KEY, { + baseUrl: process.env.CSB_BASE_URL, + }); } - return new CodeSandbox(apiKey); + + console.warn("No CSB_BASE_URL provided, defaulting to PRODUCTION"); + + return new CodeSandbox(process.env.CSB_API_KEY, { + baseUrl: "https://api.codesandbox.io", + }); +} + +export async function createSandbox(sdk: CodeSandbox) { + const templateId = TEST_TEMPLATE_ID!; + const tags = ["sdk"]; + let path = "/e2e-tests"; + + const sandbox = await sdk.sandboxes["api"].forkSandbox(templateId, { + privacy: 2, + tags, + path, + private_preview: false, + }); + + const startResponse = await sdk.sandboxes["api"].startVm( + sandbox.id, + { retryDelay: 200 } // Keep 200ms delay for creation + ); + + return new Sandbox(sandbox.id, sdk.sandboxes["api"], startResponse); } /** diff --git a/tests/e2e/sandbox-apis.test.ts b/tests/e2e/sandbox-apis.test.ts index f37c16c..3dcf56a 100644 --- a/tests/e2e/sandbox-apis.test.ts +++ b/tests/e2e/sandbox-apis.test.ts @@ -1,83 +1,51 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID, retryUntil } from './helpers.js'; +import { describe, it, expect } from "vitest"; +import { retryUntil, createTest } from "./helpers.js"; -describe('Sandbox APIs', () => { - let sdk: CodeSandbox; - let sandboxId: string | undefined; +describe("Sandbox APIs", () => { + const test = createTest(); - beforeAll(async () => { - sdk = initializeSDK(); + it("should find sandbox in list", async () => { + expect(test.sandbox.id).toBeDefined(); - // Create a sandbox for testing - const sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); - sandboxId = sandbox.id; - }); - - afterAll(async () => { - // Cleanup: shutdown and delete the sandbox - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); - // Try to force delete even if shutdown fails - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); - } - } - } - }); - - it('should find sandbox in list', async () => { - expect(sandboxId).toBeDefined(); - if (!sandboxId) throw new Error('Sandbox not created'); - - const sandboxes = await sdk.sandboxes.list(); + const sandboxes = await test.sdk.sandboxes.list({ limit: 10 }); expect(sandboxes).toBeDefined(); expect(sandboxes.sandboxes).toBeDefined(); - const found = sandboxes.sandboxes.find((s) => s.id === sandboxId); + const found = sandboxes.sandboxes.find((s) => s.id === test.sandbox.id); expect(found).toBeDefined(); }); - it('should find sandbox in running list by filter', async () => { - expect(sandboxId).toBeDefined(); - if (!sandboxId) throw new Error('Sandbox not created'); + it("should find sandbox in running list by filter", async () => { + expect(test.sandbox.id).toBeDefined(); const foundInList = await retryUntil(60000, 3000, async () => { - const runningSandboxesByFilter = await sdk.sandboxes.list({ - status: 'running', + const runningSandboxesByFilter = await test.sdk.sandboxes.list({ + status: "running", }); - return runningSandboxesByFilter.sandboxes.find((s) => s.id === sandboxId); + return runningSandboxesByFilter.sandboxes.find( + (s) => s.id === test.sandbox.id + ); }); expect(foundInList).toBeDefined(); }, 70000); - it('should find sandbox in running list by API', async () => { - expect(sandboxId).toBeDefined(); - if (!sandboxId) throw new Error('Sandbox not created'); + it("should find sandbox in running list by API", async () => { + expect(test.sandbox.id).toBeDefined(); const foundByAPI = await retryUntil(60000, 3000, async () => { - const runningSandboxByAPI = await sdk.sandboxes.listRunning(); - return runningSandboxByAPI.vms.find((s) => s.id === sandboxId); + const runningSandboxByAPI = await test.sdk.sandboxes.listRunning(); + return runningSandboxByAPI.vms.find((s) => s.id === test.sandbox.id); }); expect(foundByAPI).toBeDefined(); }, 70000); - it('should get sandbox by ID', async () => { - expect(sandboxId).toBeDefined(); - if (!sandboxId) throw new Error('Sandbox not created'); + it("should get sandbox by ID", async () => { + expect(test.sandbox.id).toBeDefined(); - const fetchedSandbox = await sdk.sandboxes.get(sandboxId); + const fetchedSandbox = await test.sdk.sandboxes.get(test.sandbox.id); expect(fetchedSandbox).toBeDefined(); - expect(fetchedSandbox.id).toBe(sandboxId); + expect(fetchedSandbox.id).toBe(test.sandbox.id); }); }); diff --git a/tests/e2e/sandbox-commands.test.ts b/tests/e2e/sandbox-commands.test.ts index 58a2bbf..068bdab 100644 --- a/tests/e2e/sandbox-commands.test.ts +++ b/tests/e2e/sandbox-commands.test.ts @@ -1,29 +1,17 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; - -describe('Sandbox Commands', () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createTest } from "./helpers.js"; + +describe("Sandbox Commands", () => { + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -31,54 +19,40 @@ describe('Sandbox Commands', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); - } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); - } - } + console.error("Failed to dispose client:", error); } }); - describe('Command execution', () => { - it('should run a simple command and get output', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Command execution", () => { + it("should run a simple command and get output", async () => { + if (!client) throw new Error("Client not initialized"); const output = await client.commands.run('echo "Hello from sandbox"'); - expect(output).toContain('Hello from sandbox'); + expect(output).toContain("Hello from sandbox"); }); - it('should get output from pwd command', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get output from pwd command", async () => { + if (!client) throw new Error("Client not initialized"); - const output = await client.commands.run('pwd'); + const output = await client.commands.run("pwd"); expect(output).toBeTruthy(); expect(output.trim()).toMatch(/^\//); // Should start with / }); - it('should run multiple commands sequentially', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should run multiple commands sequentially", async () => { + if (!client) throw new Error("Client not initialized"); const output1 = await client.commands.run('echo "first"'); const output2 = await client.commands.run('echo "second"'); const output3 = await client.commands.run('echo "third"'); - expect(output1).toContain('first'); - expect(output2).toContain('second'); - expect(output3).toContain('third'); + expect(output1).toContain("first"); + expect(output2).toContain("second"); + expect(output3).toContain("third"); }); - it('should run multiple commands with array syntax', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should run multiple commands with array syntax", async () => { + if (!client) throw new Error("Client not initialized"); // Array of commands should be joined with && const output = await client.commands.run([ @@ -87,27 +61,29 @@ describe('Sandbox Commands', () => { 'echo "third"', ]); - expect(output).toContain('first'); - expect(output).toContain('second'); - expect(output).toContain('third'); + expect(output).toContain("first"); + expect(output).toContain("second"); + expect(output).toContain("third"); }); }); - describe('Background commands', () => { - it('should run command in background', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Background commands", () => { + it("should run command in background", async () => { + if (!client) throw new Error("Client not initialized"); - const command = await client.commands.runBackground('sleep 1 && echo "done"'); + const command = await client.commands.runBackground( + 'sleep 1 && echo "done"' + ); expect(command).toBeDefined(); - expect(command.status).toBe('RUNNING'); + expect(command.status).toBe("RUNNING"); // Wait for completion const output = await command.waitUntilComplete(); - expect(output).toContain('done'); + expect(output).toContain("done"); }, 10000); - it('should run multiple commands in background with array syntax', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should run multiple commands in background with array syntax", async () => { + if (!client) throw new Error("Client not initialized"); // Array of commands should be joined with && const command = await client.commands.runBackground([ @@ -116,19 +92,19 @@ describe('Sandbox Commands', () => { 'echo "third"', ]); expect(command).toBeDefined(); - expect(command.status).toBe('RUNNING'); + expect(command.status).toBe("RUNNING"); // Wait for completion const output = await command.waitUntilComplete(); - expect(output).toContain('first'); - expect(output).toContain('second'); - expect(output).toContain('third'); + expect(output).toContain("first"); + expect(output).toContain("second"); + expect(output).toContain("third"); }, 10000); - it('should be able to kill background command', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should be able to kill background command", async () => { + if (!client) throw new Error("Client not initialized"); - const command = await client.commands.runBackground('sleep 30'); + const command = await client.commands.runBackground("sleep 30"); expect(command).toBeDefined(); await command.kill(); @@ -136,40 +112,67 @@ describe('Sandbox Commands', () => { // Command should be killed expect(command).toBeDefined(); }, 10000); + + it("should stream output from a long-running command via onOutput", async () => { + if (!client) throw new Error("Client not initialized"); + + const command = await client.commands.runBackground( + 'for i in 1 2 3; do echo "line $i"; sleep 1; done' + ); + expect(command.status).toBe("RUNNING"); + + // Register listener before open() so we don't miss chunks that arrive + // immediately after the first one unblocks the barrier + const receivedChunks: string[] = []; + command.onOutput((chunk) => { + receivedChunks.push(chunk); + }); + + // open() subscribes to output and enables the onOutput event + await command.open(); + + const output = await command.waitUntilComplete(); + + expect(output).toContain("line 1"); + expect(output).toContain("line 2"); + expect(output).toContain("line 3"); + // At least some chunks should have arrived incrementally via the event + expect(receivedChunks.length).toBeGreaterThan(0); + }, 15000); }); - describe('Command listing', () => { - it('should get all commands', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Command listing", () => { + it("should get all commands", async () => { + if (!client) throw new Error("Client not initialized"); const commands = await client.commands.getAll(); expect(Array.isArray(commands)).toBe(true); }); }); - describe('Working directory', () => { - it('should run command in specified directory', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Working directory", () => { + it("should run command in specified directory", async () => { + if (!client) throw new Error("Client not initialized"); // Create a test directory - await client.fs.mkdir('/test-cwd'); + await client.fs.mkdir("/test-cwd"); - const output = await client.commands.run('pwd', { cwd: '/test-cwd' }); - expect(output).toContain('/test-cwd'); + const output = await client.commands.run("pwd", { cwd: "/test-cwd" }); + expect(output).toContain("/test-cwd"); // Cleanup - await client.fs.remove('/test-cwd'); + await client.fs.remove("/test-cwd"); }); }); - describe('Environment variables', () => { - it('should run command with custom environment variables', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Environment variables", () => { + it("should run command with custom environment variables", async () => { + if (!client) throw new Error("Client not initialized"); - const output = await client.commands.run('echo $TEST_VAR', { - env: { TEST_VAR: 'custom_value' }, + const output = await client.commands.run("echo $TEST_VAR", { + env: { TEST_VAR: "custom_value" }, }); - expect(output).toContain('custom_value'); + expect(output).toContain("custom_value"); }); }); }); diff --git a/tests/e2e/sandbox-error-handling.test.ts b/tests/e2e/sandbox-error-handling.test.ts new file mode 100644 index 0000000..4afec61 --- /dev/null +++ b/tests/e2e/sandbox-error-handling.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createTest, TEST_TEMPLATE_ID } from "./helpers.js"; + +/** + * Scenario 7: Error handling and recovery + * + * Tests how the SDK handles invalid inputs, bad states, and interrupted operations. + */ +describe("Sandbox Error Handling", () => { + const test = createTest(); + + describe("Invalid inputs", () => { + it("should throw a clear error when creating from a nonexistent template", async () => { + const error = await test.sdk.sandboxes + .create({ id: "nonexistent-template-xyz-abc-123" }) + .catch((e) => e); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBeTruthy(); + console.log("Invalid template error:", error.message); + }, 30000); + }); + + describe("Double delete", () => { + it("should throw on deleting an already-deleted sandbox", async () => { + let sandboxId: string | undefined; + + try { + const sandbox = await test.sdk.sandboxes.create({ + id: TEST_TEMPLATE_ID, + title: "test-double-delete", + }); + sandboxId = sandbox.id; + + // First delete should succeed + await test.sdk.sandboxes.shutdown(sandboxId); + await test.sdk.sandboxes.delete(sandboxId); + sandboxId = undefined; + + // Second delete should throw + const error = await test.sdk.sandboxes + .delete(sandbox.id) + .catch((e) => e); + + expect(error).toBeInstanceOf(Error); + console.log("Double delete error:", error.message); + } finally { + if (sandboxId) { + try { + await test.sdk.sandboxes.shutdown(sandboxId); + await test.sdk.sandboxes.delete(sandboxId); + } catch {} + } + } + }, 60000); + }); + + describe("Resume behavior", () => { + it("should handle resume on an already-running sandbox without crashing", async () => { + // Resume on a running sandbox should either succeed (idempotent) or throw a clear error + const result = await test.sdk.sandboxes + .resume(test.sandbox.id) + .catch((e) => e); + + if (result instanceof Error) { + console.log("Resume-on-running error:", result.message); + expect(result.message).toBeTruthy(); + } else { + // Idempotent behavior: returned a sandbox object + expect(result.id).toBe(test.sandbox.id); + console.log("Resume-on-running returned sandbox, bootupType:", result.bootupType); + } + }, 30000); + }); + + describe("Concurrent commands", () => { + let client: SandboxClient | undefined; + + beforeAll(async () => { + client = await test.sandbox.connect(); + }, 60000); + + afterAll(async () => { + try { + if (client) { + await client.disconnect(); + client.dispose(); + client = undefined; + } + } catch {} + }); + + it("should complete all concurrent commands without deadlock", async () => { + if (!client) throw new Error("Client not initialized"); + + const results = await Promise.all([ + client.commands.run("echo cmd-1"), + client.commands.run("echo cmd-2"), + client.commands.run("echo cmd-3"), + client.commands.run("echo cmd-4"), + client.commands.run("echo cmd-5"), + ]); + + expect(results.length).toBe(5); + results.forEach((r, i) => { + expect(r).toContain(`cmd-${i + 1}`); + }); + }, 30000); + + it("should remain usable while a long-running background command is active", async () => { + if (!client) throw new Error("Client not initialized"); + + // Start a long-running command but don't await it + const longCmd = client.commands.runBackground("sleep 60"); + + try { + // We should still be able to run other commands + const other = await client.commands.run("echo still responsive"); + expect(other).toContain("still responsive"); + + const another = await client.commands.run("echo second command"); + expect(another).toContain("second command"); + } finally { + // Kill the long-running command + const cmd = await longCmd; + await cmd.kill(); + } + }, 30000); + }); +}); diff --git a/tests/e2e/sandbox-filesystem.test.ts b/tests/e2e/sandbox-filesystem.test.ts index b43ba5d..6b3dfd9 100644 --- a/tests/e2e/sandbox-filesystem.test.ts +++ b/tests/e2e/sandbox-filesystem.test.ts @@ -1,30 +1,17 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; - -describe('Sandbox Filesystem', () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createTest } from "./helpers.js"; + +describe("Sandbox Filesystem", () => { + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); - // Connect to sandbox - client = await sandbox.connect(); - }, 60000); // 1 minute timeout for setup + client = await test.sandbox.connect(); + }, 60000); afterAll(async () => { - // Cleanup: disconnect, shutdown and delete the sandbox - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -32,278 +19,284 @@ describe('Sandbox Filesystem', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); - } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); - // Try to force delete even if shutdown fails - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); - } - } + console.error("Failed to dispose client:", error); } }); - describe('File operations', () => { - it('should write and read a file', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("File operations", () => { + it("should write and read a file", async () => { + if (!client) throw new Error("Client not initialized"); - await client.fs.writeTextFile('/test-file.txt', 'Hello, Sandbox!'); - const fileContent = await client.fs.readTextFile('/test-file.txt'); + await client.fs.writeTextFile("/test-file.txt", "Hello, Sandbox!"); + const fileContent = await client.fs.readTextFile("/test-file.txt"); - expect(fileContent).toBe('Hello, Sandbox!'); + expect(fileContent).toBe("Hello, Sandbox!"); }); - it('should list files in directory', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should list files in directory", async () => { + if (!client) throw new Error("Client not initialized"); - const files = await client.fs.readdir('/'); + const files = await client.fs.readdir("/"); expect(files).toBeDefined(); - const testFile = files.find((f) => f.name === 'test-file.txt' && f.type === 'file'); + const testFile = files.find( + (f) => f.name === "test-file.txt" && f.type === "file" + ); expect(testFile).toBeDefined(); }); - it('should delete a file', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should delete a file", async () => { + if (!client) throw new Error("Client not initialized"); - await client.fs.remove('/test-file.txt'); + await client.fs.remove("/test-file.txt"); - const filesAfterDeletion = await client.fs.readdir('/'); - const testFile = filesAfterDeletion.find((f) => f.name === 'test-file.txt'); + const filesAfterDeletion = await client.fs.readdir("/"); + const testFile = filesAfterDeletion.find( + (f) => f.name === "test-file.txt" + ); expect(testFile).toBeUndefined(); }); }); - describe('Directory operations', () => { - it('should create a directory', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Directory operations", () => { + it("should create a directory", async () => { + if (!client) throw new Error("Client not initialized"); - await client.fs.mkdir('/test-dir'); + await client.fs.mkdir("/test-dir"); - const dirs = await client.fs.readdir('/'); - const testDir = dirs.find((d) => d.name === 'test-dir' && d.type === 'directory'); + const dirs = await client.fs.readdir("/"); + const testDir = dirs.find( + (d) => d.name === "test-dir" && d.type === "directory" + ); expect(testDir).toBeDefined(); }); - it('should delete a directory', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should delete a directory", async () => { + if (!client) throw new Error("Client not initialized"); - await client.fs.remove('/test-dir'); + await client.fs.remove("/test-dir"); - const dirsAfterDeletion = await client.fs.readdir('/'); - const testDir = dirsAfterDeletion.find((d) => d.name === 'test-dir'); + const dirsAfterDeletion = await client.fs.readdir("/"); + const testDir = dirsAfterDeletion.find((d) => d.name === "test-dir"); expect(testDir).toBeUndefined(); }); }); - describe('Binary file operations', () => { - it('should write and read binary files', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Binary file operations", () => { + it("should write and read binary files", async () => { + if (!client) throw new Error("Client not initialized"); const binaryData = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" in bytes - await client.fs.writeFile('/test-binary.bin', binaryData); + await client.fs.writeFile("/test-binary.bin", binaryData); - const readData = await client.fs.readFile('/test-binary.bin'); + const readData = await client.fs.readFile("/test-binary.bin"); // Compare values instead of object types (readFile may return Buffer in Node.js) expect(Array.from(readData)).toEqual(Array.from(binaryData)); // Cleanup - await client.fs.remove('/test-binary.bin'); + await client.fs.remove("/test-binary.bin"); }); }); - describe('File stat operations', () => { - it('should get file stats', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("File stat operations", () => { + it("should get file stats", async () => { + if (!client) throw new Error("Client not initialized"); - await client.fs.writeTextFile('/stat-test.txt', 'test content'); + await client.fs.writeTextFile("/stat-test.txt", "test content"); - const stats = await client.fs.stat('/stat-test.txt'); + const stats = await client.fs.stat("/stat-test.txt"); expect(stats).toBeDefined(); - expect(stats.type).toBe('file'); + expect(stats.type).toBe("file"); expect(stats.size).toBeGreaterThan(0); // Cleanup - await client.fs.remove('/stat-test.txt'); + await client.fs.remove("/stat-test.txt"); }); - it('should get directory stats', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get directory stats", async () => { + if (!client) throw new Error("Client not initialized"); - await client.fs.mkdir('/stat-dir'); + await client.fs.mkdir("/stat-dir"); - const stats = await client.fs.stat('/stat-dir'); + const stats = await client.fs.stat("/stat-dir"); expect(stats).toBeDefined(); - expect(stats.type).toBe('directory'); + expect(stats.type).toBe("directory"); // Cleanup - await client.fs.remove('/stat-dir'); + await client.fs.remove("/stat-dir"); }); }); - describe('Copy operations', () => { - it('should copy a file', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Copy operations", () => { + it("should copy a file", async () => { + if (!client) throw new Error("Client not initialized"); - await client.fs.writeTextFile('/copy-source.txt', 'copy test'); - await client.fs.copy('/copy-source.txt', '/copy-dest.txt'); + await client.fs.writeTextFile("/copy-source.txt", "copy test"); + await client.fs.copy("/copy-source.txt", "/copy-dest.txt"); - const content = await client.fs.readTextFile('/copy-dest.txt'); - expect(content).toBe('copy test'); + const content = await client.fs.readTextFile("/copy-dest.txt"); + expect(content).toBe("copy test"); // Cleanup - await client.fs.remove('/copy-source.txt'); - await client.fs.remove('/copy-dest.txt'); + await client.fs.remove("/copy-source.txt"); + await client.fs.remove("/copy-dest.txt"); }); - it('should copy a directory recursively', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should copy a directory recursively", async () => { + if (!client) throw new Error("Client not initialized"); - await client.fs.mkdir('/copy-dir'); - await client.fs.writeTextFile('/copy-dir/file.txt', 'nested file'); - await client.fs.copy('/copy-dir', '/copy-dir-dest', true); + await client.fs.mkdir("/copy-dir"); + await client.fs.writeTextFile("/copy-dir/file.txt", "nested file"); + await client.fs.copy("/copy-dir", "/copy-dir-dest", true); - const content = await client.fs.readTextFile('/copy-dir-dest/file.txt'); - expect(content).toBe('nested file'); + const content = await client.fs.readTextFile("/copy-dir-dest/file.txt"); + expect(content).toBe("nested file"); // Cleanup - await client.fs.remove('/copy-dir', true); - await client.fs.remove('/copy-dir-dest', true); + await client.fs.remove("/copy-dir", true); + await client.fs.remove("/copy-dir-dest", true); }); }); - describe('Rename operations', () => { - it('should rename a file', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Rename operations", () => { + it("should rename a file", async () => { + if (!client) throw new Error("Client not initialized"); - await client.fs.writeTextFile('/rename-old.txt', 'rename test'); - await client.fs.rename('/rename-old.txt', '/rename-new.txt'); + await client.fs.writeTextFile("/rename-old.txt", "rename test"); + await client.fs.rename("/rename-old.txt", "/rename-new.txt"); - const content = await client.fs.readTextFile('/rename-new.txt'); - expect(content).toBe('rename test'); + const content = await client.fs.readTextFile("/rename-new.txt"); + expect(content).toBe("rename test"); - const files = await client.fs.readdir('/'); - expect(files.find((f) => f.name === 'rename-old.txt')).toBeUndefined(); - expect(files.find((f) => f.name === 'rename-new.txt')).toBeDefined(); + const files = await client.fs.readdir("/"); + expect(files.find((f) => f.name === "rename-old.txt")).toBeUndefined(); + expect(files.find((f) => f.name === "rename-new.txt")).toBeDefined(); // Cleanup - await client.fs.remove('/rename-new.txt'); + await client.fs.remove("/rename-new.txt"); }); - it('should rename a directory', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should rename a directory", async () => { + if (!client) throw new Error("Client not initialized"); - await client.fs.mkdir('/rename-dir-old'); - await client.fs.writeTextFile('/rename-dir-old/file.txt', 'content'); - await client.fs.rename('/rename-dir-old', '/rename-dir-new'); + await client.fs.mkdir("/rename-dir-old"); + await client.fs.writeTextFile("/rename-dir-old/file.txt", "content"); + await client.fs.rename("/rename-dir-old", "/rename-dir-new"); - const content = await client.fs.readTextFile('/rename-dir-new/file.txt'); - expect(content).toBe('content'); + const content = await client.fs.readTextFile("/rename-dir-new/file.txt"); + expect(content).toBe("content"); - const dirs = await client.fs.readdir('/'); - expect(dirs.find((d) => d.name === 'rename-dir-old')).toBeUndefined(); - expect(dirs.find((d) => d.name === 'rename-dir-new')).toBeDefined(); + const dirs = await client.fs.readdir("/"); + expect(dirs.find((d) => d.name === "rename-dir-old")).toBeUndefined(); + expect(dirs.find((d) => d.name === "rename-dir-new")).toBeDefined(); // Cleanup - await client.fs.remove('/rename-dir-new', true); + await client.fs.remove("/rename-dir-new", true); }); }); - describe.skip('Batch write operations', () => { + describe.skip("Batch write operations", () => { // Skip these tests - batchWrite uses zip/unzip which may not be available in all sandbox environments - it('should write multiple files at once', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should write multiple files at once", async () => { + if (!client) throw new Error("Client not initialized"); - await client.fs.mkdir('/batch-test'); + await client.fs.mkdir("/batch-test"); await client.fs.batchWrite([ - { path: '/batch-test/file1.txt', content: 'content 1' }, - { path: '/batch-test/file2.txt', content: 'content 2' }, - { path: '/batch-test/file3.txt', content: 'content 3' }, + { path: "/batch-test/file1.txt", content: "content 1" }, + { path: "/batch-test/file2.txt", content: "content 2" }, + { path: "/batch-test/file3.txt", content: "content 3" }, ]); - const content1 = await client.fs.readTextFile('/batch-test/file1.txt'); - const content2 = await client.fs.readTextFile('/batch-test/file2.txt'); - const content3 = await client.fs.readTextFile('/batch-test/file3.txt'); + const content1 = await client.fs.readTextFile("/batch-test/file1.txt"); + const content2 = await client.fs.readTextFile("/batch-test/file2.txt"); + const content3 = await client.fs.readTextFile("/batch-test/file3.txt"); - expect(content1).toBe('content 1'); - expect(content2).toBe('content 2'); - expect(content3).toBe('content 3'); + expect(content1).toBe("content 1"); + expect(content2).toBe("content 2"); + expect(content3).toBe("content 3"); // Cleanup - await client.fs.remove('/batch-test', true); + await client.fs.remove("/batch-test", true); }); - it('should write nested directories in batch', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should write nested directories in batch", async () => { + if (!client) throw new Error("Client not initialized"); await client.fs.batchWrite([ - { path: '/batch-nested/dir1/file.txt', content: 'nested 1' }, - { path: '/batch-nested/dir2/file.txt', content: 'nested 2' }, + { path: "/batch-nested/dir1/file.txt", content: "nested 1" }, + { path: "/batch-nested/dir2/file.txt", content: "nested 2" }, ]); - const content1 = await client.fs.readTextFile('/batch-nested/dir1/file.txt'); - const content2 = await client.fs.readTextFile('/batch-nested/dir2/file.txt'); + const content1 = await client.fs.readTextFile( + "/batch-nested/dir1/file.txt" + ); + const content2 = await client.fs.readTextFile( + "/batch-nested/dir2/file.txt" + ); - expect(content1).toBe('nested 1'); - expect(content2).toBe('nested 2'); + expect(content1).toBe("nested 1"); + expect(content2).toBe("nested 2"); // Cleanup - await client.fs.remove('/batch-nested', true); + await client.fs.remove("/batch-nested", true); }); }); - describe('Recursive operations', () => { - it('should create nested directories', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Recursive operations", () => { + it("should create nested directories", async () => { + if (!client) throw new Error("Client not initialized"); - await client.fs.mkdir('/nested/deep/path', true); + await client.fs.mkdir("/nested/deep/path", true); - const stats = await client.fs.stat('/nested/deep/path'); - expect(stats.type).toBe('directory'); + const stats = await client.fs.stat("/nested/deep/path"); + expect(stats.type).toBe("directory"); // Cleanup - await client.fs.remove('/nested', true); + await client.fs.remove("/nested", true); }); - it('should remove directory with contents recursively', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should remove directory with contents recursively", async () => { + if (!client) throw new Error("Client not initialized"); - await client.fs.mkdir('/recursive-remove'); - await client.fs.writeTextFile('/recursive-remove/file1.txt', 'content'); - await client.fs.mkdir('/recursive-remove/subdir'); - await client.fs.writeTextFile('/recursive-remove/subdir/file2.txt', 'content'); + await client.fs.mkdir("/recursive-remove"); + await client.fs.writeTextFile("/recursive-remove/file1.txt", "content"); + await client.fs.mkdir("/recursive-remove/subdir"); + await client.fs.writeTextFile( + "/recursive-remove/subdir/file2.txt", + "content" + ); - await client.fs.remove('/recursive-remove', true); + await client.fs.remove("/recursive-remove", true); - const files = await client.fs.readdir('/'); - expect(files.find((f) => f.name === 'recursive-remove')).toBeUndefined(); + const files = await client.fs.readdir("/"); + expect(files.find((f) => f.name === "recursive-remove")).toBeUndefined(); }); }); - describe('File watching', () => { - it('should detect file system changes', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("File watching", () => { + it("should detect file system changes", async () => { + if (!client) throw new Error("Client not initialized"); - await client.fs.mkdir('/watch-dir'); + try { + await client.fs.remove("/watch-dir"); + } catch {} + + await client.fs.mkdir("/watch-dir"); let changeDetected = false; - const watcher = await client.fs.watch('/watch-dir', { recursive: true }); + + const watcher = await client.fs.watch("/watch-dir", { recursive: true }); const eventDisposable = watcher.onEvent((event) => { - if (event.paths.some((p) => p.includes('watched-file.txt'))) { + if (event.paths.some((p) => p.includes("watched-file.txt"))) { changeDetected = true; } }); - await client.fs.writeTextFile('/watch-dir/watched-file.txt', 'Watching this file'); + await client.fs.writeTextFile( + "/watch-dir/watched-file.txt", + "Watching this file" + ); // Wait up to 10 seconds for the change to be detected const maxWaitTime = 10000; @@ -320,8 +313,8 @@ describe('Sandbox Filesystem', () => { watcher.dispose(); // Cleanup - await client.fs.remove('/watch-dir/watched-file.txt'); - await client.fs.remove('/watch-dir'); + await client.fs.remove("/watch-dir/watched-file.txt"); + await client.fs.remove("/watch-dir"); }, 30000); }); }); diff --git a/tests/e2e/sandbox-hibernate-state.test.ts b/tests/e2e/sandbox-hibernate-state.test.ts new file mode 100644 index 0000000..b0c86fc --- /dev/null +++ b/tests/e2e/sandbox-hibernate-state.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { createTest } from "./helpers.js"; + +/** + * Scenario 2: Hibernate and resume with state verification + * + * Verifies that sandbox state (files) persists through hibernate/resume cycles. + */ +describe("Sandbox Hibernate State", () => { + const test = createTest(); + + it("should preserve file content through a hibernate/resume cycle", async () => { + const client = await test.sandbox.connect(); + + try { + await client.fs.writeTextFile( + "/hibernate-state-test.txt", + "testing party 2026" + ); + const beforeContent = await client.fs.readTextFile( + "/hibernate-state-test.txt" + ); + expect(beforeContent).toBe("testing party 2026"); + } finally { + await client.disconnect(); + client.dispose(); + } + + await test.sdk.sandboxes.hibernate(test.sandbox.id); + + const resumeStart = Date.now(); + const resumed = await test.sdk.sandboxes.resume(test.sandbox.id); + console.log(`Resume time: ${Date.now() - resumeStart}ms`); + + const afterClient = await resumed.connect(); + try { + const afterContent = await afterClient.fs.readTextFile( + "/hibernate-state-test.txt" + ); + expect(afterContent).toBe("testing party 2026"); + + // Verify via command as well + const cmdOutput = await afterClient.commands.run( + "cat /hibernate-state-test.txt" + ); + expect(cmdOutput).toContain("testing party 2026"); + } finally { + await afterClient.disconnect(); + afterClient.dispose(); + } + }, 120000); + + it("should preserve file content through 3 consecutive hibernate/resume cycles", async () => { + const client = await test.sandbox.connect(); + try { + await client.fs.writeTextFile("/anchor.txt", "testing party 2026"); + } finally { + await client.disconnect(); + client.dispose(); + } + + let currentSandbox = test.sandbox; + + for (let i = 0; i < 3; i++) { + await test.sdk.sandboxes.hibernate(currentSandbox.id); + + const cycleStart = Date.now(); + currentSandbox = await test.sdk.sandboxes.resume(currentSandbox.id); + console.log(`Cycle ${i + 1} resume: ${Date.now() - cycleStart}ms`); + + const cycleClient = await currentSandbox.connect(); + try { + const content = await cycleClient.fs.readTextFile("/anchor.txt"); + expect(content).toBe("testing party 2026"); + } finally { + await cycleClient.disconnect(); + cycleClient.dispose(); + } + } + }, 300000); +}); diff --git a/tests/e2e/sandbox-hosts.test.ts b/tests/e2e/sandbox-hosts.test.ts index f5ffe70..e5dc5f9 100644 --- a/tests/e2e/sandbox-hosts.test.ts +++ b/tests/e2e/sandbox-hosts.test.ts @@ -1,29 +1,17 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createTest } from "./helpers.js"; -describe('Sandbox Hosts', () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; +describe("Sandbox Hosts", () => { + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -31,67 +19,53 @@ describe('Sandbox Hosts', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); - } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); - } - } + console.error("Failed to dispose client:", error); } }); - describe('Host URL generation', () => { - it('should generate URL for a port', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Host URL generation", () => { + it("should generate URL for a port", async () => { + if (!client) throw new Error("Client not initialized"); const url = client.hosts.getUrl(3000); expect(url).toBeTruthy(); - expect(url).toContain('csb.app'); - expect(url).toContain('3000'); - expect(url).toContain(sandbox.id); + expect(url).toContain("csb.app"); + expect(url).toContain("3000"); + expect(url).toContain(test.sandbox.id); }); - it('should generate URL with custom protocol', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should generate URL with custom protocol", async () => { + if (!client) throw new Error("Client not initialized"); - const url = client.hosts.getUrl(8080, 'http'); + const url = client.hosts.getUrl(8080, "http"); expect(url).toBeTruthy(); - expect(url.startsWith('http://')).toBe(true); - expect(url).toContain('8080'); + expect(url.startsWith("http://")).toBe(true); + expect(url).toContain("8080"); }); - it('should generate URL with https by default', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should generate URL with https by default", async () => { + if (!client) throw new Error("Client not initialized"); const url = client.hosts.getUrl(4000); - expect(url.startsWith('https://')).toBe(true); + expect(url.startsWith("https://")).toBe(true); }); }); - describe('Host headers and cookies', () => { - it('should get headers', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Host headers and cookies", () => { + it("should get headers", async () => { + if (!client) throw new Error("Client not initialized"); const headers = client.hosts.getHeaders(); expect(headers).toBeDefined(); - expect(typeof headers).toBe('object'); + expect(typeof headers).toBe("object"); }); - it('should get cookies', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get cookies", async () => { + if (!client) throw new Error("Client not initialized"); const cookies = client.hosts.getCookies(); expect(cookies).toBeDefined(); - expect(typeof cookies).toBe('object'); + expect(typeof cookies).toBe("object"); }); }); }); diff --git a/tests/e2e/sandbox-interpreters.test.ts b/tests/e2e/sandbox-interpreters.test.ts index e385613..43fd2cb 100644 --- a/tests/e2e/sandbox-interpreters.test.ts +++ b/tests/e2e/sandbox-interpreters.test.ts @@ -1,29 +1,17 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createTest } from "./helpers.js"; -describe('Sandbox Interpreters', () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; +describe("Sandbox Interpreters", () => { + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -31,80 +19,66 @@ describe('Sandbox Interpreters', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); - } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); - } - } + console.error("Failed to dispose client:", error); } }); - describe('JavaScript interpreter', () => { - it('should execute simple JavaScript code', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("JavaScript interpreter", () => { + it("should execute simple JavaScript code", async () => { + if (!client) throw new Error("Client not initialized"); - const result = await client.interpreters.javascript('2 + 2'); - expect(result).toContain('4'); + const result = await client.interpreters.javascript("2 + 2"); + expect(result).toContain("4"); }); - it('should execute JavaScript with variables', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should execute JavaScript with variables", async () => { + if (!client) throw new Error("Client not initialized"); const result = await client.interpreters.javascript(` const x = 10; const y = 20; console.log(x + y); `); - expect(result).toContain('30'); + expect(result).toContain("30"); }); - it('should execute JavaScript with return statement', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should execute JavaScript with return statement", async () => { + if (!client) throw new Error("Client not initialized"); const result = await client.interpreters.javascript(` const greeting = 'Hello from JavaScript'; console.log(greeting); `); - expect(result).toContain('Hello from JavaScript'); + expect(result).toContain("Hello from JavaScript"); }); }); - describe('Python interpreter', () => { - it('should execute simple Python code', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Python interpreter", () => { + it("should execute simple Python code", async () => { + if (!client) throw new Error("Client not initialized"); - const result = await client.interpreters.python('2 + 2'); - expect(result).toContain('4'); + const result = await client.interpreters.python("2 + 2"); + expect(result).toContain("4"); }); - it('should execute Python with variables', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should execute Python with variables", async () => { + if (!client) throw new Error("Client not initialized"); const result = await client.interpreters.python(` x = 10 y = 20 print(x + y)`); - expect(result).toContain('30'); + expect(result).toContain("30"); }); - it('should execute Python with print statement', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should execute Python with print statement", async () => { + if (!client) throw new Error("Client not initialized"); const result = await client.interpreters.python(` message = 'Hello from Python' print(message) `); - expect(result).toContain('Hello from Python'); + expect(result).toContain("Hello from Python"); }); }); }); diff --git a/tests/e2e/sandbox-lifecycle.test.ts b/tests/e2e/sandbox-lifecycle.test.ts index ad8a251..cb5bad8 100644 --- a/tests/e2e/sandbox-lifecycle.test.ts +++ b/tests/e2e/sandbox-lifecycle.test.ts @@ -1,20 +1,15 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; +import { describe, it, expect } from "vitest"; +import { createTest, TEST_TEMPLATE_ID } from "./helpers.js"; -describe('Sandbox Lifecycle', () => { - let sdk: CodeSandbox; - - beforeAll(() => { - sdk = initializeSDK(); - }); +describe("Sandbox Lifecycle", () => { + const test = createTest(); it('should complete full lifecycle: create, hibernate, resume, restart, shutdown, delete', async () => { let sandboxId: string | undefined; try { // Create sandbox - let sandbox = await sdk.sandboxes.create({ + let sandbox = await test.sdk.sandboxes.create({ id: TEST_TEMPLATE_ID, }); expect(sandbox).toBeDefined(); @@ -22,30 +17,30 @@ describe('Sandbox Lifecycle', () => { sandboxId = sandbox.id; // Hibernate sandbox - await sdk.sandboxes.hibernate(sandboxId); + await test.sdk.sandboxes.hibernate(sandboxId); // Resume sandbox - sandbox = await sdk.sandboxes.resume(sandboxId); + sandbox = await test.sdk.sandboxes.resume(sandboxId); expect(sandbox).toBeDefined(); expect(sandbox.id).toBe(sandboxId); // Restart sandbox - await sdk.sandboxes.restart(sandboxId); + await test.sdk.sandboxes.restart(sandboxId); // Shutdown sandbox - await sdk.sandboxes.shutdown(sandboxId); + await test.sdk.sandboxes.shutdown(sandboxId); // Delete sandbox - await sdk.sandboxes.delete(sandboxId); + await test.sdk.sandboxes.delete(sandboxId); sandboxId = undefined; // Mark as cleaned up } finally { // Ensure cleanup even on test failure if (sandboxId) { try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); + await test.sdk.sandboxes.shutdown(sandboxId); + await test.sdk.sandboxes.delete(sandboxId); } catch (error) { - console.error('Failed to cleanup sandbox:', sandboxId, error); + console.error("Failed to cleanup sandbox:", sandboxId, error); } } } diff --git a/tests/e2e/sandbox-performance.test.ts b/tests/e2e/sandbox-performance.test.ts new file mode 100644 index 0000000..7cc1240 --- /dev/null +++ b/tests/e2e/sandbox-performance.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createTest } from "./helpers.js"; + +/** + * Scenarios 3 & 5: File operations performance and agent-driven dev session + * + * Validates file read/write and command execution performance, plus git workflows. + */ +describe("Sandbox Performance", () => { + const test = createTest(); + let client: SandboxClient | undefined; + + beforeAll(async () => { + client = await test.sandbox.connect(); + }, 60000); + + afterAll(async () => { + try { + if (client) { + await client.disconnect(); + client.dispose(); + client = undefined; + } + } catch (error) { + console.error("Failed to dispose client:", error); + } + }); + + describe("Sequential file write performance", () => { + it("should write 20 files sequentially and read them back correctly", async () => { + if (!client) throw new Error("Client not initialized"); + + await client.fs.mkdir("/perf-test"); + + const latencies: number[] = []; + + for (let i = 0; i < 20; i++) { + const start = Date.now(); + await client.fs.writeTextFile( + `/perf-test/file_${i}.txt`, + `content for file ${i}` + ); + latencies.push(Date.now() - start); + } + + const maxLatency = Math.max(...latencies); + const avgLatency = + latencies.reduce((a, b) => a + b, 0) / latencies.length; + console.log( + `File write latencies: avg=${avgLatency.toFixed( + 1 + )}ms, max=${maxLatency}ms` + ); + + // Verify file count + const files = await client.fs.readdir("/perf-test"); + expect(files.filter((f) => f.type === "file").length).toBe(20); + + // Spot-check content + const content5 = await client.fs.readTextFile("/perf-test/file_5.txt"); + expect(content5).toBe("content for file 5"); + + const content19 = await client.fs.readTextFile("/perf-test/file_19.txt"); + expect(content19).toBe("content for file 19"); + + // Cleanup + await client.fs.remove("/perf-test", true); + }, 60000); + + it("should write 30 files and report P50/P99 latency", async () => { + if (!client) throw new Error("Client not initialized"); + + await client.fs.mkdir("/sdk-perf-workspace/src", true); + + const writeLatencies: number[] = []; + + for (let i = 0; i < 30; i++) { + const start = Date.now(); + await client.fs.writeTextFile( + `/sdk-perf-workspace/src/component_${i}.ts`, + `export const Component${i} = () => "component ${i}";` + ); + writeLatencies.push(Date.now() - start); + } + + writeLatencies.sort((a, b) => a - b); + const p50 = writeLatencies[14]; + const p99 = writeLatencies[29]; + console.log(`Write P50: ${p50}ms, P99: ${p99}ms`); + + // Verify all files persisted + const files = await client.fs.readdir("/sdk-perf-workspace/src"); + expect(files.filter((f) => f.type === "file").length).toBe(30); + + // Cleanup + await client.fs.remove("/sdk-perf-workspace", true); + }, 60000); + }); + + describe("Command burst performance", () => { + it("should run 50 sequential commands and report P50/P99 latency", async () => { + if (!client) throw new Error("Client not initialized"); + + const latencies: number[] = []; + + for (let i = 0; i < 50; i++) { + const start = Date.now(); + const output = await client.commands.run(`echo "step ${i}"`); + latencies.push(Date.now() - start); + expect(output).toContain(`step ${i}`); + } + + latencies.sort((a, b) => a - b); + const p50 = latencies[24]; + const p99 = latencies[49]; + console.log(`Command P50: ${p50}ms, P99: ${p99}ms`); + + // P99 should be under 10 seconds (generous bound for E2E over network) + expect(p99).toBeLessThan(10000); + }, 180000); + }); + + describe("Package installation", () => { + it("should install a package and verify it is usable", async () => { + if (!client) throw new Error("Client not initialized"); + + await client.commands.run( + "mkdir -p /npm-test && cd /npm-test && npm init -y" + ); + + const installStart = Date.now(); + await client.commands.run("cd /npm-test && npm install express"); + const installDuration = Date.now() - installStart; + console.log(`npm install express: ${installDuration}ms`); + + expect(installDuration).toBeLessThan(60000); + + // Verify the package is usable + const verify = await client.commands.run( + "node -e \"require('/npm-test/node_modules/express'); console.log('express loaded')\"" + ); + expect(verify).toContain("express loaded"); + + // Cleanup + await client.fs.remove("/npm-test", true); + }, 120000); + + it("should complete a heavy package install within 120 seconds", async () => { + if (!client) throw new Error("Client not initialized"); + + await client.commands.run( + "mkdir -p /heavy-install && cd /heavy-install && npm init -y" + ); + + const heavyStart = Date.now(); + await client.commands.run( + "cd /heavy-install && npm install next react react-dom typescript @types/react" + ); + const heavyDuration = Date.now() - heavyStart; + console.log(`Heavy npm install: ${heavyDuration}ms`); + + expect(heavyDuration).toBeLessThan(120000); + + // Cleanup + await client.fs.remove("/heavy-install", true); + }, 180000); + }); + + describe("Git operations", () => { + it("should perform git init, add, commit and log successfully", async () => { + if (!client) throw new Error("Client not initialized"); + + await client.fs.mkdir("/git-test/src", true); + + // Configure git + await client.commands.run([ + "cd /git-test", + "git init", + "git config user.email 'test@test.com'", + "git config user.name 'Test User'", + ]); + + // Write some source files + for (let i = 0; i < 5; i++) { + await client.fs.writeTextFile( + `/git-test/src/component_${i}.ts`, + `export const Component${i} = () => "component ${i}";` + ); + } + + // Stage and commit + await client.commands.run("cd /git-test && git add ."); + await client.commands.run( + "cd /git-test && git commit -m 'initial commit'" + ); + + // Verify commit is in log + const log = await client.commands.run( + "cd /git-test && git log --oneline" + ); + expect(log).toContain("initial commit"); + + // Verify all files were committed + const trackedFiles = await client.commands.run( + "cd /git-test && git ls-files | wc -l" + ); + expect(parseInt(trackedFiles.trim())).toBe(5); + + // Cleanup + await client.fs.remove("/git-test", true); + }, 60000); + }); +}); diff --git a/tests/e2e/sandbox-ports.test.ts b/tests/e2e/sandbox-ports.test.ts index 5d6b4d4..bcd7a7d 100644 --- a/tests/e2e/sandbox-ports.test.ts +++ b/tests/e2e/sandbox-ports.test.ts @@ -1,29 +1,17 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; - -describe('Sandbox Ports', () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createTest } from "./helpers.js"; + +describe("Sandbox Ports", () => { + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -31,44 +19,34 @@ describe('Sandbox Ports', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); - } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); - } - } + console.error("Failed to dispose client:", error); } }); - describe('Port listing', () => { - it('should get all open ports', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Port listing", () => { + it("should get all open ports", async () => { + if (!client) throw new Error("Client not initialized"); const ports = await client.ports.getAll(); expect(Array.isArray(ports)).toBe(true); }); }); - describe('Port operations with server', () => { + describe("Port operations with server", () => { // Skipped - these tests have shell lifecycle management issues - it('should detect when a port opens', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should detect when a port opens", async () => { + if (!client) throw new Error("Client not initialized"); // Start a simple HTTP server in the background - const serverCommand = await client.commands.runBackground(`node -e 'require("http").createServer((req, res) => res.end("hello")).listen(8888)'`); + const serverCommand = await client.commands.runBackground( + `node -e 'require("http").createServer((req, res) => res.end("hello")).listen(8888)'` + ); try { // Wait for port to open (with timeout) - const portInfo = await client.ports.waitForPort(8888, { timeoutMs: 20000 }); + const portInfo = await client.ports.waitForPort(8888, { + timeoutMs: 20000, + }); expect(portInfo).toBeDefined(); expect(portInfo.port).toBe(8888); expect(portInfo.host).toBeTruthy(); @@ -78,11 +56,13 @@ describe('Sandbox Ports', () => { } }, 40000); - it('should get port information', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get port information", async () => { + if (!client) throw new Error("Client not initialized"); // Start a server - const serverCommand = await client.commands.runBackground(`node -e 'require("http").createServer((req, res) => res.end("test")).listen(9999)'`); + const serverCommand = await client.commands.runBackground( + `node -e 'require("http").createServer((req, res) => res.end("test")).listen(9999)'` + ); try { // Wait for port to open @@ -102,10 +82,10 @@ describe('Sandbox Ports', () => { }, 40000); }); - describe('Port events', () => { + describe("Port events", () => { // Skipped - these tests have shell lifecycle management issues - it('should listen to port opened events', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should listen to port opened events", async () => { + if (!client) throw new Error("Client not initialized"); let portOpened = false; let openedPort = 0; @@ -119,7 +99,9 @@ describe('Sandbox Ports', () => { }); // Start a server - const serverCommand = await client.commands.runBackground(`node -e 'require("http").createServer((req, res) => res.end("test")).listen(7777)'`); + const serverCommand = await client.commands.runBackground( + `node -e 'require("http").createServer((req, res) => res.end("test")).listen(7777)'` + ); try { // Wait for the port to be detected diff --git a/tests/e2e/sandbox-setup.test.ts b/tests/e2e/sandbox-setup.test.ts index 995bef9..485606c 100644 --- a/tests/e2e/sandbox-setup.test.ts +++ b/tests/e2e/sandbox-setup.test.ts @@ -1,29 +1,17 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createTest } from "./helpers.js"; -describe('Sandbox Setup', () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; +describe("Sandbox Setup", () => { + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -31,61 +19,47 @@ describe('Sandbox Setup', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); - } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); - } - } + console.error("Failed to dispose client:", error); } }); - describe('Setup operations', () => { - it('should get setup status', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Setup operations", () => { + it("should get setup status", async () => { + if (!client) throw new Error("Client not initialized"); const status = client.setup.status; expect(status).toBeDefined(); - expect(['RUNNING', 'FINISHED', 'STOPPED', 'IDLE']).toContain(status); + expect(["RUNNING", "FINISHED", "STOPPED", "IDLE"]).toContain(status); }); - it('should get setup steps', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get setup steps", async () => { + if (!client) throw new Error("Client not initialized"); const steps = client.setup.getSteps(); expect(Array.isArray(steps)).toBe(true); }); - it('should get current step index', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get current step index", async () => { + if (!client) throw new Error("Client not initialized"); const currentStepIndex = client.setup.currentStepIndex; - expect(typeof currentStepIndex).toBe('number'); + expect(typeof currentStepIndex).toBe("number"); }); - it('should wait until setup completes', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should wait until setup completes", async () => { + if (!client) throw new Error("Client not initialized"); // If setup is already finished, this should resolve immediately await client.setup.waitUntilComplete(); const status = client.setup.status; - expect(status).toBe('FINISHED'); + expect(status).toBe("FINISHED"); }, 60000); }); - describe('Setup steps', () => { - it('should have step properties', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Setup steps", () => { + it("should have step properties", async () => { + if (!client) throw new Error("Client not initialized"); const steps = client.setup.getSteps(); diff --git a/tests/e2e/sandbox-state-consistency.test.ts b/tests/e2e/sandbox-state-consistency.test.ts new file mode 100644 index 0000000..806cc32 --- /dev/null +++ b/tests/e2e/sandbox-state-consistency.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { createTest } from "./helpers.js"; + +/** + * Scenario 8: State consistency edge cases + * + * Tests rapid hibernate/resume cycles, concurrent operations, and + * state integrity when interrupting long-running operations. + */ +describe("Sandbox State Consistency", () => { + const test = createTest(); + + it("should handle 5 rapid hibernate/resume cycles with state intact", async () => { + // Write anchor file before cycling + const setupClient = await test.sandbox.connect(); + try { + await setupClient.fs.writeTextFile( + "/anchor.txt", + "do not lose me" + ); + } finally { + await setupClient.disconnect(); + setupClient.dispose(); + } + + let currentSandbox = test.sandbox; + + for (let i = 0; i < 5; i++) { + await test.sdk.sandboxes.hibernate(currentSandbox.id); + currentSandbox = await test.sdk.sandboxes.resume(currentSandbox.id); + + const cycleClient = await currentSandbox.connect(); + try { + const content = await cycleClient.fs.readTextFile("/anchor.txt"); + expect(content).toBe("do not lose me"); + console.log(`Cycle ${i + 1}: OK`); + } finally { + await cycleClient.disconnect(); + cycleClient.dispose(); + } + } + }, 300000); + + it("should complete multiple concurrent commands without error", async () => { + const client = await test.sandbox.connect(); + + try { + const results = await Promise.all([ + client.commands.run("echo cmd-1"), + client.commands.run("echo cmd-2"), + client.commands.run("echo cmd-3"), + client.commands.run("echo cmd-4"), + client.commands.run("echo cmd-5"), + ]); + + expect(results.length).toBe(5); + expect(results[0]).toContain("cmd-1"); + expect(results[1]).toContain("cmd-2"); + expect(results[2]).toContain("cmd-3"); + expect(results[3]).toContain("cmd-4"); + expect(results[4]).toContain("cmd-5"); + + console.log("All concurrent commands completed:", results.length); + } finally { + await client.disconnect(); + client.dispose(); + } + }, 30000); + + it("should recover gracefully after hibernating during a running npm install", async () => { + const client = await test.sandbox.connect(); + + try { + await client.commands.run( + "mkdir -p /mid-install-test && cd /mid-install-test && npm init -y" + ); + + // Start npm install and do NOT await (fire and forget) + const installPromise = client.commands + .run( + "cd /mid-install-test && npm install next react react-dom typescript" + ) + .catch(() => { + // Expected: install may be interrupted by hibernate + }); + + // Hibernate after a short delay (mid-install) + await new Promise((r) => setTimeout(r, 5000)); + } finally { + await client.disconnect(); + client.dispose(); + } + + await test.sdk.sandboxes.hibernate(test.sandbox.id); + const resumed = await test.sdk.sandboxes.resume(test.sandbox.id); + + const afterClient = await resumed.connect(); + try { + // State should be recoverable: either install completed or can be re-run + const checkResult = await afterClient.commands.run( + "ls /mid-install-test/node_modules 2>/dev/null | head -5 || echo 'no node_modules'" + ); + console.log("After resume mid-install state:", checkResult.trim()); + + // The sandbox should be responsive + const echo = await afterClient.commands.run("echo still alive"); + expect(echo).toContain("still alive"); + + // Cleanup + await afterClient.fs.remove("/mid-install-test", true); + } finally { + await afterClient.disconnect(); + afterClient.dispose(); + } + }, 120000); +}); diff --git a/tests/e2e/sandbox-tasks.test.ts b/tests/e2e/sandbox-tasks.test.ts index 210212b..456fb0b 100644 --- a/tests/e2e/sandbox-tasks.test.ts +++ b/tests/e2e/sandbox-tasks.test.ts @@ -1,29 +1,17 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; - -describe('Sandbox Tasks', () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createTest } from "./helpers.js"; + +describe("Sandbox Tasks", () => { + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -31,34 +19,20 @@ describe('Sandbox Tasks', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); - } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); - } - } + console.error("Failed to dispose client:", error); } }); - describe('Task listing', () => { - it('should get all tasks', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Task listing", () => { + it("should get all tasks", async () => { + if (!client) throw new Error("Client not initialized"); const tasks = await client.tasks.getAll(); expect(Array.isArray(tasks)).toBe(true); }); - it('should get task by ID if tasks exist', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get task by ID if tasks exist", async () => { + if (!client) throw new Error("Client not initialized"); const tasks = await client.tasks.getAll(); @@ -75,9 +49,9 @@ describe('Sandbox Tasks', () => { }); }); - describe('Task properties', () => { - it('should have task properties', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Task properties", () => { + it("should have task properties", async () => { + if (!client) throw new Error("Client not initialized"); const tasks = await client.tasks.getAll(); @@ -86,18 +60,18 @@ describe('Sandbox Tasks', () => { expect(task.id).toBeTruthy(); expect(task.name).toBeTruthy(); expect(task.command).toBeTruthy(); - expect(typeof task.runAtStart).toBe('boolean'); + expect(typeof task.runAtStart).toBe("boolean"); expect(task.status).toBeDefined(); expect(Array.isArray(task.ports)).toBe(true); } }); }); - describe('Task operations', () => { + describe("Task operations", () => { // These tests are skipped as they require specific task configurations // and may interfere with running tasks - it('should run a task', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should run a task", async () => { + if (!client) throw new Error("Client not initialized"); const tasks = await client.tasks.getAll(); @@ -112,8 +86,8 @@ describe('Sandbox Tasks', () => { } }); - it('should stop a running task', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should stop a running task", async () => { + if (!client) throw new Error("Client not initialized"); const tasks = await client.tasks.getAll(); @@ -125,8 +99,8 @@ describe('Sandbox Tasks', () => { } }); - it('should restart a task', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should restart a task", async () => { + if (!client) throw new Error("Client not initialized"); const tasks = await client.tasks.getAll(); diff --git a/tests/e2e/sandbox-terminals.test.ts b/tests/e2e/sandbox-terminals.test.ts index 6400417..65bc934 100644 --- a/tests/e2e/sandbox-terminals.test.ts +++ b/tests/e2e/sandbox-terminals.test.ts @@ -1,29 +1,17 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; - -describe('Sandbox Terminals', () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createTest } from "./helpers.js"; + +describe("Sandbox Terminals", () => { + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -31,27 +19,13 @@ describe('Sandbox Terminals', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); - } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); - } - } + console.error("Failed to dispose client:", error); } }); - describe('Terminal creation', () => { - it('should create a terminal', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Terminal creation", () => { + it("should create a terminal", async () => { + if (!client) throw new Error("Client not initialized"); const terminal = await client.terminals.create(); expect(terminal).toBeDefined(); @@ -61,10 +35,10 @@ describe('Sandbox Terminals', () => { await terminal.kill(); }); - it('should create terminal with custom dimensions', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should create terminal with custom dimensions", async () => { + if (!client) throw new Error("Client not initialized"); - const terminal = await client.terminals.create('bash', { + const terminal = await client.terminals.create("bash", { dimensions: { cols: 120, rows: 40 }, }); expect(terminal).toBeDefined(); @@ -75,9 +49,9 @@ describe('Sandbox Terminals', () => { }); }); - describe('Terminal listing', () => { - it('should get all terminals', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Terminal listing", () => { + it("should get all terminals", async () => { + if (!client) throw new Error("Client not initialized"); const terminal1 = await client.terminals.create(); const terminal2 = await client.terminals.create(); @@ -91,8 +65,8 @@ describe('Sandbox Terminals', () => { await terminal2.kill(); }, 15000); - it('should get terminal by ID', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get terminal by ID", async () => { + if (!client) throw new Error("Client not initialized"); const terminal = await client.terminals.create(); const retrieved = await client.terminals.get(terminal.id); @@ -107,9 +81,9 @@ describe('Sandbox Terminals', () => { }); }); - describe('Terminal operations', () => { - it('should write to terminal', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Terminal operations", () => { + it("should write to terminal", async () => { + if (!client) throw new Error("Client not initialized"); const terminal = await client.terminals.create(); @@ -123,8 +97,8 @@ describe('Sandbox Terminals', () => { await terminal.kill(); }); - it('should run command in terminal', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should run command in terminal", async () => { + if (!client) throw new Error("Client not initialized"); const terminal = await client.terminals.create(); @@ -138,19 +112,22 @@ describe('Sandbox Terminals', () => { await terminal.kill(); }); - it('should receive output from terminal', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should receive output from terminal", async () => { + if (!client) throw new Error("Client not initialized"); const terminal = await client.terminals.create(); let receivedOutput = false; // Listen for output const disposable = terminal.onOutput((data) => { - if (data.includes('unique_test_string')) { + if (data.includes("unique_test_string")) { receivedOutput = true; } }); + // Users have to open first to get current output + await terminal.open(); + // Write a command await terminal.write('echo "unique_test_string"\n'); @@ -165,9 +142,9 @@ describe('Sandbox Terminals', () => { }, 10000); }); - describe('Terminal lifecycle', () => { - it('should kill terminal', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Terminal lifecycle", () => { + it("should kill terminal", async () => { + if (!client) throw new Error("Client not initialized"); const terminal = await client.terminals.create(); expect(terminal).toBeDefined(); @@ -179,8 +156,8 @@ describe('Sandbox Terminals', () => { expect(terminal.id).toBeTruthy(); }); - it('should handle multiple terminals', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should handle multiple terminals", async () => { + if (!client) throw new Error("Client not initialized"); const terminals = await Promise.all([ client.terminals.create(), diff --git a/tests/e2e/sandbox-vibe-coder.test.ts b/tests/e2e/sandbox-vibe-coder.test.ts new file mode 100644 index 0000000..e5082b9 --- /dev/null +++ b/tests/e2e/sandbox-vibe-coder.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createTest } from "./helpers.js"; + +/** + * Scenario 4: Full vibe coder workflow + * + * Simulates a realistic user journey: create sandbox, write code, + * install deps, start dev server, verify preview URL, update code. + */ +describe("Sandbox Vibe Coder Workflow", () => { + const test = createTest(); + let client: SandboxClient | undefined; + + beforeAll(async () => { + client = await test.sandbox.connect(); + + // Write application files + await client.fs.mkdir("/app"); + await client.fs.writeTextFile( + "/app/package.json", + JSON.stringify( + { + name: "testing-party", + scripts: { start: "node server.js" }, + dependencies: { express: "^4.18.0" }, + }, + null, + 2 + ) + ); + await client.fs.writeTextFile( + "/app/server.js", + [ + "const express = require('express');", + "const app = express();", + "app.get('/', (req, res) => res.send('

Testing Party 2026

'));", + "app.get('/health', (req, res) => res.json({ status: 'ok' }));", + "app.listen(3000, () => console.log('Server running on port 3000'));", + ].join("\n") + ); + + // Write additional files simulating a typical AI coding session + await client.fs.mkdir("/app/routes"); + await client.fs.mkdir("/app/middleware"); + await client.fs.mkdir("/app/utils"); + + const additionalFiles = [ + { + path: "/app/routes/index.js", + content: "module.exports = require('./home');", + }, + { + path: "/app/routes/home.js", + content: + "const router = require('express').Router();\nrouter.get('/', (req, res) => res.send('home'));\nmodule.exports = router;", + }, + { + path: "/app/middleware/logger.js", + content: + "module.exports = (req, res, next) => { console.log(req.method, req.url); next(); };", + }, + { + path: "/app/utils/helpers.js", + content: "exports.formatDate = (d) => d.toISOString();", + }, + { + path: "/app/config.js", + content: + "module.exports = { port: 3000, env: process.env.NODE_ENV || 'development' };", + }, + { path: "/app/.env.example", content: "NODE_ENV=development\nPORT=3000" }, + { + path: "/app/README.md", + content: "# Testing Party 2026\n\nA simple Express server.", + }, + ]; + + for (const file of additionalFiles) { + await client.fs.writeTextFile(file.path, file.content); + } + + // Install dependencies — shared prerequisite for all tests below + const installStart = Date.now(); + await client.commands.run("cd /app && npm install"); + console.log(`npm install: ${Date.now() - installStart}ms`); + }, 180000); + + afterAll(async () => { + try { + if (client) { + await client.disconnect(); + client.dispose(); + client = undefined; + } + } catch (error) { + console.error("Failed to dispose client:", error); + } + }); + + it("should have all application files written and dependencies installed", async () => { + if (!client) throw new Error("Client not initialized"); + + const appFiles = await client.fs.readdir("/app"); + expect(appFiles.find((f) => f.name === "package.json")).toBeDefined(); + expect(appFiles.find((f) => f.name === "server.js")).toBeDefined(); + + const expressExists = await client.fs.stat("/app/node_modules/express"); + expect(expressExists).toBeDefined(); + expect(expressExists.type).toBe("directory"); + }, 30000); + + it("should start a dev server and respond to HTTP requests", async () => { + if (!client) throw new Error("Client not initialized"); + + const serverCmd = await client.commands.runBackground( + "cd /app && node server.js" + ); + + try { + await client.ports.waitForPort(3000, { timeoutMs: 20000 }); + + const response = await client.commands.run( + "curl -s http://localhost:3000" + ); + expect(response).toContain("Testing Party 2026"); + + const healthResponse = await client.commands.run( + "curl -s http://localhost:3000/health" + ); + expect(healthResponse).toContain("ok"); + + const previewUrl = client.hosts.getUrl(3000); + expect(previewUrl).toBeTruthy(); + expect(previewUrl).toContain("3000"); + expect(previewUrl).toContain(test.sandbox.id); + } finally { + await serverCmd.kill(); + } + }, 60000); + + it("should reflect code changes after server restart", async () => { + if (!client) throw new Error("Client not initialized"); + + // Use port 3001 to avoid conflicts with port 3000 from the previous test + await client.fs.writeTextFile( + "/app/server.js", + [ + "const express = require('express');", + "const app = express();", + "app.get('/', (req, res) => res.send('

Updated: Testing Party 2026

'));", + "app.listen(3001, () => console.log('Server running on port 3001'));", + ].join("\n") + ); + + const serverCmd = await client.commands.runBackground( + "cd /app && node server.js" + ); + + try { + await client.ports.waitForPort(3001, { timeoutMs: 20000 }); + + const response = await client.commands.run( + "curl -s http://localhost:3001" + ); + expect(response).toContain("Updated: Testing Party 2026"); + } finally { + await serverCmd.kill(); + } + }, 60000); +}); diff --git a/tests/emitter-subscription.test.ts b/tests/emitter-subscription.test.ts index 669d226..8c31b33 100644 --- a/tests/emitter-subscription.test.ts +++ b/tests/emitter-subscription.test.ts @@ -1,211 +1,218 @@ -import { describe, it, expect, vi } from 'vitest' -import { EmitterSubscription } from '../src/utils/event' -import { Disposable } from '../src/utils/disposable' +import { describe, it, expect, vi } from "vitest"; +import { EmitterSubscription } from "../src/utils/event"; +import { Disposable } from "../src/utils/disposable"; +import { sleep } from "../src/utils/sleep"; -describe('EmitterSubscription', () => { - it('should create subscription when first listener is added', () => { - const createSubscription = vi.fn(() => Disposable.create(() => {})) - const subscription = new EmitterSubscription(createSubscription) +describe("EmitterSubscription", () => { + it("should create subscription when first listener is added", () => { + const createSubscription = vi.fn(() => Disposable.create(() => {})); + const subscription = new EmitterSubscription(createSubscription); - expect(createSubscription).not.toHaveBeenCalled() + expect(createSubscription).not.toHaveBeenCalled(); - const disposable = subscription.event(() => {}) + const disposable = subscription.event(() => {}); - expect(createSubscription).toHaveBeenCalledTimes(1) + expect(createSubscription).toHaveBeenCalledTimes(1); - disposable.dispose() - }) + disposable.dispose(); + }); - it('should not create multiple subscriptions for multiple listeners', () => { - const createSubscription = vi.fn(() => Disposable.create(() => {})) - const subscription = new EmitterSubscription(createSubscription) + it("should not create multiple subscriptions for multiple listeners", () => { + const createSubscription = vi.fn(() => Disposable.create(() => {})); + const subscription = new EmitterSubscription(createSubscription); - const disposable1 = subscription.event(() => {}) - const disposable2 = subscription.event(() => {}) - const disposable3 = subscription.event(() => {}) + const disposable1 = subscription.event(() => {}); + const disposable2 = subscription.event(() => {}); + const disposable3 = subscription.event(() => {}); - expect(createSubscription).toHaveBeenCalledTimes(1) + expect(createSubscription).toHaveBeenCalledTimes(1); - disposable1.dispose() - disposable2.dispose() - disposable3.dispose() - }) + disposable1.dispose(); + disposable2.dispose(); + disposable3.dispose(); + }); - it('should fire events to all listeners', () => { + it("should fire events to all listeners", async () => { const subscription = new EmitterSubscription((fire) => { - fire(42) - return Disposable.create(() => {}) - }) + setTimeout(() => { + fire(42); + }, 10); + return Disposable.create(() => {}); + }); - const listener1 = vi.fn() - const listener2 = vi.fn() - const listener3 = vi.fn() + const listener1 = vi.fn(); + const listener2 = vi.fn(); + const listener3 = vi.fn(); - subscription.event(listener1) - subscription.event(listener2) - subscription.event(listener3) + subscription.event(listener1); + subscription.event(listener2); + subscription.event(listener3); - expect(listener1).toHaveBeenCalledWith(42) - expect(listener2).toHaveBeenCalledWith(42) - expect(listener3).toHaveBeenCalledWith(42) - }) + await sleep(100); - it('should allow firing events from subscription callback', () => { - let fireFn: ((value: number) => void) | undefined + expect(listener1).toHaveBeenCalledWith(42); + expect(listener2).toHaveBeenCalledWith(42); + expect(listener3).toHaveBeenCalledWith(42); + }); + + it("should allow firing events from subscription callback", () => { + let fireFn: ((value: number) => void) | undefined; const subscription = new EmitterSubscription((fire) => { - fireFn = fire - return Disposable.create(() => {}) - }) + fireFn = fire; + return Disposable.create(() => {}); + }); - const listener = vi.fn() - subscription.event(listener) + const listener = vi.fn(); + subscription.event(listener); - expect(fireFn).toBeDefined() + expect(fireFn).toBeDefined(); - fireFn!(100) - fireFn!(200) - fireFn!(300) + fireFn!(100); + fireFn!(200); + fireFn!(300); - expect(listener).toHaveBeenCalledTimes(3) - expect(listener).toHaveBeenNthCalledWith(1, 100) - expect(listener).toHaveBeenNthCalledWith(2, 200) - expect(listener).toHaveBeenNthCalledWith(3, 300) - }) + expect(listener).toHaveBeenCalledTimes(3); + expect(listener).toHaveBeenNthCalledWith(1, 100); + expect(listener).toHaveBeenNthCalledWith(2, 200); + expect(listener).toHaveBeenNthCalledWith(3, 300); + }); - it('should dispose subscription when last listener is removed', () => { - const dispose = vi.fn() - const createSubscription = vi.fn(() => Disposable.create(dispose)) - const subscription = new EmitterSubscription(createSubscription) + it("should dispose subscription when last listener is removed", () => { + const dispose = vi.fn(); + const createSubscription = vi.fn(() => Disposable.create(dispose)); + const subscription = new EmitterSubscription(createSubscription); - const disposable1 = subscription.event(() => {}) - const disposable2 = subscription.event(() => {}) + const disposable1 = subscription.event(() => {}); + const disposable2 = subscription.event(() => {}); - expect(dispose).not.toHaveBeenCalled() + expect(dispose).not.toHaveBeenCalled(); - disposable1.dispose() - expect(dispose).not.toHaveBeenCalled() + disposable1.dispose(); + expect(dispose).not.toHaveBeenCalled(); - disposable2.dispose() - expect(dispose).toHaveBeenCalledTimes(1) - }) + disposable2.dispose(); + expect(dispose).toHaveBeenCalledTimes(1); + }); - it('should recreate subscription if listener is added again after all removed', () => { - const dispose = vi.fn() - const createSubscription = vi.fn(() => Disposable.create(dispose)) - const subscription = new EmitterSubscription(createSubscription) + it("should recreate subscription if listener is added again after all removed", () => { + const dispose = vi.fn(); + const createSubscription = vi.fn(() => Disposable.create(dispose)); + const subscription = new EmitterSubscription(createSubscription); - const disposable1 = subscription.event(() => {}) - disposable1.dispose() + const disposable1 = subscription.event(() => {}); + disposable1.dispose(); - expect(createSubscription).toHaveBeenCalledTimes(1) - expect(dispose).toHaveBeenCalledTimes(1) + expect(createSubscription).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); - const disposable2 = subscription.event(() => {}) + const disposable2 = subscription.event(() => {}); - expect(createSubscription).toHaveBeenCalledTimes(2) - expect(dispose).toHaveBeenCalledTimes(1) + expect(createSubscription).toHaveBeenCalledTimes(2); + expect(dispose).toHaveBeenCalledTimes(1); - disposable2.dispose() - expect(dispose).toHaveBeenCalledTimes(2) - }) + disposable2.dispose(); + expect(dispose).toHaveBeenCalledTimes(2); + }); - it('should stop firing to disposed listeners', () => { - let fireFn: ((value: number) => void) | undefined + it("should stop firing to disposed listeners", () => { + let fireFn: ((value: number) => void) | undefined; const subscription = new EmitterSubscription((fire) => { - fireFn = fire - return Disposable.create(() => {}) - }) - - const listener1 = vi.fn() - const listener2 = vi.fn() - const listener3 = vi.fn() - - const disposable1 = subscription.event(listener1) - subscription.event(listener2) - subscription.event(listener3) - - fireFn!(1) - expect(listener1).toHaveBeenCalledTimes(1) - expect(listener2).toHaveBeenCalledTimes(1) - expect(listener3).toHaveBeenCalledTimes(1) - - disposable1.dispose() - - fireFn!(2) - expect(listener1).toHaveBeenCalledTimes(1) // Not called again - expect(listener2).toHaveBeenCalledTimes(2) - expect(listener3).toHaveBeenCalledTimes(2) - }) - - it('should cleanup everything on dispose', () => { - const subscriptionDispose = vi.fn() - const createSubscription = vi.fn(() => Disposable.create(subscriptionDispose)) - - let fireFn: ((value: number) => void) | undefined + fireFn = fire; + return Disposable.create(() => {}); + }); + + const listener1 = vi.fn(); + const listener2 = vi.fn(); + const listener3 = vi.fn(); + + const disposable1 = subscription.event(listener1); + subscription.event(listener2); + subscription.event(listener3); + + fireFn!(1); + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); + expect(listener3).toHaveBeenCalledTimes(1); + + disposable1.dispose(); + + fireFn!(2); + expect(listener1).toHaveBeenCalledTimes(1); // Not called again + expect(listener2).toHaveBeenCalledTimes(2); + expect(listener3).toHaveBeenCalledTimes(2); + }); + + it("should cleanup everything on dispose", () => { + const subscriptionDispose = vi.fn(); + const createSubscription = vi.fn(() => + Disposable.create(subscriptionDispose) + ); + + let fireFn: ((value: number) => void) | undefined; const subscription = new EmitterSubscription((fire) => { - fireFn = fire - return Disposable.create(subscriptionDispose) - }) + fireFn = fire; + return Disposable.create(subscriptionDispose); + }); - const listener = vi.fn() - subscription.event(listener) + const listener = vi.fn(); + subscription.event(listener); - subscription.dispose() + subscription.dispose(); - expect(subscriptionDispose).toHaveBeenCalledTimes(1) + expect(subscriptionDispose).toHaveBeenCalledTimes(1); // Should not fire to listeners after dispose - fireFn!(42) - expect(listener).not.toHaveBeenCalled() - }) + fireFn!(42); + expect(listener).not.toHaveBeenCalled(); + }); - it('should work with interval example', () => { - vi.useFakeTimers() + it("should work with interval example", () => { + vi.useFakeTimers(); - let intervalId: NodeJS.Timeout + let intervalId: NodeJS.Timeout; const subscription = new EmitterSubscription((fire) => { - intervalId = setInterval(() => fire(Date.now()), 1000) - return Disposable.create(() => clearInterval(intervalId)) - }) + intervalId = setInterval(() => fire(Date.now()), 1000); + return Disposable.create(() => clearInterval(intervalId)); + }); - const listener = vi.fn() - const disposable = subscription.event(listener) + const listener = vi.fn(); + const disposable = subscription.event(listener); - vi.advanceTimersByTime(3500) - expect(listener).toHaveBeenCalledTimes(3) + vi.advanceTimersByTime(3500); + expect(listener).toHaveBeenCalledTimes(3); - disposable.dispose() + disposable.dispose(); // Should not receive more events after dispose - vi.advanceTimersByTime(5000) - expect(listener).toHaveBeenCalledTimes(3) + vi.advanceTimersByTime(5000); + expect(listener).toHaveBeenCalledTimes(3); - vi.useRealTimers() - }) + vi.useRealTimers(); + }); - it('should handle multiple add/remove cycles correctly', () => { - const dispose = vi.fn() - const createSubscription = vi.fn(() => Disposable.create(dispose)) - const subscription = new EmitterSubscription(createSubscription) + it("should handle multiple add/remove cycles correctly", () => { + const dispose = vi.fn(); + const createSubscription = vi.fn(() => Disposable.create(dispose)); + const subscription = new EmitterSubscription(createSubscription); // Cycle 1 - const d1 = subscription.event(() => {}) - d1.dispose() - expect(createSubscription).toHaveBeenCalledTimes(1) - expect(dispose).toHaveBeenCalledTimes(1) + const d1 = subscription.event(() => {}); + d1.dispose(); + expect(createSubscription).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); // Cycle 2 - const d2 = subscription.event(() => {}) - d2.dispose() - expect(createSubscription).toHaveBeenCalledTimes(2) - expect(dispose).toHaveBeenCalledTimes(2) + const d2 = subscription.event(() => {}); + d2.dispose(); + expect(createSubscription).toHaveBeenCalledTimes(2); + expect(dispose).toHaveBeenCalledTimes(2); // Cycle 3 - const d3 = subscription.event(() => {}) - d3.dispose() - expect(createSubscription).toHaveBeenCalledTimes(3) - expect(dispose).toHaveBeenCalledTimes(3) - }) -}) \ No newline at end of file + const d3 = subscription.event(() => {}); + d3.dispose(); + expect(createSubscription).toHaveBeenCalledTimes(3); + expect(dispose).toHaveBeenCalledTimes(3); + }); +}); diff --git a/tests/pint-fs-watcher.test.ts b/tests/pint-fs-watcher.test.ts new file mode 100644 index 0000000..542a696 --- /dev/null +++ b/tests/pint-fs-watcher.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import * as http from 'node:http' +import { PintFsClient } from '../src/PintClient/fs' +import { createClient, createConfig } from '../src/api-clients/pint/client' + +/** + * Creates a minimal mock server that mimics pint's SSE watcher endpoint. + * Mirrors the Go test helper `setupV1TestServer` in the pint project. + * + * The server guarantees the watcher is active before sending 200 OK, + * just like pint's `CreateWatcher` uses the `ready` channel. + */ +function createMockPintServer() { + let activeSseResponse: http.ServerResponse | null = null + + const server = http.createServer((req, res) => { + if (req.url?.includes('/api/v1/stream/directories/watcher/')) { + // Simulate pint: watcher is set up synchronously before headers are written. + // The 200 OK signals to the client that the watcher is fully active. + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + res.flushHeaders() + activeSseResponse = res + req.on('close', () => { + activeSseResponse = null + }) + } else { + res.writeHead(404) + res.end() + } + }) + + return { + server, + /** Send a filesystem event over the active SSE connection. */ + sendEvent(event: { paths: string[]; type: string }) { + activeSseResponse?.write(`data: ${JSON.stringify(event)}\n\n`) + }, + isConnected() { + return activeSseResponse !== null + }, + } +} + +describe('PintFsClient filesystem watcher', () => { + let server: http.Server + let sendEvent: (event: { paths: string[]; type: string }) => void + let isConnected: () => boolean + let fsClient: PintFsClient + let port: number + let activeWatcher: { dispose(): void } | null = null + + beforeEach(async () => { + activeWatcher = null + const mock = createMockPintServer() + server = mock.server + sendEvent = mock.sendEvent + isConnected = mock.isConnected + + await new Promise((resolve) => server.listen(0, resolve)) + port = (server.address() as http.AddressInfo).port + + const apiClient = createClient( + createConfig({ + baseUrl: `http://localhost:${port}`, + headers: { Authorization: 'Bearer test-token' }, + }) + ) + fsClient = new PintFsClient(apiClient) + }) + + afterEach(async () => { + // Dispose any active watcher to close the SSE connection so server.close() can complete. + activeWatcher?.dispose() + activeWatcher = null + await new Promise((resolve) => server.close(() => resolve())) + }) + + it('watch() resolves only after the server has confirmed the watcher is active (200 OK)', async () => { + // Mirrors TestFileWatcherIsReadyWhenConnectionEstablished: + // The watcher must be active the moment watch() resolves — no sleep needed. + const result = await fsClient.watch('/sandbox/project', { recursive: true }, () => {}) + + expect(result.type).toBe('success') + expect(isConnected()).toBe(true) + + if (result.type === 'success') activeWatcher = result + }) + + it('delivers SSE events to onEvent immediately after watch() resolves', async () => { + // Mirrors the core of TestFileWatcherIsReadyWhenConnectionEstablished: + // send an event right after watch() resolves, no sleep. + const events: Array<{ paths: string[]; type: string }> = [] + + const result = await fsClient.watch('/sandbox/project', { recursive: true }, (event) => { + events.push(event as any) + }) + + expect(result.type).toBe('success') + if (result.type === 'success') activeWatcher = result + + // Send event immediately — watcher is already active, no sleep needed. + sendEvent({ paths: ['/sandbox/project/new-file.txt'], type: 'ADD' }) + + // Wait for the event loop to process the SSE data. + await new Promise((resolve) => setTimeout(resolve, 200)) + + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ paths: ['/sandbox/project/new-file.txt'], type: 'ADD' }) + }) + + it('delivers multiple event types (ADD, CHANGE, REMOVE)', async () => { + const events: Array<{ paths: string[]; type: string }> = [] + + const result = await fsClient.watch('/sandbox/project', {}, (event) => { + events.push(event as any) + }) + expect(result.type).toBe('success') + if (result.type === 'success') activeWatcher = result + + sendEvent({ paths: ['/sandbox/project/a.txt'], type: 'ADD' }) + sendEvent({ paths: ['/sandbox/project/b.txt'], type: 'CHANGE' }) + sendEvent({ paths: ['/sandbox/project/c.txt'], type: 'REMOVE' }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + + expect(events).toHaveLength(3) + expect(events[0].type).toBe('ADD') + expect(events[1].type).toBe('CHANGE') + expect(events[2].type).toBe('REMOVE') + }) + + it('returns error when server returns non-200', async () => { + // Close the default server and replace with one that returns 400. + await new Promise((resolve) => server.close(() => resolve())) + server = http.createServer((_req, res) => { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ message: 'Directory not found', code: 400 })) + }) + await new Promise((resolve) => server.listen(port, resolve)) + + const result = await fsClient.watch('/nonexistent/path', {}, () => {}) + + expect(result.type).toBe('error') + }) + + it('stops receiving events after dispose()', async () => { + const events: Array<{ paths: string[]; type: string }> = [] + + const result = await fsClient.watch('/sandbox/project', {}, (event) => { + events.push(event as any) + }) + expect(result.type).toBe('success') + if (result.type !== 'success') return + + sendEvent({ paths: ['/sandbox/project/before.txt'], type: 'ADD' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(events).toHaveLength(1) + + result.dispose() + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Any events sent after dispose should not arrive. + sendEvent({ paths: ['/sandbox/project/after.txt'], type: 'ADD' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(events).toHaveLength(1) + }) +}) diff --git a/tests/pint-shells-client.test.ts b/tests/pint-shells-client.test.ts index a03ee1d..8bfaad8 100644 --- a/tests/pint-shells-client.test.ts +++ b/tests/pint-shells-client.test.ts @@ -1,13 +1,11 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { PintShellsClient } from '../src/PintClient/execs'; -import { Client } from '../src/api-clients/pint/client'; -import * as pintApi from '../src/api-clients/pint'; -import { ExecItem } from '../src/api-clients/pint'; -import { ShellSize, ShellProcessType } from '../src/pitcher-protocol/messages/shell'; -import { IDisposable } from '../src/utils/disposable'; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { PintShellsClient } from "../src/PintClient/execs"; +import { Client } from "../src/api-clients/pint/client"; +import * as pintApi from "../src/api-clients/pint"; +import { ExecItem } from "../src/api-clients/pint"; // Mock the API functions -vi.mock('../src/api-clients/pint', () => ({ +vi.mock("../src/api-clients/pint", () => ({ createExec: vi.fn(), getExec: vi.fn(), listExecs: vi.fn(), @@ -19,9 +17,9 @@ vi.mock('../src/api-clients/pint', () => ({ })); // Mock the utils parseStreamEvent function -vi.mock('../src/PintClient/utils', () => ({ +vi.mock("../src/PintClient/utils", () => ({ parseStreamEvent: vi.fn((evt) => { - if (typeof evt === 'string') { + if (typeof evt === "string") { return JSON.parse(evt.substring(5)); } return evt; @@ -38,134 +36,133 @@ const createMockResponse = (data: any, error?: any) => ({ // Mock ExecItem for testing const createMockExecItem = (overrides: Partial = {}): ExecItem => ({ - id: 'exec-123', - command: 'bash', + id: "exec-123", + command: "bash", args: [], interactive: true, - status: 'RUNNING', + status: "RUNNING", exitCode: 0, pid: 1234, + pty: false, ...overrides, }); -describe('PintShellsClient', () => { +describe("PintShellsClient", () => { let client: PintShellsClient; let mockApiClient: Client; beforeEach(() => { vi.clearAllMocks(); mockApiClient = {} as Client; - client = new PintShellsClient(mockApiClient, 'sandbox-123'); + client = new PintShellsClient(mockApiClient, "sandbox-123"); }); - describe('create', () => { - it('should successfully create a new shell with command', async () => { + describe("create", () => { + it("should successfully create a new shell with command", async () => { const mockExec = createMockExecItem(); const mockResponse = createMockResponse(mockExec); - + vi.mocked(pintApi.createExec).mockResolvedValue(mockResponse); - - // Mock the open method call - const mockOpenResponse = createMockResponse(mockExec); - vi.mocked(pintApi.getExec).mockResolvedValue(mockOpenResponse); - vi.mocked(pintApi.getExecOutput).mockResolvedValue({ - ...createMockResponse({}), - stream: (async function* (): AsyncGenerator { - yield 'data:{"type":"stdout","output":"Welcome","sequence":1,"timestamp":"2023-01-01T12:00:00Z"}'; - })(), - }); - const result = await client.create( - '/workspace', - { cols: 80, rows: 24 }, - 'npm start', - 'COMMAND', - false - ); + const result = await client.create({ + command: "npm", + args: ["start"], + projectPath: "/workspace", + size: { cols: 80, rows: 24 }, + type: "COMMAND", + }); expect(result).toEqual({ isSystemShell: true, name: JSON.stringify({ - type: 'command', - command: 'bash', - name: '', + type: "command", + command: "bash", + name: "", }), - ownerUsername: 'root', - shellId: 'exec-123', - shellType: 'TERMINAL', - startCommand: 'bash', - status: 'RUNNING', + ownerUsername: "root", + shellId: "exec-123", + shellType: "TERMINAL", + startCommand: "bash", + status: "RUNNING", buffer: [], }); expect(pintApi.createExec).toHaveBeenCalledWith({ client: mockApiClient, body: { - args: ['start'], - command: 'npm', + args: ["start"], + command: "npm", interactive: false, }, }); }); - it('should create a shell with default bash command', async () => { - const mockExec = createMockExecItem({ command: 'bash' }); + it("should create a shell with default bash command", async () => { + const mockExec = createMockExecItem({ command: "bash" }); const mockResponse = createMockResponse(mockExec); - + vi.mocked(pintApi.createExec).mockResolvedValue(mockResponse); - vi.mocked(pintApi.getExec).mockResolvedValue(mockResponse); - vi.mocked(pintApi.getExecOutput).mockResolvedValue({ - ...createMockResponse({}), - stream: (async function* (): AsyncGenerator {})(), - }); - await client.create('/workspace', { cols: 80, rows: 24 }); + await client.create({ + command: "bash", + args: [], + projectPath: "/workspace", + size: { cols: 80, rows: 24 }, + }); expect(pintApi.createExec).toHaveBeenCalledWith({ client: mockApiClient, body: { args: [], - command: 'bash', + command: "bash", interactive: true, }, }); }); - it('should handle API error during creation', async () => { - const mockResponse = createMockResponse(null, { message: 'Creation failed' }); + it("should handle API error during creation", async () => { + const mockResponse = createMockResponse(null, { + message: "Creation failed", + }); vi.mocked(pintApi.createExec).mockResolvedValue(mockResponse); await expect( - client.create('/workspace', { cols: 80, rows: 24 }) - ).rejects.toThrow('Creation failed'); + client.create({ + command: "bash", + args: [], + projectPath: "/workspace", + size: { cols: 80, rows: 24 }, + }) + ).rejects.toThrow("Creation failed"); }); - it('should set interactive based on shell type', async () => { + it("should set interactive based on shell type", async () => { const mockExec = createMockExecItem(); const mockResponse = createMockResponse(mockExec); - + vi.mocked(pintApi.createExec).mockResolvedValue(mockResponse); - vi.mocked(pintApi.getExec).mockResolvedValue(mockResponse); - vi.mocked(pintApi.getExecOutput).mockResolvedValue({ - ...createMockResponse({}), - stream: (async function* (): AsyncGenerator {})(), - }); - await client.create('/workspace', { cols: 80, rows: 24 }, 'echo test', 'TERMINAL'); + await client.create({ + command: "echo", + args: ["test"], + projectPath: "/workspace", + size: { cols: 80, rows: 24 }, + type: "TERMINAL", + }); expect(pintApi.createExec).toHaveBeenCalledWith({ client: mockApiClient, body: { - args: ['test'], - command: 'echo', + args: ["test"], + command: "echo", interactive: true, }, }); }); }); - describe('delete', () => { - it('should successfully delete an existing shell', async () => { + describe("delete", () => { + it("should successfully delete an existing shell", async () => { const mockExec = createMockExecItem(); const getResponse = createMockResponse(mockExec); const deleteResponse = createMockResponse({ success: true }); @@ -173,43 +170,43 @@ describe('PintShellsClient', () => { vi.mocked(pintApi.getExec).mockResolvedValue(getResponse); vi.mocked(pintApi.deleteExec).mockResolvedValue(deleteResponse); - const result = await client.delete('exec-123'); + const result = await client.delete("exec-123"); expect(result).toEqual({ isSystemShell: true, name: JSON.stringify({ - type: 'command', - command: 'bash', - name: '', + type: "command", + command: "bash", + name: "", }), - ownerUsername: 'root', - shellId: 'exec-123', - shellType: 'TERMINAL', - startCommand: 'bash', - status: 'RUNNING', + ownerUsername: "root", + shellId: "exec-123", + shellType: "TERMINAL", + startCommand: "bash", + status: "RUNNING", }); expect(pintApi.getExec).toHaveBeenCalledWith({ client: mockApiClient, - path: { id: 'exec-123' }, + path: { id: "exec-123" }, }); expect(pintApi.deleteExec).toHaveBeenCalledWith({ client: mockApiClient, - path: { id: 'exec-123' }, + path: { id: "exec-123" }, }); }); - it('should return null if shell does not exist', async () => { + it("should return null if shell does not exist", async () => { const getResponse = createMockResponse(null); vi.mocked(pintApi.getExec).mockResolvedValue(getResponse); - const result = await client.delete('nonexistent'); + const result = await client.delete("nonexistent"); expect(result).toBeNull(); expect(pintApi.deleteExec).not.toHaveBeenCalled(); }); - it('should return null if deletion fails', async () => { + it("should return null if deletion fails", async () => { const mockExec = createMockExecItem(); const getResponse = createMockResponse(mockExec); const deleteResponse = createMockResponse(null); @@ -217,25 +214,29 @@ describe('PintShellsClient', () => { vi.mocked(pintApi.getExec).mockResolvedValue(getResponse); vi.mocked(pintApi.deleteExec).mockResolvedValue(deleteResponse); - const result = await client.delete('exec-123'); + const result = await client.delete("exec-123"); expect(result).toBeNull(); }); - it('should handle exceptions gracefully', async () => { - vi.mocked(pintApi.getExec).mockRejectedValue(new Error('Network error')); + it("should handle exceptions gracefully", async () => { + vi.mocked(pintApi.getExec).mockRejectedValue(new Error("Network error")); - const result = await client.delete('exec-123'); + const result = await client.delete("exec-123"); expect(result).toBeNull(); }); }); - describe('getShells', () => { - it('should return list of shells converted from execs', async () => { + describe("getShells", () => { + it("should return list of shells converted from execs", async () => { const mockExecs = [ - createMockExecItem({ id: 'exec-1', command: 'bash' }), - createMockExecItem({ id: 'exec-2', command: 'npm', status: 'EXITED' as any }), + createMockExecItem({ id: "exec-1", command: "bash" }), + createMockExecItem({ + id: "exec-2", + command: "npm", + status: "EXITED" as any, + }), ]; const mockResponse = createMockResponse({ execs: mockExecs }); vi.mocked(pintApi.listExecs).mockResolvedValue(mockResponse); @@ -246,20 +247,20 @@ describe('PintShellsClient', () => { expect(result[0]).toEqual({ isSystemShell: true, name: JSON.stringify({ - type: 'command', - command: 'bash', - name: '', + type: "command", + command: "bash", + name: "", }), - ownerUsername: 'root', - shellId: 'exec-1', - shellType: 'TERMINAL', - startCommand: 'bash', - status: 'RUNNING', + ownerUsername: "root", + shellId: "exec-1", + shellType: "TERMINAL", + startCommand: "bash", + status: "RUNNING", }); - expect(result[1].status).toBe('EXITED'); + expect(result[1].status).toBe("EXITED"); }); - it('should return empty array if no execs found', async () => { + it("should return empty array if no execs found", async () => { const mockResponse = createMockResponse({ execs: [] }); vi.mocked(pintApi.listExecs).mockResolvedValue(mockResponse); @@ -268,7 +269,7 @@ describe('PintShellsClient', () => { expect(result).toEqual([]); }); - it('should handle API error by returning empty array', async () => { + it("should handle API error by returning empty array", async () => { const mockResponse = createMockResponse(null); vi.mocked(pintApi.listExecs).mockResolvedValue(mockResponse); @@ -278,117 +279,80 @@ describe('PintShellsClient', () => { }); }); - describe('open', () => { - it('should successfully open a shell and return with output buffer', async () => { - const mockExec = createMockExecItem(); - const getResponse = createMockResponse(mockExec); - const outputStream = (async function* (): AsyncGenerator { - yield 'data:{"type":"stdout","output":"Hello","sequence":1,"timestamp":"2023-01-01T12:00:00Z"}'; - })(); - - vi.mocked(pintApi.getExec).mockResolvedValue(getResponse); - vi.mocked(pintApi.getExecOutput).mockResolvedValue({ - ...createMockResponse({}), - stream: outputStream, - }); - - const result = await client.open('exec-123', { cols: 80, rows: 24 }); - - expect(result).toEqual({ - buffer: ['Hello'], - isSystemShell: true, - name: JSON.stringify({ - type: 'command', - command: 'bash', - name: '', - }), - ownerUsername: 'root', - shellId: 'exec-123', - shellType: 'TERMINAL', - startCommand: 'bash', - status: 'RUNNING', - }); - - expect(pintApi.getExec).toHaveBeenCalledWith({ - client: mockApiClient, - path: { id: 'exec-123' }, - }); - }); - - it('should handle shell that does not exist', async () => { - const getResponse = createMockResponse(null, { message: 'Not found' }); - vi.mocked(pintApi.getExec).mockResolvedValue(getResponse); - - await expect( - client.open('nonexistent', { cols: 80, rows: 24 }) - ).rejects.toThrow('Not found'); - }); - }); - - describe('rename', () => { - it('should return null as rename is not implemented', async () => { - const result = await client.rename('exec-123', 'new-name'); + describe("rename", () => { + it("should return null as rename is not implemented", async () => { + const result = await client.rename("exec-123", "new-name"); expect(result).toBeNull(); }); }); - describe('restart', () => { - it('should successfully restart a shell', async () => { + describe("restart", () => { + it("should successfully restart a shell", async () => { const mockResponse = createMockResponse({ success: true }); vi.mocked(pintApi.updateExec).mockResolvedValue(mockResponse); - const result = await client.restart('exec-123'); + const result = await client.restart("exec-123"); expect(result).toBeNull(); expect(pintApi.updateExec).toHaveBeenCalledWith({ client: mockApiClient, - path: { id: 'exec-123' }, - body: { status: 'running' }, + path: { id: "exec-123" }, + body: { status: "running" }, }); }); - it('should handle restart failure gracefully', async () => { - vi.mocked(pintApi.updateExec).mockRejectedValue(new Error('Restart failed')); + it("should handle restart failure gracefully", async () => { + vi.mocked(pintApi.updateExec).mockRejectedValue( + new Error("Restart failed") + ); - const result = await client.restart('exec-123'); + const result = await client.restart("exec-123"); expect(result).toBeNull(); }); }); - describe('send', () => { - it('should successfully send input to shell', async () => { + describe("send", () => { + it("should successfully send input to shell", async () => { const mockResponse = createMockResponse({ success: true }); vi.mocked(pintApi.execExecStdin).mockResolvedValue(mockResponse); - const result = await client.send('exec-123', 'echo hello', { cols: 80, rows: 24 }); + const result = await client.send("exec-123", "echo hello", { + cols: 80, + rows: 24, + }); expect(result).toBeNull(); expect(pintApi.execExecStdin).toHaveBeenCalledWith({ client: mockApiClient, - path: { id: 'exec-123' }, + path: { id: "exec-123" }, body: { - type: 'stdin', - input: 'echo hello', + type: "stdin", + input: "echo hello", }, }); }); - it('should handle send failure gracefully', async () => { - vi.mocked(pintApi.execExecStdin).mockRejectedValue(new Error('Send failed')); + it("should handle send failure gracefully", async () => { + vi.mocked(pintApi.execExecStdin).mockRejectedValue( + new Error("Send failed") + ); - const result = await client.send('exec-123', 'test', { cols: 80, rows: 24 }); + const result = await client.send("exec-123", "test", { + cols: 80, + rows: 24, + }); expect(result).toBeNull(); }); }); - describe('convertExecToShellDTO', () => { - it('should convert ExecItem to ShellDTO format', async () => { + describe("convertExecToShellDTO", () => { + it("should convert ExecItem to ShellDTO format", async () => { const mockExec = createMockExecItem({ - id: 'test-exec', - command: 'node server.js', - status: 'RUNNING' as any, + id: "test-exec", + command: "node server.js", + status: "RUNNING" as any, }); // Access private method via bracket notation for testing @@ -397,58 +361,180 @@ describe('PintShellsClient', () => { expect(result).toEqual({ isSystemShell: true, name: JSON.stringify({ - type: 'command', - command: 'node server.js', - name: '', + type: "command", + command: "node server.js", + name: "", }), - ownerUsername: 'root', - shellId: 'test-exec', - shellType: 'TERMINAL', - startCommand: 'node server.js', - status: 'RUNNING', + ownerUsername: "root", + shellId: "test-exec", + shellType: "TERMINAL", + startCommand: "node server.js", + status: "RUNNING", }); }); }); - describe('event emitters', () => { - it('should have onShellExited event emitter', () => { - expect(client.onShellExited).toBeDefined(); - expect(typeof client.onShellExited).toBe('function'); - }); + describe("subscribe", () => { + it("should subscribe to shell exit events", async () => { + // Mock the stream for subscribeAndEvaluateExecsUpdates + const streamMock = (async function* (): AsyncGenerator< + string, + any, + unknown + > { + // First yield: initial state with RUNNING status + yield 'data:{"execs":[{"id":"exec-123","status":"RUNNING","exitCode":null,"command":"bash","args":[],"interactive":true,"pid":1234}]}'; + // Second yield: state change to EXITED + yield 'data:{"execs":[{"id":"exec-123","status":"EXITED","exitCode":0,"command":"bash","args":[],"interactive":true,"pid":1234}]}'; + })(); - it('should have onShellOut event emitter', () => { - expect(client.onShellOut).toBeDefined(); - expect(typeof client.onShellOut).toBe('function'); - }); + vi.mocked(pintApi.streamExecsList).mockResolvedValue({ + ...createMockResponse({}), + stream: streamMock, + }); + + const events: any[] = []; + const disposable = client.subscribe("exec-123", (event) => { + events.push(event); + }); + + // Give some time for the stream to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: "exit", + exitCode: 0, + }); - it('should have onShellTerminated event emitter', () => { - expect(client.onShellTerminated).toBeDefined(); - expect(typeof client.onShellTerminated).toBe('function'); + disposable.dispose(); }); - it('should emit shell exit events when status changes from RUNNING to EXITED', async () => { - // Mock the stream for subscribeAndEvaluateExecsUpdates - const streamMock = (async function* (): AsyncGenerator { - yield 'data:{"execs":[{"id":"exec-123","status":"EXITED","exitCode":0,"command":"bash","args":[],"interactive":true,"pid":1234}]}'; - })(); - + it("should return disposable for cleanup", () => { + const streamMock = (async function* (): AsyncGenerator< + string, + any, + unknown + > {})(); + vi.mocked(pintApi.streamExecsList).mockResolvedValue({ ...createMockResponse({}), stream: streamMock, }); - // Test that the event emitter is properly set up - const unsubscribe: IDisposable = client.onShellExited((event) => { - expect(event.shellId).toBe('exec-123'); - expect(event.exitCode).toBe(0); + const disposable = client.subscribe("exec-123", () => {}); + + expect(disposable).toBeDefined(); + expect(typeof disposable.dispose).toBe("function"); + + disposable.dispose(); + }); + }); + + describe("subscribeOutput", () => { + it("should subscribe to shell output events", async () => { + const outputStream = (async function* (): AsyncGenerator< + string, + any, + unknown + > { + yield 'data:{"type":"stdout","output":"Hello World","sequence":1,"timestamp":"2023-01-01T12:00:00Z"}'; + yield 'data:{"type":"stdout","output":"Second line","sequence":2,"timestamp":"2023-01-01T12:00:01Z"}'; + })(); + + vi.mocked(pintApi.getExecOutput).mockResolvedValue({ + ...createMockResponse({}), + stream: outputStream, }); + const outputs: any[] = []; + const disposable = client.subscribeOutput( + "exec-123", + { cols: 80, rows: 24 }, + (event) => { + outputs.push(event); + } + ); + // Give some time for the stream to process - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(outputs.length).toBeGreaterThan(0); + expect(outputs[0]).toEqual({ + out: "Hello World", + exitCode: undefined, + }); + + disposable.dispose(); - unsubscribe.dispose(); - // Note: Due to the async nature of the stream, we can't easily test the actual firing - // without more complex mocking, but we can verify the structure exists + expect(pintApi.getExecOutput).toHaveBeenCalledWith({ + client: mockApiClient, + path: { id: "exec-123" }, + query: { lastSequence: 0 }, + signal: expect.any(AbortSignal), + headers: { + Accept: "text/event-stream", + }, + }); + }); + + it("should handle output with exit code", async () => { + const outputStream = (async function* (): AsyncGenerator< + string, + any, + unknown + > { + yield 'data:{"type":"stdout","output":"Done","sequence":1,"timestamp":"2023-01-01T12:00:00Z","exitCode":0}'; + })(); + + vi.mocked(pintApi.getExecOutput).mockResolvedValue({ + ...createMockResponse({}), + stream: outputStream, + }); + + const outputs: any[] = []; + const disposable = client.subscribeOutput( + "exec-123", + { cols: 80, rows: 24 }, + (event) => { + outputs.push(event); + } + ); + + // Give some time for the stream to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(outputs.length).toBeGreaterThan(0); + expect(outputs[0]).toEqual({ + out: "Done", + exitCode: 0, + }); + + disposable.dispose(); + }); + + it("should return disposable for cleanup", () => { + const outputStream = (async function* (): AsyncGenerator< + string, + any, + unknown + > {})(); + + vi.mocked(pintApi.getExecOutput).mockResolvedValue({ + ...createMockResponse({}), + stream: outputStream, + }); + + const disposable = client.subscribeOutput( + "exec-123", + { cols: 80, rows: 24 }, + () => {} + ); + + expect(disposable).toBeDefined(); + expect(typeof disposable.dispose).toBe("function"); + + disposable.dispose(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/sandbox-creation.test.ts b/tests/sandbox-creation.test.ts index e93444b..22ab9e7 100644 --- a/tests/sandbox-creation.test.ts +++ b/tests/sandbox-creation.test.ts @@ -1,80 +1,89 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import nock from 'nock' -import { CodeSandbox } from '../src/index' -import { - mockForkSandboxSuccess, - mockStartVMSuccess, - setupTestEnvironment, - cleanupTestEnvironment -} from './test-utils' - -describe('Sandbox Creation', () => { +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import nock from "nock"; +import { CodeSandbox } from "../src/index"; +import { + mockForkSandboxSuccess, + mockStartVMSuccess, + setupTestEnvironment, + cleanupTestEnvironment, +} from "./test-utils"; + +describe("Sandbox Creation", () => { beforeEach(() => { - setupTestEnvironment() - }) + setupTestEnvironment(); + }); afterEach(() => { - cleanupTestEnvironment() - }) + cleanupTestEnvironment(); + }); - it('should successfully create and start a sandbox', async () => { + it("should successfully create and start a sandbox", async () => { // Mock the fork sandbox API call (pcz35m is the default template) - const forkScope = mockForkSandboxSuccess('test-sandbox-123', { - title: 'Test Sandbox', - description: 'Integration test sandbox', + const forkScope = mockForkSandboxSuccess("test-sandbox-123", { + title: "Test Sandbox", + description: "Integration test sandbox", privacy: 1, - tags: ['integration-test', 'sdk'] - }) + tags: ["integration-test", "sdk"], + }); // Mock the start VM API call - use regex to match any ID - const startScope = mockStartVMSuccess('test-sandbox-123') + const startScope = mockStartVMSuccess("test-sandbox-123"); // Initialize SDK - const sdk = new CodeSandbox() - + const sdk = new CodeSandbox(); + // Create sandbox const sandbox = await sdk.sandboxes.create({ - title: 'Test Sandbox', - description: 'Integration test sandbox', - privacy: 'unlisted', - tags: ['integration-test'] - }) + title: "Test Sandbox", + description: "Integration test sandbox", + privacy: "unlisted", + tags: ["integration-test"], + }); // Verify sandbox was created successfully - expect(sandbox).toBeDefined() - expect(sandbox.id).toBe('test-sandbox-123') - + expect(sandbox).toBeDefined(); + expect(sandbox.id).toBe("test-sandbox-123"); + // Verify all API calls were made - expect(forkScope.isDone()).toBe(true) - expect(startScope.isDone()).toBe(true) - }, 10000) // 10 second timeout for integration test + expect(forkScope.isDone()).toBe(true); + expect(startScope.isDone()).toBe(true); + }, 10000); // 10 second timeout for integration test - it('should use default template when no id is provided', async () => { + it("should use default template when no id is provided", async () => { // Mock default template call - pcz35m is the default template - const forkScope = mockForkSandboxSuccess('default-sandbox-456') + // Default privacy is "public-hosts" which maps to privacy: 2, private_preview: false + const forkScope = mockForkSandboxSuccess("default-sandbox-456", { + privacy: 2, + private_preview: false, + }); + + const startScope = mockStartVMSuccess("default-sandbox-456"); - const startScope = mockStartVMSuccess('default-sandbox-456') + const sdk = new CodeSandbox(); - const sdk = new CodeSandbox() - // Create sandbox without specifying template id - const sandbox = await sdk.sandboxes.create() - - expect(sandbox).toBeDefined() - expect(sandbox.id).toBe('default-sandbox-456') - expect(forkScope.isDone()).toBe(true) - expect(startScope.isDone()).toBe(true) - }) - - it('should handle API errors gracefully', async () => { - // Mock fork sandbox failure - nock('https://api.codesandbox.io') - .post('/sandbox/pcz35m/fork') - .reply(500, { message: 'Internal server error' }) - - const sdk = new CodeSandbox() - + const sandbox = await sdk.sandboxes.create(); + + expect(sandbox).toBeDefined(); + expect(sandbox.id).toBe("default-sandbox-456"); + expect(forkScope.isDone()).toBe(true); + expect(startScope.isDone()).toBe(true); + }); + + it("should handle API errors gracefully", async () => { + // Mock fork sandbox failure with expected request body + nock("https://api.codesandbox.io") + .post("/sandbox/pcz35m/fork", { + privacy: 2, + tags: ["sdk"], + path: "/SDK", + private_preview: false + }) + .reply(500, { message: "Internal server error" }); + + const sdk = new CodeSandbox(); + // Expect the creation to throw an error - await expect(sdk.sandboxes.create()).rejects.toThrow() - }) -}) \ No newline at end of file + await expect(sdk.sandboxes.create()).rejects.toThrow(); + }); +}); diff --git a/tests/sandbox-retry-behavior.test.ts b/tests/sandbox-retry-behavior.test.ts index 4e03c80..f9e2853 100644 --- a/tests/sandbox-retry-behavior.test.ts +++ b/tests/sandbox-retry-behavior.test.ts @@ -20,10 +20,15 @@ describe('Create operation retry behavior', () => { it('should fail immediately on fork API error (no retry for fork)', async () => { let forkRequestCount = 0 - + // Mock fork to fail once - should fail immediately since fork doesn't retry const forkScope = nock('https://api.codesandbox.io') - .post('/sandbox/pcz35m/fork') + .post('/sandbox/pcz35m/fork', { + privacy: 2, + tags: ['sdk'], + path: '/SDK', + private_preview: false + }) .reply(500, () => { forkRequestCount++ return { error: { errors: ['Fork failed'] } } @@ -44,9 +49,12 @@ describe('Create operation retry behavior', () => { it('should retry start VM failures and eventually succeed', async () => { let startVMRequestCount = 0 - - // Mock successful fork - const forkScope = mockForkSandboxSuccess('test-sandbox-start-retry') + + // Mock successful fork with default privacy settings + const forkScope = mockForkSandboxSuccess('test-sandbox-start-retry', { + privacy: 2, + private_preview: false, + }) // Mock start VM to fail twice const failureScope = nock('https://api.codesandbox.io') @@ -92,9 +100,12 @@ describe('Create operation retry behavior', () => { it('should fail create after start VM exhausts all retries', async () => { let startVMRequestCount = 0 - - // Mock successful fork - const forkScope = mockForkSandboxSuccess('test-sandbox-start-fail') + + // Mock successful fork with default privacy settings + const forkScope = mockForkSandboxSuccess('test-sandbox-start-fail', { + privacy: 2, + private_preview: false, + }) // Mock start VM to fail all 3 retry attempts const failureScope = nock('https://api.codesandbox.io') @@ -117,9 +128,12 @@ describe('Create operation retry behavior', () => { it('should validate retry timing for start VM failures', async () => { let startVMRequestCount = 0 - - // Mock successful fork - const forkScope = mockForkSandboxSuccess('test-sandbox-timing') + + // Mock successful fork with default privacy settings + const forkScope = mockForkSandboxSuccess('test-sandbox-timing', { + privacy: 2, + private_preview: false, + }) // Mock start VM to fail twice const failureScope = nock('https://api.codesandbox.io') diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 6ad1cf8..219c219 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,99 +1,150 @@ -import nock from 'nock' +import nock from "nock"; -export const mockForkSandboxSuccess = (sandboxId: string, options?: { - title?: string - description?: string - privacy?: number - tags?: string[] -}) => { - return nock('https://api.codesandbox.io') - .post('/sandbox/pcz35m/fork', { - privacy: options?.privacy ?? 1, - ...(options?.title && { title: options.title }), - ...(options?.description && { description: options.description }), - tags: options?.tags ?? ['sdk'], - path: '/SDK' - }) +export const mockForkSandboxSuccess = ( + sandboxId: string, + options?: { + title?: string; + description?: string; + privacy?: number; + tags?: string[]; + private_preview?: boolean; + } +) => { + const requestBody: Record = { + privacy: options?.privacy ?? 1, + ...(options?.title && { title: options.title }), + ...(options?.description && { description: options.description }), + tags: options?.tags ?? ["sdk"], + path: "/SDK", + }; + + // Only add private_preview if explicitly provided + if (options?.private_preview !== undefined) { + requestBody.private_preview = options.private_preview; + } + + return nock("https://api.codesandbox.io") + .post("/sandbox/pcz35m/fork", requestBody) .reply(200, { data: { id: sandboxId, - title: options?.title ?? 'Test Sandbox', + title: options?.title ?? "Test Sandbox", description: options?.description, privacy: options?.privacy ?? 1, - tags: options?.tags ?? ['sdk'], - created_at: '2025-01-21T12:00:00Z', - updated_at: '2025-01-21T12:00:00Z' - } - }) -} + tags: options?.tags ?? ["sdk"], + created_at: "2025-01-21T12:00:00Z", + updated_at: "2025-01-21T12:00:00Z", + }, + }); +}; -export const mockStartVMSuccess = (sandboxId: string, bootupType: 'CLEAN' | 'RESUME' = 'CLEAN') => { - return nock('https://api.codesandbox.io') +export const mockStartVMSuccess = ( + sandboxId: string, + bootupType: "CLEAN" | "RESUME" = "CLEAN" +) => { + return nock("https://api.codesandbox.io") .post(/\/vm\/.*\/start/) .reply(200, { data: { bootup_type: bootupType, - cluster: 'test-cluster', + cluster: "test-cluster", pitcher_url: `wss://pitcher.codesandbox.io/${sandboxId}`, - workspace_path: '/project/sandbox', - user_workspace_path: '/project/sandbox', - pitcher_manager_version: '1.0.0', - pitcher_version: '1.0.0', - latest_pitcher_version: '1.0.0', - pitcher_token: `pitcher-token-${sandboxId.split('-').pop()}` - } - }) -} + workspace_path: "/project/sandbox", + user_workspace_path: "/project/sandbox", + pitcher_manager_version: "1.0.0", + pitcher_version: "1.0.0", + latest_pitcher_version: "1.0.0", + pitcher_token: `pitcher-token-${sandboxId.split("-").pop()}`, + }, + }); +}; -export const mockStartVMFailure = (times: number = 1, errorMessage: string = 'Start failed') => { - return nock('https://api.codesandbox.io') +export const mockStartVMFailure = ( + times: number = 1, + errorMessage: string = "Start failed" +) => { + return nock("https://api.codesandbox.io") .post(/\/vm\/.*\/start/) .times(times) - .reply(500, { error: { errors: [errorMessage] } }) -} + .reply(500, { error: { errors: [errorMessage] } }); +}; export const mockHibernateSuccess = (sandboxId: string) => { - return nock('https://api.codesandbox.io') + return nock("https://api.codesandbox.io") .post(`/vm/${sandboxId}/hibernate`) .reply(200, { data: { - success: true - } - }) -} + success: true, + }, + }); +}; -export const mockHibernateFailure = (sandboxId: string, times: number = 1, errorMessage: string = 'Server error') => { - return nock('https://api.codesandbox.io') +export const mockHibernateFailure = ( + sandboxId: string, + times: number = 1, + errorMessage: string = "Server error" +) => { + return nock("https://api.codesandbox.io") .post(`/vm/${sandboxId}/hibernate`) .times(times) - .reply(500, { error: { errors: [errorMessage] } }) -} + .reply(500, { error: { errors: [errorMessage] } }); +}; export const mockShutdownSuccess = (sandboxId: string) => { - return nock('https://api.codesandbox.io') + return nock("https://api.codesandbox.io") .post(`/vm/${sandboxId}/shutdown`) .reply(200, { data: { - success: true - } - }) -} + success: true, + }, + }); +}; -export const mockShutdownFailure = (sandboxId: string, times: number = 1, errorMessage: string = 'Shutdown failed') => { - return nock('https://api.codesandbox.io') +export const mockShutdownFailure = ( + sandboxId: string, + times: number = 1, + errorMessage: string = "Shutdown failed" +) => { + return nock("https://api.codesandbox.io") .post(`/vm/${sandboxId}/shutdown`) .times(times) - .reply(500, { error: { errors: [errorMessage] } }) -} + .reply(500, { error: { errors: [errorMessage] } }); +}; export const setupTestEnvironment = () => { - process.env.CSB_API_KEY = 'csb_test_key_123' - nock.cleanAll() -} + process.env.CSB_API_KEY = "csb_test_key_123"; + nock.cleanAll(); +}; export const cleanupTestEnvironment = () => { if (!nock.isDone()) { - console.error('Unused nock interceptors:', nock.pendingMocks()) + console.error("Unused nock interceptors:", nock.pendingMocks()); + } + nock.cleanAll(); +}; + +/** + * Properly cleanup test sandbox with correct sequencing: + * 1. Wait for client cleanup (disconnect and dispose) + * 2. Wait for shutdown with 10 second timeout + * 3. Fire-and-forget delete (with small delay to ensure request goes through) + */ +export const cleanupTestSandbox = async ( + client: any | undefined, + sandboxId: string | undefined, + sdk: any +): Promise => { + if (client) { + try { + await client.disconnect(); + client.dispose(); + } catch (error) { + console.error("Failed to disconnect client:", error); + } + } + + if (sandboxId) { + await sdk.sandboxes.shutdown(sandboxId); + await sdk.sandboxes.delete(sandboxId); } - nock.cleanAll() -} \ No newline at end of file +}; diff --git a/vitest.benchmark.config.ts b/vitest.benchmark.config.ts new file mode 100644 index 0000000..bcf0eab --- /dev/null +++ b/vitest.benchmark.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/benchmark/**/*.test.ts"], + reporters: ["verbose"], + }, + define: { + CSB_SDK_VERSION: JSON.stringify("2.5.0"), + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index cba2eed..6cd2bf3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { environment: 'node', include: ['tests/**/*.test.ts'], + testTimeout: 10000, // Doubled from default 5000ms }, define: { CSB_SDK_VERSION: JSON.stringify('2.1.0-rc.4'), diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..b2aba9a --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/e2e/**/*.test.ts'] + }, + define: { + CSB_SDK_VERSION: JSON.stringify('2.1.0-rc.4'), + }, +}) \ No newline at end of file