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
1 change: 1 addition & 0 deletions examples/template-workspace/sample-file.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
template workspace sample file
31 changes: 25 additions & 6 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down Expand Up @@ -693,7 +709,7 @@ export async function resolveWorkspaceConfig(input: {
configSource: "template",
sourceConfigPath: null,
generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath),
legacyGeneratedConfigPath: null,
legacyGeneratedConfigPath: getLegacyTemplateGeneratedConfigPath(input.workspacePath),
template,
};
}
Expand All @@ -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),
};
}
Expand All @@ -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),
};
}
Expand Down Expand Up @@ -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<string, string>,
userDataDir: record.userDataDir,
template: (record.template as WorkspaceTemplateState | null | undefined) ?? null,
Expand Down
37 changes: 36 additions & 1 deletion tests/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import {
getContainerSshAuthSockPath,
getGeneratedConfigPath,
getLegacyGeneratedConfigPath,
getLegacyTemplateGeneratedConfigPath,
getManagedContainerName,
getManagedPortFromContainerName,
getManagedLabels,
getTemplateGeneratedConfigPath,
helpText,
parseArgs,
prepareKnownHostsMount,
Expand Down Expand Up @@ -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,
Expand All @@ -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", () => {
Expand Down
40 changes: 40 additions & 0 deletions tests/examples.live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
46 changes: 24 additions & 22 deletions tests/examples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -19,7 +19,6 @@ interface ExampleFixtureOptions {
};
knownHosts?: string;
sshAuthSock?: boolean;
withoutDevcontainer?: boolean;
}

interface ExampleFixture {
Expand Down Expand Up @@ -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);
Expand All @@ -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");
});
});

Expand All @@ -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) {
Expand Down Expand Up @@ -632,21 +625,30 @@ 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"),
workspacePath,
};
}

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<string> {
const binDir = path.join(fakeHostDir, "bin");
await mkdir(binDir, { recursive: true });
Expand Down
Loading