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
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ devbox up <port> --ssh-public-key ~/.ssh/work-devbox.pub
# Use a specific devcontainer under .devcontainer/services/api
devbox up <port> --devcontainer-subpath services/api

# Start from a built-in template instead of a repo devcontainer
devbox up --template python

# List the built-in templates as JSON
devbox templates

# Rebuild/recreate the managed devcontainer
devbox rebuild <port>

Expand All @@ -92,10 +98,25 @@ When you run `devbox up`, the port precedence is:

When you run `devbox rebuild`, omitting the port reuses the last stored port for the current workspace.

`devbox rebuild` reuses the previously selected source for the workspace. If the workspace was started from `--template`, rebuild uses that saved template again. `rebuild --template ...` is intentionally not supported.

`devbox shell` requires an already running managed container for the current workspace. If none is running, use `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 `ssh-server-runner`.

Built-in templates:

- `ubuntu`
- `dotnet`
- `node-typescript`
- `bun`
- `python`
- `go`
- `rust`
- `java`

`devbox arise` is a global recovery command. It scans existing stopped devbox-managed containers, recovers each host workspace from the bind mount targeting `/workspaces/...`, checks that the workspace still has devbox leftovers from a previous `up` run, and then reruns `devbox up` for that workspace. It logs each discovery, skip, restart, and failure, and continues with the remaining workspaces if one restart fails.

Example:
Expand Down Expand Up @@ -171,14 +192,16 @@ The complex example uses several devcontainer features, so the first `up` or `re

## Notes

- 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 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 into devbox-managed state instead of creating a source devcontainer definition inside the repo.
- `--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.
- `devbox shell` opens an interactive shell inside the running managed container for the current workspace.
- `devbox status` reports live container state when available and falls back to saved workspace state plus the persisted `.sshcred` password file and `.devbox-ssh.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, `.sshcred`, `.devbox-ssh.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 does not delete the workspace `.sshcred`, `.devbox-ssh.json`, or `.devbox-ssh-host-keys/`, so the SSH password, SSH metadata, and SSH host identity survive rebuilds.
- `down` removes managed containers but keeps devbox state plus the workspace `.sshcred`, `.devbox-ssh.json`, and `.devbox-ssh-host-keys/`, so rebuilds can reuse the last selected port/config source/template.
- Re-running `devbox up` after a host restart recreates the desired state: container up, port published, SSH runner started again.
- 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.
Expand Down
2 changes: 1 addition & 1 deletion src/arise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ export async function ariseManagedWorkspaces(deps: AriseDependencies): Promise<A

export function getStoredDevcontainerSubpath(
workspacePath: string,
sourceConfigPath: string | undefined,
sourceConfigPath: string | null | undefined,
): string | undefined {
if (!sourceConfigPath) {
return undefined;
Expand Down
66 changes: 48 additions & 18 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,9 @@ import path from "node:path";
import {
buildManagedConfig,
createWorkspaceState,
deleteWorkspaceState,
describeUpPortStrategy,
discoverDevcontainerConfig,
formatReadyMessage,
getDefaultRemoteWorkspaceFolder,
getGeneratedConfigPath,
getLegacyGeneratedConfigPath,
getManagedContainerName,
getManagedPortFromContainerName,
getManagedLabels,
Expand All @@ -20,6 +16,7 @@ import {
helpText,
loadWorkspaceState,
parseArgs,
resolveWorkspaceConfig,
removeGeneratedConfig,
resolvePort,
resolveUpPortPreference,
Expand Down Expand Up @@ -68,6 +65,7 @@ import {
import { createRunnerMetadata, serializeRunnerMetadata } from "./runnerState";
import { getDevboxStatus } from "./status";
import { ariseManagedWorkspaces } from "./arise";
import { listTemplateSummaries } from "./templates";

async function main(): Promise<void> {
const parsed = parseArgs(process.argv.slice(2));
Expand All @@ -81,6 +79,11 @@ async function main(): Promise<void> {
return;
}

if (parsed.command === "templates") {
console.log(JSON.stringify(listTemplateSummaries(), null, 2));
return;
}

const workspacePath = await realpath(process.cwd());
const state = await loadWorkspaceState(workspacePath);

Expand All @@ -107,6 +110,7 @@ async function main(): Promise<void> {
parsed.allowMissingSsh,
parsed.devcontainerSubpath,
parsed.sshPublicKeyPath,
parsed.templateName,
);
}

Expand All @@ -118,6 +122,7 @@ async function handleUpLike(
allowMissingSsh: boolean,
devcontainerSubpath: string | undefined,
sshPublicKeyPath?: string,
templateName?: string,
): Promise<void> {
const environment = await ensureHostEnvironment({ allowMissingSsh, workspacePath });
const resolvedSshPublicKey = await resolveSshPublicKey({ overridePath: sshPublicKeyPath });
Expand All @@ -143,14 +148,19 @@ async function handleUpLike(
: resolvePort(command, explicitPort, state);

console.log(`Using port ${port}. ${command === "up" ? describeUpPortStrategy() : ""}`.trim());
const discovered = await discoverDevcontainerConfig(workspacePath, devcontainerSubpath);
const generatedConfigPath = getGeneratedConfigPath(discovered.path);
const legacyGeneratedConfigPath = getLegacyGeneratedConfigPath(discovered.path);
const resolvedConfig = await resolveWorkspaceConfig({
workspacePath,
devcontainerSubpath,
templateName,
state,
preferStateSource: command === "rebuild",
});
const generatedConfigPath = resolvedConfig.generatedConfigPath;
const userDataDir = getWorkspaceUserDataDir(workspacePath);
const preparedKnownHosts = await prepareKnownHostsMount({ userDataDir });
const containerName = getManagedContainerName(workspacePath, port);

const managedConfig = buildManagedConfig(discovered.config, {
const managedConfig = buildManagedConfig(resolvedConfig.config, {
port,
containerName,
sshAuthSock: environment.sshAuthSock,
Expand Down Expand Up @@ -186,8 +196,12 @@ async function handleUpLike(
console.log("Using host GitHub authentication from gh.");
}

await ensureGeneratedConfigIgnored(workspacePath, generatedConfigPath);
await removeGeneratedConfig(legacyGeneratedConfigPath);
if (resolvedConfig.configSource === "repo" && resolvedConfig.sourceConfigPath) {
await ensureGeneratedConfigIgnored(workspacePath, generatedConfigPath);
}
if (resolvedConfig.legacyGeneratedConfigPath) {
await removeGeneratedConfig(resolvedConfig.legacyGeneratedConfigPath);
}
await writeManagedConfig(generatedConfigPath, managedConfig);

if (command === "rebuild") {
Expand Down Expand Up @@ -290,10 +304,12 @@ async function handleUpLike(
createWorkspaceState({
workspacePath,
port,
sourceConfigPath: discovered.path,
configSource: resolvedConfig.configSource,
sourceConfigPath: resolvedConfig.sourceConfigPath,
generatedConfigPath,
userDataDir,
labels,
template: resolvedConfig.template,
containerId: upResult.containerId,
}),
);
Expand Down Expand Up @@ -347,19 +363,33 @@ async function handleDown(
generatedConfigPaths.add(state.generatedConfigPath);
}

try {
const discovered = await discoverDevcontainerConfig(workspacePath, devcontainerSubpath);
generatedConfigPaths.add(getGeneratedConfigPath(discovered.path));
generatedConfigPaths.add(getLegacyGeneratedConfigPath(discovered.path));
} catch {
// Workspace may no longer contain a devcontainer definition; cleanup still continues.
if (state?.configSource === "repo") {
try {
const resolvedConfig = await resolveWorkspaceConfig({
workspacePath,
devcontainerSubpath,
state,
});
generatedConfigPaths.add(resolvedConfig.generatedConfigPath);
if (resolvedConfig.legacyGeneratedConfigPath) {
generatedConfigPaths.add(resolvedConfig.legacyGeneratedConfigPath);
}
} catch {
// Workspace may no longer contain a devcontainer definition; cleanup still continues.
}
}

for (const generatedConfigPath of generatedConfigPaths) {
await removeGeneratedConfig(generatedConfigPath);
}

await deleteWorkspaceState(workspacePath);
if (state) {
await saveWorkspaceState({
...state,
lastContainerId: undefined,
updatedAt: new Date().toISOString(),
});
}

if (containerIds.length === 0) {
console.log("No managed container was running for this workspace.");
Expand Down
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ export const DEVBOX_SSH_METADATA_FILENAME = ".devbox-ssh.json";
export const RUNNER_HOST_KEYS_DIRNAME = ".devbox-ssh-host-keys";
export const RUNNER_URL =
"https://raw.githubusercontent.com/PabloZaiden/ssh-server-runner/main/ssh-server.sh";
export const STATE_VERSION = 1;
export const STATE_VERSION = 2;
Loading
Loading