From 1fd713a1d5c2a0171536f8bd322c8f24a48f8ff9 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Wed, 8 Jul 2026 14:26:52 +0000 Subject: [PATCH 1/2] feat: fallback to ubuntu template when devcontainer is missing --- README.md | 12 ++++++---- src/cli.ts | 3 +++ src/core.ts | 22 +++++++++++++---- tests/core.test.ts | 20 ++++++++++++++++ tests/examples.live.test.ts | 5 ++-- tests/examples.test.ts | 48 +++++++++++++++++++++++++++++++++++-- 6 files changed, 96 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 62e3e52..01972d6 100644 --- a/README.md +++ b/README.md @@ -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//devcontainer.json` with a flag. +- 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. @@ -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. @@ -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 --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 @@ -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 ` 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 ` and optional `--gh-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 --user `. The selection precedence is: explicit flags, `DEVBOX_GH_USER` / `DEVBOX_GH_HOST`, saved `.devbox/state.json`, then the currently active `gh` account. @@ -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 ` explicitly chooses a built-in template, even if the repo already has a devcontainer definition. +- `--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 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. diff --git a/src/cli.ts b/src/cli.ts index 126547a..1a370a9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 }); diff --git a/src/core.ts b/src/core.ts index dd0a8ee..962ea63 100644 --- a/src/core.ts +++ b/src/core.ts @@ -98,6 +98,7 @@ export interface ResolvedWorkspaceConfig { generatedConfigPath: string; legacyGeneratedConfigPath: string | null; template: WorkspaceTemplateState | null; + templateSelection: "explicit" | "state" | "fallback" | null; } export interface UpResult { @@ -136,7 +137,7 @@ 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 ] [--ssh-public-key ] [--template ] [--gh-user ] [--gh-host ]\n ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ] [--gh-user ] [--gh-host ]\n ${CLI_NAME} shell\n ${CLI_NAME} status\n ${CLI_NAME} templates\n ${CLI_NAME} arise\n ${CLI_NAME} down [--devcontainer-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 Publish the same port on host and container.\n --allow-missing-ssh Continue without SSH agent sharing when unavailable.\n --devcontainer-subpath Use .devcontainer//devcontainer.json.\n --ssh-public-key Use a specific SSH public key file instead of ~/.ssh/id_rsa.pub.\n --template Use a built-in template instead of a repo devcontainer.\n --gh-user Use and persist a specific GitHub CLI account for GH_TOKEN injection.\n --gh-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\n\nUsage:\n ${CLI_NAME}\n ${CLI_NAME} up [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ] [--template ] [--gh-user ] [--gh-host ]\n ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ] [--gh-user ] [--gh-host ]\n ${CLI_NAME} shell\n ${CLI_NAME} status\n ${CLI_NAME} templates\n ${CLI_NAME} arise\n ${CLI_NAME} down [--devcontainer-subpath ]\n ${CLI_NAME} help\n ${CLI_NAME} --help\n\nCommands:\n up Start or reuse the managed devcontainer; falls back to the ubuntu template when none is found.\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 Publish the same port on host and container.\n --allow-missing-ssh Continue without SSH agent sharing when unavailable.\n --devcontainer-subpath Use .devcontainer//devcontainer.json.\n --ssh-public-key Use a specific SSH public key file instead of ~/.ssh/id_rsa.pub.\n --template Use a built-in template instead of a repo devcontainer.\n --gh-user Use and persist a specific GitHub CLI account for GH_TOKEN injection.\n --gh-host GitHub host for --gh-user. Defaults to github.com.\n -h, --help Show this help.`; } export function parseArgs(argv: string[]): ParsedArgs { @@ -786,6 +787,7 @@ export async function resolveWorkspaceConfig(input: { generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath), legacyGeneratedConfigPath: null, template, + templateSelection: "explicit", }; } @@ -798,6 +800,7 @@ export async function resolveWorkspaceConfig(input: { generatedConfigPath: getGeneratedConfigPath(discovered.path), legacyGeneratedConfigPath: getLegacyGeneratedConfigPath(discovered.path), template: null, + templateSelection: null, }; } @@ -809,6 +812,7 @@ export async function resolveWorkspaceConfig(input: { generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath), legacyGeneratedConfigPath: null, template: cloneTemplateState(input.state.template), + templateSelection: "state", }; } @@ -821,6 +825,7 @@ export async function resolveWorkspaceConfig(input: { generatedConfigPath: getGeneratedConfigPath(discovered.path), legacyGeneratedConfigPath: getLegacyGeneratedConfigPath(discovered.path), template: null, + templateSelection: null, }; } @@ -832,13 +837,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 \`.`, - ); + 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: { diff --git a/tests/core.test.ts b/tests/core.test.ts index e530b00..2646a09 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -510,6 +510,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, @@ -520,6 +521,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: { @@ -561,6 +563,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"); }); }); diff --git a/tests/examples.live.test.ts b/tests/examples.live.test.ts index fce29e3..11f0a88 100644 --- a/tests/examples.live.test.ts +++ b/tests/examples.live.test.ts @@ -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."); diff --git a/tests/examples.test.ts b/tests/examples.test.ts index 7d7a87a..7d2cd61 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -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"]); @@ -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); @@ -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"]); @@ -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 { From 4bb3e2d16dabfe1b84cb063c601068734d50b8a5 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Wed, 8 Jul 2026 14:37:17 +0000 Subject: [PATCH 2/2] docs(help): mention ubuntu fallback for rebuild command --- src/core.ts | 36 +++++++++++++++++++++++++++++++++++- tests/core.test.ts | 10 ++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/core.ts b/src/core.ts index 962ea63..9fbb855 100644 --- a/src/core.ts +++ b/src/core.ts @@ -137,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 ] [--ssh-public-key ] [--template ] [--gh-user ] [--gh-host ]\n ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ] [--gh-user ] [--gh-host ]\n ${CLI_NAME} shell\n ${CLI_NAME} status\n ${CLI_NAME} templates\n ${CLI_NAME} arise\n ${CLI_NAME} down [--devcontainer-subpath ]\n ${CLI_NAME} help\n ${CLI_NAME} --help\n\nCommands:\n up Start or reuse the managed devcontainer; falls back to the ubuntu template when none is found.\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 Publish the same port on host and container.\n --allow-missing-ssh Continue without SSH agent sharing when unavailable.\n --devcontainer-subpath Use .devcontainer//devcontainer.json.\n --ssh-public-key Use a specific SSH public key file instead of ~/.ssh/id_rsa.pub.\n --template Use a built-in template instead of a repo devcontainer.\n --gh-user Use and persist a specific GitHub CLI account for GH_TOKEN injection.\n --gh-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 ] [--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} shell`, + ` ${CLI_NAME} status`, + ` ${CLI_NAME} templates`, + ` ${CLI_NAME} arise`, + ` ${CLI_NAME} down [--devcontainer-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 Publish the same port on host and container.", + " --allow-missing-ssh Continue without SSH agent sharing when unavailable.", + " --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.", + " --gh-user Use and persist a specific GitHub CLI account for GH_TOKEN injection.", + " --gh-host GitHub host for --gh-user. Defaults to github.com.", + " -h, --help Show this help.", + ].join("\n"); } export function parseArgs(argv: string[]): ParsedArgs { diff --git a/tests/core.test.ts b/tests/core.test.ts index 2646a09..03da973 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -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", () => {