Created
June 8, 2025 08:55
-
-
Save zackiles/c4734c1d18eadee34337545bee392d20 to your computer and use it in GitHub Desktop.
Runs a Cursor Dev Container (In Deno and Node)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env deno run --allow-run --allow-read --allow-env | |
| /** | |
| * @module run-cursor-dev-container | |
| * @fileoverview Generic Cursor Dev Container Launcher | |
| * | |
| * Automates the process of launching Cursor directly into any dev container, | |
| * bypassing the need for manual "Reopen in Container" command palette actions. | |
| * | |
| * @description This script manages Docker container lifecycle and constructs the proper | |
| * vscode-remote:// URI format required by Cursor's dev container extension. Key differences | |
| * from VS Code include specific JSON structure requirements and Docker context settings | |
| * for cross-platform Docker Desktop integration. | |
| * | |
| * KEY DIFFERENCES FROM VS CODE: | |
| * 1. Cursor uses the same dev-container URI format as VS Code, but requires specific | |
| * JSON structure and Docker context settings for cross-platform Docker Desktop | |
| * 2. The --folder-uri flag must be used with a properly constructed vscode-remote:// URI | |
| * 3. Docker context varies by platform: desktop-linux (macOS), default (Linux), desktop-windows (Windows) | |
| * | |
| * @author Zachary Iles | |
| * @version 0.0.1 | |
| * @since 2025 | |
| * @requires deno | |
| * @requires docker | |
| * @requires cursor | |
| * | |
| * @example | |
| * ```bash | |
| * # Use current directory with default settings | |
| * deno run -A run-cursor-dev-container-deno.ts | |
| * | |
| * # Specify workspace and container name | |
| * deno run -A run-cursor-dev-container-deno.ts --workspace /path/to/project --name my-container | |
| * | |
| * # Use custom devcontainer path | |
| * deno run -A run-cursor-dev-container-deno.ts --devcontainer-path .devcontainer-custom | |
| * ``` | |
| * | |
| * @see ~/.cursor/extensions/ms-vscode-remote.remote-containers-0.394.0/dist/extension.js | |
| * Contains URI construction logic around lines 50000-60000, including Buffer.from(configJSON,"utf8").toString("hex") pattern | |
| * @see ~/.cursor/extensions/ms-vscode-remote.remote-containers-0.394.0/dist/common/uri.js | |
| * Authority type definitions (dev-container, attached-container, k8s-container) and URI parsing functions | |
| * @see ~/.cursor/extensions/ms-vscode-remote.remote-containers-0.394.0/dist/common/containerConfiguration.js | |
| * JSON config structure requirements for container settings and Docker context handling | |
| */ | |
| import * as path from 'jsr:@std/path' | |
| import { parseArgs } from 'jsr:@std/cli' | |
| import { parse as parseJsonc } from 'jsr:@std/jsonc' | |
| interface DevContainerConfig { | |
| name?: string | |
| workspaceFolder?: string | |
| workspaceMount?: string | |
| dockerFile?: string | |
| image?: string | |
| build?: { | |
| dockerfile?: string | |
| context?: string | |
| } | |
| } | |
| interface CliArgs { | |
| workspace: string | |
| devcontainerPath: string | |
| containerName?: string | undefined | |
| help: boolean | |
| verbose: boolean | |
| reload: boolean | |
| } | |
| interface RunNewContainerOptions { | |
| containerName: string | |
| config: DevContainerConfig | |
| workspacePath: string | |
| log: (message: string) => void | |
| } | |
| interface StartContainerOptions extends RunNewContainerOptions { | |
| devcontainerPath: string | |
| } | |
| function showHelp() { | |
| console.log(` | |
| Generic Cursor Dev Container Launcher | |
| USAGE: | |
| deno run -A run.ts [OPTIONS] | |
| OPTIONS: | |
| -w, --workspace <PATH> Workspace directory path (default: current directory) | |
| -d, --devcontainer-path <PATH> Path to devcontainer directory (default: .devcontainer) | |
| -n, --name <NAME> Override container name | |
| -v, --verbose Show detailed output during execution | |
| --no-reload Don't kill existing Cursor instances after launching container | |
| -h, --help Show this help message | |
| EXAMPLES: | |
| deno run -A run.ts (assume .devcontainer in current directory with Dockerfile + devcontainer.json) | |
| deno run -A run.ts --workspace /path/to/project | |
| deno run -A run.ts --name my-custom-container --devcontainer-path .devcontainer-prod | |
| deno run -A run.ts --verbose | |
| `) | |
| } | |
| function createLogger(verbose: boolean) { | |
| return function log(message: string) { | |
| if (verbose) { | |
| console.log(message) | |
| } | |
| } | |
| } | |
| async function run(cmd: string, args: string[] = []) { | |
| const command = new Deno.Command(cmd, { | |
| args, | |
| stdout: 'piped', | |
| stderr: 'piped', | |
| }) | |
| const { success, stdout, stderr } = await command.output() | |
| const decoder = new TextDecoder() | |
| return { | |
| success, | |
| stdout: decoder.decode(stdout).trim(), | |
| stderr: decoder.decode(stderr).trim(), | |
| } | |
| } | |
| async function readDevContainerConfig( | |
| configPath: string, | |
| ): Promise<DevContainerConfig> { | |
| try { | |
| const configFile = await Deno.readTextFile(configPath) | |
| return parseJsonc(configFile) as DevContainerConfig | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : String(error) | |
| throw new Error(`Failed to read devcontainer.json: ${message}`) | |
| } | |
| } | |
| function detectPlatform(): string { | |
| const os = Deno.build.os | |
| switch (os) { | |
| case 'darwin': | |
| return 'desktop-linux' | |
| case 'windows': | |
| return 'desktop-windows' | |
| case 'linux': | |
| return 'default' | |
| default: | |
| return 'default' | |
| } | |
| } | |
| function generateContainerName( | |
| config: DevContainerConfig, | |
| workspacePath: string, | |
| override?: string, | |
| ): string { | |
| if (override) return override | |
| if (config.name) return config.name | |
| const workspaceName = path.basename(workspacePath) || 'devcontainer' | |
| return `${workspaceName}-devcontainer` | |
| } | |
| /** | |
| * Checks if a container exists (running or stopped). | |
| * @param containerName The name of the container to check. | |
| * @returns The status of the container. | |
| * @description We need to ensure the container is running before launching Cursor | |
| * because Cursor's dev container extension expects an existing container, unlike VS Code | |
| * which can build containers on-demand more reliably. | |
| */ | |
| async function getContainerStatus(containerName: string) { | |
| const { stdout } = await run('docker', [ | |
| 'ps', | |
| '-a', | |
| '--filter', | |
| `name=${containerName}`, | |
| '--format', | |
| '{{.Status}}', | |
| ]) | |
| return stdout | |
| } | |
| async function buildContainer( | |
| containerName: string, | |
| devcontainerPath: string, | |
| config: DevContainerConfig, | |
| ) { | |
| const buildArgs: string[] = ['build', '-t', containerName.toLowerCase()] | |
| if (config.build?.dockerfile || config.dockerFile) { | |
| const dockerfile = config.build?.dockerfile || config.dockerFile || | |
| 'Dockerfile' | |
| buildArgs.push('-f', path.join(devcontainerPath, dockerfile)) | |
| } | |
| const context = config.build?.context || devcontainerPath | |
| buildArgs.push(context) | |
| const result = await run('docker', buildArgs) | |
| if (!result.success) { | |
| throw new Error(`Build failed: ${result.stderr}`) | |
| } | |
| } | |
| async function startExistingContainer( | |
| containerName: string, | |
| log: (message: string) => void, | |
| ) { | |
| log('Starting existing container...') | |
| const result = await run('docker', ['start', containerName]) | |
| if (!result.success) { | |
| throw new Error(`Start failed: ${result.stderr}`) | |
| } | |
| } | |
| async function runNewContainer({ | |
| containerName, | |
| config, | |
| workspacePath, | |
| log, | |
| }: RunNewContainerOptions) { | |
| log('Running new container...') | |
| const runArgs = ['run', '-d', '--name', containerName] | |
| if (config.workspaceMount) { | |
| const mountParts = Object.fromEntries( | |
| config.workspaceMount.split(',').map((part) => part.split('=')), | |
| ) as Record<string, string> | |
| const source = mountParts.source?.replace( | |
| '${localWorkspaceFolder}', | |
| workspacePath, | |
| ) | |
| const target = mountParts.target | |
| if (source && target) { | |
| runArgs.push('-v', `${source}:${target}`) | |
| } else { | |
| log('Warning: could not parse workspaceMount string, using default.') | |
| const workspaceFolder = config.workspaceFolder || '/workspace' | |
| runArgs.push('-v', `${workspacePath}:${workspaceFolder}`) | |
| } | |
| } else { | |
| const workspaceFolder = config.workspaceFolder || '/workspace' | |
| runArgs.push('-v', `${workspacePath}:${workspaceFolder}`) | |
| } | |
| runArgs.push(containerName.toLowerCase()) | |
| runArgs.push('sleep', 'infinity') | |
| const result = await run('docker', runArgs) | |
| if (!result.success) { | |
| throw new Error(`Run failed: ${result.stderr}`) | |
| } | |
| } | |
| async function startContainer({ | |
| containerName, | |
| devcontainerPath, | |
| config, | |
| workspacePath, | |
| log, | |
| }: StartContainerOptions) { | |
| const status = await getContainerStatus(containerName) | |
| if (!status) { | |
| const runOptions = { containerName, config, workspacePath, log } | |
| if (config.image) { | |
| await runNewContainer(runOptions) | |
| } else { | |
| await buildContainer(containerName, devcontainerPath, config) | |
| await runNewContainer(runOptions) | |
| } | |
| return | |
| } | |
| if (status.startsWith('Up')) { | |
| log('Container already running') | |
| return | |
| } | |
| await startExistingContainer(containerName, log) | |
| } | |
| function createDevContainerUri( | |
| workspacePath: string, | |
| config: DevContainerConfig, | |
| ) { | |
| // CURSOR DEV CONTAINER URI CONSTRUCTION | |
| // This is the critical part that differs from VS Code documentation. | |
| // Cursor expects a very specific URI format for programmatic dev container opening. | |
| const dockerContext = detectPlatform() | |
| const workspaceFolder = config.workspaceFolder || '/workspace' | |
| const uriConfig = { | |
| hostPath: workspacePath, | |
| localDocker: true, | |
| settings: { | |
| context: dockerContext, // Platform-specific Docker context | |
| }, | |
| } | |
| // Convert JSON to hex encoding as expected by Cursor's dev container extension | |
| // This matches the Buffer.from(s,"utf8").toString("hex") pattern from the extension source | |
| const hex = Array.from(new TextEncoder().encode(JSON.stringify(uriConfig))) | |
| .map((b) => b.toString(16).padStart(2, '0')) | |
| .join('') | |
| return `vscode-remote://dev-container+${hex}${workspaceFolder}` | |
| } | |
| async function openCursor(uri: string) { | |
| // Launch Cursor with the properly constructed dev container URI | |
| // Format: vscode-remote://dev-container+<hex-encoded-config><workspace-path> | |
| // This bypasses the need for manual "Reopen in Container" command palette action | |
| const result = await run('cursor', ['--folder-uri', uri]) | |
| if (!result.success) { | |
| throw new Error(`Cursor launch failed: ${result.stderr}`) | |
| } | |
| } | |
| /** | |
| * Finds parent PIDs of Cursor processes on Unix-like systems. | |
| */ | |
| async function getCursorPidsForUnix(workspace: string): Promise<Set<string>> { | |
| const decoder = new TextDecoder() | |
| const parentPids = new Set<string>() | |
| const lsof = new Deno.Command('lsof', { args: ['-Fpn'], stdout: 'piped' }) | |
| const lsofOutput = await lsof.output() | |
| if (!lsofOutput.success) return parentPids | |
| const lines = decoder.decode(lsofOutput.stdout).split('\n') | |
| const pidsWithOpenWorkspaceFile = new Set<string>() | |
| let currentPid = '' | |
| for (const line of lines) { | |
| if (line.startsWith('p')) { | |
| currentPid = line.slice(1) | |
| } else if (line.startsWith('n') && line.includes(workspace) && currentPid) { | |
| pidsWithOpenWorkspaceFile.add(currentPid) | |
| } | |
| } | |
| if (pidsWithOpenWorkspaceFile.size === 0) return parentPids | |
| const checkPidIsCursor = async (pid: string): Promise<string | null> => { | |
| const ps = new Deno.Command('ps', { | |
| args: ['-o', 'comm=', '-p', pid], | |
| stdout: 'piped', | |
| }) | |
| const { success, stdout } = await ps.output() | |
| const isCursor = success && | |
| decoder.decode(stdout).trim().toLowerCase().includes('cursor') | |
| return isCursor ? pid : null | |
| } | |
| const pidChecks = await Promise.all( | |
| Array.from(pidsWithOpenWorkspaceFile).map(checkPidIsCursor), | |
| ) | |
| const cursorPids = new Set(pidChecks.filter((p): p is string => p !== null)) | |
| if (cursorPids.size === 0) return parentPids | |
| const ppidPs = new Deno.Command('ps', { | |
| args: ['-o', 'ppid=', '-p', Array.from(cursorPids).join(',')], | |
| stdout: 'piped', | |
| }) | |
| const ppidOutput = await ppidPs.output() | |
| if (ppidOutput.success) { | |
| for ( | |
| const ppid of decoder.decode(ppidOutput.stdout).split('\n').map((l) => | |
| l.trim() | |
| ).filter(Boolean) | |
| ) { | |
| parentPids.add(ppid) | |
| } | |
| } | |
| return parentPids | |
| } | |
| /** | |
| * Finds parent PIDs of Cursor processes on Windows. | |
| */ | |
| async function getCursorPidsForWindows( | |
| workspace: string, | |
| ): Promise<Set<string>> { | |
| const decoder = new TextDecoder() | |
| const parentPids = new Set<string>() | |
| const powershell = new Deno.Command('powershell', { | |
| args: [ | |
| '-Command', | |
| `Get-Process | Where-Object { $_.Name -like "*cursor*" -and $_.MainModule.FileName -ne $null } | ForEach-Object { $proc = $_; try { $handles = Get-Handle -ProcessId $proc.Id -ErrorAction SilentlyContinue | Where-Object { $_.Name -like "*${ | |
| workspace.replace(/\\/g, '\\\\') | |
| }*" }; if ($handles) { Write-Output "$($proc.Id),$($proc.Parent.Id)" } } catch { } }`, | |
| ], | |
| stdout: 'piped', | |
| stderr: 'piped', | |
| }) | |
| const result = await powershell.output() | |
| if (result.success) { | |
| for ( | |
| const line of decoder.decode(result.stdout).trim().split('\n').filter( | |
| Boolean, | |
| ) | |
| ) { | |
| const parts = line.split(',') | |
| if (parts.length === 2) parentPids.add(parts[1]) | |
| } | |
| return parentPids | |
| } | |
| const tasklist = new Deno.Command('tasklist', { | |
| args: ['/FO', 'CSV', '/V'], | |
| stdout: 'piped', | |
| }) | |
| const taskResult = await tasklist.output() | |
| if (taskResult.success) { | |
| for ( | |
| const line of decoder.decode(taskResult.stdout).split('\n').slice(1) | |
| .filter(Boolean) | |
| ) { | |
| if ( | |
| line.toLowerCase().includes('cursor') && line.includes(workspace) | |
| ) { | |
| const match = line.match(/"(\d+)"/g) | |
| if (match && match.length >= 2) { | |
| parentPids.add(match[1].replace(/"/g, '')) | |
| } | |
| } | |
| } | |
| } | |
| return parentPids | |
| } | |
| /** | |
| * Finds all parent PIDs of cursor processes that have files open | |
| * in the specified workspace path | |
| */ | |
| async function getCursorPidsForWorkspace(workspace: string): Promise<string> { | |
| const os = Deno.build.os | |
| const pids = os === 'windows' | |
| ? await getCursorPidsForWindows(workspace) | |
| : await getCursorPidsForUnix(workspace) | |
| return [...pids].join(',') | |
| } | |
| async function main() { | |
| const parsedCliArgs = parseArgs(Deno.args, { | |
| alias: { | |
| help: 'h', | |
| verbose: 'v', | |
| workspace: 'w', | |
| name: 'n', | |
| 'devcontainer-path': 'd', | |
| }, | |
| boolean: ['help', 'verbose', 'reload'], | |
| string: ['workspace', 'name', 'devcontainer-path'], | |
| negatable: ['reload'], | |
| default: { | |
| reload: true, | |
| help: false, | |
| verbose: false, | |
| workspace: Deno.cwd(), | |
| 'devcontainer-path': '.devcontainer', | |
| }, | |
| }) | |
| const args: CliArgs = { | |
| help: parsedCliArgs.help, | |
| reload: parsedCliArgs.reload, | |
| verbose: parsedCliArgs.verbose, | |
| workspace: parsedCliArgs.workspace, | |
| containerName: parsedCliArgs.name, | |
| devcontainerPath: parsedCliArgs['devcontainer-path'], | |
| } | |
| if (args.help) { | |
| showHelp() | |
| return | |
| } | |
| let openCursorPids: string[] = [] | |
| if (args.reload) { | |
| openCursorPids = | |
| (await getCursorPidsForWorkspace(args.workspace || Deno.cwd())).split(',') | |
| .filter(Boolean) | |
| } | |
| const log = createLogger(args.verbose) | |
| try { | |
| const devcontainerConfigPath = path.join( | |
| args.workspace, | |
| args.devcontainerPath, | |
| 'devcontainer.json', | |
| ) | |
| const config = await readDevContainerConfig(devcontainerConfigPath) | |
| const containerName = generateContainerName( | |
| config, | |
| args.workspace, | |
| args.containerName, | |
| ) | |
| log(`Using workspace: ${args.workspace}`) | |
| log(`Using container: ${containerName}`) | |
| log(`Platform detected: ${detectPlatform()}`) | |
| await startContainer({ | |
| containerName, | |
| devcontainerPath: path.join(args.workspace, args.devcontainerPath), | |
| config, | |
| workspacePath: args.workspace, | |
| log, | |
| }) | |
| log('Creating dev container URI...') | |
| const uri = createDevContainerUri(args.workspace, config) | |
| log('Opening Cursor...') | |
| await openCursor(uri) | |
| console.log('Success! Cursor is running in dev container') | |
| if (args.reload && openCursorPids.length > 0) { | |
| try { | |
| await Promise.allSettled( | |
| // This will kill the current process | |
| openCursorPids.map((pid) => | |
| Deno.kill(Number.parseInt(pid), 'SIGKILL') | |
| ), | |
| ) | |
| } catch { | |
| // Ignore errors | |
| } | |
| } | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : String(error) | |
| console.error('Error:', message) | |
| console.log("Fallback: Use 'Dev Containers: Reopen in Container' in Cursor") | |
| Deno.exit(1) | |
| } | |
| } | |
| if (import.meta.main) { | |
| await main() | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * @module run-cursor-dev-container | |
| * @fileoverview Generic Cursor Dev Container Launcher | |
| * | |
| * Automates the process of launching Cursor directly into any dev container, | |
| * bypassing the need for manual "Reopen in Container" command palette actions. | |
| * | |
| * @description This script manages Docker container lifecycle and constructs the proper | |
| * vscode-remote:// URI format required by Cursor's dev container extension. Key differences | |
| * from VS Code include specific JSON structure requirements and Docker context settings | |
| * for cross-platform Docker Desktop integration. | |
| * | |
| * KEY DIFFERENCES FROM VS CODE: | |
| * 1. Cursor uses the same dev-container URI format as VS Code, but requires specific | |
| * JSON structure and Docker context settings for cross-platform Docker Desktop | |
| * 2. The --folder-uri flag must be used with a properly constructed vscode-remote:// URI | |
| * 3. Docker context varies by platform: desktop-linux (macOS), default (Linux), desktop-windows (Windows) | |
| * | |
| * @author Zachary Iles | |
| * @version 1.0.0 | |
| * @since 2025 | |
| * @requires deno | |
| * @requires docker | |
| * @requires cursor | |
| * | |
| * @example | |
| * ```bash | |
| * # Use current directory with default settings | |
| * deno run -A run.ts | |
| * | |
| * # Specify workspace and container name | |
| * deno run -A run.ts --workspace /path/to/project --name my-container | |
| * | |
| * # Use custom devcontainer path | |
| * deno run -A run.ts --devcontainer-path .devcontainer-custom | |
| * ``` | |
| * | |
| * @see ~/.cursor/extensions/ms-vscode-remote.remote-containers-0.394.0/dist/extension.js | |
| * Contains URI construction logic around lines 50000-60000, including Buffer.from(configJSON,"utf8").toString("hex") pattern | |
| * @see ~/.cursor/extensions/ms-vscode-remote.remote-containers-0.394.0/dist/common/uri.js | |
| * Authority type definitions (dev-container, attached-container, k8s-container) and URI parsing functions | |
| * @see ~/.cursor/extensions/ms-vscode-remote.remote-containers-0.394.0/dist/common/containerConfiguration.js | |
| * JSON config structure requirements for container settings and Docker context handling | |
| */ | |
| "use strict"; | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| const { spawnSync } = require("child_process"); | |
| /** | |
| * @typedef {Object} DevContainerConfig | |
| * @property {string=} name | |
| * @property {string=} workspaceFolder | |
| * @property {string=} workspaceMount | |
| * @property {string=} dockerFile | |
| * @property {string=} image | |
| * @property {{ dockerfile?: string, context?: string }=} build | |
| */ | |
| /** | |
| * @typedef {Object} CliArgs | |
| * @property {string} workspace | |
| * @property {string} devcontainerPath | |
| * @property {string=} containerName | |
| * @property {boolean} help | |
| * @property {boolean} verbose | |
| * @property {boolean} reload | |
| */ | |
| function parseArgs(argv) { | |
| const args = { | |
| help: false, | |
| verbose: false, | |
| reload: true, | |
| workspace: process.cwd(), | |
| devcontainerPath: ".devcontainer", | |
| }; | |
| for (let i = 2; i < argv.length; i++) { | |
| const a = argv[i]; | |
| if (a === "-h" || a === "--help") args.help = true; | |
| else if (a === "-v" || a === "--verbose") args.verbose = true; | |
| else if (a === "--no-reload") args.reload = false; | |
| else if (a === "-w" || a === "--workspace") args.workspace = argv[++i]; | |
| else if (a === "-d" || a === "--devcontainer-path") | |
| args.devcontainerPath = argv[++i]; | |
| else if (a === "-n" || a === "--name") args.containerName = argv[++i]; | |
| } | |
| return args; | |
| } | |
| function showHelp() { | |
| console.log(` | |
| Generic Cursor Dev Container Launcher | |
| USAGE: | |
| node run.js [OPTIONS] | |
| OPTIONS: | |
| -w, --workspace <PATH> Workspace directory path (default: current directory) | |
| -d, --devcontainer-path <PATH> Path to devcontainer directory (default: .devcontainer) | |
| -n, --name <NAME> Override container name | |
| -v, --verbose Show detailed output during execution | |
| --no-reload Don't kill existing Cursor instances after launching container | |
| -h, --help Show this help message | |
| EXAMPLES: | |
| node run.js (assume .devcontainer in current directory with Dockerfile + devcontainer.json) | |
| node run.js --workspace /path/to/project | |
| node run.js --name my-custom-container --devcontainer-path .devcontainer-prod | |
| node run.js --verbose | |
| `); | |
| } | |
| function createLogger(verbose) { | |
| return (msg) => { | |
| if (verbose) console.log(msg); | |
| }; | |
| } | |
| function run(cmd, args = []) { | |
| const { status, stdout, stderr } = spawnSync(cmd, args, { encoding: "utf8" }); | |
| return { success: status === 0, stdout: stdout.trim(), stderr: stderr.trim() }; | |
| } | |
| /** | |
| * Reads devcontainer.json with JSONC support (simple comment stripping) | |
| */ | |
| function readDevContainerConfig(configPath) { | |
| const text = fs.readFileSync(configPath, "utf8"); | |
| const json = text.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, ""); | |
| return JSON.parse(json); | |
| } | |
| function detectPlatform() { | |
| switch (process.platform) { | |
| case "darwin": | |
| return "desktop-linux"; | |
| case "win32": | |
| return "desktop-windows"; | |
| case "linux": | |
| default: | |
| return "default"; | |
| } | |
| } | |
| function generateContainerName(config, workspacePath, override) { | |
| if (override) return override; | |
| if (config.name) return config.name; | |
| return `${path.basename(workspacePath) || "devcontainer"}-devcontainer`; | |
| } | |
| /** | |
| * Checks if a container exists (running or stopped). | |
| * @param {string} containerName | |
| * @returns {string} Status string if found, else empty. | |
| * @description We need to ensure the container is running before launching Cursor | |
| * because Cursor's dev container extension expects an existing container, unlike VS Code | |
| * which can build containers on-demand more reliably. | |
| */ | |
| function getContainerStatus(containerName) { | |
| return run("docker", [ | |
| "ps", | |
| "-a", | |
| "--filter", | |
| `name=${containerName}`, | |
| "--format", | |
| "{{.Status}}", | |
| ]).stdout; | |
| } | |
| function buildContainer(containerName, devcontainerPath, config) { | |
| const buildArgs = ["build", "-t", containerName.toLowerCase()]; | |
| const dockerfile = | |
| config.build?.dockerfile || config.dockerFile || "Dockerfile"; | |
| buildArgs.push("-f", path.join(devcontainerPath, dockerfile)); | |
| buildArgs.push(config.build?.context || devcontainerPath); | |
| const res = run("docker", buildArgs); | |
| if (!res.success) throw new Error(`Build failed: ${res.stderr}`); | |
| } | |
| function startExistingContainer(containerName, log) { | |
| log("Starting existing container..."); | |
| const res = run("docker", ["start", containerName]); | |
| if (!res.success) throw new Error(`Start failed: ${res.stderr}`); | |
| } | |
| function runNewContainer({ containerName, config, workspacePath, log }) { | |
| log("Running new container..."); | |
| const runArgs = ["run", "-d", "--name", containerName]; | |
| if (config.workspaceMount) { | |
| const mountParts = Object.fromEntries( | |
| config.workspaceMount.split(",").map((p) => p.split("=")) | |
| ); | |
| const source = mountParts.source?.replace( | |
| "${localWorkspaceFolder}", | |
| workspacePath | |
| ); | |
| const target = mountParts.target; | |
| if (source && target) { | |
| runArgs.push("-v", `${source}:${target}`); | |
| } else { | |
| log("Warning: could not parse workspaceMount string, using default."); | |
| runArgs.push("-v", `${workspacePath}:${config.workspaceFolder || "/workspace"}`); | |
| } | |
| } else { | |
| runArgs.push("-v", `${workspacePath}:${config.workspaceFolder || "/workspace"}`); | |
| } | |
| runArgs.push(containerName.toLowerCase(), "sleep", "infinity"); | |
| const res = run("docker", runArgs); | |
| if (!res.success) throw new Error(`Run failed: ${res.stderr}`); | |
| } | |
| function startContainer({ containerName, devcontainerPath, config, workspacePath, log }) { | |
| const status = getContainerStatus(containerName); | |
| if (!status) { | |
| if (config.image) { | |
| runNewContainer({ containerName, config, workspacePath, log }); | |
| } else { | |
| buildContainer(containerName, devcontainerPath, config); | |
| runNewContainer({ containerName, config, workspacePath, log }); | |
| } | |
| return; | |
| } | |
| if (status.startsWith("Up")) { | |
| log("Container already running"); | |
| return; | |
| } | |
| startExistingContainer(containerName, log); | |
| } | |
| function createDevContainerUri(workspacePath, config) { | |
| // CURSOR DEV CONTAINER URI CONSTRUCTION | |
| // This is the critical part that differs from VS Code documentation. | |
| // Cursor expects a very specific URI format for programmatic dev container opening. | |
| const dockerContext = detectPlatform(); | |
| const workspaceFolder = config.workspaceFolder || "/workspace"; | |
| const uriConfig = { | |
| hostPath: workspacePath, | |
| localDocker: true, | |
| settings: { | |
| context: dockerContext, // Platform-specific Docker context | |
| }, | |
| }; | |
| // Convert JSON to hex encoding as expected by Cursor's dev container extension | |
| // This matches the Buffer.from(s,"utf8").toString("hex") pattern from the extension source | |
| const hex = Buffer.from(JSON.stringify(uriConfig), "utf8").toString("hex"); | |
| return `vscode-remote://dev-container+${hex}${workspaceFolder}`; | |
| } | |
| function openCursor(uri) { | |
| // Launch Cursor with the properly constructed dev container URI | |
| // Format: vscode-remote://dev-container+<hex-encoded-config><workspace-path> | |
| // This bypasses the need for manual "Reopen in Container" command palette action | |
| const res = run("cursor", ["--folder-uri", uri]); | |
| if (!res.success) throw new Error(`Cursor launch failed: ${res.stderr}`); | |
| } | |
| /** | |
| * Finds parent PIDs of Cursor processes on Unix-like systems. | |
| */ | |
| function getCursorPidsForUnix(workspace) { | |
| const parentPids = new Set(); | |
| const lsof = spawnSync("lsof", ["-Fpn"], { encoding: "utf8" }); | |
| if (lsof.status !== 0) return ""; | |
| let currentPid = ""; | |
| for (const line of lsof.stdout.split("\n")) { | |
| if (line.startsWith("p")) currentPid = line.slice(1); | |
| else if (line.startsWith("n") && line.includes(workspace) && currentPid) { | |
| const cmd = spawnSync("ps", ["-o", "comm=", "-p", currentPid], { encoding: "utf8" }); | |
| if (cmd.status === 0 && cmd.stdout.trim().toLowerCase().includes("cursor")) { | |
| const ppid = spawnSync("ps", ["-o", "ppid=", "-p", currentPid], { encoding: "utf8" }); | |
| if (ppid.status === 0) parentPids.add(ppid.stdout.trim()); | |
| } | |
| } | |
| } | |
| return [...parentPids].join(","); | |
| } | |
| /** | |
| * Finds parent PIDs of Cursor processes on Windows. | |
| */ | |
| function getCursorPidsForWindows(workspace) { | |
| const parentPids = new Set(); | |
| const tasklist = spawnSync("tasklist", ["/FO", "CSV", "/V"], { encoding: "utf8" }); | |
| if (tasklist.status !== 0) return ""; | |
| for (const line of tasklist.stdout.split("\n").slice(1)) { | |
| if (line.toLowerCase().includes("cursor") && line.includes(workspace)) { | |
| const m = line.match(/"(\d+)"/g); | |
| if (m && m.length >= 2) parentPids.add(m[1].replace(/"/g, "")); | |
| } | |
| } | |
| return [...parentPids].join(","); | |
| } | |
| /** | |
| * Finds all parent PIDs of cursor processes that have files open | |
| * in the specified workspace path | |
| */ | |
| function getCursorPidsForWorkspace(workspace) { | |
| return process.platform === "win32" | |
| ? getCursorPidsForWindows(workspace) | |
| : getCursorPidsForUnix(workspace); | |
| } | |
| function main() { | |
| const args = parseArgs(process.argv); | |
| if (args.help) { | |
| showHelp(); | |
| return; | |
| } | |
| const openCursorPids = args.reload | |
| ? getCursorPidsForWorkspace(args.workspace).split(",").filter(Boolean) | |
| : []; | |
| const log = createLogger(args.verbose); | |
| try { | |
| const devcontainerConfigPath = path.join( | |
| args.workspace, | |
| args.devcontainerPath, | |
| "devcontainer.json" | |
| ); | |
| const config = readDevContainerConfig(devcontainerConfigPath); | |
| const containerName = generateContainerName( | |
| config, | |
| args.workspace, | |
| args.containerName | |
| ); | |
| log(`Using workspace: ${args.workspace}`); | |
| log(`Using container: ${containerName}`); | |
| log(`Platform detected: ${detectPlatform()}`); | |
| startContainer({ | |
| containerName, | |
| devcontainerPath: path.join(args.workspace, args.devcontainerPath), | |
| config, | |
| workspacePath: args.workspace, | |
| log, | |
| }); | |
| log("Creating dev container URI..."); | |
| const uri = createDevContainerUri(args.workspace, config); | |
| log("Opening Cursor..."); | |
| openCursor(uri); | |
| console.log("Success! Cursor is running in dev container"); | |
| if (args.reload) { | |
| openCursorPids.forEach((pid) => { | |
| try { | |
| process.kill(Number(pid), "SIGKILL"); | |
| } catch { | |
| /* ignore */ | |
| } | |
| }); | |
| } | |
| } catch (error) { | |
| console.error("Error:", error instanceof Error ? error.message : String(error)); | |
| console.log("Fallback: Use 'Dev Containers: Reopen in Container' in Cursor"); | |
| process.exit(1); | |
| } | |
| } | |
| if (require.main === module) { | |
| main(); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment