From 89b49927a9cc2edac6abb0f4af122f28b3c4fe86 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Tue, 8 Sep 2026 12:24:15 +0000 Subject: [PATCH 1/4] feat: add configurable ports and devbox exec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 45 ++++++-- package.json | 2 +- src/cli.ts | 215 ++++++++++++++++++++++++++--------- src/constants.ts | 2 +- src/core.ts | 220 +++++++++++++++++++++++++++++++++--- src/runtime.ts | 70 ++++++++++-- src/status.ts | 82 ++++++++++---- tests/core.test.ts | 104 ++++++++++++++++- tests/examples.live.test.ts | 94 +++++++++++++++ tests/examples.test.ts | 128 +++++++++++++++++++-- tests/runtime.test.ts | 37 ++++++ tests/status.test.ts | 72 ++++++++++++ 12 files changed, 943 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 1463c10..1dca413 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # devbox -`devbox` is a CLI that turns the devcontainer definition in the current directory into a repeatable "start my workspace and expose an SSH entrypoint" workflow. +`devbox` is a CLI that turns the devcontainer definition in the current directory into a repeatable "start my workspace and expose one or more service ports" workflow, with an optional bundled SSH entrypoint. It does not modify the original `devcontainer.json`. Instead, it generates a derived config next to it, ignores that file locally when possible, and manages the resulting container with stable labels. @@ -9,13 +9,13 @@ It does not modify the original `devcontainer.json`. Instead, it generates a der - Discovers `.devcontainer/devcontainer.json` or `.devcontainer.json` in the current directory, can target `.devcontainer//devcontainer.json` with a flag, and falls back to the built-in `ubuntu` template when no repo devcontainer is present. - Reuses or creates the devcontainer with Docker + Dev Container CLI. - Names the managed container as `devbox--`. -- Publishes the same TCP port on host and container. +- Publishes one or more TCP ports using the same host and container port numbers. - Mounts the current directory into the container as the workspace. - Shares a usable SSH agent socket with the container and copies a validated, non-empty `known_hosts` snapshot into the container. -- Exposes the SSH service on the chosen host port and, when a host public key is available, installs it for key-based SSH login inside the devcontainer. +- When enabled, exposes the bundled SSH service on the first published port and, when a host public key is available, installs it for key-based SSH login inside the devcontainer. - Seeds the container user's global Git `user.name` and `user.email` from the host when available. - Injects `GH_TOKEN` from the GitHub CLI when available, optionally using a persisted per-workspace `gh` account. -- Runs devbox's bundled SSH server setup script inside the devcontainer. +- Runs devbox's bundled SSH server setup script inside the devcontainer unless SSH is disabled. - Stores devbox-owned state, SSH credentials, SSH metadata, and SSH host keys under the workspace-local `.devbox/` directory so they survive `down` / `rebuild`. ## Installation @@ -54,13 +54,22 @@ Use `devbox update --check` to check for a newer release without installing it, # Show CLI help devbox -# Start or reuse the devcontainer on a chosen port +# Start or reuse the devcontainer on a chosen port (bundled SSH is enabled by default) devbox up -# Start or reuse the devcontainer, reusing the stored workspace port when available +# Start or reuse the devcontainer, reusing stored workspace ports when available # or auto-assigning the first free port from 5001 when none has been stored yet devbox up +# Publish three ports, using the first one for bundled SSH +devbox up --ports 3 + +# Publish three ports without installing or starting bundled SSH +devbox up --ports 3 --no-ssh + +# Re-enable bundled SSH for a workspace previously started with --no-ssh +devbox up --ssh + # Continue even if SSH agent sharing is unavailable devbox up --allow-missing-ssh @@ -91,6 +100,9 @@ devbox rebuild # Open an interactive shell in the running managed devcontainer for this workspace devbox shell +# Run a non-interactive command in the running managed devcontainer +devbox exec -- npm test + # Print machine-readable JSON describing the managed devbox for this workspace devbox status @@ -103,11 +115,13 @@ devbox down When you run `devbox up`, the port precedence is: -1. the explicit port you passed, -2. the last stored port for the current workspace, or +1. the explicit port you passed as the first port, +2. the last stored port list for the current workspace, or 3. the first free port starting at `5001`. -When you run `devbox rebuild`, omitting the port reuses the last stored port for the current workspace. +Use `--ports ` to request how many ports should be published. If the first port is explicit, later ports are auto-assigned from the next port, skipping ports already in use. When SSH is enabled, only the first port is used by the bundled SSH server; the remaining ports are available for services in the devcontainer. `--no-ssh` disables only the bundled SSH server, while SSH agent sharing and the other Git/known-hosts integrations remain unchanged. `--ssh` re-enables the bundled server. + +When you run `devbox rebuild`, omitting the port reuses the last stored port list for the current workspace. If no repo devcontainer is found and no previous template source is stored, `devbox up` automatically starts from the built-in `ubuntu` template. `devbox rebuild ` does the same when there is enough information to create the devbox but no prior workspace state exists. Devbox prints a message when this automatic fallback is used. @@ -117,6 +131,8 @@ GitHub CLI authentication for `GH_TOKEN` injection can be pinned per workspace w `devbox shell` requires an already running managed container for the current workspace. If none is running, use `devbox up` first. +`devbox exec -- [args...]` runs a non-interactive command in the already running managed container for the current workspace. Everything after the `--` separator is forwarded unchanged, and the command's standard input/output/error and exit code are preserved, which makes it suitable for scripts and automation. If no managed container is running, run `devbox up` first. + `devbox status` always prints JSON so it can be used directly from scripts and automation. `devbox templates` always prints JSON. Each entry includes the template name, description, pinned image/reference, runtime version, language tags, and whether the template is compatible with the bundled devbox SSH runner. @@ -139,6 +155,8 @@ Example: { "running": true, "port": 5001, + "ports": [5001, 5002], + "sshEnabled": true, "password": "password", "workdir": "/workspaces/my-project", "workspacePath": "/host/path/to/my-project", @@ -219,15 +237,18 @@ The complex example uses several devcontainer features, so the first `up` or `re - `--template ` explicitly chooses a built-in template, even if the repo already has a devcontainer definition. If no repo devcontainer exists and no template was previously saved, omitting `--template` falls back to `ubuntu`. - `--gh-user ` and `--gh-host ` select the GitHub CLI account used for `GH_TOKEN` injection without changing the globally active `gh` account. - `devbox shell` opens an interactive shell inside the running managed container for the current workspace. +- `devbox exec -- [args...]` runs an automation command inside the running managed container and forwards its exit code. - `devbox status` reports live container state when available and falls back to saved workspace state in `.devbox/state.json` plus the persisted `.devbox/ssh/credentials` password file and `.devbox/ssh/metadata.json` metadata when the container is stopped or Docker is unavailable. - `devbox arise` only attempts workspaces it can recover from stopped managed containers and that still have at least one persisted devbox leftover, such as saved state, `.devbox/ssh/credentials`, `.devbox/ssh/metadata.json`, or `.devbox/ssh/host-keys/`. - For workspaces that pass the restart-readiness checks and are actually attempted, if there is more than one stopped managed container, `devbox arise` keeps the newest stopped container as the source of truth, removes the older stopped duplicates, and then reruns `devbox up`. Skipped or unrecoverable workspaces may retain older stopped duplicates. -- `devbox up` prints the chosen port near the start of execution, before the longer devcontainer setup steps. -- `down` removes managed containers but keeps `.devbox/`, so rebuilds can reuse the last selected port/config source/template and SSH artifacts. -- Re-running `devbox up` after a host restart recreates the desired state: container up, port published, SSH runner started again. +- `devbox up` prints all selected ports near the start of execution, before the longer devcontainer setup steps. +- `down` removes managed containers but keeps `.devbox/`, so rebuilds can reuse the last selected ports/config source/template and SSH artifacts. +- Re-running `devbox up` after a host restart recreates the desired state: container up, all configured ports published, and the SSH runner restarted when it is enabled. - When Docker Desktop host services are available, `devbox` can share the SSH agent without relying on a host-shell `SSH_AUTH_SOCK`. - On Docker Desktop, `devbox` prefers the Docker-provided SSH agent socket over the host `SSH_AUTH_SOCK`, which avoids macOS launchd socket mount issues. - `--allow-missing-ssh` starts the workspace without mounting an SSH agent and prints a warning instead of failing. +- `--no-ssh` skips installation and startup of the bundled SSH server but still shares the SSH agent and configures the other host integrations. +- `--ssh` explicitly enables the bundled SSH server for a workspace whose saved state has SSH disabled. - `devbox` stages a snapshot of the host `~/.ssh/known_hosts` before startup and skips injection with a warning when that file is missing, unreadable, empty, symlinked, or not a regular file. - `devbox` tries to install the host public key from `~/.ssh/id_rsa.pub` for SSH key-based login inside the container; if that default file is missing, it simply skips that step. - `--ssh-public-key /path/to/key.pub` overrides the default public key source. The override is validated and must point to a readable SSH public key file. diff --git a/package.json b/package.json index 24e8907..93ae14b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.0.0-development", "private": true, "type": "module", - "description": "CLI to run and expose a devcontainer with SSH agent sharing and a bundled SSH server.", + "description": "CLI to run and expose a devcontainer with SSH agent sharing and an optional bundled SSH server.", "repository": { "type": "git", "url": "git+https://github.com/PabloZaiden/devbox.git" diff --git a/src/cli.ts b/src/cli.ts index aa5837f..47c2b97 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,8 @@ import { getManagedContainerName, getManagedPortFromContainerName, getManagedLabels, + getWorkspacePorts, + getWorkspaceSshEnabled, prepareKnownHostsMount, parseGithubHost, parseGithubUser, @@ -23,7 +25,7 @@ import { resolveWorkspaceConfig, removeGeneratedConfig, resolvePort, - resolveUpPortPreference, + resolveUpPortsPreference, saveWorkspaceState, type DockerInspect, type GithubAuthPreference, @@ -32,7 +34,7 @@ import { } from "./core"; import { assertConfiguredSshAuthSockAvailable, - assertPortAvailable, + assertPortsAvailable, configureGitIdentity, configureAuthorizedKeys, copyKnownHosts, @@ -42,7 +44,7 @@ import { ensureGeneratedConfigIgnored, ensureHostEnvironment, ensurePathIgnored, - findFirstAvailablePort, + findAvailablePorts, formatCommandError, isExecutableAvailable, inspectContainers, @@ -56,6 +58,7 @@ import { requiresSshAuthSockPermissionFix, removeContainers, restoreRunnerHostKeys, + runDevcontainerCommand, startRunner, stopManagedSshd, } from "./runtime"; @@ -106,6 +109,11 @@ async function main(): Promise { return; } + if (parsed.command === "exec") { + await handleExec(workspacePath, state, parsed.execArgs ?? []); + return; + } + if (parsed.command === "status") { await handleStatus(workspacePath, state); return; @@ -121,6 +129,8 @@ async function main(): Promise { workspacePath, state, parsed.port, + parsed.portCount, + parsed.sshEnabled, parsed.allowMissingSsh, parsed.devcontainerSubpath, parsed.sshPublicKeyPath, @@ -135,6 +145,8 @@ async function handleUpLike( workspacePath: string, state: Awaited>, explicitPort: number | undefined, + explicitPortCount: number | undefined, + explicitSshEnabled: boolean | undefined, allowMissingSsh: boolean, devcontainerSubpath: string | undefined, sshPublicKeyPath?: string, @@ -148,8 +160,11 @@ async function handleUpLike( state, env: process.env, }); + const sshEnabled = explicitSshEnabled ?? getWorkspaceSshEnabled(state); const environment = await ensureHostEnvironment({ allowMissingSsh, workspacePath, githubAuth }); - const resolvedSshPublicKey = await resolveSshPublicKey({ overridePath: sshPublicKeyPath }); + const resolvedSshPublicKey = sshEnabled + ? await resolveSshPublicKey({ overridePath: sshPublicKeyPath }) + : { publicKey: null, sourcePath: null, source: null }; const workspaceHash = hashWorkspacePath(workspacePath); const labels = getManagedLabels(workspaceHash); const existingContainerIds = await listManagedContainers(labels); @@ -162,16 +177,28 @@ async function handleUpLike( existingInspects = await inspectContainers(existingContainerIds); } - const port = + const preferredPorts = command === "up" - ? (resolveUpPortPreference({ + ? resolveUpPortsPreference({ explicitPort, + portCount: explicitPortCount, state, existingPublishedPort: getManagedPortFromContainerName(existingInspects[0]?.Name), - }) ?? (await findFirstAvailablePort(DEFAULT_UP_AUTO_PORT_START))) - : resolvePort(command, explicitPort, state); + }) + : explicitPort !== undefined + ? [explicitPort] + : state + ? getWorkspacePorts(state) + : (resolvePort(command, explicitPort, state), undefined); + const requestedPortCount = explicitPortCount ?? preferredPorts?.length ?? 1; + const ports = await resolveRequestedPorts({ + preferredPorts, + requestedPortCount, + }); - console.log(`Using port ${port}. ${command === "up" ? describeUpPortStrategy() : ""}`.trim()); + console.log( + `Using ${ports.length === 1 ? "port" : "ports"} ${ports.join(", ")}. ${command === "up" ? describeUpPortStrategy() : ""}`.trim(), + ); const resolvedConfig = await resolveWorkspaceConfig({ workspacePath, devcontainerSubpath, @@ -185,10 +212,10 @@ async function handleUpLike( const generatedConfigPath = resolvedConfig.generatedConfigPath; const userDataDir = getWorkspaceUserDataDir(workspacePath); const preparedKnownHosts = await prepareKnownHostsMount({ userDataDir }); - const containerName = getManagedContainerName(workspacePath, port); + const containerName = getManagedContainerName(workspacePath, ports[0]); const managedConfig = buildManagedConfig(resolvedConfig.config, { - port, + ports, containerName, sshAuthSock: environment.sshAuthSock, knownHostsPath: preparedKnownHosts.knownHostsPath, @@ -241,17 +268,20 @@ async function handleUpLike( existingInspects = []; } else if (existingInspects[0]) { const publishedPorts = getPublishedHostPorts(existingInspects[0]); - if (publishedPorts.length > 0 && !publishedPorts.includes(port)) { + const missingPorts = ports.filter((port) => !publishedPorts.includes(port)); + if (publishedPorts.length > 0 && missingPorts.length > 0) { throw new UserError( - `This workspace already has a managed container publishing port(s) ${publishedPorts.join(", ")}. Use \`devbox rebuild ${port}\` to change the port.`, + `This workspace already has a managed container publishing port(s) ${publishedPorts.join(", ")}. Use \`devbox rebuild ${ports[0]}\` to change the port list.`, ); } } - const allowCurrentPort = existingInspects.some( - (container) => container.State?.Running && getPublishedHostPorts(container).includes(port), + const managedContainerPorts = new Set( + existingInspects.flatMap((container) => + container.State?.Running ? getPublishedHostPorts(container) : [], + ), ); - await assertPortAvailable(port, allowCurrentPort); + await assertPortsAvailable(ports, managedContainerPorts); const sshMountCompatibility = existingInspects[0] ? await ensureManagedContainerSshMountCompatibility(existingInspects[0], environment.sshAuthSock) @@ -262,7 +292,7 @@ async function handleUpLike( console.log("Updated the stale host SSH agent mount symlink to point at the current SSH_AUTH_SOCK."); } - console.log(`Starting workspace on port ${port}...`); + console.log(`Starting workspace on ${ports.length === 1 ? "port" : "ports"} ${ports.join(", ")}...`); const upResult = await runStepWithHeartbeat({ startMessage: "Preparing devcontainer. First builds with features may take several minutes...", heartbeatMessage: "Still preparing devcontainer", @@ -278,8 +308,11 @@ async function handleUpLike( }); const remoteWorkspaceFolder = upResult.remoteWorkspaceFolder ?? getDefaultRemoteWorkspaceFolder(workspacePath); - console.log("Configuring SSH access inside the devcontainer..."); - const runnerMetadataPath = getWorkspaceSshMetadataFile(workspacePath); + console.log( + sshEnabled + ? "Configuring SSH access inside the devcontainer..." + : "Configuring devcontainer access without installing the bundled SSH server...", + ); if (requiresSshAuthSockPermissionFix(environment.sshAuthSock)) { console.log("Making the forwarded SSH agent socket accessible to the container user..."); await ensureSshAuthSockAccessible(upResult.containerId, environment.sshAuthSock); @@ -295,45 +328,63 @@ async function handleUpLike( console.log("Syncing Git author identity from the host into the devcontainer..."); await configureGitIdentity(upResult.containerId, environment.gitUserName, environment.gitUserEmail); } - await stopManagedSshd(upResult.containerId, port); - await restoreRunnerHostKeys(upResult.containerId, remoteWorkspaceFolder); - const runnerCredentials = await runStepWithHeartbeat({ - startMessage: "Installing and starting the SSH server inside the container (first run can take a bit)...", - heartbeatMessage: "Still installing and starting the SSH server", - successMessage: "SSH server is ready", - action: () => startRunner(upResult.containerId, port, remoteWorkspaceFolder), - }); - if (resolvedSshPublicKey.publicKey) { - const sshUser = runnerCredentials.user ?? upResult.remoteUser; - if (!sshUser) { - throw new UserError( - "SSH public key auth was requested, but devbox could not determine which container user should receive authorized_keys.", - ); + if (sshEnabled) { + await stopManagedSshd(upResult.containerId, ports[0]); + await restoreRunnerHostKeys(upResult.containerId, remoteWorkspaceFolder); + const runnerCredentials = await runStepWithHeartbeat({ + startMessage: "Installing and starting the SSH server inside the container (first run can take a bit)...", + heartbeatMessage: "Still installing and starting the SSH server", + successMessage: "SSH server is ready", + action: () => startRunner(upResult.containerId, ports[0], remoteWorkspaceFolder), + }); + if (resolvedSshPublicKey.publicKey) { + const sshUser = runnerCredentials.user ?? upResult.remoteUser; + if (!sshUser) { + throw new UserError( + "SSH public key auth was requested, but devbox could not determine which container user should receive authorized_keys.", + ); + } + console.log("Installing SSH public key for key-based login..."); + await configureAuthorizedKeys(upResult.containerId, sshUser, resolvedSshPublicKey.publicKey); } - console.log("Installing SSH public key for key-based login..."); - await configureAuthorizedKeys(upResult.containerId, sshUser, resolvedSshPublicKey.publicKey); - } - await mkdir(path.dirname(runnerMetadataPath), { recursive: true }); - await writeFile( - runnerMetadataPath, - serializeRunnerMetadata( - createRunnerMetadata({ - sshUser: runnerCredentials.user, - sshPort: runnerCredentials.sshPort ?? port, - permitRootLogin: runnerCredentials.permitRootLogin, - publicKeyConfigured: resolvedSshPublicKey.publicKey !== null, - publicKeySource: resolvedSshPublicKey.sourcePath, - }), - ), - "utf8", - ); - console.log("Saving SSH server state for future runs..."); - await persistRunnerHostKeys(upResult.containerId, remoteWorkspaceFolder); + const runnerMetadataPath = getWorkspaceSshMetadataFile(workspacePath); + await mkdir(path.dirname(runnerMetadataPath), { recursive: true }); + await writeFile( + runnerMetadataPath, + serializeRunnerMetadata( + createRunnerMetadata({ + sshUser: runnerCredentials.user, + sshPort: runnerCredentials.sshPort ?? ports[0], + permitRootLogin: runnerCredentials.permitRootLogin, + publicKeyConfigured: resolvedSshPublicKey.publicKey !== null, + publicKeySource: resolvedSshPublicKey.sourcePath, + }), + ), + "utf8", + ); + console.log("Saving SSH server state for future runs..."); + await persistRunnerHostKeys(upResult.containerId, remoteWorkspaceFolder); + } else { + if (existingInspects.length > 0) { + const previousPorts = new Set([ + ...getWorkspacePorts(state), + ...(getManagedPortFromContainerName(existingInspects[0]?.Name) !== undefined + ? [getManagedPortFromContainerName(existingInspects[0]?.Name)!] + : []), + ]); + for (const previousPort of previousPorts) { + await stopManagedSshd(upResult.containerId, previousPort); + } + } + console.log("Bundled SSH server installation skipped; published ports are ready for the devcontainer service."); + } await saveWorkspaceState( createWorkspaceState({ workspacePath, - port, + port: ports[0], + ports, + sshEnabled, configSource: resolvedConfig.configSource, sourceConfigPath: resolvedConfig.sourceConfigPath, generatedConfigPath, @@ -345,7 +396,7 @@ async function handleUpLike( }), ); - console.log(formatReadyMessage(upResult.containerId, port, remoteWorkspaceFolder)); + console.log(formatReadyMessage(upResult.containerId, ports, remoteWorkspaceFolder)); if (!preparedKnownHosts.knownHostsPath || knownHostsCopyResult !== "copied") { console.log("Host known_hosts was unavailable for injection, so only SSH agent sharing was configured."); } @@ -376,6 +427,30 @@ async function handleShell( process.exitCode = await openInteractiveShell(containerId); } +async function handleExec( + workspacePath: string, + state: Awaited>, + commandArgs: string[], +): Promise { + if (!isExecutableAvailable("docker")) { + throw new UserError("Docker is required but was not found in PATH."); + } + + if (!isExecutableAvailable("devcontainer")) { + throw new UserError("Dev Container CLI is required but was not found in PATH."); + } + + const labels = labelsForWorkspaceHash(hashWorkspacePath(workspacePath)); + const containerIds = await listManagedContainers(labels); + const containers = await inspectContainers(containerIds); + const containerId = resolveShellContainerId({ + containers, + preferredContainerId: state?.lastContainerId, + }); + + process.exitCode = await runDevcontainerCommand(containerId, commandArgs); +} + async function handleDown( workspacePath: string, state: Awaited>, @@ -457,7 +532,16 @@ async function handleArise(): Promise { loadWorkspaceState, removeContainers, restartWorkspace: async (input) => { - await handleUpLike("up", input.workspacePath, input.state, input.explicitPort, false, input.devcontainerSubpath); + await handleUpLike( + "up", + input.workspacePath, + input.state, + input.explicitPort, + undefined, + undefined, + false, + input.devcontainerSubpath, + ); }, log: (message) => console.log(message), formatError: formatAriseError, @@ -515,6 +599,27 @@ function getPublishedHostPorts(container: DockerInspect): number[] { return [...values]; } +async function resolveRequestedPorts(input: { + preferredPorts: number[] | undefined; + requestedPortCount: number; +}): Promise { + const preferredPorts = input.preferredPorts ?? []; + if (preferredPorts.length >= input.requestedPortCount) { + return preferredPorts.slice(0, input.requestedPortCount); + } + + if (preferredPorts.length > 0) { + const lastPreferredPort = Math.max(...preferredPorts); + const additionalPorts = await findAvailablePorts( + lastPreferredPort + 1, + input.requestedPortCount - preferredPorts.length, + ); + return [...preferredPorts, ...additionalPorts]; + } + + return findAvailablePorts(DEFAULT_UP_AUTO_PORT_START, input.requestedPortCount); +} + async function runStepWithHeartbeat(input: { startMessage: string; heartbeatMessage: string; diff --git a/src/constants.ts b/src/constants.ts index bd00724..a434b14 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,4 +11,4 @@ export const DEVBOX_SSH_DIRNAME = "ssh"; export const RUNNER_CRED_FILENAME = "credentials"; export const DEVBOX_SSH_METADATA_FILENAME = "metadata.json"; export const RUNNER_HOST_KEYS_DIRNAME = "host-keys"; -export const STATE_VERSION = 2; +export const STATE_VERSION = 3; diff --git a/src/core.ts b/src/core.ts index 67f73fa..5c241b2 100644 --- a/src/core.ts +++ b/src/core.ts @@ -23,17 +23,30 @@ import { import { getTemplateDefinition } from "./templates"; import { DEVBOX_VERSION } from "./version"; -export type CommandName = "up" | "down" | "rebuild" | "shell" | "status" | "arise" | "templates" | "update" | "help"; +export type CommandName = + | "up" + | "down" + | "rebuild" + | "shell" + | "exec" + | "status" + | "arise" + | "templates" + | "update" + | "help"; export interface ParsedArgs { command: CommandName; port?: number; + portCount?: number; allowMissingSsh: boolean; + sshEnabled?: boolean; devcontainerSubpath?: string; sshPublicKeyPath?: string; templateName?: string; githubUser?: string; githubHost?: string; + execArgs?: string[]; checkOnly?: boolean; version?: string; } @@ -46,7 +59,8 @@ export interface DiscoveredConfig { } export interface ManagedConfigOptions { - port: number; + ports?: number[]; + port?: number; containerName: string; sshAuthSock: string | null; knownHostsPath: string | null; @@ -64,6 +78,8 @@ export interface WorkspaceState { workspacePath: string; workspaceHash: string; port: number; + ports?: number[]; + sshEnabled?: boolean; configSource: "repo" | "template"; sourceConfigPath: string | null; generatedConfigPath: string; @@ -140,13 +156,14 @@ export class UserError extends Error { export function helpText(): string { return [ - `${CLI_NAME} v${DEVBOX_VERSION} - manage a devcontainer plus a bundled SSH server`, + `${CLI_NAME} v${DEVBOX_VERSION} - manage a devcontainer with optional bundled SSH`, "", "Usage:", ` ${CLI_NAME}`, - ` ${CLI_NAME} up [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ] [--template ] [--gh-user ] [--gh-host ]`, - ` ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ] [--gh-user ] [--gh-host ]`, + ` ${CLI_NAME} up [port] [--ports ] [--allow-missing-ssh] [--no-ssh|--ssh] [--devcontainer-subpath ] [--ssh-public-key ] [--template ] [--gh-user ] [--gh-host ]`, + ` ${CLI_NAME} rebuild [port] [--ports ] [--allow-missing-ssh] [--no-ssh|--ssh] [--devcontainer-subpath ] [--ssh-public-key ] [--gh-user ] [--gh-host ]`, ` ${CLI_NAME} shell`, + ` ${CLI_NAME} exec -- [args...]`, ` ${CLI_NAME} status`, ` ${CLI_NAME} templates`, ` ${CLI_NAME} arise`, @@ -159,6 +176,7 @@ export function helpText(): string { " up Start or reuse the managed devcontainer; falls back to the ubuntu template when none is found.", " rebuild Recreate the managed devcontainer; falls back to the ubuntu template when no repo devcontainer or prior state exists.", " shell Open an interactive shell in the running managed container.", + " exec Run a non-interactive command in the running managed container.", " status Print JSON describing the managed devbox for this workspace.", " templates Print JSON describing the built-in templates.", " arise Restart stopped managed workspaces discovered from existing containers.", @@ -168,7 +186,10 @@ export function helpText(): string { "", "Options:", " -p, --port Publish the same port on host and container.", + " --ports Publish this many ports, auto-selecting later ports when needed.", " --allow-missing-ssh Continue without SSH agent sharing when unavailable.", + " --no-ssh Do not install or start devbox's bundled SSH server.", + " --ssh Install and start devbox's bundled SSH server.", " --devcontainer-subpath Use .devcontainer//devcontainer.json.", " --ssh-public-key Use a specific SSH public key file instead of ~/.ssh/id_rsa.pub.", " --template Use a built-in template instead of a repo devcontainer.", @@ -193,6 +214,7 @@ export function parseArgs(argv: string[]): ParsedArgs { first === "down" || first === "rebuild" || first === "shell" || + first === "exec" || first === "status" || first === "arise" || first === "templates" || @@ -208,8 +230,36 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError(`A command is required. Run \`${CLI_NAME} --help\` for usage.`); } + if (command === "exec") { + if (args[0] === "--help" || args[0] === "-h") { + return { command: "help", allowMissingSsh: false }; + } + + if (args.length === 0) { + throw new UserError(`The exec command requires a command. Usage: \`${CLI_NAME} exec -- [args...]\``); + } + + const separatorIndex = args.indexOf("--"); + if (separatorIndex === -1) { + throw new UserError(`The exec command requires \`--\` before the command. Usage: \`${CLI_NAME} exec -- [args...]\``); + } + + const execArgs = args.slice(separatorIndex + 1); + if (execArgs.length === 0) { + throw new UserError(`The exec command requires a command. Usage: \`${CLI_NAME} exec -- [args...]\``); + } + + return { + command, + allowMissingSsh: false, + execArgs, + }; + } + let port: number | undefined; + let portCount: number | undefined; let allowMissingSsh = false; + let sshEnabled: boolean | undefined; let devcontainerSubpath: string | undefined; let sshPublicKeyPath: string | undefined; let templateName: string | undefined; @@ -231,6 +281,30 @@ export function parseArgs(argv: string[]): ParsedArgs { continue; } + if (arg === "--no-ssh" || arg === "--ssh") { + const nextSshEnabled = arg === "--ssh"; + if (sshEnabled !== undefined && sshEnabled !== nextSshEnabled) { + throw new UserError("Cannot combine --ssh with --no-ssh."); + } + sshEnabled = nextSshEnabled; + continue; + } + + if (arg === "--ports") { + const value = args[index + 1]; + if (!value) { + throw new UserError("Expected a value after --ports."); + } + portCount = parsePortCount(value); + index += 1; + continue; + } + + if (arg.startsWith("--ports=")) { + portCount = parsePortCount(arg.slice("--ports=".length)); + continue; + } + if (arg === "--check") { checkOnly = true; continue; @@ -404,6 +478,14 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError("The update command does not accept a port."); } + if (command !== "up" && command !== "rebuild" && portCount !== undefined) { + throw new UserError(`The ${command} command does not accept --ports.`); + } + + if (command !== "up" && command !== "rebuild" && sshEnabled !== undefined) { + throw new UserError(`The ${command} command does not accept ${sshEnabled ? "--ssh" : "--no-ssh"}.`); + } + if (command === "shell" && devcontainerSubpath !== undefined) { throw new UserError("The shell command does not accept --devcontainer-subpath."); } @@ -512,6 +594,10 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError(`The ${command} command does not accept --version.`); } + if (sshEnabled === false && sshPublicKeyPath !== undefined) { + throw new UserError("--ssh-public-key cannot be combined with --no-ssh."); + } + if (command === "update" && checkOnly && version !== undefined) { throw new UserError("Cannot combine --check with --version."); } @@ -519,7 +605,9 @@ export function parseArgs(argv: string[]): ParsedArgs { return { command, port, + ...(portCount !== undefined ? { portCount } : {}), allowMissingSsh, + ...(sshEnabled !== undefined ? { sshEnabled } : {}), ...(devcontainerSubpath ? { devcontainerSubpath } : {}), ...(sshPublicKeyPath ? { sshPublicKeyPath } : {}), ...(templateName ? { templateName } : {}), @@ -543,6 +631,19 @@ export function parsePort(raw: string): number { return port; } +export function parsePortCount(raw: string): number { + if (!/^\d+$/.test(raw)) { + throw new UserError(`Invalid port count: ${raw}`); + } + + const count = Number(raw); + if (!Number.isInteger(count) || count < 1 || count > 65535) { + throw new UserError(`Port count must be between 1 and 65535. Received: ${raw}`); + } + + return count; +} + export function parseGithubUser(raw: string): string { const value = raw.trim(); if (!value || /\s/.test(value)) { @@ -626,8 +727,14 @@ export function getDefaultRemoteWorkspaceFolder(workspacePath: string): string { return path.posix.join("/workspaces", path.basename(workspacePath)); } -export function formatReadyMessage(containerId: string, port: number, remoteWorkspaceFolder: string): string { - return `\nReady. ${containerId.slice(0, 12)} is available on port ${port}.\nProject root inside the container: ${remoteWorkspaceFolder}`; +export function formatReadyMessage( + containerId: string, + ports: number | number[], + remoteWorkspaceFolder: string, +): string { + const normalizedPorts = typeof ports === "number" ? [ports] : ports; + const portLabel = normalizedPorts.length === 1 ? "port" : "ports"; + return `\nReady. ${containerId.slice(0, 12)} is available on ${portLabel} ${normalizedPorts.join(", ")}.\nProject root inside the container: ${remoteWorkspaceFolder}`; } export function getManagedContainerName(workspacePath: string, port: number): string { @@ -693,6 +800,22 @@ export async function deleteWorkspaceState(workspacePath: string): Promise await rm(getWorkspaceStateDir(workspacePath), { recursive: true, force: true }); } +export function getWorkspacePorts(state: WorkspaceState | null | undefined): number[] { + if (!state) { + return []; + } + + if (Array.isArray(state.ports) && state.ports.length > 0) { + return [...state.ports]; + } + + return [state.port]; +} + +export function getWorkspaceSshEnabled(state: WorkspaceState | null | undefined): boolean { + return state?.sshEnabled ?? true; +} + export function resolvePort(command: CommandName, explicitPort: number | undefined, state: WorkspaceState | null): number { if (command === "down" || command === "shell" || command === "arise" || command === "help") { throw new UserError(`resolvePort cannot be used for ${command}.`); @@ -711,24 +834,40 @@ export function resolvePort(command: CommandName, explicitPort: number | undefin ); } -export function resolveUpPortPreference(input: { +export function resolveUpPortsPreference(input: { explicitPort: number | undefined; + portCount?: number; state: WorkspaceState | null; existingPublishedPort?: number; -}): number | undefined { +}): number[] | undefined { + const storedPorts = getWorkspacePorts(input.state); + const requestedCount = input.portCount ?? (storedPorts.length > 0 ? storedPorts.length : 1); + if (input.explicitPort !== undefined) { - return input.explicitPort; + return [input.explicitPort]; + } + + if (storedPorts.length > 0) { + return storedPorts.slice(0, requestedCount); } - if (input.state) { - return input.state.port; + if (input.existingPublishedPort !== undefined) { + return [input.existingPublishedPort]; } - return input.existingPublishedPort; + return undefined; +} + +export function resolveUpPortPreference(input: { + explicitPort: number | undefined; + state: WorkspaceState | null; + existingPublishedPort?: number; +}): number | undefined { + return resolveUpPortsPreference(input)?.[0]; } export function describeUpPortStrategy(): string { - return `Reuse the previous workspace port when available, otherwise auto-assign the first free port starting at ${DEFAULT_UP_AUTO_PORT_START}.`; + return `Reuse the previous workspace ports when available, otherwise auto-assign the first free port(s) starting at ${DEFAULT_UP_AUTO_PORT_START}.`; } export async function discoverDevcontainerConfig( @@ -820,8 +959,11 @@ export async function removeGeneratedConfig(generatedConfigPath: string): Promis export function buildManagedConfig(baseConfig: DevcontainerConfig, options: ManagedConfigOptions): DevcontainerConfig { const managedConfig = structuredClone(baseConfig); const runArgs = withManagedContainerName(getStringArray(managedConfig.runArgs, "runArgs"), options.containerName); - if (!hasPublishedPort(runArgs, options.port)) { - runArgs.push("-p", `${options.port}:${options.port}`); + const ports = normalizePortList(options.ports ?? (options.port !== undefined ? [options.port] : [])); + for (const port of ports) { + if (!hasPublishedPort(runArgs, port)) { + runArgs.push("-p", `${port}:${port}`); + } } managedConfig.runArgs = runArgs; @@ -852,6 +994,8 @@ export function buildManagedConfig(baseConfig: DevcontainerConfig, options: Mana export function createWorkspaceState(input: { workspacePath: string; port: number; + ports?: number[]; + sshEnabled?: boolean; configSource: "repo" | "template"; sourceConfigPath: string | null; generatedConfigPath: string; @@ -861,11 +1005,15 @@ export function createWorkspaceState(input: { githubAuth: GithubAuthPreference | null; containerId?: string; }): WorkspaceState { + const ports = normalizePortList(input.ports ?? [input.port]); + return { version: STATE_VERSION, workspacePath: input.workspacePath, workspaceHash: hashWorkspacePath(input.workspacePath), - port: input.port, + port: ports[0], + ports, + sshEnabled: input.sshEnabled ?? true, configSource: input.configSource, sourceConfigPath: input.sourceConfigPath, generatedConfigPath: input.generatedConfigPath, @@ -1112,6 +1260,15 @@ function dedupe(values: string[]): string[] { return [...new Set(values)]; } +function normalizePortList(ports: number[]): number[] { + const normalized = [...new Set(ports)]; + if (normalized.length === 0 || normalized.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) { + throw new UserError("At least one valid port is required."); + } + + return normalized; +} + function withManagedContainerName(runArgs: string[], containerName: string): string[] { const next: string[] = []; @@ -1264,6 +1421,12 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null { return null; } + const ports = parsePersistedPortList(record.ports, record.port); + if (!ports) { + return null; + } + + const sshEnabled = typeof record.sshEnabled === "boolean" ? record.sshEnabled : true; const updatedAt = typeof record.updatedAt === "string" ? record.updatedAt : new Date().toISOString(); const lastContainerId = typeof record.lastContainerId === "string" ? record.lastContainerId : undefined; const githubAuth = normalizeGithubAuthPreference(record.githubAuth); @@ -1278,6 +1441,8 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null { workspacePath: record.workspacePath, workspaceHash: record.workspaceHash, port: record.port, + ports, + sshEnabled, configSource: "repo", sourceConfigPath: record.sourceConfigPath, generatedConfigPath: record.generatedConfigPath, @@ -1291,7 +1456,7 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null { } if ( - record.version !== STATE_VERSION || + (record.version !== 2 && record.version !== STATE_VERSION) || (record.configSource !== "repo" && record.configSource !== "template") || (record.sourceConfigPath !== null && typeof record.sourceConfigPath !== "string") ) { @@ -1307,6 +1472,8 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null { workspacePath: record.workspacePath, workspaceHash: record.workspaceHash, port: record.port, + ports, + sshEnabled, configSource: record.configSource, sourceConfigPath: record.sourceConfigPath, generatedConfigPath: @@ -1320,6 +1487,23 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null { }; } +function parsePersistedPortList(value: unknown, fallbackPort: number): number[] | null { + if (value === undefined) { + return Number.isInteger(fallbackPort) && fallbackPort >= 1 && fallbackPort <= 65535 ? [fallbackPort] : null; + } + + if ( + !Array.isArray(value) || + value.length === 0 || + value.some((port) => typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) + ) { + return null; + } + + const ports = [...new Set(value as number[])]; + return ports.length === value.length ? ports : null; +} + function normalizeGithubAuthPreference(value: unknown): GithubAuthPreference | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; diff --git a/src/runtime.ts b/src/runtime.ts index 4224692..3ed664e 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -45,6 +45,7 @@ interface ExecOptions { cwd?: string; env?: Record; stdin?: string | Uint8Array; + inheritStdio?: boolean; stdoutMode?: "capture" | "raw" | "devcontainer-json"; stderrMode?: "capture" | "raw" | "devcontainer-json"; allowFailure?: boolean; @@ -589,21 +590,50 @@ export async function assertPortAvailable(port: number, allowIfManagedContainerO throw new UserError(`Host port ${port} is already in use.`); } -export async function findFirstAvailablePort( +export async function assertPortsAvailable( + ports: number[], + managedContainerPorts: ReadonlySet = new Set(), +): Promise { + for (const port of ports) { + await assertPortAvailable(port, managedContainerPorts.has(port)); + } +} + +export async function findAvailablePorts( startPort: number, + count: number, isPortAvailable: (port: number) => Promise = defaultIsPortAvailable, -): Promise { +): Promise { if (!Number.isInteger(startPort) || startPort < 1 || startPort > 65535) { throw new UserError(`Port must be between 1 and 65535. Received: ${startPort}`); } + if (!Number.isInteger(count) || count < 1 || count > 65535) { + throw new UserError(`Port count must be between 1 and 65535. Received: ${count}`); + } - for (let port = startPort; port <= 65535; port += 1) { + const ports: number[] = []; + for (let port = startPort; port <= 65535 && ports.length < count; port += 1) { if (await isPortAvailable(port)) { - return port; + ports.push(port); } } - throw new UserError(`No available host port was found starting at ${startPort}.`); + if (ports.length === count) { + return ports; + } + + if (count === 1) { + throw new UserError(`No available host port was found starting at ${startPort}.`); + } + + throw new UserError(`No available host ports were found starting at ${startPort}; requested ${count}.`); +} + +export async function findFirstAvailablePort( + startPort: number, + isPortAvailable: (port: number) => Promise = defaultIsPortAvailable, +): Promise { + return (await findAvailablePorts(startPort, 1, isPortAvailable))[0]; } export async function removeContainers(containerIds: string[]): Promise { @@ -898,6 +928,22 @@ export async function openInteractiveShell(containerId: string): Promise ); } +export function buildDevcontainerExecCommand(containerId: string, commandArgs: string[]): string[] { + if (commandArgs.length === 0) { + throw new UserError("A command is required."); + } + + return ["devcontainer", "exec", "--container-id", containerId, ...commandArgs]; +} + +export async function runDevcontainerCommand(containerId: string, commandArgs: string[]): Promise { + const result = await execute(buildDevcontainerExecCommand(containerId, commandArgs), { + inheritStdio: true, + allowFailure: true, + }); + return result.exitCode; +} + async function hasDockerDesktopHostService(): Promise { const result = await execute(["docker", "info", "--format", "{{.OperatingSystem}}"], { stdoutMode: "capture", @@ -1341,14 +1387,20 @@ async function execute(command: string[], options: ExecOptions): Promise((resolve, reject) => { subprocess.once("error", reject); subprocess.once("close", (exitCode) => { diff --git a/src/status.ts b/src/status.ts index acadbfc..2e996e5 100644 --- a/src/status.ts +++ b/src/status.ts @@ -9,6 +9,8 @@ import { getDefaultRemoteWorkspaceFolder, getManagedLabels, getManagedPortFromContainerName, + getWorkspacePorts, + getWorkspaceSshEnabled, getWorkspaceRunnerCredentialFile, getWorkspaceSshMetadataFile, getWorkspaceStateFile, @@ -26,6 +28,8 @@ export interface DevboxStatusPortBinding { export interface DevboxStatus { running: boolean; port: number | null; + ports: number[]; + sshEnabled: boolean; password: string | null; workdir: string; workdirSource: "config" | "default"; @@ -117,7 +121,8 @@ export async function getDevboxStatus( const credentialPath = getWorkspaceRunnerCredentialFile(input.workspacePath); const credentialFile = await readRunnerCredentialsFile(credentialPath, readFile); const sshMetadataPath = getWorkspaceSshMetadataFile(input.workspacePath); - const sshMetadataFile = await readRunnerMetadataFile(sshMetadataPath, readFile, warnings); + const sshEnabled = getWorkspaceSshEnabled(state); + const sshMetadataFile = await readRunnerMetadataFile(sshMetadataPath, readFile, sshEnabled ? warnings : []); const configHints = state?.template ? readConfigHintsFromConfig({ config: state.template.config, @@ -132,22 +137,30 @@ export async function getDevboxStatus( workspacePath: input.workspacePath, }); const publishedPorts = getPublishedPorts(primaryContainer); - const configuredSshPort = - state?.port - ?? sshMetadataFile.value?.sshPort - ?? credentialFile.value?.sshPort - ?? getManagedPortFromContainerName(primaryContainer?.Name) - ?? null; - const effectivePort = configuredSshPort === null - ? firstPublishedHostPort(publishedPorts) - : getPublishedHostPortForPort(publishedPorts, configuredSshPort) ?? configuredSshPort; - const sshUser = sshMetadataFile.value?.sshUser ?? credentialFile.value?.user ?? null; - const permitRootLogin = sshMetadataFile.value?.permitRootLogin ?? credentialFile.value?.permitRootLogin ?? null; - const publicKeyConfigured = sshMetadataFile.value?.publicKeyConfigured ?? null; - const publicKeySource = sshMetadataFile.value?.publicKeySource ?? null; - const password = credentialFile.value?.password ?? null; + const configuredWorkspacePorts = getWorkspacePorts(state); + const configuredSshPort = sshEnabled + ? state?.port + ?? sshMetadataFile.value?.sshPort + ?? credentialFile.value?.sshPort + ?? getManagedPortFromContainerName(primaryContainer?.Name) + ?? null + : null; + const ports = resolveStatusPorts({ + configuredWorkspacePorts, + configuredSshPort, + publishedPorts, + }); + const effectivePort = ports[0] ?? null; + const sshUser = sshEnabled ? sshMetadataFile.value?.sshUser ?? credentialFile.value?.user ?? null : null; + const permitRootLogin = sshEnabled + ? sshMetadataFile.value?.permitRootLogin ?? credentialFile.value?.permitRootLogin ?? null + : null; + const publicKeyConfigured = sshEnabled ? sshMetadataFile.value?.publicKeyConfigured ?? null : null; + const publicKeySource = sshEnabled ? sshMetadataFile.value?.publicKeySource ?? null : null; + const password = sshEnabled ? credentialFile.value?.password ?? null : null; appendMissingDataWarnings({ warnings, + sshEnabled, credentialFile, credentialPath, password, @@ -164,6 +177,8 @@ export async function getDevboxStatus( return { running: Boolean(primaryContainer?.State?.Running), port: effectivePort, + ports, + sshEnabled, password, workdir: configHints.workdir ?? getDefaultRemoteWorkspaceFolder(input.workspacePath), workdirSource: configHints.workdirSource, @@ -322,6 +337,7 @@ function readConfigHintsFromConfig(input: { function appendMissingDataWarnings(input: { warnings: string[]; + sshEnabled: boolean; credentialFile: OptionalParsedFile; credentialPath: string; password: string | null; @@ -334,6 +350,15 @@ function appendMissingDataWarnings(input: { publicKeySource: string | null; remoteUser: string | null; }): void { + if (!input.sshEnabled) { + if (input.remoteUser === null) { + input.warnings.push( + "`remoteUser` is unavailable because the devcontainer config does not set `remoteUser` or `containerUser`.", + ); + } + return; + } + if (!input.credentialFile.exists) { input.warnings.push(`Runner password file was not found: ${input.credentialPath}. \`password\` is unavailable.`); } else if (input.password === null) { @@ -463,6 +488,23 @@ function getPublishedPorts(container: DockerInspect | null): Record; +}): number[] { + const configuredPorts = + input.configuredWorkspacePorts.length > 0 + ? input.configuredWorkspacePorts + : input.configuredSshPort !== null + ? [input.configuredSshPort] + : firstPublishedHostPorts(input.publishedPorts); + + return configuredPorts.map( + (port) => getPublishedHostPortForPort(input.publishedPorts, port) ?? port, + ); +} + function getPublishedHostPortForPort( publishedPorts: Record, port: number, @@ -477,16 +519,16 @@ function getPublishedHostPortForPort( return null; } -function firstPublishedHostPort(publishedPorts: Record): number | null { +function firstPublishedHostPorts(publishedPorts: Record): number[] { + const ports: number[] = []; for (const bindings of Object.values(publishedPorts)) { for (const binding of bindings) { - if (binding.hostPort !== null) { - return binding.hostPort; + if (binding.hostPort !== null && !ports.includes(binding.hostPort)) { + ports.push(binding.hostPort); } } } - - return null; + return ports; } function formatErrorMessage(error: unknown): string { diff --git a/tests/core.test.ts b/tests/core.test.ts index 82eb31b..aa55c5b 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -21,8 +21,10 @@ import { parseArgs, prepareKnownHostsMount, loadWorkspaceState, + parsePortCount, resolveWorkspaceConfig, resolvePort, + resolveUpPortsPreference, resolveUpPortPreference, validateSupportedDevcontainerConfig, getWorkspaceStateFile, @@ -58,10 +60,43 @@ describe("parseArgs", () => { }); }); + test("supports multiple ports and explicit SSH mode", () => { + expect(parseArgs(["up", "5001", "--ports", "3", "--no-ssh"])).toEqual({ + command: "up", + port: 5001, + portCount: 3, + allowMissingSsh: false, + sshEnabled: false, + }); + expect(parseArgs(["rebuild", "--ports=2", "--ssh"])).toEqual({ + command: "rebuild", + portCount: 2, + allowMissingSsh: false, + sshEnabled: true, + }); + }); + test("supports the shell subcommand", () => { expect(parseArgs(["shell"])).toEqual({ command: "shell", allowMissingSsh: false }); }); + test("supports forwarding exec arguments without interpreting them", () => { + expect(parseArgs(["exec", "--", "npm", "run", "test", "--", "--watch"])).toEqual({ + command: "exec", + allowMissingSsh: false, + execArgs: ["npm", "run", "test", "--", "--watch"], + }); + }); + + test("requires a command for exec", () => { + expect(() => parseArgs(["exec"])).toThrow( + "The exec command requires a command. Usage: `devbox exec -- [args...]`", + ); + expect(() => parseArgs(["exec", "printf", "hello"])).toThrow( + "The exec command requires `--` before the command. Usage: `devbox exec -- [args...]`", + ); + }); + test("supports the status subcommand", () => { expect(parseArgs(["status"])).toEqual({ command: "status", allowMissingSsh: false }); }); @@ -232,6 +267,15 @@ describe("parseArgs", () => { expect(() => parseArgs(["up", "--gh-user", "not valid"])).toThrow("Invalid GitHub user:"); expect(() => parseArgs(["up", "--gh-host", "https://github.com"])).toThrow("Invalid GitHub host:"); }); + + test("rejects invalid port counts and conflicting SSH options", () => { + expect(() => parsePortCount("0")).toThrow("Port count must be between 1 and 65535."); + expect(() => parseArgs(["up", "--ports", "two"])).toThrow("Invalid port count:"); + expect(() => parseArgs(["up", "--ssh", "--no-ssh"])).toThrow("Cannot combine --ssh with --no-ssh."); + expect(() => parseArgs(["up", "--no-ssh", "--ssh-public-key", "/tmp/id_rsa.pub"])).toThrow( + "--ssh-public-key cannot be combined with --no-ssh.", + ); + }); }); describe("helpText", () => { @@ -248,6 +292,9 @@ describe("helpText", () => { expect(text).toContain("Usage:"); expect(text).toContain("Commands:"); expect(text).toContain("Options:"); + expect(text).toContain("--ports "); + expect(text).toContain("--no-ssh"); + expect(text).toContain("--ssh"); }); test("lists all commands", () => { @@ -255,6 +302,7 @@ describe("helpText", () => { expect(text).toContain("up"); expect(text).toContain("rebuild"); expect(text).toContain("shell"); + expect(text).toContain("exec -- [args...]"); expect(text).toContain("status"); expect(text).toContain("templates"); expect(text).toContain("arise"); @@ -325,6 +373,8 @@ describe("loadWorkspaceState", () => { workspacePath, workspaceHash: "hash", port: 5001, + ports: [5001], + sshEnabled: true, configSource: "repo", sourceConfigPath: path.join(workspacePath, ".devcontainer", "devcontainer.json"), generatedConfigPath: path.join(workspacePath, ".devcontainer", ".devbox.generated.devcontainer.json"), @@ -429,6 +479,27 @@ describe("resolveUpPortPreference", () => { test("returns undefined when up should auto-assign a new port", () => { expect(resolveUpPortPreference({ explicitPort: undefined, state: null, existingPublishedPort: undefined })).toBeUndefined(); }); + + test("reuses all stored ports when the requested count matches", () => { + const multiPortState = { ...state, ports: [5003, 5004, 5006] }; + expect( + resolveUpPortsPreference({ + explicitPort: undefined, + portCount: 3, + state: multiPortState, + }), + ).toEqual([5003, 5004, 5006]); + }); + + test("uses the explicit first port and lets the caller assign the remaining ports", () => { + expect( + resolveUpPortsPreference({ + explicitPort: 6000, + portCount: 3, + state: null, + }), + ).toEqual([6000]); + }); }); describe("getManagedPortFromContainerName", () => { @@ -447,7 +518,7 @@ describe("getManagedPortFromContainerName", () => { describe("describeUpPortStrategy", () => { test("describes the stored-port reuse and auto-assignment behavior", () => { expect(describeUpPortStrategy()).toBe( - "Reuse the previous workspace port when available, otherwise auto-assign the first free port starting at 5001.", + "Reuse the previous workspace ports when available, otherwise auto-assign the first free port(s) starting at 5001.", ); }); }); @@ -730,6 +801,31 @@ describe("buildManagedConfig", () => { expect(managed.runArgs).toEqual(["-p", "5001:5001", "--name", "devbox-example-5001"]); }); + test("publishes every requested port", () => { + const managed = buildManagedConfig( + { + image: "mcr.microsoft.com/devcontainers/base:ubuntu", + }, + { + ports: [5001, 5002, 5003], + containerName: "devbox-example-5001", + sshAuthSock: null, + knownHostsPath: null, + }, + ); + + expect(managed.runArgs).toEqual([ + "--name", + "devbox-example-5001", + "-p", + "5001:5001", + "-p", + "5002:5002", + "-p", + "5003:5003", + ]); + }); + test("stores a localEnv placeholder instead of a persisted github token value", () => { const managed = buildManagedConfig( { @@ -887,4 +983,10 @@ describe("formatReadyMessage", () => { "\nReady. abcdef123456 is available on port 6000.\nProject root inside the container: /workspace/custom-root", ); }); + + test("includes all published ports", () => { + expect(formatReadyMessage("abcdef1234567890", [6000, 6001], "/workspace/custom-root")).toBe( + "\nReady. abcdef123456 is available on ports 6000, 6001.\nProject root inside the container: /workspace/custom-root", + ); + }); }); diff --git a/tests/examples.live.test.ts b/tests/examples.live.test.ts index 12f7c9c..28217d3 100644 --- a/tests/examples.live.test.ts +++ b/tests/examples.live.test.ts @@ -103,6 +103,8 @@ describe("example workspaces (real devcontainers)", () => { const selectedPort = Number(state.port); const containerId = String(state.lastContainerId); expect(up.stdout).toContain(`Using port ${selectedPort}.`); + expect(state.ports).toEqual([selectedPort]); + expect(state.sshEnabled).toBe(true); expect(state.configSource).toBe("template"); expect(state.sourceConfigPath).toBeNull(); expect(state.template.name).toBe("ubuntu"); @@ -184,6 +186,95 @@ describe("example workspaces (real devcontainers)", () => { { timeout: 8 * 60_000 }, ); + liveTest( + "publishes multiple real ports and uses the first port for the bundled SSH runner", + async () => { + const fixture = await setupLiveFixture("smoke-workspace"); + const up = runCli(fixture, ["up", String(fixture.port), "--ports", "3", "--allow-missing-ssh"]); + + expect(up.exitCode).toBe(0); + expect(up.stdout).toContain(`Using ports ${fixture.port},`); + expect(up.stdout).toContain(`SSH port: ${fixture.port}`); + expect(up.stdout).toContain("Ready."); + + const state = await readJson(fixture.statePath); + const containerId = String(state.lastContainerId); + expect(state.ports).toHaveLength(3); + expect(state.ports[0]).toBe(fixture.port); + expect(state.sshEnabled).toBe(true); + + const inspect = inspectContainer(fixture, containerId); + for (const port of state.ports as number[]) { + expect(getPublishedHostPort(inspect, port)).toBe(String(port)); + } + + const runnerMetadata = await readJson(fixture.runnerMetadataPath); + expectRunnerMetadata(runnerMetadata, fixture.port); + expect((runnerMetadata as { sshPort: number }).sshPort).toBe(fixture.port); + + const down = runCli(fixture, ["down"]); + expect(down.exitCode).toBe(0); + expect(await listManagedContainerIds(fixture)).toEqual([]); + }, + { timeout: 10 * 60_000 }, + ); + + liveTest( + "publishes multiple real ports without SSH and runs devbox exec with output and exit-code forwarding", + async () => { + const fixture = await setupLiveFixture("smoke-workspace"); + const up = runCli(fixture, [ + "up", + String(fixture.port), + "--ports", + "3", + "--no-ssh", + "--allow-missing-ssh", + ]); + + expect(up.exitCode).toBe(0); + expect(up.stdout).toContain(`Using ports ${fixture.port},`); + expect(up.stdout).toContain("Bundled SSH server installation skipped"); + expect(up.stdout).not.toContain("SSH server:"); + + const state = await readJson(fixture.statePath); + const containerId = String(state.lastContainerId); + expect(state.ports).toHaveLength(3); + expect(state.ports[0]).toBe(fixture.port); + expect(state.sshEnabled).toBe(false); + + const inspect = inspectContainer(fixture, containerId); + for (const port of state.ports as number[]) { + expect(getPublishedHostPort(inspect, port)).toBe(String(port)); + } + expect(existsSync(fixture.runnerCredPath)).toBe(false); + expect(existsSync(fixture.runnerMetadataPath)).toBe(false); + expect(existsSync(getWorkspaceRunnerHostKeysDir(fixture.workspacePath))).toBe(false); + + const status = runCli(fixture, ["status"]); + expect(status.exitCode).toBe(0); + const statusPayload = JSON.parse(status.stdout); + expect(statusPayload.ports).toEqual(state.ports); + expect(statusPayload.sshEnabled).toBe(false); + expect(statusPayload.sshPort).toBeNull(); + + const exec = runCli(fixture, ["exec", "--", "printf", "%s:%s", "left", "right"]); + expect(exec.exitCode).toBe(0); + expect(exec.stdout).toBe("left:right"); + + const failedExec = runCli(fixture, ["exec", "--", "sh", "-lc", "exit 17"], true); + expect(failedExec.exitCode).toBe(17); + + const down = runCli(fixture, ["down"]); + expect(down.exitCode).toBe(0); + + const missingExec = runCli(fixture, ["exec", "--", "printf", "not-running"], true); + expect(missingExec.exitCode).toBe(1); + expect(missingExec.stderr).toContain("No running managed container was found for this workspace."); + }, + { timeout: 10 * 60_000 }, + ); + liveTest( "complex workspace exercises real features and host integration", async () => { @@ -368,6 +459,9 @@ async function setupLiveFixture(exampleName: string, options: LiveFixtureOptions const workspaceCopyPath = path.join(tempRoot, exampleName); await cp(path.join(repoRoot, "examples", exampleName), workspaceCopyPath, { recursive: true }); await resetWorkspaceArtifacts(workspaceCopyPath); + const devboxDir = path.join(workspaceCopyPath, ".devbox"); + await mkdir(devboxDir, { recursive: true }); + await chmod(devboxDir, 0o777); runCommand(["git", "init", workspaceCopyPath]); if (options.gitIdentity) { diff --git a/tests/examples.test.ts b/tests/examples.test.ts index 52a7bfc..0698dfd 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -124,21 +124,28 @@ function getContainerName(runArgs, fallbackName) { return String(runArgs[index + 1] ?? fallbackName); } -function getPublishedPort(runArgs) { - const index = runArgs.indexOf("-p"); - if (index === -1) { - return undefined; +function getPublishedPorts(runArgs) { + const ports = []; + for (let index = 0; index < runArgs.length; index += 1) { + if (runArgs[index] !== "-p") { + continue; + } + + const mapping = String(runArgs[index + 1] ?? ""); + const hostPort = Number(mapping.split(":")[0]); + if (Number.isInteger(hostPort)) { + ports.push(hostPort); + } + index += 1; } - const mapping = String(runArgs[index + 1] ?? ""); - const hostPort = Number(mapping.split(":")[0]); - return Number.isInteger(hostPort) ? hostPort : undefined; + return ports; } function buildInspectPayload(container) { const ports = {}; - if (container.port !== undefined) { - ports[String(container.port) + "/tcp"] = [{ HostIp: "0.0.0.0", HostPort: String(container.port) }]; + for (const port of container.ports ?? []) { + ports[String(port) + "/tcp"] = [{ HostIp: "0.0.0.0", HostPort: String(port) }]; } return { @@ -275,7 +282,7 @@ function handleDevcontainer() { const runArgs = Array.isArray(config.runArgs) ? config.runArgs.map(String) : []; const containerId = "fake-container-" + state.nextId; const containerName = getContainerName(runArgs, "devbox-fake-" + state.nextId); - const port = getPublishedPort(runArgs); + const ports = getPublishedPorts(runArgs); const labels = parseLabels(args); const remoteWorkspaceFolder = typeof config.workspaceFolder === "string" && config.workspaceFolder.length > 0 @@ -287,7 +294,8 @@ function handleDevcontainer() { id: containerId, labels, name: containerName, - port, + port: ports[0], + ports, remoteWorkspaceFolder, running: true, workspacePath, @@ -336,6 +344,16 @@ function handleDevcontainer() { return; } + const containerIdIndex = args.indexOf("--container-id"); + const commandArgs = containerIdIndex === -1 ? [] : args.slice(containerIdIndex + 2); + if (commandArgs[0] === "false") { + process.exit(7); + } + if (commandArgs[0] === "printf") { + process.stdout.write(commandArgs.slice(1).join(" ")); + return; + } + console.log(commandArgs.join(" ")); return; } @@ -421,6 +439,21 @@ describe("example workspaces (simulated host tools)", () => { expect(shell.exitCode).toBe(0); expect(shell.stdout).toContain("Opening shell inside "); + const exec = runCli(fixture, ["exec", "--", "printf", "automation-ok"]); + expect(exec.exitCode).toBe(0); + expect(exec.stdout).toBe("automation-ok"); + const commandsAfterExec = await readCommandLog(fixture.commandLogPath); + expect( + commandsAfterExec.some( + (entry) => + entry.tool === "devcontainer" && + entry.args[0] === "exec" && + entry.args.includes("--container-id") && + entry.args.includes("fake-container-1") && + entry.args.slice(entry.args.indexOf("--container-id") + 2).join("\u0000") === "printf\u0000automation-ok", + ), + ).toBe(true); + const statusWhileRunning = runCli(fixture, ["status"]); expect(statusWhileRunning.exitCode).toBe(0); const runningStatus = JSON.parse(statusWhileRunning.stdout); @@ -457,6 +490,79 @@ describe("example workspaces (simulated host tools)", () => { expect(stoppedStatus.hasSshMetadataFile).toBe(true); }); + test("exec fails clearly when no managed container is running and propagates command failures", async () => { + const fixture = await setupExampleFixture("smoke-workspace"); + + const missing = runCli(fixture, ["exec", "--", "printf", "never-run"]); + expect(missing.exitCode).toBe(1); + expect(missing.stderr).toContain("No running managed container was found for this workspace."); + + const up = runCli(fixture, ["up", "--allow-missing-ssh"]); + expect(up.exitCode).toBe(0); + + const failed = runCli(fixture, ["exec", "--", "false"]); + expect(failed.exitCode).toBe(7); + }); + + test("publishes multiple ports without the bundled SSH server and can re-enable it", async () => { + const fixture = await setupExampleFixture("smoke-workspace"); + + const withoutSsh = runCli(fixture, ["up", "6000", "--ports", "3", "--no-ssh", "--allow-missing-ssh"]); + expect(withoutSsh.exitCode).toBe(0); + expect(withoutSsh.stdout).toContain("Using ports 6000, 6001, 6002."); + expect(withoutSsh.stdout).toContain("Bundled SSH server installation skipped"); + expect(withoutSsh.stdout).not.toContain("SSH server:"); + expect(withoutSsh.stdout).toContain("Ready."); + expect(withoutSsh.stdout).toContain("ports 6000, 6001, 6002"); + + const generatedConfig = await readJson(fixture.generatedConfigPath); + expect(generatedConfig.runArgs).toEqual([ + "--name", + "devbox-smoke-workspace-6000", + "-p", + "6000:6000", + "-p", + "6001:6001", + "-p", + "6002:6002", + ]); + + const stateWithoutSsh = await readJson(fixture.statePath); + expect(stateWithoutSsh.port).toBe(6000); + expect(stateWithoutSsh.ports).toEqual([6000, 6001, 6002]); + expect(stateWithoutSsh.sshEnabled).toBe(false); + + const commandsWithoutSsh = await readCommandLog(fixture.commandLogPath); + expect( + commandsWithoutSsh.some((entry) => typeof entry.script === "string" && entry.script.includes("SSH_PORT=")), + ).toBe(false); + + const statusWithoutSsh = runCli(fixture, ["status"]); + expect(statusWithoutSsh.exitCode).toBe(0); + const status = JSON.parse(statusWithoutSsh.stdout); + expect(status.ports).toEqual([6000, 6001, 6002]); + expect(status.sshEnabled).toBe(false); + expect(status.password).toBeNull(); + expect(status.sshPort).toBeNull(); + expect(Object.keys(status.publishedPorts)).toEqual(["6000/tcp", "6001/tcp", "6002/tcp"]); + + const withSsh = runCli(fixture, ["up", "--ssh", "--allow-missing-ssh"]); + expect(withSsh.exitCode).toBe(0); + expect(withSsh.stdout).toContain("SSH server:"); + expect(withSsh.stdout).toContain("SSH port: 6000"); + + const stateWithSsh = await readJson(fixture.statePath); + expect(stateWithSsh.ports).toEqual([6000, 6001, 6002]); + expect(stateWithSsh.sshEnabled).toBe(true); + + const rebuiltWithoutSsh = runCli(fixture, ["rebuild", "--no-ssh", "--allow-missing-ssh"]); + expect(rebuiltWithoutSsh.exitCode).toBe(0); + expect(rebuiltWithoutSsh.stdout).toContain("Bundled SSH server installation skipped"); + const rebuiltState = await readJson(fixture.statePath); + expect(rebuiltState.ports).toEqual([6000, 6001, 6002]); + expect(rebuiltState.sshEnabled).toBe(false); + }); + test("complex workspace preserves features and supports rebuild via the CLI", async () => { const fixture = await setupExampleFixture("complex-workspace", { ghToken: "ghs_example_token", diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 7c81a51..9374dca 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -10,10 +10,12 @@ import { buildCopyKnownHostsScript, buildConfigureGitIdentityScript, buildGhCliTokenArgs, + buildDevcontainerExecCommand, buildDevcontainerShellCommand, buildEnsureSshAuthSockAccessibleScript, ensurePathIgnored, buildInteractiveShellScript, + findAvailablePorts, findFirstAvailablePort, buildPersistRunnerHostKeysScript, buildRestoreRunnerHostKeysScript, @@ -257,6 +259,27 @@ describe("findFirstAvailablePort", () => { }); }); +describe("findAvailablePorts", () => { + test("returns multiple available ports while skipping unavailable ports", async () => { + const checkedPorts: number[] = []; + + await expect( + findAvailablePorts(5001, 3, async (port) => { + checkedPorts.push(port); + return port !== 5001 && port !== 5003; + }), + ).resolves.toEqual([5002, 5004, 5005]); + + expect(checkedPorts).toEqual([5001, 5002, 5003, 5004, 5005]); + }); + + test("reports the requested count when the port range is exhausted", async () => { + await expect(findAvailablePorts(65535, 2, async () => false)).rejects.toThrow( + "No available host ports were found starting at 65535; requested 2.", + ); + }); +}); + describe("probePortAvailability", () => { test("uses lsof PID results when available", async () => { await expect( @@ -318,6 +341,20 @@ describe("interactive shell helpers", () => { ]); }); + test("builds the non-interactive devcontainer exec command", () => { + expect(buildDevcontainerExecCommand("abc123", ["npm", "run", "test", "--", "--watch"])).toEqual([ + "devcontainer", + "exec", + "--container-id", + "abc123", + "npm", + "run", + "test", + "--", + "--watch", + ]); + }); + test("uses the preferred running container when available", () => { const containers: DockerInspect[] = [ { Id: "stopped", State: { Running: false } }, diff --git a/tests/status.test.ts b/tests/status.test.ts index 6651416..ef925b4 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -111,6 +111,8 @@ describe("getDevboxStatus", () => { expect(status.running).toBe(true); expect(status.port).toBe(5001); + expect(status.ports).toEqual([5001]); + expect(status.sshEnabled).toBe(true); expect(status.password).toBe("secret"); expect(status.sshUser).toBe("vscode"); expect(status.sshPort).toBe(5001); @@ -129,6 +131,76 @@ describe("getDevboxStatus", () => { ]); }); + test("reports all published ports without exposing SSH details when SSH is disabled", async () => { + const state: WorkspaceState = { + version: 3, + workspacePath: "/tmp/no-ssh", + workspaceHash: "workspace-hash", + port: 5001, + ports: [5001, 5002], + sshEnabled: false, + configSource: "repo", + sourceConfigPath: "/tmp/no-ssh/.devcontainer/devcontainer.json", + generatedConfigPath: "/tmp/no-ssh/.devcontainer/.devcontainer.json", + labels: { "devbox.managed": "true", "devbox.workspace": "workspace-hash" }, + userDataDir: "/tmp/state", + template: null, + githubAuth: null, + lastContainerId: "container-no-ssh", + updatedAt: "2026-03-16T00:00:00.000Z", + }; + + const status = await getDevboxStatus( + { workspacePath: state.workspacePath, state }, + { + isDockerAvailable: () => true, + listManagedContainers: async () => ["container-no-ssh"], + inspectContainers: async () => [ + { + Id: "container-no-ssh", + Name: "/devbox-no-ssh-5001", + State: { Running: true, Status: "running" }, + NetworkSettings: { + Ports: { + "5001/tcp": [{ HostIp: "0.0.0.0", HostPort: "15001" }], + "5002/tcp": [{ HostIp: "0.0.0.0", HostPort: "15002" }], + }, + }, + }, + ], + readFile: async (filePath) => { + if (filePath === state.sourceConfigPath) { + return '{ "remoteUser": "vscode" }'; + } + if (filePath.endsWith("/.devbox/ssh/credentials")) { + return "stale-password\n"; + } + if (filePath.endsWith("/.devbox/ssh/metadata.json")) { + return "stale metadata"; + } + const error = new Error(`Missing file: ${filePath}`) as Error & { code?: string }; + error.code = "ENOENT"; + throw error; + }, + }, + ); + + expect(status.running).toBe(true); + expect(status.port).toBe(15001); + expect(status.ports).toEqual([15001, 15002]); + expect(status.sshEnabled).toBe(false); + expect(status.password).toBeNull(); + expect(status.sshUser).toBeNull(); + expect(status.sshPort).toBeNull(); + expect(status.permitRootLogin).toBeNull(); + expect(status.publicKeyConfigured).toBeNull(); + expect(status.publicKeySource).toBeNull(); + expect(status.hasCredentialFile).toBe(true); + expect(status.hasSshMetadataFile).toBe(true); + expect(status.remoteUser).toBe("vscode"); + expect(status.warnings).toEqual([]); + }); + test("falls back to default workdir, password file, and metadata file without saved state", async () => { const status = await getDevboxStatus( { From 8f20c0e77431f3a0cb043b1fd8741aaee4e82484 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Tue, 8 Sep 2026 13:18:39 +0000 Subject: [PATCH 2/4] fix: address PR review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cli.ts | 28 +++++++++++++++------------- src/status.ts | 8 ++++---- tests/status.test.ts | 31 +++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 47c2b97..4ceb3be 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -177,19 +177,21 @@ async function handleUpLike( existingInspects = await inspectContainers(existingContainerIds); } - const preferredPorts = - command === "up" - ? resolveUpPortsPreference({ - explicitPort, - portCount: explicitPortCount, - state, - existingPublishedPort: getManagedPortFromContainerName(existingInspects[0]?.Name), - }) - : explicitPort !== undefined - ? [explicitPort] - : state - ? getWorkspacePorts(state) - : (resolvePort(command, explicitPort, state), undefined); + let preferredPorts: number[] | undefined; + if (command === "up") { + preferredPorts = resolveUpPortsPreference({ + explicitPort, + portCount: explicitPortCount, + state, + existingPublishedPort: getManagedPortFromContainerName(existingInspects[0]?.Name), + }); + } else if (explicitPort !== undefined) { + preferredPorts = [explicitPort]; + } else if (state) { + preferredPorts = getWorkspacePorts(state); + } else { + preferredPorts = [resolvePort(command, explicitPort, state)]; + } const requestedPortCount = explicitPortCount ?? preferredPorts?.length ?? 1; const ports = await resolveRequestedPorts({ preferredPorts, diff --git a/src/status.ts b/src/status.ts index 2e996e5..b50e788 100644 --- a/src/status.ts +++ b/src/status.ts @@ -520,15 +520,15 @@ function getPublishedHostPortForPort( } function firstPublishedHostPorts(publishedPorts: Record): number[] { - const ports: number[] = []; + const ports = new Set(); for (const bindings of Object.values(publishedPorts)) { for (const binding of bindings) { - if (binding.hostPort !== null && !ports.includes(binding.hostPort)) { - ports.push(binding.hostPort); + if (binding.hostPort !== null) { + ports.add(binding.hostPort); } } } - return ports; + return [...ports].sort((left, right) => left - right); } function formatErrorMessage(error: unknown): string { diff --git a/tests/status.test.ts b/tests/status.test.ts index ef925b4..4dd4ecd 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -485,6 +485,37 @@ describe("getDevboxStatus", () => { expect(status.publishedPorts["5001/tcp"]?.[0]?.hostPort).toBe(15001); }); + test("sorts and deduplicates fallback published ports when state is unavailable", async () => { + const status = await getDevboxStatus( + { workspacePath: "/tmp/sorted-published-ports", state: null }, + { + isDockerAvailable: () => true, + listManagedContainers: async () => ["container-1"], + inspectContainers: async () => [ + { + Id: "container-1", + State: { Running: true, Status: "running" }, + NetworkSettings: { + Ports: { + "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "18080" }], + "3000/tcp": [{ HostIp: "0.0.0.0", HostPort: "13000" }], + "4000/tcp": [{ HostIp: "0.0.0.0", HostPort: "13000" }], + }, + }, + }, + ], + readFile: async () => { + const error = new Error("Missing file") as Error & { code?: string }; + error.code = "ENOENT"; + throw error; + }, + }, + ); + + expect(status.ports).toEqual([13000, 18080]); + expect(status.port).toBe(13000); + }); + test("falls back to state and credential data when docker is unavailable", async () => { const status = await getDevboxStatus( { From dab85daa77ceab4563453d36a7161b17bab8b3c9 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Tue, 8 Sep 2026 16:07:21 +0000 Subject: [PATCH 3/4] feat: make ports the canonical workspace schema Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 17 ++++-- src/cli.ts | 1 - src/constants.ts | 2 +- src/core.ts | 100 +++++++++--------------------------- src/status.ts | 5 +- tests/arise.test.ts | 6 ++- tests/core.test.ts | 94 ++++++++++++++++++--------------- tests/examples.live.test.ts | 11 ++-- tests/examples.test.ts | 17 +++--- tests/status.test.ts | 43 ++++++++-------- 10 files changed, 140 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index 1dca413..f02fc22 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ devbox up # Publish three ports, using the first one for bundled SSH devbox up --ports 3 -# Publish three ports without installing or starting bundled SSH -devbox up --ports 3 --no-ssh +# Publish two ports without installing or starting bundled SSH +devbox up --ports 2 --no-ssh # Re-enable bundled SSH for a workspace previously started with --no-ssh devbox up --ssh @@ -123,6 +123,18 @@ Use `--ports ` to request how many ports should be published. If the firs When you run `devbox rebuild`, omitting the port reuses the last stored port list for the current workspace. +The workspace state file uses schema version `4` and stores the selected ports only as `ports`; it never contains a singular `port` field: + +```json +{ + "version": 4, + "ports": [5001, 5002], + "sshEnabled": false +} +``` + +State files from older devbox versions are intentionally not migrated. Remove `.devbox/state.json` and run `devbox up` again with the desired port options when upgrading an existing workspace. + If no repo devcontainer is found and no previous template source is stored, `devbox up` automatically starts from the built-in `ubuntu` template. `devbox rebuild ` does the same when there is enough information to create the devbox but no prior workspace state exists. Devbox prints a message when this automatic fallback is used. `devbox rebuild` reuses the previously selected source for the workspace. If the workspace was started from `--template` or the automatic Ubuntu fallback, rebuild uses that saved template again. `rebuild --template ...` is intentionally not supported. @@ -154,7 +166,6 @@ Example: ```json { "running": true, - "port": 5001, "ports": [5001, 5002], "sshEnabled": true, "password": "password", diff --git a/src/cli.ts b/src/cli.ts index 4ceb3be..1c7744f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -384,7 +384,6 @@ async function handleUpLike( await saveWorkspaceState( createWorkspaceState({ workspacePath, - port: ports[0], ports, sshEnabled, configSource: resolvedConfig.configSource, diff --git a/src/constants.ts b/src/constants.ts index a434b14..60312ef 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,4 +11,4 @@ export const DEVBOX_SSH_DIRNAME = "ssh"; export const RUNNER_CRED_FILENAME = "credentials"; export const DEVBOX_SSH_METADATA_FILENAME = "metadata.json"; export const RUNNER_HOST_KEYS_DIRNAME = "host-keys"; -export const STATE_VERSION = 3; +export const STATE_VERSION = 4; diff --git a/src/core.ts b/src/core.ts index 5c241b2..a2f23e2 100644 --- a/src/core.ts +++ b/src/core.ts @@ -59,8 +59,7 @@ export interface DiscoveredConfig { } export interface ManagedConfigOptions { - ports?: number[]; - port?: number; + ports: number[]; containerName: string; sshAuthSock: string | null; knownHostsPath: string | null; @@ -77,9 +76,8 @@ export interface WorkspaceState { version: number; workspacePath: string; workspaceHash: string; - port: number; - ports?: number[]; - sshEnabled?: boolean; + ports: number[]; + sshEnabled: boolean; configSource: "repo" | "template"; sourceConfigPath: string | null; generatedConfigPath: string; @@ -781,13 +779,15 @@ export async function loadWorkspaceState(workspacePath: string): Promise { @@ -801,15 +801,7 @@ export async function deleteWorkspaceState(workspacePath: string): Promise } export function getWorkspacePorts(state: WorkspaceState | null | undefined): number[] { - if (!state) { - return []; - } - - if (Array.isArray(state.ports) && state.ports.length > 0) { - return [...state.ports]; - } - - return [state.port]; + return state ? [...state.ports] : []; } export function getWorkspaceSshEnabled(state: WorkspaceState | null | undefined): boolean { @@ -825,8 +817,8 @@ export function resolvePort(command: CommandName, explicitPort: number | undefin return explicitPort; } - if (state) { - return state.port; + if (state && state.ports.length > 0) { + return state.ports[0]; } throw new UserError( @@ -858,14 +850,6 @@ export function resolveUpPortsPreference(input: { return undefined; } -export function resolveUpPortPreference(input: { - explicitPort: number | undefined; - state: WorkspaceState | null; - existingPublishedPort?: number; -}): number | undefined { - return resolveUpPortsPreference(input)?.[0]; -} - export function describeUpPortStrategy(): string { return `Reuse the previous workspace ports when available, otherwise auto-assign the first free port(s) starting at ${DEFAULT_UP_AUTO_PORT_START}.`; } @@ -959,7 +943,7 @@ export async function removeGeneratedConfig(generatedConfigPath: string): Promis export function buildManagedConfig(baseConfig: DevcontainerConfig, options: ManagedConfigOptions): DevcontainerConfig { const managedConfig = structuredClone(baseConfig); const runArgs = withManagedContainerName(getStringArray(managedConfig.runArgs, "runArgs"), options.containerName); - const ports = normalizePortList(options.ports ?? (options.port !== undefined ? [options.port] : [])); + const ports = normalizePortList(options.ports); for (const port of ports) { if (!hasPublishedPort(runArgs, port)) { runArgs.push("-p", `${port}:${port}`); @@ -993,9 +977,8 @@ export function buildManagedConfig(baseConfig: DevcontainerConfig, options: Mana export function createWorkspaceState(input: { workspacePath: string; - port: number; - ports?: number[]; - sshEnabled?: boolean; + ports: number[]; + sshEnabled: boolean; configSource: "repo" | "template"; sourceConfigPath: string | null; generatedConfigPath: string; @@ -1005,15 +988,14 @@ export function createWorkspaceState(input: { githubAuth: GithubAuthPreference | null; containerId?: string; }): WorkspaceState { - const ports = normalizePortList(input.ports ?? [input.port]); + const ports = normalizePortList(input.ports); return { version: STATE_VERSION, workspacePath: input.workspacePath, workspaceHash: hashWorkspacePath(input.workspacePath), - port: ports[0], ports, - sshEnabled: input.sshEnabled ?? true, + sshEnabled: input.sshEnabled, configSource: input.configSource, sourceConfigPath: input.sourceConfigPath, generatedConfigPath: input.generatedConfigPath, @@ -1402,16 +1384,19 @@ function assertValidTemplateState(value: unknown): asserts value is WorkspaceTem } } -function migrateWorkspaceState(value: unknown): WorkspaceState | null { +function parseWorkspaceState(value: unknown): WorkspaceState | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } const record = value as Record; if ( + record.version !== STATE_VERSION || typeof record.workspacePath !== "string" || typeof record.workspaceHash !== "string" || - typeof record.port !== "number" || + Object.prototype.hasOwnProperty.call(record, "port") || + !parsePersistedPortList(record.ports) || + typeof record.sshEnabled !== "boolean" || typeof record.generatedConfigPath !== "string" || typeof record.userDataDir !== "string" || !record.labels || @@ -1421,42 +1406,12 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null { return null; } - const ports = parsePersistedPortList(record.ports, record.port); - if (!ports) { - return null; - } - - const sshEnabled = typeof record.sshEnabled === "boolean" ? record.sshEnabled : true; + const ports = record.ports as number[]; const updatedAt = typeof record.updatedAt === "string" ? record.updatedAt : new Date().toISOString(); const lastContainerId = typeof record.lastContainerId === "string" ? record.lastContainerId : undefined; const githubAuth = normalizeGithubAuthPreference(record.githubAuth); - if (record.version === 1) { - if (typeof record.sourceConfigPath !== "string") { - return null; - } - - return { - version: STATE_VERSION, - workspacePath: record.workspacePath, - workspaceHash: record.workspaceHash, - port: record.port, - ports, - sshEnabled, - configSource: "repo", - sourceConfigPath: record.sourceConfigPath, - generatedConfigPath: record.generatedConfigPath, - labels: record.labels as Record, - userDataDir: record.userDataDir, - template: null, - githubAuth: null, - lastContainerId, - updatedAt, - }; - } - if ( - (record.version !== 2 && record.version !== STATE_VERSION) || (record.configSource !== "repo" && record.configSource !== "template") || (record.sourceConfigPath !== null && typeof record.sourceConfigPath !== "string") ) { @@ -1471,9 +1426,8 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null { version: STATE_VERSION, workspacePath: record.workspacePath, workspaceHash: record.workspaceHash, - port: record.port, ports, - sshEnabled, + sshEnabled: record.sshEnabled, configSource: record.configSource, sourceConfigPath: record.sourceConfigPath, generatedConfigPath: @@ -1487,11 +1441,7 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null { }; } -function parsePersistedPortList(value: unknown, fallbackPort: number): number[] | null { - if (value === undefined) { - return Number.isInteger(fallbackPort) && fallbackPort >= 1 && fallbackPort <= 65535 ? [fallbackPort] : null; - } - +function parsePersistedPortList(value: unknown): number[] | null { if ( !Array.isArray(value) || value.length === 0 || diff --git a/src/status.ts b/src/status.ts index b50e788..224f426 100644 --- a/src/status.ts +++ b/src/status.ts @@ -27,7 +27,6 @@ export interface DevboxStatusPortBinding { export interface DevboxStatus { running: boolean; - port: number | null; ports: number[]; sshEnabled: boolean; password: string | null; @@ -139,7 +138,7 @@ export async function getDevboxStatus( const publishedPorts = getPublishedPorts(primaryContainer); const configuredWorkspacePorts = getWorkspacePorts(state); const configuredSshPort = sshEnabled - ? state?.port + ? configuredWorkspacePorts[0] ?? sshMetadataFile.value?.sshPort ?? credentialFile.value?.sshPort ?? getManagedPortFromContainerName(primaryContainer?.Name) @@ -150,7 +149,6 @@ export async function getDevboxStatus( configuredSshPort, publishedPorts, }); - const effectivePort = ports[0] ?? null; const sshUser = sshEnabled ? sshMetadataFile.value?.sshUser ?? credentialFile.value?.user ?? null : null; const permitRootLogin = sshEnabled ? sshMetadataFile.value?.permitRootLogin ?? credentialFile.value?.permitRootLogin ?? null @@ -176,7 +174,6 @@ export async function getDevboxStatus( return { running: Boolean(primaryContainer?.State?.Running), - port: effectivePort, ports, sshEnabled, password, diff --git a/tests/arise.test.ts b/tests/arise.test.ts index 192fd7e..4d20060 100644 --- a/tests/arise.test.ts +++ b/tests/arise.test.ts @@ -9,6 +9,7 @@ import { inspectWorkspaceRestartReadiness, recoverWorkspaceMount, } from "../src/arise"; +import { STATE_VERSION } from "../src/constants"; import type { DockerInspect, WorkspaceState } from "../src/core"; const tempPaths: string[] = []; @@ -236,10 +237,11 @@ describe("ariseManagedWorkspaces", () => { [ "/tmp/ok", { - version: 2, + version: STATE_VERSION, workspacePath: "/tmp/ok", workspaceHash: "hash-ok", - port: 5001, + ports: [5001], + sshEnabled: true, configSource: "repo", sourceConfigPath: "/tmp/ok/.devcontainer/services/api/devcontainer.json", generatedConfigPath: "/tmp/ok/.devcontainer/.devcontainer.json", diff --git a/tests/core.test.ts b/tests/core.test.ts index aa55c5b..7534061 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -25,7 +25,6 @@ import { resolveWorkspaceConfig, resolvePort, resolveUpPortsPreference, - resolveUpPortPreference, validateSupportedDevcontainerConfig, getWorkspaceStateFile, type DevcontainerConfig, @@ -326,10 +325,11 @@ describe("resolvePort", () => { test("reuses stored port", () => { expect( resolvePort("up", undefined, { - version: 2, + version: STATE_VERSION, workspacePath: "/tmp/ws", workspaceHash: "hash", - port: 5003, + ports: [5003], + sshEnabled: true, configSource: "repo", sourceConfigPath: "/tmp/ws/.devcontainer/devcontainer.json", generatedConfigPath: "/tmp/ws/.devcontainer/.devbox.generated.devcontainer.json", @@ -344,7 +344,7 @@ describe("resolvePort", () => { }); describe("loadWorkspaceState", () => { - test("loads a version 1 workspace state file from local workspace state", async () => { + test("rejects a legacy workspace state file", async () => { const workspacePath = await mkdtemp(path.join(os.tmpdir(), "devbox-workspace-")); tempPaths.push(workspacePath); @@ -368,23 +368,7 @@ describe("loadWorkspaceState", () => { "utf8", ); - await expect(loadWorkspaceState(workspacePath)).resolves.toEqual({ - version: STATE_VERSION, - workspacePath, - workspaceHash: "hash", - port: 5001, - ports: [5001], - sshEnabled: true, - configSource: "repo", - sourceConfigPath: path.join(workspacePath, ".devcontainer", "devcontainer.json"), - generatedConfigPath: path.join(workspacePath, ".devcontainer", ".devbox.generated.devcontainer.json"), - labels: { managed: "true" }, - userDataDir: path.join(workspacePath, ".devbox", "user-data"), - template: null, - githubAuth: null, - lastContainerId: "container-123", - updatedAt: "2026-04-23T00:00:00.000Z", - }); + await expect(loadWorkspaceState(workspacePath)).rejects.toThrow("State file is invalid"); }); test("loads a persisted GitHub auth preference", async () => { @@ -399,7 +383,8 @@ describe("loadWorkspaceState", () => { version: STATE_VERSION, workspacePath, workspaceHash: "hash", - port: 5001, + ports: [5001], + sshEnabled: true, configSource: "repo", sourceConfigPath: path.join(workspacePath, ".devcontainer", "devcontainer.json"), generatedConfigPath: path.join(workspacePath, ".devcontainer", ".devbox.generated.devcontainer.json"), @@ -429,7 +414,8 @@ describe("loadWorkspaceState", () => { version: STATE_VERSION, workspacePath, workspaceHash: "hash", - port: 5001, + ports: [5001], + sshEnabled: true, configSource: "repo", sourceConfigPath: path.join(workspacePath, ".devcontainer", "devcontainer.json"), generatedConfigPath: path.join(workspacePath, ".devcontainer", ".devbox.generated.devcontainer.json"), @@ -448,12 +434,13 @@ describe("loadWorkspaceState", () => { }); }); -describe("resolveUpPortPreference", () => { +describe("resolveUpPortsPreference", () => { const state: WorkspaceState = { - version: 2, + version: STATE_VERSION, workspacePath: "/tmp/ws", workspaceHash: "hash", - port: 5003, + ports: [5003], + sshEnabled: true, configSource: "repo", sourceConfigPath: "/tmp/ws/.devcontainer/devcontainer.json", generatedConfigPath: "/tmp/ws/.devcontainer/.devbox.generated.devcontainer.json", @@ -464,20 +451,45 @@ describe("resolveUpPortPreference", () => { updatedAt: new Date().toISOString(), }; - test("prefers an explicit port", () => { - expect(resolveUpPortPreference({ explicitPort: 5001, state, existingPublishedPort: 5002 })).toBe(5001); + test("prefers an explicit first port", () => { + expect( + resolveUpPortsPreference({ + explicitPort: 5001, + portCount: 3, + state, + existingPublishedPort: 5002, + }), + ).toEqual([5001]); }); - test("reuses the stored workspace port when no explicit port is provided", () => { - expect(resolveUpPortPreference({ explicitPort: undefined, state, existingPublishedPort: 5002 })).toBe(5003); + test("reuses the stored workspace ports when no explicit port is provided", () => { + expect( + resolveUpPortsPreference({ + explicitPort: undefined, + state, + existingPublishedPort: 5002, + }), + ).toEqual([5003]); }); test("falls back to an existing managed container port when state is missing", () => { - expect(resolveUpPortPreference({ explicitPort: undefined, state: null, existingPublishedPort: 5004 })).toBe(5004); + expect( + resolveUpPortsPreference({ + explicitPort: undefined, + state: null, + existingPublishedPort: 5004, + }), + ).toEqual([5004]); }); test("returns undefined when up should auto-assign a new port", () => { - expect(resolveUpPortPreference({ explicitPort: undefined, state: null, existingPublishedPort: undefined })).toBeUndefined(); + expect( + resolveUpPortsPreference({ + explicitPort: undefined, + state: null, + existingPublishedPort: undefined, + }), + ).toBeUndefined(); }); test("reuses all stored ports when the requested count matches", () => { @@ -615,7 +627,8 @@ describe("resolveWorkspaceConfig", () => { version: STATE_VERSION, workspacePath: tempDir, workspaceHash: "workspace-hash", - port: 5001, + ports: [5001], + sshEnabled: true, configSource: "template", sourceConfigPath: null, generatedConfigPath: getTemplateGeneratedConfigPath(tempDir), @@ -666,7 +679,8 @@ describe("resolveWorkspaceConfig", () => { version: STATE_VERSION, workspacePath: tempDir, workspaceHash: "workspace-hash", - port: 5001, + ports: [5001], + sshEnabled: true, configSource: "template", sourceConfigPath: null, generatedConfigPath: path.join(os.tmpdir(), "old-devbox-state", ".devcontainer.json"), @@ -718,7 +732,7 @@ describe("buildManagedConfig", () => { }; const managed = buildManagedConfig(baseConfig, { - port: 5001, + ports: [5001], containerName: "devbox-example-5001", sshAuthSock: "/tmp/agent.sock", knownHostsPath: "/tmp/known_hosts", @@ -747,7 +761,7 @@ describe("buildManagedConfig", () => { }, }, { - port: 5001, + ports: [5001], containerName: "devbox-example-5001", sshAuthSock: null, knownHostsPath: null, @@ -767,7 +781,7 @@ describe("buildManagedConfig", () => { image: "mcr.microsoft.com/devcontainers/base:ubuntu", }, { - port: 5001, + ports: [5001], containerName: "devbox-example-5001", sshAuthSock: DOCKER_DESKTOP_SSH_AUTH_SOCK_SOURCE, knownHostsPath: null, @@ -790,7 +804,7 @@ describe("buildManagedConfig", () => { runArgs: ["--name", "custom-name", "-p", "5001:5001"], }, { - port: 5001, + ports: [5001], containerName: "devbox-example-5001", sshAuthSock: "/tmp/agent.sock", knownHostsPath: null, @@ -832,7 +846,7 @@ describe("buildManagedConfig", () => { image: "mcr.microsoft.com/devcontainers/base:ubuntu", }, { - port: 5001, + ports: [5001], containerName: "devbox-example-5001", sshAuthSock: null, knownHostsPath: null, @@ -852,7 +866,7 @@ describe("buildManagedConfig", () => { containerUser: "vscode", }, { - port: 5001, + ports: [5001], containerName: "devbox-example-5001", sshAuthSock: null, knownHostsPath: null, diff --git a/tests/examples.live.test.ts b/tests/examples.live.test.ts index 28217d3..2f8fdec 100644 --- a/tests/examples.live.test.ts +++ b/tests/examples.live.test.ts @@ -100,7 +100,7 @@ describe("example workspaces (real devcontainers)", () => { expect(up.stdout).toContain("Ready."); const state = await readJson(fixture.statePath); - const selectedPort = Number(state.port); + const selectedPort = Number(state.ports[0]); const containerId = String(state.lastContainerId); expect(up.stdout).toContain(`Using port ${selectedPort}.`); expect(state.ports).toEqual([selectedPort]); @@ -145,7 +145,8 @@ describe("example workspaces (real devcontainers)", () => { const state = await readJson(fixture.statePath); const containerId = String(state.lastContainerId); - expect(state.port).toBe(fixture.port); + expect(state.ports).toEqual([fixture.port]); + expect(state.port).toBeUndefined(); const inspect = inspectContainer(fixture, containerId); expect(inspect.Name).toBe(`/${getManagedContainerName(fixture.workspacePath, fixture.port)}`); @@ -303,7 +304,8 @@ describe("example workspaces (real devcontainers)", () => { const state = await readJson(fixture.statePath); const firstContainerId = String(state.lastContainerId); - expect(state.port).toBe(fixture.port); + expect(state.ports).toEqual([fixture.port]); + expect(state.port).toBeUndefined(); const inspect = inspectContainer(fixture, firstContainerId); expect(inspect.Name).toBe(`/${getManagedContainerName(fixture.workspacePath, fixture.port)}`); @@ -370,7 +372,8 @@ describe("example workspaces (real devcontainers)", () => { const rebuiltState = await readJson(fixture.statePath); const rebuiltContainerId = String(rebuiltState.lastContainerId); - expect(rebuiltState.port).toBe(fixture.port); + expect(rebuiltState.ports).toEqual([fixture.port]); + expect(rebuiltState.port).toBeUndefined(); expect(rebuiltContainerId).not.toBe(firstContainerId); const restoredHostKey = execInContainerAsRoot(fixture, rebuiltContainerId, "find /etc/ssh -maxdepth 1 -type f -name 'ssh_host_*_key' | head -n 1"); expect(restoredHostKey.stdout.trim().length).toBeGreaterThan(0); diff --git a/tests/examples.test.ts b/tests/examples.test.ts index 0698dfd..0d7c241 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -413,7 +413,8 @@ describe("example workspaces (simulated host tools)", () => { expect(generatedConfig.containerEnv).toEqual({}); const state = await readJson(fixture.statePath); - expect(state.port).toBe(5001); + expect(state.ports).toEqual([5001]); + expect(state.port).toBeUndefined(); expect(state.sourceConfigPath).toBe(fixture.sourceConfigPath); expect(state.configSource).toBe("repo"); expect(state.generatedConfigPath).toBe(fixture.generatedConfigPath); @@ -458,7 +459,8 @@ describe("example workspaces (simulated host tools)", () => { expect(statusWhileRunning.exitCode).toBe(0); const runningStatus = JSON.parse(statusWhileRunning.stdout); expect(runningStatus.running).toBe(true); - expect(runningStatus.port).toBe(5001); + expect(runningStatus.ports).toEqual([5001]); + expect(runningStatus.port).toBeUndefined(); expect(runningStatus.password).toBe("password"); expect(runningStatus.workdir).toBe("/workspaces/smoke-workspace"); expect(runningStatus.containerCount).toBe(1); @@ -483,7 +485,8 @@ describe("example workspaces (simulated host tools)", () => { expect(statusAfterDown.exitCode).toBe(0); const stoppedStatus = JSON.parse(statusAfterDown.stdout); expect(stoppedStatus.running).toBe(false); - expect(stoppedStatus.port).toBe(5001); + expect(stoppedStatus.ports).toEqual([5001]); + expect(stoppedStatus.port).toBeUndefined(); expect(stoppedStatus.password).toBe("password"); expect(stoppedStatus.hasStateFile).toBe(true); expect(stoppedStatus.hasCredentialFile).toBe(true); @@ -528,8 +531,8 @@ describe("example workspaces (simulated host tools)", () => { ]); const stateWithoutSsh = await readJson(fixture.statePath); - expect(stateWithoutSsh.port).toBe(6000); expect(stateWithoutSsh.ports).toEqual([6000, 6001, 6002]); + expect(stateWithoutSsh.port).toBeUndefined(); expect(stateWithoutSsh.sshEnabled).toBe(false); const commandsWithoutSsh = await readCommandLog(fixture.commandLogPath); @@ -620,7 +623,8 @@ describe("example workspaces (simulated host tools)", () => { expect(rebuild.stdout).toContain("Ready."); const rebuiltState = await readJson(fixture.statePath); - expect(rebuiltState.port).toBe(5001); + expect(rebuiltState.ports).toEqual([5001]); + expect(rebuiltState.port).toBeUndefined(); expect(rebuiltState.lastContainerId).not.toBe(initialState.lastContainerId); const down = runCli(fixture, ["down"]); @@ -704,7 +708,8 @@ describe("example workspaces (simulated host tools)", () => { expect(existsSync(fixture.generatedConfigPath)).toBe(true); const state = await readJson(fixture.statePath); - expect(state.port).toBe(5010); + expect(state.ports).toEqual([5010]); + expect(state.port).toBeUndefined(); expect(state.configSource).toBe("template"); expect(state.sourceConfigPath).toBeNull(); expect(state.template.name).toBe("ubuntu"); diff --git a/tests/status.test.ts b/tests/status.test.ts index 4dd4ecd..7e80122 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { STATE_VERSION } from "../src/constants"; import type { DockerInspect, WorkspaceState } from "../src/core"; import { createRunnerMetadata, parseRunnerCredentials, serializeRunnerMetadata } from "../src/runnerState"; import { getDevboxStatus } from "../src/status"; @@ -43,10 +44,11 @@ describe("parseRunnerCredentials", () => { describe("getDevboxStatus", () => { test("prefers live container data and config hints when available", async () => { const state: WorkspaceState = { - version: 2, + version: STATE_VERSION, workspacePath: "/tmp/ws", workspaceHash: "workspace-hash", - port: 5001, + ports: [5001], + sshEnabled: true, configSource: "repo", sourceConfigPath: "/tmp/ws/.devcontainer/devcontainer.json", generatedConfigPath: "/tmp/ws/.devcontainer/.devcontainer.json", @@ -110,7 +112,6 @@ describe("getDevboxStatus", () => { ); expect(status.running).toBe(true); - expect(status.port).toBe(5001); expect(status.ports).toEqual([5001]); expect(status.sshEnabled).toBe(true); expect(status.password).toBe("secret"); @@ -133,10 +134,9 @@ describe("getDevboxStatus", () => { test("reports all published ports without exposing SSH details when SSH is disabled", async () => { const state: WorkspaceState = { - version: 3, + version: STATE_VERSION, workspacePath: "/tmp/no-ssh", workspaceHash: "workspace-hash", - port: 5001, ports: [5001, 5002], sshEnabled: false, configSource: "repo", @@ -186,7 +186,6 @@ describe("getDevboxStatus", () => { ); expect(status.running).toBe(true); - expect(status.port).toBe(15001); expect(status.ports).toEqual([15001, 15002]); expect(status.sshEnabled).toBe(false); expect(status.password).toBeNull(); @@ -233,7 +232,7 @@ describe("getDevboxStatus", () => { ); expect(status.running).toBe(false); - expect(status.port).toBe(5010); + expect(status.ports).toEqual([5010]); expect(status.password).toBe("password"); expect(status.sshUser).toBe("root"); expect(status.sshPort).toBe(5010); @@ -255,10 +254,11 @@ describe("getDevboxStatus", () => { { workspacePath: "/tmp/parse-fallback", state: { - version: 2, + version: STATE_VERSION, workspacePath: "/tmp/parse-fallback", workspaceHash: "workspace-hash", - port: 5001, + ports: [5001], + sshEnabled: true, configSource: "repo", sourceConfigPath: "/tmp/parse-fallback/custom/devcontainer.json", generatedConfigPath: "/tmp/parse-fallback/.devcontainer/.devcontainer.json", @@ -298,10 +298,11 @@ describe("getDevboxStatus", () => { { workspacePath: "/tmp/object-fallback", state: { - version: 2, + version: STATE_VERSION, workspacePath: "/tmp/object-fallback", workspaceHash: "workspace-hash", - port: 5001, + ports: [5001], + sshEnabled: true, configSource: "repo", sourceConfigPath: "/tmp/object-fallback/custom/devcontainer.json", generatedConfigPath: "/tmp/object-fallback/.devcontainer/.devcontainer.json", @@ -341,10 +342,11 @@ describe("getDevboxStatus", () => { { workspacePath: "/tmp/no-container", state: { - version: 2, + version: STATE_VERSION, workspacePath: "/tmp/no-container", workspaceHash: "workspace-hash", - port: 5001, + ports: [5001], + sshEnabled: true, configSource: "repo", sourceConfigPath: "/tmp/no-container/.devcontainer/devcontainer.json", generatedConfigPath: "/tmp/no-container/.devcontainer/.devcontainer.json", @@ -378,10 +380,11 @@ describe("getDevboxStatus", () => { { workspacePath: "/tmp/password-only", state: { - version: 2, + version: STATE_VERSION, workspacePath: "/tmp/password-only", workspaceHash: "workspace-hash", - port: 5005, + ports: [5005], + sshEnabled: true, configSource: "repo", sourceConfigPath: "/tmp/password-only/.devcontainer/devcontainer.json", generatedConfigPath: "/tmp/password-only/.devcontainer/.devcontainer.json", @@ -440,10 +443,11 @@ describe("getDevboxStatus", () => { { workspacePath: "/tmp/port-selection", state: { - version: 2, + version: STATE_VERSION, workspacePath: "/tmp/port-selection", workspaceHash: "workspace-hash", - port: 5001, + ports: [5001], + sshEnabled: true, configSource: "repo", sourceConfigPath: "/tmp/port-selection/.devcontainer/devcontainer.json", generatedConfigPath: "/tmp/port-selection/.devcontainer/.devcontainer.json", @@ -479,7 +483,7 @@ describe("getDevboxStatus", () => { }, ); - expect(status.port).toBe(15001); + expect(status.ports).toEqual([15001]); expect(status.sshPort).toBe(5001); expect(status.publishedPorts["3000/tcp"]?.[0]?.hostPort).toBe(3000); expect(status.publishedPorts["5001/tcp"]?.[0]?.hostPort).toBe(15001); @@ -513,7 +517,6 @@ describe("getDevboxStatus", () => { ); expect(status.ports).toEqual([13000, 18080]); - expect(status.port).toBe(13000); }); test("falls back to state and credential data when docker is unavailable", async () => { @@ -552,7 +555,7 @@ describe("getDevboxStatus", () => { expect(status.running).toBe(false); expect(status.containerCount).toBe(0); - expect(status.port).toBe(5010); + expect(status.ports).toEqual([5010]); expect(status.warnings).toContain( "Docker was not found in PATH; reporting saved workspace state and persisted SSH files only.", ); From e0a66c19fc3507b5ffa3e101eed0c960374a31c8 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Tue, 8 Sep 2026 17:07:38 +0000 Subject: [PATCH 4/4] fix: address latest PR review comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 6 ++++-- src/cli.ts | 12 +++++++++--- src/core.ts | 5 +++++ src/runtime.ts | 7 ++++++- tests/core.test.ts | 3 +++ tests/examples.test.ts | 8 ++++++++ tests/runtime.test.ts | 6 ++++++ 7 files changed, 41 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f02fc22..da47d0e 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ devbox update devbox templates # Rebuild/recreate the managed devcontainer -devbox rebuild +devbox rebuild [--ports ] # Open an interactive shell in the running managed devcontainer for this workspace devbox shell @@ -123,6 +123,8 @@ Use `--ports ` to request how many ports should be published. If the firs When you run `devbox rebuild`, omitting the port reuses the last stored port list for the current workspace. +When changing the number of ports for an already running workspace, pass the desired count to `rebuild`, for example `devbox rebuild 6000 --ports 2`. + The workspace state file uses schema version `4` and stores the selected ports only as `ports`; it never contains a singular `port` field: ```json @@ -143,7 +145,7 @@ GitHub CLI authentication for `GH_TOKEN` injection can be pinned per workspace w `devbox shell` requires an already running managed container for the current workspace. If none is running, use `devbox up` first. -`devbox exec -- [args...]` runs a non-interactive command in the already running managed container for the current workspace. Everything after the `--` separator is forwarded unchanged, and the command's standard input/output/error and exit code are preserved, which makes it suitable for scripts and automation. If no managed container is running, run `devbox up` first. +`devbox exec -- [args...]` runs a non-interactive command in the already running managed container for the current workspace. The `--` separator must be the first argument after `exec`; everything after it is forwarded unchanged. The command's standard input/output/error and exit code are preserved, which makes it suitable for scripts and automation. If no managed container is running, run `devbox up` first. `devbox status` always prints JSON so it can be used directly from scripts and automation. diff --git a/src/cli.ts b/src/cli.ts index 1c7744f..8cca282 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -270,10 +270,16 @@ async function handleUpLike( existingInspects = []; } else if (existingInspects[0]) { const publishedPorts = getPublishedHostPorts(existingInspects[0]); - const missingPorts = ports.filter((port) => !publishedPorts.includes(port)); - if (publishedPorts.length > 0 && missingPorts.length > 0) { + const publishedPortSet = new Set(publishedPorts); + const requestedPortSet = new Set(ports); + const portListChanged = + publishedPorts.length !== ports.length || + ports.some((port) => !publishedPortSet.has(port)) || + publishedPorts.some((port) => !requestedPortSet.has(port)); + if (publishedPorts.length > 0 && portListChanged) { + const rebuildCommand = `devbox rebuild ${ports[0]}${ports.length > 1 ? ` --ports ${ports.length}` : ""}`; throw new UserError( - `This workspace already has a managed container publishing port(s) ${publishedPorts.join(", ")}. Use \`devbox rebuild ${ports[0]}\` to change the port list.`, + `This workspace already has a managed container publishing port(s) ${publishedPorts.join(", ")}. Use \`${rebuildCommand}\` to change the port list.`, ); } } diff --git a/src/core.ts b/src/core.ts index a2f23e2..cb14a56 100644 --- a/src/core.ts +++ b/src/core.ts @@ -241,6 +241,11 @@ export function parseArgs(argv: string[]): ParsedArgs { if (separatorIndex === -1) { throw new UserError(`The exec command requires \`--\` before the command. Usage: \`${CLI_NAME} exec -- [args...]\``); } + if (separatorIndex !== 0) { + throw new UserError( + `The exec command requires \`--\` as its first argument. Usage: \`${CLI_NAME} exec -- [args...]\``, + ); + } const execArgs = args.slice(separatorIndex + 1); if (execArgs.length === 0) { diff --git a/src/runtime.ts b/src/runtime.ts index 3ed664e..5516af5 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -626,7 +626,12 @@ export async function findAvailablePorts( throw new UserError(`No available host port was found starting at ${startPort}.`); } - throw new UserError(`No available host ports were found starting at ${startPort}; requested ${count}.`); + if (ports.length === 0) { + throw new UserError(`No available host ports were found starting at ${startPort}; requested ${count}.`); + } + + const portLabel = ports.length === 1 ? "port was" : "ports were"; + throw new UserError(`Only ${ports.length} available host ${portLabel} found starting at ${startPort}; requested ${count}.`); } export async function findFirstAvailablePort( diff --git a/tests/core.test.ts b/tests/core.test.ts index 7534061..90c3286 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -94,6 +94,9 @@ describe("parseArgs", () => { expect(() => parseArgs(["exec", "printf", "hello"])).toThrow( "The exec command requires `--` before the command. Usage: `devbox exec -- [args...]`", ); + expect(() => parseArgs(["exec", "printf", "--", "hello"])).toThrow( + "The exec command requires `--` as its first argument. Usage: `devbox exec -- [args...]`", + ); }); test("supports the status subcommand", () => { diff --git a/tests/examples.test.ts b/tests/examples.test.ts index 0d7c241..9c175e6 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -540,6 +540,14 @@ describe("example workspaces (simulated host tools)", () => { commandsWithoutSsh.some((entry) => typeof entry.script === "string" && entry.script.includes("SSH_PORT=")), ).toBe(false); + const increasedPortCount = runCli(fixture, ["up", "--ports", "4", "--no-ssh", "--allow-missing-ssh"]); + expect(increasedPortCount.exitCode).toBe(1); + expect(increasedPortCount.stderr).toContain("Use `devbox rebuild 6000 --ports 4` to change the port list."); + + const reducedPortCount = runCli(fixture, ["up", "--ports", "1", "--no-ssh", "--allow-missing-ssh"]); + expect(reducedPortCount.exitCode).toBe(1); + expect(reducedPortCount.stderr).toContain("Use `devbox rebuild 6000` to change the port list."); + const statusWithoutSsh = runCli(fixture, ["status"]); expect(statusWithoutSsh.exitCode).toBe(0); const status = JSON.parse(statusWithoutSsh.stdout); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 9374dca..80f506f 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -278,6 +278,12 @@ describe("findAvailablePorts", () => { "No available host ports were found starting at 65535; requested 2.", ); }); + + test("reports partial allocation when the port range has insufficient capacity", async () => { + await expect(findAvailablePorts(65534, 2, async (port) => port === 65534)).rejects.toThrow( + "Only 1 available host port was found starting at 65534; requested 2.", + ); + }); }); describe("probePortAvailability", () => {