Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -121,6 +128,10 @@ When you run `devbox up`, the port precedence is:

Use `--ports <count>` 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 <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`.
Expand All @@ -131,7 +142,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"
}
```

Expand Down Expand Up @@ -244,7 +256,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 <name>` 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`.
Expand All @@ -256,7 +268,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.
Expand Down
47 changes: 32 additions & 15 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
requiresSshAuthSockPermissionFix,
removeContainers,
restoreRunnerHostKeys,
runStartupCommand,
runDevcontainerCommand,
startRunner,
stopManagedSshd,
Expand Down Expand Up @@ -137,6 +138,8 @@ async function main(): Promise<void> {
parsed.templateName,
parsed.githubUser,
parsed.githubHost,
parsed.startupCommand,
parsed.clearStartupCommand ?? false,
);
}

Expand All @@ -153,6 +156,8 @@ async function handleUpLike(
templateName?: string,
explicitGithubUser?: string,
explicitGithubHost?: string,
explicitStartupCommand?: string,
clearStartupCommand = false,
): Promise<void> {
const githubAuth = resolveGithubAuthPreference({
explicitUser: explicitGithubUser,
Expand All @@ -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 })
Expand Down Expand Up @@ -387,21 +395,30 @@ async function handleUpLike(
console.log("Bundled SSH server installation skipped; published ports are ready for the devcontainer service.");
}

await saveWorkspaceState(
createWorkspaceState({
workspacePath,
ports,
sshEnabled,
configSource: resolvedConfig.configSource,
sourceConfigPath: resolvedConfig.sourceConfigPath,
generatedConfigPath,
userDataDir,
labels,
template: resolvedConfig.template,
githubAuth: environment.githubAuth,
containerId: upResult.containerId,
}),
);
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...",
heartbeatMessage: "Still running the configured post-start command",
successMessage: "Configured post-start command completed",
action: () => runStartupCommand(upResult.containerId, startupCommand),
});
}

console.log(formatReadyMessage(upResult.containerId, ports, remoteWorkspaceFolder));
if (!preparedKnownHosts.knownHostsPath || knownHostsCopyResult !== "copied") {
Expand Down
84 changes: 82 additions & 2 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export interface ParsedArgs {
portCount?: number;
allowMissingSsh: boolean;
sshEnabled?: boolean;
startupCommand?: string;
clearStartupCommand?: boolean;
devcontainerSubpath?: string;
sshPublicKeyPath?: string;
templateName?: string;
Expand Down Expand Up @@ -78,6 +80,7 @@ export interface WorkspaceState {
workspaceHash: string;
ports: number[];
sshEnabled: boolean;
startupCommand?: string;
configSource: "repo" | "template";
sourceConfigPath: string | null;
generatedConfigPath: string;
Expand Down Expand Up @@ -158,8 +161,8 @@ export function helpText(): string {
"",
"Usage:",
` ${CLI_NAME}`,
` ${CLI_NAME} up [port] [--ports <count>] [--allow-missing-ssh] [--no-ssh|--ssh] [--devcontainer-subpath <subpath>] [--ssh-public-key <path>] [--template <name>] [--gh-user <login>] [--gh-host <host>]`,
` ${CLI_NAME} rebuild [port] [--ports <count>] [--allow-missing-ssh] [--no-ssh|--ssh] [--devcontainer-subpath <subpath>] [--ssh-public-key <path>] [--gh-user <login>] [--gh-host <host>]`,
` ${CLI_NAME} up [port] [--ports <count>] [--allow-missing-ssh] [--no-ssh|--ssh] [--startup-command <command>|--no-startup-command] [--devcontainer-subpath <subpath>] [--ssh-public-key <path>] [--template <name>] [--gh-user <login>] [--gh-host <host>]`,
` ${CLI_NAME} rebuild [port] [--ports <count>] [--allow-missing-ssh] [--no-ssh|--ssh] [--startup-command <command>|--no-startup-command] [--devcontainer-subpath <subpath>] [--ssh-public-key <path>] [--gh-user <login>] [--gh-host <host>]`,
` ${CLI_NAME} shell`,
` ${CLI_NAME} exec -- <command> [args...]`,
` ${CLI_NAME} status`,
Expand Down Expand Up @@ -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 <command> Run and persist a command after the container starts.",
" --no-startup-command Clear the persisted post-start command.",
" --devcontainer-subpath <subpath> Use .devcontainer/<subpath>/devcontainer.json.",
" --ssh-public-key <path> Use a specific SSH public key file instead of ~/.ssh/id_rsa.pub.",
" --template <name> Use a built-in template instead of a repo devcontainer.",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.");
}
Expand Down Expand Up @@ -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 } : {}),
Expand All @@ -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}`);
Expand Down Expand Up @@ -792,6 +847,10 @@ export async function loadWorkspaceState(workspacePath: string): Promise<Workspa
);
}

if (isInvalidPersistedStartupCommand(asRecord(parsed)?.startupCommand)) {
console.warn(`Warning: Ignoring invalid startupCommand in workspace state: ${statePath}`);
}

return parsedState;
}

Expand Down Expand Up @@ -984,6 +1043,7 @@ export function createWorkspaceState(input: {
workspacePath: string;
ports: number[];
sshEnabled: boolean;
startupCommand?: string;
configSource: "repo" | "template";
sourceConfigPath: string | null;
generatedConfigPath: string;
Expand All @@ -1001,6 +1061,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,
Expand Down Expand Up @@ -1414,6 +1475,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 (
Expand All @@ -1433,6 +1495,7 @@ function parseWorkspaceState(value: unknown): WorkspaceState | null {
workspaceHash: record.workspaceHash,
ports,
sshEnabled: record.sshEnabled,
...(startupCommand !== undefined ? { startupCommand } : {}),
configSource: record.configSource,
sourceConfigPath: record.sourceConfigPath,
generatedConfigPath:
Expand All @@ -1446,6 +1509,23 @@ function parseWorkspaceState(value: unknown): WorkspaceState | null {
};
}

function parsePersistedStartupCommand(value: unknown): string | undefined {
if (value === undefined) {
return undefined;
}

if (typeof value !== "string") {
return undefined;
}

const command = value.trim();
return command.length > 0 ? command : undefined;
}

function isInvalidPersistedStartupCommand(value: unknown): boolean {
return value !== undefined && parsePersistedStartupCommand(value) === undefined;
}

function parsePersistedPortList(value: unknown): number[] | null {
if (
!Array.isArray(value) ||
Expand Down
13 changes: 13 additions & 0 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,19 @@ export async function startRunner(
};
}

export async function runStartupCommand(containerId: string, command: string): Promise<void> {
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`;
}
Expand Down
Loading