diff --git a/examples/template-workspace/sample-file.txt b/examples/template-workspace/sample-file.txt new file mode 100644 index 0000000..468c7b2 --- /dev/null +++ b/examples/template-workspace/sample-file.txt @@ -0,0 +1 @@ +template workspace sample file diff --git a/src/core.ts b/src/core.ts index 94463f7..8fc4227 100644 --- a/src/core.ts +++ b/src/core.ts @@ -422,9 +422,25 @@ export function getWorkspaceUserDataDir(workspacePath: string): string { } export function getTemplateGeneratedConfigPath(workspacePath: string): string { + return path.join(getWorkspaceStateDir(workspacePath), ".devcontainer.json"); +} + +export function getLegacyTemplateGeneratedConfigPath(workspacePath: string): string { return path.join(getWorkspaceStateDir(workspacePath), "template.devcontainer.json"); } +function resolveTemplateGeneratedConfigPath(workspacePath: string, generatedConfigPath?: string | null): string { + if (!generatedConfigPath) { + return getTemplateGeneratedConfigPath(workspacePath); + } + + if (path.resolve(generatedConfigPath) === path.resolve(getLegacyTemplateGeneratedConfigPath(workspacePath))) { + return getTemplateGeneratedConfigPath(workspacePath); + } + + return generatedConfigPath; +} + export function getDefaultRemoteWorkspaceFolder(workspacePath: string): string { return path.posix.join("/workspaces", path.basename(workspacePath)); } @@ -693,7 +709,7 @@ export async function resolveWorkspaceConfig(input: { configSource: "template", sourceConfigPath: null, generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath), - legacyGeneratedConfigPath: null, + legacyGeneratedConfigPath: getLegacyTemplateGeneratedConfigPath(input.workspacePath), template, }; } @@ -715,8 +731,8 @@ export async function resolveWorkspaceConfig(input: { config: structuredClone(input.state.template.config), configSource: "template", sourceConfigPath: null, - generatedConfigPath: input.state.generatedConfigPath || getTemplateGeneratedConfigPath(input.workspacePath), - legacyGeneratedConfigPath: null, + generatedConfigPath: resolveTemplateGeneratedConfigPath(input.workspacePath, input.state.generatedConfigPath), + legacyGeneratedConfigPath: getLegacyTemplateGeneratedConfigPath(input.workspacePath), template: cloneTemplateState(input.state.template), }; } @@ -738,8 +754,8 @@ export async function resolveWorkspaceConfig(input: { config: structuredClone(input.state.template.config), configSource: "template", sourceConfigPath: null, - generatedConfigPath: input.state.generatedConfigPath || getTemplateGeneratedConfigPath(input.workspacePath), - legacyGeneratedConfigPath: null, + generatedConfigPath: resolveTemplateGeneratedConfigPath(input.workspacePath, input.state.generatedConfigPath), + legacyGeneratedConfigPath: getLegacyTemplateGeneratedConfigPath(input.workspacePath), template: cloneTemplateState(input.state.template), }; } @@ -1097,7 +1113,10 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null { port: record.port, configSource: record.configSource, sourceConfigPath: record.sourceConfigPath, - generatedConfigPath: record.generatedConfigPath, + generatedConfigPath: + record.configSource === "template" + ? resolveTemplateGeneratedConfigPath(record.workspacePath, record.generatedConfigPath) + : record.generatedConfigPath, labels: record.labels as Record, userDataDir: record.userDataDir, template: (record.template as WorkspaceTemplateState | null | undefined) ?? null, diff --git a/tests/core.test.ts b/tests/core.test.ts index 383c797..1bfbeaa 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -14,9 +14,11 @@ import { getContainerSshAuthSockPath, getGeneratedConfigPath, getLegacyGeneratedConfigPath, + getLegacyTemplateGeneratedConfigPath, getManagedContainerName, getManagedPortFromContainerName, getManagedLabels, + getTemplateGeneratedConfigPath, helpText, parseArgs, prepareKnownHostsMount, @@ -421,7 +423,7 @@ describe("resolveWorkspaceConfig", () => { port: 5001, configSource: "template", sourceConfigPath: null, - generatedConfigPath: path.join(tempDir, ".devbox", "generated-template-devcontainer.json"), + generatedConfigPath: getTemplateGeneratedConfigPath(tempDir), labels: {}, userDataDir: path.join(tempDir, ".devbox", "user-data"), template, @@ -446,6 +448,39 @@ describe("resolveWorkspaceConfig", () => { expect(rebuildStyleResolution.template?.name).toBe("python"); expect(rebuildStyleResolution.config.image).toBe("mcr.microsoft.com/devcontainers/python:3.0.7-3.14-bookworm"); }); + + test("normalizes legacy template generated config paths from saved state", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "devbox-test-")); + tempPaths.push(tempDir); + const template = getTemplateDefinition("ubuntu"); + expect(template).not.toBeNull(); + if (!template) { + throw new Error("Expected the built-in ubuntu template to exist."); + } + + const state: WorkspaceState = { + version: STATE_VERSION, + workspacePath: tempDir, + workspaceHash: "workspace-hash", + port: 5001, + configSource: "template", + sourceConfigPath: null, + generatedConfigPath: getLegacyTemplateGeneratedConfigPath(tempDir), + labels: {}, + userDataDir: path.join(tempDir, ".devbox", "user-data"), + template, + updatedAt: new Date().toISOString(), + }; + + const resolution = await resolveWorkspaceConfig({ + workspacePath: tempDir, + state, + preferStateSource: true, + }); + + expect(resolution.generatedConfigPath).toBe(getTemplateGeneratedConfigPath(tempDir)); + expect(resolution.legacyGeneratedConfigPath).toBe(getLegacyTemplateGeneratedConfigPath(tempDir)); + }); }); describe("buildManagedConfig", () => { diff --git a/tests/examples.live.test.ts b/tests/examples.live.test.ts index 9463226..46d8705 100644 --- a/tests/examples.live.test.ts +++ b/tests/examples.live.test.ts @@ -90,6 +90,46 @@ afterEach(async () => { }); describe("example workspaces (real devcontainers)", () => { + liveTest( + "template workspace starts from the ubuntu template without a repo devcontainer", + async () => { + const fixture = await setupLiveFixture("template-workspace"); + const up = runCli(fixture, ["up", "--template", "ubuntu", "--allow-missing-ssh"]); + + expect(up.exitCode).toBe(0); + expect(up.stdout).toContain("Devcontainer is ready"); + expect(up.stdout).toContain("SSH server:"); + expect(up.stdout).toContain("Ready."); + + const state = await readJson(fixture.statePath); + const selectedPort = Number(state.port); + const containerId = String(state.lastContainerId); + expect(up.stdout).toContain(`Using port ${selectedPort}.`); + expect(state.configSource).toBe("template"); + expect(state.sourceConfigPath).toBeNull(); + expect(state.template.name).toBe("ubuntu"); + + const inspect = inspectContainer(fixture, containerId); + expect(inspect.Name).toBe(`/${getManagedContainerName(fixture.workspacePath, selectedPort)}`); + expect(inspect.Config?.Labels).toEqual(expect.objectContaining(state.labels)); + expect(getPublishedHostPort(inspect, selectedPort)).toBe(String(selectedPort)); + + const sampleFileContent = await readFile(fixture.sampleFilePath, "utf8"); + const sample = execInContainer( + fixture, + containerId, + `cat ${quoteShell(path.posix.join(fixture.remoteWorkspaceFolder, "sample-file.txt"))}`, + ); + expect(sample.stdout).toBe(sampleFileContent); + + const down = runCli(fixture, ["down"]); + expect(down.exitCode).toBe(0); + expect(down.stdout).toContain("Removed 1 managed container(s)."); + expect(await listManagedContainerIds(fixture)).toEqual([]); + }, + { timeout: 8 * 60_000 }, + ); + liveTest( "smoke workspace exercises the real docker-in-docker devcontainer path", async () => { diff --git a/tests/examples.test.ts b/tests/examples.test.ts index 4197b25..18e4dbd 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { KNOWN_HOSTS_SNAPSHOT_FILENAME, SSH_AUTH_SOCK_TARGET } from "../src/constants"; -import { hashWorkspacePath } from "../src/core"; +import { getGeneratedConfigPath, hashWorkspacePath } from "../src/core"; import { buildInteractiveShellScript } from "../src/runtime"; const repoRoot = process.cwd(); @@ -19,7 +19,6 @@ interface ExampleFixtureOptions { }; knownHosts?: string; sshAuthSock?: boolean; - withoutDevcontainer?: boolean; } interface ExampleFixture { @@ -529,38 +528,36 @@ describe("example workspaces (simulated host tools)", () => { }); test("template-backed workspace lists templates, starts without a repo devcontainer, and rebuilds from saved state", async () => { - const fixture = await setupExampleFixture("smoke-workspace", { - withoutDevcontainer: true, - }); + const fixture = await setupExampleFixture("template-workspace"); const templates = runCli(fixture, ["templates"]); expect(templates.exitCode).toBe(0); const templateList = JSON.parse(templates.stdout); + expect(templateList.some((entry: { name: string }) => entry.name === "ubuntu")).toBe(true); expect(templateList.some((entry: { name: string }) => entry.name === "bun")).toBe(true); - expect(templateList.some((entry: { name: string }) => entry.name === "rust")).toBe(true); - const up = runCli(fixture, ["up", "--template", "bun", "--allow-missing-ssh"]); + const up = runCli(fixture, ["up", "--template", "ubuntu", "--allow-missing-ssh"]); expect(up.exitCode).toBe(0); expect(up.stdout).toContain("Using port 5001."); expect(existsSync(fixture.generatedConfigPath)).toBe(true); const generatedConfig = await readJson(fixture.generatedConfigPath); - expect(generatedConfig.image).toBe("oven/bun:1.3.13"); + expect(generatedConfig.image).toBe("mcr.microsoft.com/devcontainers/base:2.1.8-ubuntu24.04"); expect(generatedConfig.postCreateCommand).toBeUndefined(); const state = await readJson(fixture.statePath); expect(state.configSource).toBe("template"); expect(state.sourceConfigPath).toBeNull(); - expect(state.template.name).toBe("bun"); - expect(state.template.image).toBe("oven/bun:1.3.13"); - expect(state.template.pinnedReference).toBe("oven/bun:1.3.13"); - expect(state.template.runtimeVersion).toBe("Bun 1.3.13"); + expect(state.template.name).toBe("ubuntu"); + expect(state.template.image).toBe("mcr.microsoft.com/devcontainers/base:2.1.8-ubuntu24.04"); + expect(state.template.pinnedReference).toBe("mcr.microsoft.com/devcontainers/base:2.1.8-ubuntu24.04"); + expect(state.template.runtimeVersion).toBe("Ubuntu 24.04"); const statusWhileRunning = runCli(fixture, ["status"]); expect(statusWhileRunning.exitCode).toBe(0); const runningStatus = JSON.parse(statusWhileRunning.stdout); expect(runningStatus.configSource).toBe("template"); - expect(runningStatus.templateName).toBe("bun"); + expect(runningStatus.templateName).toBe("ubuntu"); const rebuild = runCli(fixture, ["rebuild", "--allow-missing-ssh"]); expect(rebuild.exitCode).toBe(0); @@ -581,7 +578,7 @@ describe("example workspaces (simulated host tools)", () => { const stoppedStatus = JSON.parse(statusAfterDown.stdout); expect(stoppedStatus.hasStateFile).toBe(true); expect(stoppedStatus.configSource).toBe("template"); - expect(stoppedStatus.templateName).toBe("bun"); + expect(stoppedStatus.templateName).toBe("ubuntu"); }); }); @@ -591,10 +588,6 @@ async function setupExampleFixture(exampleName: string, options: ExampleFixtureO const workspaceCopyPath = path.join(tempDir, exampleName); await cp(path.join(repoRoot, "examples", exampleName), workspaceCopyPath, { recursive: true }); - if (options.withoutDevcontainer) { - await rm(path.join(workspaceCopyPath, ".devcontainer"), { recursive: true, force: true }); - await rm(path.join(workspaceCopyPath, ".devcontainer.json"), { force: true }); - } await runHostCommand(["git", "init", workspaceCopyPath]); if (options.gitIdentity) { @@ -632,14 +625,13 @@ async function setupExampleFixture(exampleName: string, options: ExampleFixtureO env.XDG_STATE_HOME = xdgStateHome; const stateDir = getStateDir(homeDir, workspacePath, xdgStateHome); + const sourceConfigPath = resolveExampleSourceConfigPath(workspacePath); return { commandLogPath, env, - generatedConfigPath: options.withoutDevcontainer - ? path.join(stateDir, "template.devcontainer.json") - : path.join(workspacePath, ".devcontainer", ".devcontainer.json"), + generatedConfigPath: sourceConfigPath ? getGeneratedConfigPath(sourceConfigPath) : path.join(stateDir, ".devcontainer.json"), homeDir, - sourceConfigPath: options.withoutDevcontainer ? null : path.join(workspacePath, ".devcontainer", "devcontainer.json"), + sourceConfigPath, sshAuthSockPath, statePath: path.join(stateDir, "state.json"), userDataDir: path.join(stateDir, "user-data"), @@ -647,6 +639,16 @@ async function setupExampleFixture(exampleName: string, options: ExampleFixtureO }; } +function resolveExampleSourceConfigPath(workspacePath: string): string | null { + const nestedConfigPath = path.join(workspacePath, ".devcontainer", "devcontainer.json"); + if (existsSync(nestedConfigPath)) { + return nestedConfigPath; + } + + const rootConfigPath = path.join(workspacePath, ".devcontainer.json"); + return existsSync(rootConfigPath) ? rootConfigPath : null; +} + async function createFakeHostToolchain(fakeHostDir: string): Promise { const binDir = path.join(fakeHostDir, "bin"); await mkdir(binDir, { recursive: true });