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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ It does not modify the original `devcontainer.json`. Instead, it generates a der

## What it does

- Discovers `.devcontainer/devcontainer.json` or `.devcontainer.json` in the current directory, and can target `.devcontainer/<subpath>/devcontainer.json` with a flag.
- Discovers `.devcontainer/devcontainer.json` or `.devcontainer.json` in the current directory, can target `.devcontainer/<subpath>/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-<project>-<port>`.
- Publishes the same TCP port on host and container.
Expand Down Expand Up @@ -43,7 +43,7 @@ Run `devbox` with no arguments to see the CLI help.
- Docker
- Dev Container CLI available as `devcontainer` in whatever environment runs `devbox`
- For SSH agent sharing: either a valid host `SSH_AUTH_SOCK`, or Docker Desktop host services
- A devcontainer using `image` or `Dockerfile`
- For repo-defined environments: a devcontainer using `image` or `Dockerfile`

`dockerComposeFile`-based devcontainers are intentionally out of scope for v1.

Expand Down Expand Up @@ -75,7 +75,7 @@ devbox up --gh-user work-account --gh-host github.example.com
# 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
# Start from a specific built-in template instead of a repo devcontainer
devbox up --template python

# List the built-in templates as JSON
Expand Down Expand Up @@ -105,7 +105,9 @@ 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.
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 <port>` 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.

GitHub CLI authentication for `GH_TOKEN` injection can be pinned per workspace with `--gh-user <login>` and optional `--gh-host <host>`. Devbox stores only the selected account metadata in `.devbox/state.json` as `githubAuth`; it does not store the token. Later `up`, `rebuild`, and `arise` runs reuse that account by calling `gh auth token --hostname <host> --user <login>`. The selection precedence is: explicit flags, `DEVBOX_GH_USER` / `DEVBOX_GH_HOST`, saved `.devbox/state.json`, then the currently active `gh` account.

Expand Down Expand Up @@ -209,7 +211,7 @@ The complex example uses several devcontainer features, so the first `up` or `re
- `.devbox/` contains all devbox-owned local state (`state.json`, `user-data/`, template generated configs, and `ssh/`) 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.
- `--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`.
- `--gh-user <login>` and `--gh-host <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 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.
Expand Down
3 changes: 3 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ async function handleUpLike(
state,
preferStateSource: command === "rebuild",
});
if (resolvedConfig.templateSelection === "fallback") {
console.log("No devcontainer definition found; using built-in ubuntu template.");
}
const generatedConfigPath = resolvedConfig.generatedConfigPath;
const userDataDir = getWorkspaceUserDataDir(workspacePath);
const preparedKnownHosts = await prepareKnownHostsMount({ userDataDir });
Expand Down
56 changes: 51 additions & 5 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export interface ResolvedWorkspaceConfig {
generatedConfigPath: string;
legacyGeneratedConfigPath: string | null;
template: WorkspaceTemplateState | null;
templateSelection: "explicit" | "state" | "fallback" | null;
}

export interface UpResult {
Expand Down Expand Up @@ -136,7 +137,41 @@ export class UserError extends Error {
}

export function helpText(): string {
return `${CLI_NAME} v${pkg.version} - manage a devcontainer plus a bundled SSH server\n\nUsage:\n ${CLI_NAME}\n ${CLI_NAME} up [port] [--allow-missing-ssh] [--devcontainer-subpath <subpath>] [--ssh-public-key <path>] [--template <name>] [--gh-user <login>] [--gh-host <host>]\n ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath <subpath>] [--ssh-public-key <path>] [--gh-user <login>] [--gh-host <host>]\n ${CLI_NAME} shell\n ${CLI_NAME} status\n ${CLI_NAME} templates\n ${CLI_NAME} arise\n ${CLI_NAME} down [--devcontainer-subpath <subpath>]\n ${CLI_NAME} help\n ${CLI_NAME} --help\n\nCommands:\n up Start or reuse the managed devcontainer.\n rebuild Recreate the managed devcontainer.\n shell Open an interactive shell in the running managed container.\n status Print JSON describing the managed devbox for this workspace.\n templates Print JSON describing the built-in templates.\n arise Restart stopped managed workspaces discovered from existing containers.\n down Stop and remove the managed container for this workspace.\n help Show this help.\n\nOptions:\n -p, --port <port> Publish the same port on host and container.\n --allow-missing-ssh Continue without SSH agent sharing when unavailable.\n --devcontainer-subpath <subpath> Use .devcontainer/<subpath>/devcontainer.json.\n --ssh-public-key <path> Use a specific SSH public key file instead of ~/.ssh/id_rsa.pub.\n --template <name> Use a built-in template instead of a repo devcontainer.\n --gh-user <login> Use and persist a specific GitHub CLI account for GH_TOKEN injection.\n --gh-host <host> GitHub host for --gh-user. Defaults to github.com.\n -h, --help Show this help.`;
return [
`${CLI_NAME} v${pkg.version} - manage a devcontainer plus a bundled SSH server`,
"",
"Usage:",
` ${CLI_NAME}`,
` ${CLI_NAME} up [port] [--allow-missing-ssh] [--devcontainer-subpath <subpath>] [--ssh-public-key <path>] [--template <name>] [--gh-user <login>] [--gh-host <host>]`,
` ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath <subpath>] [--ssh-public-key <path>] [--gh-user <login>] [--gh-host <host>]`,
` ${CLI_NAME} shell`,
` ${CLI_NAME} status`,
` ${CLI_NAME} templates`,
` ${CLI_NAME} arise`,
` ${CLI_NAME} down [--devcontainer-subpath <subpath>]`,
` ${CLI_NAME} help`,
` ${CLI_NAME} --help`,
"",
"Commands:",
" 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.",
" 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.",
" down Stop and remove the managed container for this workspace.",
" help Show this help.",
"",
"Options:",
" -p, --port <port> Publish the same port on host and container.",
" --allow-missing-ssh Continue without SSH agent sharing when unavailable.",
" --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.",
" --gh-user <login> Use and persist a specific GitHub CLI account for GH_TOKEN injection.",
" --gh-host <host> GitHub host for --gh-user. Defaults to github.com.",
" -h, --help Show this help.",
].join("\n");
}

export function parseArgs(argv: string[]): ParsedArgs {
Expand Down Expand Up @@ -786,6 +821,7 @@ export async function resolveWorkspaceConfig(input: {
generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath),
legacyGeneratedConfigPath: null,
template,
templateSelection: "explicit",
};
}

Expand All @@ -798,6 +834,7 @@ export async function resolveWorkspaceConfig(input: {
generatedConfigPath: getGeneratedConfigPath(discovered.path),
legacyGeneratedConfigPath: getLegacyGeneratedConfigPath(discovered.path),
template: null,
templateSelection: null,
};
}

Expand All @@ -809,6 +846,7 @@ export async function resolveWorkspaceConfig(input: {
generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath),
legacyGeneratedConfigPath: null,
template: cloneTemplateState(input.state.template),
templateSelection: "state",
};
}

Expand All @@ -821,6 +859,7 @@ export async function resolveWorkspaceConfig(input: {
generatedConfigPath: getGeneratedConfigPath(discovered.path),
legacyGeneratedConfigPath: getLegacyGeneratedConfigPath(discovered.path),
template: null,
templateSelection: null,
};
}

Expand All @@ -832,13 +871,20 @@ export async function resolveWorkspaceConfig(input: {
generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath),
legacyGeneratedConfigPath: null,
template: cloneTemplateState(input.state.template),
templateSelection: "state",
};
}

throw new UserError(
`No devcontainer definition was found in ${input.workspacePath}. ` +
`Use \`${CLI_NAME} templates\` to list built-in templates, then run \`${CLI_NAME} up --template <name>\`.`,
);
const template = resolveBuiltInTemplate("ubuntu");
return {
config: structuredClone(template.config),
configSource: "template",
sourceConfigPath: null,
generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath),
legacyGeneratedConfigPath: null,
template,
templateSelection: "fallback",
};
}

export async function prepareKnownHostsMount(input: {
Expand Down
30 changes: 30 additions & 0 deletions tests/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,16 @@ describe("helpText", () => {
expect(text).toContain("down");
expect(text).toContain("help");
});

test("mentions ubuntu fallback for up and rebuild", () => {
const text = helpText();
expect(text).toContain(
"\n up Start or reuse the managed devcontainer; falls back to the ubuntu template when none is found.",
);
expect(text).toContain(
"\n rebuild Recreate the managed devcontainer; falls back to the ubuntu template when no repo devcontainer or prior state exists.",
);
});
});

describe("resolvePort", () => {
Expand Down Expand Up @@ -510,6 +520,7 @@ describe("resolveWorkspaceConfig", () => {
});
expect(upStyleResolution.configSource).toBe("repo");
expect(upStyleResolution.sourceConfigPath).toBe(path.join(tempDir, ".devcontainer", "devcontainer.json"));
expect(upStyleResolution.templateSelection).toBeNull();

const rebuildStyleResolution = await resolveWorkspaceConfig({
workspacePath: tempDir,
Expand All @@ -520,6 +531,7 @@ describe("resolveWorkspaceConfig", () => {
expect(rebuildStyleResolution.sourceConfigPath).toBeNull();
expect(rebuildStyleResolution.generatedConfigPath).toBe(state.generatedConfigPath);
expect(rebuildStyleResolution.template?.name).toBe("python");
expect(rebuildStyleResolution.templateSelection).toBe("state");
expect(rebuildStyleResolution.config).toEqual({
image: "mcr.microsoft.com/devcontainers/base:noble",
features: {
Expand Down Expand Up @@ -561,6 +573,24 @@ describe("resolveWorkspaceConfig", () => {

expect(resolution.generatedConfigPath).toBe(getTemplateGeneratedConfigPath(tempDir));
expect(resolution.legacyGeneratedConfigPath).toBeNull();
expect(resolution.templateSelection).toBe("state");
});

test("falls back to the ubuntu template when no devcontainer or prior template state exists", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "devbox-test-"));
tempPaths.push(tempDir);

const resolution = await resolveWorkspaceConfig({
workspacePath: tempDir,
state: null,
});

expect(resolution.configSource).toBe("template");
expect(resolution.sourceConfigPath).toBeNull();
expect(resolution.generatedConfigPath).toBe(getTemplateGeneratedConfigPath(tempDir));
expect(resolution.legacyGeneratedConfigPath).toBeNull();
expect(resolution.template?.name).toBe("ubuntu");
expect(resolution.templateSelection).toBe("fallback");
});
});

Expand Down
5 changes: 3 additions & 2 deletions tests/examples.live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,13 @@ afterEach(async () => {

describe("example workspaces (real devcontainers)", () => {
liveTest(
"template workspace starts from the ubuntu template without a repo devcontainer",
"template workspace falls back to the ubuntu template without a repo devcontainer",
async () => {
const fixture = await setupLiveFixture("template-workspace");
const up = runCli(fixture, ["up", "--template", "ubuntu", "--allow-missing-ssh"]);
const up = runCli(fixture, ["up", "--allow-missing-ssh"]);

expect(up.exitCode).toBe(0);
expect(up.stdout).toContain("No devcontainer definition found; using built-in ubuntu template.");
expect(up.stdout).toContain("Devcontainer is ready");
expect(up.stdout).toContain("SSH server:");
expect(up.stdout).toContain("Ready.");
Expand Down
48 changes: 46 additions & 2 deletions tests/examples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ describe("example workspaces (simulated host tools)", () => {
expect(commandsAfterDown.filter((entry) => entry.tool === "docker" && entry.args[0] === "rm").length).toBeGreaterThanOrEqual(2);
});

test("template-backed workspace lists templates, starts without a repo devcontainer, and rebuilds from saved state", async () => {
test("template-backed workspace lists templates, falls back to ubuntu, and rebuilds from saved state", async () => {
const fixture = await setupExampleFixture("template-workspace");

const templates = runCli(fixture, ["templates"]);
Expand All @@ -536,9 +536,10 @@ describe("example workspaces (simulated host tools)", () => {
expect(templateList.some((entry: { name: string }) => entry.name === "ubuntu")).toBe(true);
expect(templateList.some((entry: { name: string }) => entry.name === "typescript")).toBe(true);

const up = runCli(fixture, ["up", "--template", "ubuntu", "--allow-missing-ssh"]);
const up = runCli(fixture, ["up", "--allow-missing-ssh"]);
expect(up.exitCode).toBe(0);
expect(up.stdout).toContain("Using port 5001.");
expect(up.stdout).toContain("No devcontainer definition found; using built-in ubuntu template.");
expect(existsSync(fixture.generatedConfigPath)).toBe(true);

const generatedConfig = await readJson(fixture.generatedConfigPath);
Expand Down Expand Up @@ -567,6 +568,7 @@ describe("example workspaces (simulated host tools)", () => {
const rebuild = runCli(fixture, ["rebuild", "--allow-missing-ssh"]);
expect(rebuild.exitCode).toBe(0);
expect(rebuild.stdout).toContain("Using port 5001.");
expect(rebuild.stdout).not.toContain("No devcontainer definition found; using built-in ubuntu template.");
expect(rebuild.stdout).toContain("Ready.");

const rebuildWithTemplate = runCli(fixture, ["rebuild", "--template", "python"]);
Expand All @@ -585,6 +587,48 @@ describe("example workspaces (simulated host tools)", () => {
expect(stoppedStatus.configSource).toBe("template");
expect(stoppedStatus.templateName).toBe("ubuntu");
});

test("rebuild without prior state falls back to ubuntu when a port is provided", async () => {
const fixture = await setupExampleFixture("template-workspace");

const rebuild = runCli(fixture, ["rebuild", "5010", "--allow-missing-ssh"]);
expect(rebuild.exitCode).toBe(0);
expect(rebuild.stdout).toContain("Using port 5010.");
expect(rebuild.stdout).toContain("No devcontainer definition found; using built-in ubuntu template.");
expect(existsSync(fixture.generatedConfigPath)).toBe(true);

const state = await readJson(fixture.statePath);
expect(state.port).toBe(5010);
expect(state.configSource).toBe("template");
expect(state.sourceConfigPath).toBeNull();
expect(state.template.name).toBe("ubuntu");
});

test("workspace without a repo devcontainer reuses a saved non-ubuntu template", async () => {
const fixture = await setupExampleFixture("template-workspace");

const explicitTemplateUp = runCli(fixture, ["up", "--template", "python", "--allow-missing-ssh"]);
expect(explicitTemplateUp.exitCode).toBe(0);
expect(explicitTemplateUp.stdout).not.toContain("No devcontainer definition found; using built-in ubuntu template.");

const down = runCli(fixture, ["down"]);
expect(down.exitCode).toBe(0);

const upFromState = runCli(fixture, ["up", "--allow-missing-ssh"]);
expect(upFromState.exitCode).toBe(0);
expect(upFromState.stdout).not.toContain("No devcontainer definition found; using built-in ubuntu template.");

const state = await readJson(fixture.statePath);
expect(state.configSource).toBe("template");
expect(state.sourceConfigPath).toBeNull();
expect(state.template.name).toBe("python");

const generatedConfig = await readJson(fixture.generatedConfigPath);
expect(generatedConfig.features).toEqual({
"ghcr.io/devcontainers/features/docker-in-docker:3": {},
"ghcr.io/devcontainers-extra/features/uv:1": {},
});
});
});

async function setupExampleFixture(exampleName: string, options: ExampleFixtureOptions = {}): Promise<ExampleFixture> {
Expand Down
Loading