From 571466ff7c6e4bbaa303f6ccfafeabe24bc18f9a Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Wed, 9 Sep 2026 03:18:03 +0000 Subject: [PATCH 1/5] feat: add persistent startup command hook Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 18 ++++++-- src/cli.ts | 18 ++++++++ src/core.ts | 75 +++++++++++++++++++++++++++++- src/runtime.ts | 13 ++++++ tests/core.test.ts | 102 +++++++++++++++++++++++++++++++++++++++++ tests/examples.test.ts | 46 +++++++++++++++++++ tests/runtime.test.ts | 13 ++++++ 7 files changed, 279 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index da47d0e..fd275a4 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 one or more service ports" workflow, with an optional bundled SSH entrypoint. +`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 and persistent post-start command hook. 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. @@ -16,6 +16,7 @@ It does not modify the original `devcontainer.json`. Instead, it generates a der - 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 unless SSH is disabled. +- Runs an optional persisted startup command inside the devcontainer after it is ready. - Stores devbox-owned state, SSH credentials, SSH metadata, and SSH host keys under the workspace-local `.devbox/` directory so they survive `down` / `rebuild`. ## Installation @@ -67,6 +68,12 @@ devbox up --ports 3 # Publish two ports without installing or starting bundled SSH devbox up --ports 2 --no-ssh +# Run a persistent command after every devbox up or rebuild +devbox up --no-ssh --startup-command '.devbox/clanky-worker/start.sh' + +# Clear the persistent post-start command +devbox up --no-startup-command + # Re-enable bundled SSH for a workspace previously started with --no-ssh devbox up --ssh @@ -121,6 +128,8 @@ When you run `devbox up`, the port precedence is: 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. +`--startup-command ` stores a shell command in the workspace state and runs it inside the container after each successful `devbox up` or `devbox rebuild`, including runs started by `devbox arise`. The command runs independently of the bundled SSH server and is executed through `devcontainer exec`; it should be idempotent and daemonize any long-running process it starts. Use `--no-startup-command` to clear the stored command. The hook is invoked by Devbox's CLI lifecycle, so a raw `docker restart` does not invoke it. + 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`. @@ -131,7 +140,8 @@ The workspace state file uses schema version `4` and stores the selected ports o { "version": 4, "ports": [5001, 5002], - "sshEnabled": false + "sshEnabled": false, + "startupCommand": ".devbox/clanky-worker/start.sh" } ``` @@ -244,7 +254,7 @@ The complex example uses several devcontainer features, so the first `up` or `re - When `devbox` uses a repo devcontainer, the generated config is written next to the original devcontainer config, using the alternate accepted devcontainer filename so relative Dockerfile paths keep working. - When `devbox` uses `--template`, it writes the generated config to `.devbox/.devcontainer.json` instead of creating a source devcontainer definition inside the repo. -- `.devbox/` contains all devbox-owned local state (`state.json`, `user-data/`, template generated configs, and `ssh/`) and should stay ignored by version control. +- `.devbox/` contains all devbox-owned local state (`state.json`, `user-data/`, template generated configs, `ssh/`, and any startup-command integration data) and should stay ignored by version control. - `.devbox/state.json` may include `githubAuth: { "host": "...", "user": "..." }` so tools can detect or preserve the GitHub CLI account devbox will use for future `GH_TOKEN` injection. - `--devcontainer-subpath services/api` tells `devbox` to use `.devcontainer/services/api/devcontainer.json`. - `--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`. @@ -256,7 +266,7 @@ The complex example uses several devcontainer features, so the first `up` or `re - 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 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. +- Re-running `devbox up` after a host restart recreates the desired state: container up, all configured ports published, the SSH runner restarted when it is enabled, and the persisted startup command rerun when configured. - 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. diff --git a/src/cli.ts b/src/cli.ts index 8cca282..77bf899 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -58,6 +58,7 @@ import { requiresSshAuthSockPermissionFix, removeContainers, restoreRunnerHostKeys, + runStartupCommand, runDevcontainerCommand, startRunner, stopManagedSshd, @@ -137,6 +138,8 @@ async function main(): Promise { parsed.templateName, parsed.githubUser, parsed.githubHost, + parsed.startupCommand, + parsed.clearStartupCommand ?? false, ); } @@ -153,6 +156,8 @@ async function handleUpLike( templateName?: string, explicitGithubUser?: string, explicitGithubHost?: string, + explicitStartupCommand?: string, + clearStartupCommand = false, ): Promise { const githubAuth = resolveGithubAuthPreference({ explicitUser: explicitGithubUser, @@ -161,6 +166,9 @@ async function handleUpLike( env: process.env, }); const sshEnabled = explicitSshEnabled ?? getWorkspaceSshEnabled(state); + const startupCommand = clearStartupCommand + ? undefined + : explicitStartupCommand ?? state?.startupCommand; const environment = await ensureHostEnvironment({ allowMissingSsh, workspacePath, githubAuth }); const resolvedSshPublicKey = sshEnabled ? await resolveSshPublicKey({ overridePath: sshPublicKeyPath }) @@ -387,11 +395,21 @@ async function handleUpLike( console.log("Bundled SSH server installation skipped; published ports are ready for the devcontainer service."); } + if (startupCommand) { + await runStepWithHeartbeat({ + startMessage: "Running the configured post-start command...", + heartbeatMessage: "Still running the configured post-start command", + successMessage: "Configured post-start command completed", + action: () => runStartupCommand(upResult.containerId, startupCommand), + }); + } + await saveWorkspaceState( createWorkspaceState({ workspacePath, ports, sshEnabled, + startupCommand, configSource: resolvedConfig.configSource, sourceConfigPath: resolvedConfig.sourceConfigPath, generatedConfigPath, diff --git a/src/core.ts b/src/core.ts index cb14a56..62bf0e3 100644 --- a/src/core.ts +++ b/src/core.ts @@ -41,6 +41,8 @@ export interface ParsedArgs { portCount?: number; allowMissingSsh: boolean; sshEnabled?: boolean; + startupCommand?: string; + clearStartupCommand?: boolean; devcontainerSubpath?: string; sshPublicKeyPath?: string; templateName?: string; @@ -78,6 +80,7 @@ export interface WorkspaceState { workspaceHash: string; ports: number[]; sshEnabled: boolean; + startupCommand?: string; configSource: "repo" | "template"; sourceConfigPath: string | null; generatedConfigPath: string; @@ -158,8 +161,8 @@ export function helpText(): string { "", "Usage:", ` ${CLI_NAME}`, - ` ${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} up [port] [--ports ] [--allow-missing-ssh] [--no-ssh|--ssh] [--startup-command |--no-startup-command] [--devcontainer-subpath ] [--ssh-public-key ] [--template ] [--gh-user ] [--gh-host ]`, + ` ${CLI_NAME} rebuild [port] [--ports ] [--allow-missing-ssh] [--no-ssh|--ssh] [--startup-command |--no-startup-command] [--devcontainer-subpath ] [--ssh-public-key ] [--gh-user ] [--gh-host ]`, ` ${CLI_NAME} shell`, ` ${CLI_NAME} exec -- [args...]`, ` ${CLI_NAME} status`, @@ -188,6 +191,8 @@ export function helpText(): string { " --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.", + " --startup-command Run and persist a command after the container starts.", + " --no-startup-command Clear the persisted post-start command.", " --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.", @@ -263,6 +268,8 @@ export function parseArgs(argv: string[]): ParsedArgs { let portCount: number | undefined; let allowMissingSsh = false; let sshEnabled: boolean | undefined; + let startupCommand: string | undefined; + let clearStartupCommand = false; let devcontainerSubpath: string | undefined; let sshPublicKeyPath: string | undefined; let templateName: string | undefined; @@ -293,6 +300,35 @@ export function parseArgs(argv: string[]): ParsedArgs { continue; } + if (arg === "--startup-command") { + const value = args[index + 1]; + if (!value) { + throw new UserError("Expected a value after --startup-command."); + } + if (clearStartupCommand) { + throw new UserError("Cannot combine --startup-command with --no-startup-command."); + } + startupCommand = parseStartupCommand(value); + index += 1; + continue; + } + + if (arg.startsWith("--startup-command=")) { + if (clearStartupCommand) { + throw new UserError("Cannot combine --startup-command with --no-startup-command."); + } + startupCommand = parseStartupCommand(arg.slice("--startup-command=".length)); + continue; + } + + if (arg === "--no-startup-command") { + if (startupCommand !== undefined) { + throw new UserError("Cannot combine --startup-command with --no-startup-command."); + } + clearStartupCommand = true; + continue; + } + if (arg === "--ports") { const value = args[index + 1]; if (!value) { @@ -489,6 +525,14 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError(`The ${command} command does not accept ${sshEnabled ? "--ssh" : "--no-ssh"}.`); } + if (command !== "up" && command !== "rebuild" && startupCommand !== undefined) { + throw new UserError(`The ${command} command does not accept --startup-command.`); + } + + if (command !== "up" && command !== "rebuild" && clearStartupCommand) { + throw new UserError(`The ${command} command does not accept --no-startup-command.`); + } + if (command === "shell" && devcontainerSubpath !== undefined) { throw new UserError("The shell command does not accept --devcontainer-subpath."); } @@ -611,6 +655,8 @@ export function parseArgs(argv: string[]): ParsedArgs { ...(portCount !== undefined ? { portCount } : {}), allowMissingSsh, ...(sshEnabled !== undefined ? { sshEnabled } : {}), + ...(startupCommand !== undefined ? { startupCommand } : {}), + ...(clearStartupCommand ? { clearStartupCommand } : {}), ...(devcontainerSubpath ? { devcontainerSubpath } : {}), ...(sshPublicKeyPath ? { sshPublicKeyPath } : {}), ...(templateName ? { templateName } : {}), @@ -634,6 +680,15 @@ export function parsePort(raw: string): number { return port; } +export function parseStartupCommand(raw: string): string { + const command = raw.trim(); + if (!command) { + throw new UserError("Startup command must not be empty."); + } + + return command; +} + export function parsePortCount(raw: string): number { if (!/^\d+$/.test(raw)) { throw new UserError(`Invalid port count: ${raw}`); @@ -984,6 +1039,7 @@ export function createWorkspaceState(input: { workspacePath: string; ports: number[]; sshEnabled: boolean; + startupCommand?: string; configSource: "repo" | "template"; sourceConfigPath: string | null; generatedConfigPath: string; @@ -1001,6 +1057,7 @@ export function createWorkspaceState(input: { workspaceHash: hashWorkspacePath(input.workspacePath), ports, sshEnabled: input.sshEnabled, + ...(input.startupCommand !== undefined ? { startupCommand: input.startupCommand } : {}), configSource: input.configSource, sourceConfigPath: input.sourceConfigPath, generatedConfigPath: input.generatedConfigPath, @@ -1414,6 +1471,7 @@ function parseWorkspaceState(value: unknown): WorkspaceState | null { 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 startupCommand = parsePersistedStartupCommand(record.startupCommand); const githubAuth = normalizeGithubAuthPreference(record.githubAuth); if ( @@ -1433,6 +1491,7 @@ function parseWorkspaceState(value: unknown): WorkspaceState | null { workspaceHash: record.workspaceHash, ports, sshEnabled: record.sshEnabled, + ...(startupCommand !== undefined ? { startupCommand } : {}), configSource: record.configSource, sourceConfigPath: record.sourceConfigPath, generatedConfigPath: @@ -1446,6 +1505,18 @@ function parseWorkspaceState(value: unknown): WorkspaceState | null { }; } +function parsePersistedStartupCommand(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + + if (typeof value !== "string" || value.trim().length === 0) { + throw new UserError("State file startupCommand must be a non-empty string."); + } + + return value.trim(); +} + function parsePersistedPortList(value: unknown): number[] | null { if ( !Array.isArray(value) || diff --git a/src/runtime.ts b/src/runtime.ts index 5516af5..da70dc7 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -869,6 +869,19 @@ export async function startRunner( }; } +export async function runStartupCommand(containerId: string, command: string): Promise { + await devcontainerExec(containerId, buildStartupCommandScript(command), { quiet: true }); +} + +export function buildStartupCommandScript(command: string): string { + const trimmed = command.trim(); + if (!trimmed) { + throw new UserError("Startup command must not be empty."); + } + + return trimmed; +} + export function buildStartRunnerScript(port: number, remoteWorkspaceFolder: string): string { return `env SSH_PORT=${quoteShell(String(port))} CRED_FILE=${quoteShell(getRunnerCredFile(remoteWorkspaceFolder))} bash -s`; } diff --git a/tests/core.test.ts b/tests/core.test.ts index 90c3286..e8fb302 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -22,6 +22,7 @@ import { prepareKnownHostsMount, loadWorkspaceState, parsePortCount, + parseStartupCommand, resolveWorkspaceConfig, resolvePort, resolveUpPortsPreference, @@ -75,6 +76,24 @@ describe("parseArgs", () => { }); }); + test("supports configuring and clearing a persisted startup command", () => { + expect(parseArgs(["up", "--startup-command", ".devbox/clanky-worker/start.sh"])).toEqual({ + command: "up", + allowMissingSsh: false, + startupCommand: ".devbox/clanky-worker/start.sh", + }); + expect(parseArgs(["rebuild", "--startup-command=.devbox/start.sh"])).toEqual({ + command: "rebuild", + allowMissingSsh: false, + startupCommand: ".devbox/start.sh", + }); + expect(parseArgs(["up", "--no-startup-command"])).toEqual({ + command: "up", + allowMissingSsh: false, + clearStartupCommand: true, + }); + }); + test("supports the shell subcommand", () => { expect(parseArgs(["shell"])).toEqual({ command: "shell", allowMissingSsh: false }); }); @@ -270,6 +289,23 @@ describe("parseArgs", () => { expect(() => parseArgs(["up", "--gh-host", "https://github.com"])).toThrow("Invalid GitHub host:"); }); + test("rejects invalid and conflicting startup command options", () => { + expect(() => parseStartupCommand(" ")).toThrow("Startup command must not be empty."); + expect(() => parseArgs(["up", "--startup-command", " "])).toThrow("Startup command must not be empty."); + expect(() => parseArgs(["up", "--startup-command", "start", "--no-startup-command"])).toThrow( + "Cannot combine --startup-command with --no-startup-command.", + ); + expect(() => parseArgs(["up", "--no-startup-command", "--startup-command", "start"])).toThrow( + "Cannot combine --startup-command with --no-startup-command.", + ); + expect(() => parseArgs(["status", "--startup-command", "start"])).toThrow( + "The status command does not accept --startup-command.", + ); + expect(() => parseArgs(["status", "--no-startup-command"])).toThrow( + "The status command does not accept --no-startup-command.", + ); + }); + 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:"); @@ -297,6 +333,8 @@ describe("helpText", () => { expect(text).toContain("--ports "); expect(text).toContain("--no-ssh"); expect(text).toContain("--ssh"); + expect(text).toContain("--startup-command "); + expect(text).toContain("--no-startup-command"); }); test("lists all commands", () => { @@ -405,6 +443,70 @@ describe("loadWorkspaceState", () => { }); }); + test("loads a persisted startup command without requiring a schema migration", async () => { + const workspacePath = await mkdtemp(path.join(os.tmpdir(), "devbox-workspace-")); + tempPaths.push(workspacePath); + + const statePath = getWorkspaceStateFile(workspacePath); + await mkdir(path.dirname(statePath), { recursive: true }); + await writeFile( + statePath, + `${JSON.stringify({ + version: STATE_VERSION, + workspacePath, + workspaceHash: "hash", + ports: [5001], + sshEnabled: false, + startupCommand: ".devbox/clanky-worker/start.sh", + configSource: "repo", + sourceConfigPath: path.join(workspacePath, ".devcontainer", "devcontainer.json"), + generatedConfigPath: path.join(workspacePath, ".devcontainer", ".devcontainer.json"), + labels: { managed: "true" }, + userDataDir: path.join(workspacePath, ".devbox", "user-data"), + template: null, + githubAuth: null, + updatedAt: "2026-04-23T00:00:00.000Z", + }, null, 2)}\n`, + "utf8", + ); + + await expect(loadWorkspaceState(workspacePath)).resolves.toMatchObject({ + startupCommand: ".devbox/clanky-worker/start.sh", + }); + }); + + test("rejects an empty persisted startup command", async () => { + const workspacePath = await mkdtemp(path.join(os.tmpdir(), "devbox-workspace-")); + tempPaths.push(workspacePath); + + const statePath = getWorkspaceStateFile(workspacePath); + await mkdir(path.dirname(statePath), { recursive: true }); + await writeFile( + statePath, + `${JSON.stringify({ + version: STATE_VERSION, + workspacePath, + workspaceHash: "hash", + ports: [5001], + sshEnabled: false, + startupCommand: " ", + configSource: "repo", + sourceConfigPath: path.join(workspacePath, ".devcontainer", "devcontainer.json"), + generatedConfigPath: path.join(workspacePath, ".devcontainer", ".devcontainer.json"), + labels: { managed: "true" }, + userDataDir: path.join(workspacePath, ".devbox", "user-data"), + template: null, + githubAuth: null, + updatedAt: "2026-04-23T00:00:00.000Z", + }, null, 2)}\n`, + "utf8", + ); + + await expect(loadWorkspaceState(workspacePath)).rejects.toThrow( + "State file startupCommand must be a non-empty string.", + ); + }); + test("drops invalid persisted GitHub auth preferences", async () => { const workspacePath = await mkdtemp(path.join(os.tmpdir(), "devbox-workspace-")); tempPaths.push(workspacePath); diff --git a/tests/examples.test.ts b/tests/examples.test.ts index 9c175e6..4371e07 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -574,6 +574,52 @@ describe("example workspaces (simulated host tools)", () => { expect(rebuiltState.sshEnabled).toBe(false); }); + test("runs and persists the startup command across up and rebuild", async () => { + const fixture = await setupExampleFixture("smoke-workspace"); + const startupCommand = ".devbox/clanky-worker/start.sh"; + + const firstUp = runCli(fixture, [ + "up", + "--no-ssh", + "--startup-command", + startupCommand, + "--allow-missing-ssh", + ]); + expect(firstUp.exitCode).toBe(0); + + const stateAfterFirstUp = await readJson(fixture.statePath); + expect(stateAfterFirstUp.startupCommand).toBe(startupCommand); + + const countStartupCommands = async (): Promise => { + const commands = await readCommandLog(fixture.commandLogPath); + return commands.filter( + (entry) => + entry.tool === "devcontainer" && + entry.args[0] === "exec" && + entry.script === startupCommand, + ).length; + }; + + expect(await countStartupCommands()).toBe(1); + + const down = runCli(fixture, ["down"]); + expect(down.exitCode).toBe(0); + + const secondUp = runCli(fixture, ["up", "--no-ssh", "--allow-missing-ssh"]); + expect(secondUp.exitCode).toBe(0); + expect(await countStartupCommands()).toBe(2); + + const rebuild = runCli(fixture, ["rebuild", "--no-ssh", "--allow-missing-ssh"]); + expect(rebuild.exitCode).toBe(0); + expect(await countStartupCommands()).toBe(3); + + const clear = runCli(fixture, ["up", "--no-ssh", "--no-startup-command", "--allow-missing-ssh"]); + expect(clear.exitCode).toBe(0); + const stateAfterClear = await readJson(fixture.statePath); + expect(stateAfterClear.startupCommand).toBeUndefined(); + expect(await countStartupCommands()).toBe(3); + }); + 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 80f506f..b091512 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -20,6 +20,7 @@ import { buildPersistRunnerHostKeysScript, buildRestoreRunnerHostKeysScript, buildStartRunnerScript, + buildStartupCommandScript, buildStopManagedSshdScript, formatDevcontainerProgressLine, getRunnerCredFile, @@ -228,6 +229,18 @@ describe("buildStopManagedSshdScript", () => { }); }); +describe("buildStartupCommandScript", () => { + test("preserves a configured shell command for devcontainer exec", () => { + expect(buildStartupCommandScript(" .devbox/clanky-worker/start.sh ")).toBe( + ".devbox/clanky-worker/start.sh", + ); + }); + + test("rejects an empty startup command", () => { + expect(() => buildStartupCommandScript(" ")).toThrow("Startup command must not be empty."); + }); +}); + describe("findFirstAvailablePort", () => { test("returns the starting port when it is available", async () => { await expect(findFirstAvailablePort(5001, async () => true)).resolves.toBe(5001); From 08ab9e1d7ac19475beb3a7073d4efca3279c8fe8 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Wed, 9 Sep 2026 03:19:52 +0000 Subject: [PATCH 2/5] fix: retain startup hook after failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- src/cli.ts | 33 ++++++++++++++++----------------- tests/examples.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index fd275a4..b9f2ace 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ When you run `devbox up`, the port precedence is: 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. -`--startup-command ` stores a shell command in the workspace state and runs it inside the container after each successful `devbox up` or `devbox rebuild`, including runs started by `devbox arise`. The command runs independently of the bundled SSH server and is executed through `devcontainer exec`; it should be idempotent and daemonize any long-running process it starts. Use `--no-startup-command` to clear the stored command. The hook is invoked by Devbox's CLI lifecycle, so a raw `docker restart` does not invoke it. +`--startup-command ` stores a shell command in the workspace state and runs it inside the container after each successful `devbox up` or `devbox rebuild`, including runs started by `devbox arise`. The state is saved before the hook runs, so a failed first attempt remains configured for the next invocation. The command runs independently of the bundled SSH server and is executed through `devcontainer exec`; it should be idempotent and daemonize any long-running process it starts. Use `--no-startup-command` to clear the stored command. The hook is invoked by Devbox's CLI lifecycle, so a raw `docker restart` does not invoke it. When you run `devbox rebuild`, omitting the port reuses the last stored port list for the current workspace. diff --git a/src/cli.ts b/src/cli.ts index 77bf899..dbaf71e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -395,6 +395,22 @@ async function handleUpLike( console.log("Bundled SSH server installation skipped; published ports are ready for the devcontainer service."); } + const workspaceState = createWorkspaceState({ + workspacePath, + ports, + sshEnabled, + startupCommand, + configSource: resolvedConfig.configSource, + sourceConfigPath: resolvedConfig.sourceConfigPath, + generatedConfigPath, + userDataDir, + labels, + template: resolvedConfig.template, + githubAuth: environment.githubAuth, + containerId: upResult.containerId, + }); + await saveWorkspaceState(workspaceState); + if (startupCommand) { await runStepWithHeartbeat({ startMessage: "Running the configured post-start command...", @@ -404,23 +420,6 @@ async function handleUpLike( }); } - await saveWorkspaceState( - createWorkspaceState({ - workspacePath, - ports, - sshEnabled, - startupCommand, - configSource: resolvedConfig.configSource, - sourceConfigPath: resolvedConfig.sourceConfigPath, - generatedConfigPath, - userDataDir, - labels, - template: resolvedConfig.template, - githubAuth: environment.githubAuth, - containerId: upResult.containerId, - }), - ); - 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."); diff --git a/tests/examples.test.ts b/tests/examples.test.ts index 4371e07..f57b8bb 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -344,6 +344,10 @@ function handleDevcontainer() { return; } + if (script.trim() === "false") { + process.exit(7); + } + const containerIdIndex = args.indexOf("--container-id"); const commandArgs = containerIdIndex === -1 ? [] : args.slice(containerIdIndex + 2); if (commandArgs[0] === "false") { @@ -620,6 +624,25 @@ describe("example workspaces (simulated host tools)", () => { expect(await countStartupCommands()).toBe(3); }); + test("persists the startup command when its first execution fails", async () => { + const fixture = await setupExampleFixture("smoke-workspace"); + + const failedUp = runCli(fixture, [ + "up", + "--no-ssh", + "--startup-command", + "false", + "--allow-missing-ssh", + ]); + expect(failedUp.exitCode).toBe(7); + + const stateAfterFailure = await readJson(fixture.statePath); + expect(stateAfterFailure.startupCommand).toBe("false"); + + const retry = runCli(fixture, ["up", "--no-ssh", "--allow-missing-ssh"]); + expect(retry.exitCode).toBe(7); + }); + test("complex workspace preserves features and supports rebuild via the CLI", async () => { const fixture = await setupExampleFixture("complex-workspace", { ghToken: "ghs_example_token", From 0dc99b8c020d126bdce2608c4e81c2ce44fb185f Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Wed, 9 Sep 2026 03:26:48 +0000 Subject: [PATCH 3/5] fix: recover from invalid startup state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 ++ src/core.ts | 15 ++++++++++++--- tests/core.test.ts | 7 +++---- tests/examples.test.ts | 18 ++++++++++++++++++ 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b9f2ace..92a2a3e 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ Use `--ports ` to request how many ports should be published. If the firs `--startup-command ` stores a shell command in the workspace state and runs it inside the container after each successful `devbox up` or `devbox rebuild`, including runs started by `devbox arise`. The state is saved before the hook runs, so a failed first attempt remains configured for the next invocation. The command runs independently of the bundled SSH server and is executed through `devcontainer exec`; it should be idempotent and daemonize any long-running process it starts. Use `--no-startup-command` to clear the stored command. The hook is invoked by Devbox's CLI lifecycle, so a raw `docker restart` does not invoke it. +If an older or manually edited state file contains an invalid `startupCommand`, Devbox ignores that value with a warning so the workspace remains manageable; the next `up` or `rebuild` rewrites the state cleanly. + 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`. diff --git a/src/core.ts b/src/core.ts index 62bf0e3..175ffe9 100644 --- a/src/core.ts +++ b/src/core.ts @@ -847,6 +847,10 @@ export async function loadWorkspaceState(workspacePath: string): Promise 0 ? command : undefined; +} + +function isInvalidPersistedStartupCommand(value: unknown): boolean { + return value !== undefined && parsePersistedStartupCommand(value) === undefined; } function parsePersistedPortList(value: unknown): number[] | null { diff --git a/tests/core.test.ts b/tests/core.test.ts index e8fb302..cf9e838 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -475,7 +475,7 @@ describe("loadWorkspaceState", () => { }); }); - test("rejects an empty persisted startup command", async () => { + test("ignores an empty persisted startup command", async () => { const workspacePath = await mkdtemp(path.join(os.tmpdir(), "devbox-workspace-")); tempPaths.push(workspacePath); @@ -502,9 +502,8 @@ describe("loadWorkspaceState", () => { "utf8", ); - await expect(loadWorkspaceState(workspacePath)).rejects.toThrow( - "State file startupCommand must be a non-empty string.", - ); + const state = await loadWorkspaceState(workspacePath); + expect(state?.startupCommand).toBeUndefined(); }); test("drops invalid persisted GitHub auth preferences", async () => { diff --git a/tests/examples.test.ts b/tests/examples.test.ts index f57b8bb..bba3518 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -624,6 +624,24 @@ describe("example workspaces (simulated host tools)", () => { expect(await countStartupCommands()).toBe(3); }); + test("recovers from an invalid persisted startup command", async () => { + const fixture = await setupExampleFixture("smoke-workspace"); + + const initialUp = runCli(fixture, ["up", "--no-ssh", "--allow-missing-ssh"]); + expect(initialUp.exitCode).toBe(0); + + const invalidState = await readJson(fixture.statePath); + invalidState.startupCommand = 42; + await writeFile(fixture.statePath, `${JSON.stringify(invalidState, null, 2)}\n`, "utf8"); + + const clear = runCli(fixture, ["up", "--no-ssh", "--no-startup-command", "--allow-missing-ssh"]); + expect(clear.exitCode).toBe(0); + expect(clear.stderr).toContain("Ignoring invalid startupCommand"); + + const stateAfterClear = await readJson(fixture.statePath); + expect(stateAfterClear.startupCommand).toBeUndefined(); + }); + test("persists the startup command when its first execution fails", async () => { const fixture = await setupExampleFixture("smoke-workspace"); From 6bab8466cdb7f64252d73119db9b790b2c87ea9d Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Wed, 9 Sep 2026 03:28:06 +0000 Subject: [PATCH 4/5] test: cover live startup state recovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/examples.live.test.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/examples.live.test.ts b/tests/examples.live.test.ts index 2f8fdec..3c6cc05 100644 --- a/tests/examples.live.test.ts +++ b/tests/examples.live.test.ts @@ -276,6 +276,38 @@ describe("example workspaces (real devcontainers)", () => { { timeout: 10 * 60_000 }, ); + liveTest( + "recovers a real devcontainer from an invalid persisted startup command", + async () => { + const fixture = await setupLiveFixture("smoke-workspace"); + const initialUp = runCli(fixture, [ + "up", + String(fixture.port), + "--no-ssh", + "--allow-missing-ssh", + ]); + + expect(initialUp.exitCode).toBe(0); + + const invalidState = await readJson(fixture.statePath); + invalidState.startupCommand = 42; + await writeFile(fixture.statePath, `${JSON.stringify(invalidState, null, 2)}\n`, "utf8"); + + const clear = runCli(fixture, ["up", "--no-ssh", "--no-startup-command", "--allow-missing-ssh"]); + expect(clear.exitCode).toBe(0); + expect(clear.stderr).toContain("Ignoring invalid startupCommand"); + + const stateAfterClear = await readJson(fixture.statePath); + expect(stateAfterClear.startupCommand).toBeUndefined(); + expect(inspectContainer(fixture, String(stateAfterClear.lastContainerId)).State?.Running).toBe(true); + + const down = runCli(fixture, ["down"]); + expect(down.exitCode).toBe(0); + expect(await listManagedContainerIds(fixture)).toEqual([]); + }, + { timeout: 10 * 60_000 }, + ); + liveTest( "complex workspace exercises real features and host integration", async () => { From 8c48a5eb3d43d85491d46bb25c2dbeeb6856f11b Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Wed, 9 Sep 2026 03:29:36 +0000 Subject: [PATCH 5/5] test: cover live startup command execution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/examples.live.test.ts | 50 +++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/examples.live.test.ts b/tests/examples.live.test.ts index 3c6cc05..5516fb2 100644 --- a/tests/examples.live.test.ts +++ b/tests/examples.live.test.ts @@ -308,6 +308,56 @@ describe("example workspaces (real devcontainers)", () => { { timeout: 10 * 60_000 }, ); + liveTest( + "runs and reuses a startup command in a real devcontainer", + async () => { + const fixture = await setupLiveFixture("smoke-workspace"); + const markerPath = path.posix.join( + fixture.remoteWorkspaceFolder, + ".devbox", + "startup-command-runs.txt", + ); + const startupCommand = `printf '%s\\n' startup-ran >> ${quoteShell(markerPath)}`; + + const initialUp = runCli(fixture, [ + "up", + String(fixture.port), + "--no-ssh", + "--startup-command", + startupCommand, + "--allow-missing-ssh", + ]); + expect(initialUp.exitCode).toBe(0); + expect(initialUp.stdout).toContain("Configured post-start command completed"); + expect(await readFile(path.join(fixture.workspacePath, ".devbox", "startup-command-runs.txt"), "utf8")).toBe( + "startup-ran\n", + ); + + const stateAfterInitialUp = await readJson(fixture.statePath); + expect(stateAfterInitialUp.startupCommand).toBe(startupCommand); + + const down = runCli(fixture, ["down"]); + expect(down.exitCode).toBe(0); + + const secondUp = runCli(fixture, ["up", "--no-ssh", "--allow-missing-ssh"]); + expect(secondUp.exitCode).toBe(0); + expect(await readFile(path.join(fixture.workspacePath, ".devbox", "startup-command-runs.txt"), "utf8")).toBe( + "startup-ran\nstartup-ran\n", + ); + + const rebuild = runCli(fixture, ["rebuild", "--no-ssh", "--allow-missing-ssh"]); + expect(rebuild.exitCode).toBe(0); + expect(await readFile(path.join(fixture.workspacePath, ".devbox", "startup-command-runs.txt"), "utf8")).toBe( + "startup-ran\nstartup-ran\nstartup-ran\n", + ); + + const finalDown = runCli(fixture, ["down"]); + expect(finalDown.exitCode).toBe(0); + expect(await listManagedContainerIds(fixture)).toEqual([]); + }, + { timeout: 12 * 60_000 }, + ); + liveTest( "complex workspace exercises real features and host integration", async () => {