diff --git a/.gitignore b/.gitignore index ddee7b1..f56a5ab 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ dist/ coverage/ .sshcred .devbox-ssh-host-keys/ +.devbox/ .planning/ diff --git a/README.md b/README.md index 7e71f61..787231d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ It does not modify the original `devcontainer.json`. Instead, it generates a der - Exposes the SSH service on the chosen host port and, when a host public key is available, installs it for key-based SSH login inside the devcontainer. - Seeds the container user's global Git `user.name` and `user.email` from the host when available. - Runs the [`ssh-server-runner`](https://github.com/PabloZaiden/ssh-server-runner) one-liner inside the devcontainer. -- Persists the runner password on the mounted workspace as a local `.sshcred` file, stores devbox-owned SSH metadata in `.devbox-ssh.json`, and keeps SSH host keys in `.devbox-ssh-host-keys/`, so they survive `down` / `rebuild`. +- Stores devbox-owned state in the workspace-local `.devbox/` directory, and persists the runner password as `.sshcred`, SSH metadata in `.devbox-ssh.json`, and SSH host keys in `.devbox-ssh-host-keys/`, so they survive `down` / `rebuild`. ## Installation @@ -86,7 +86,7 @@ devbox status # Find stopped managed devbox containers and rerun `devbox up` for each recoverable workspace devbox arise -# Stop and remove the managed container while preserving the workspace-mounted SSH password, metadata, and host keys +# Stop and remove the managed container while preserving workspace-local devbox state and SSH artifacts devbox down ``` @@ -141,7 +141,7 @@ Example: } ``` -The full payload also includes useful diagnostic fields such as `workspaceHash`, `labels`, `publishedPorts`, `statePath`, `credentialPath`, `sshMetadataPath`, `updatedAt`, and the stored/generated config paths. +The full payload also includes useful diagnostic fields such as `workspaceHash`, `labels`, `publishedPorts`, `statePath`, `credentialPath`, `sshMetadataPath`, `updatedAt`, and the stored/generated config paths. Devbox-owned state paths point inside the current workspace's `.devbox/` directory. When available, the status payload also includes `publicKeyConfigured` and `publicKeySource` so automation can tell whether devbox installed a host SSH public key for key-based login. @@ -192,15 +192,16 @@ The complex example uses several devcontainer features, so the first `up` or `re ## Notes - 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. +- When `devbox` uses `--template`, it writes the generated config to `.devbox/.devcontainer.json` instead of creating a source devcontainer definition inside the repo. +- `.devbox/` contains devbox-owned local state (`state.json`, `user-data/`, and template generated configs) and should stay ignored by version control. - `--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. - `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 status` reports live container state when available and falls back to saved workspace state in `.devbox/state.json` 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 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. +- `down` removes managed containers but keeps `.devbox/` 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. diff --git a/src/cli.ts b/src/cli.ts index b98c98e..b6a0f9e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { getManagedPortFromContainerName, getManagedLabels, prepareKnownHostsMount, + getWorkspaceStateDir, getWorkspaceUserDataDir, hashWorkspacePath, helpText, @@ -199,6 +200,7 @@ async function handleUpLike( if (resolvedConfig.configSource === "repo" && resolvedConfig.sourceConfigPath) { await ensureGeneratedConfigIgnored(workspacePath, generatedConfigPath); } + await ensurePathIgnored(workspacePath, getWorkspaceStateDir(workspacePath)); if (resolvedConfig.legacyGeneratedConfigPath) { await removeGeneratedConfig(resolvedConfig.legacyGeneratedConfigPath); } diff --git a/src/core.ts b/src/core.ts index 8fc4227..c97cbc9 100644 --- a/src/core.ts +++ b/src/core.ts @@ -396,21 +396,8 @@ export function getManagedLabels(workspaceHash: string): Record }; } -export function getStateRoot(): string { - if (process.platform === "darwin") { - return path.join(os.homedir(), "Library", "Application Support", CLI_NAME); - } - - const xdgStateHome = process.env.XDG_STATE_HOME; - if (xdgStateHome) { - return path.join(xdgStateHome, CLI_NAME); - } - - return path.join(os.homedir(), ".local", "state", CLI_NAME); -} - export function getWorkspaceStateDir(workspacePath: string): string { - return path.join(getStateRoot(), "workspaces", hashWorkspacePath(workspacePath)); + return path.join(workspacePath, ".devbox"); } export function getWorkspaceStateFile(workspacePath: string): string { @@ -425,22 +412,6 @@ 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)); } @@ -709,7 +680,7 @@ export async function resolveWorkspaceConfig(input: { configSource: "template", sourceConfigPath: null, generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath), - legacyGeneratedConfigPath: getLegacyTemplateGeneratedConfigPath(input.workspacePath), + legacyGeneratedConfigPath: null, template, }; } @@ -731,8 +702,8 @@ export async function resolveWorkspaceConfig(input: { config: structuredClone(input.state.template.config), configSource: "template", sourceConfigPath: null, - generatedConfigPath: resolveTemplateGeneratedConfigPath(input.workspacePath, input.state.generatedConfigPath), - legacyGeneratedConfigPath: getLegacyTemplateGeneratedConfigPath(input.workspacePath), + generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath), + legacyGeneratedConfigPath: null, template: cloneTemplateState(input.state.template), }; } @@ -754,8 +725,8 @@ export async function resolveWorkspaceConfig(input: { config: structuredClone(input.state.template.config), configSource: "template", sourceConfigPath: null, - generatedConfigPath: resolveTemplateGeneratedConfigPath(input.workspacePath, input.state.generatedConfigPath), - legacyGeneratedConfigPath: getLegacyTemplateGeneratedConfigPath(input.workspacePath), + generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath), + legacyGeneratedConfigPath: null, template: cloneTemplateState(input.state.template), }; } @@ -1114,9 +1085,7 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null { configSource: record.configSource, sourceConfigPath: record.sourceConfigPath, generatedConfigPath: - record.configSource === "template" - ? resolveTemplateGeneratedConfigPath(record.workspacePath, record.generatedConfigPath) - : record.generatedConfigPath, + record.configSource === "template" ? getTemplateGeneratedConfigPath(record.workspacePath) : 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 d2dad97..2be7e27 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1,4 +1,3 @@ -import { dirname } from "node:path"; import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -14,7 +13,6 @@ import { getContainerSshAuthSockPath, getGeneratedConfigPath, getLegacyGeneratedConfigPath, - getLegacyTemplateGeneratedConfigPath, getManagedContainerName, getManagedPortFromContainerName, getManagedLabels, @@ -223,55 +221,44 @@ describe("resolvePort", () => { }); describe("loadWorkspaceState", () => { - test("migrates a version 1 workspace state file", async () => { + test("loads a version 1 workspace state file from local workspace state", async () => { const workspacePath = await mkdtemp(path.join(os.tmpdir(), "devbox-workspace-")); - const stateHome = await mkdtemp(path.join(os.tmpdir(), "devbox-state-home-")); - tempPaths.push(workspacePath, stateHome); - - const previousStateHome = process.env.XDG_STATE_HOME; - process.env.XDG_STATE_HOME = stateHome; - - try { - const statePath = getWorkspaceStateFile(workspacePath); - await mkdir(dirname(statePath), { recursive: true }); - await writeFile( - statePath, - `${JSON.stringify({ - version: 1, - workspacePath, - workspaceHash: "hash", - port: 5001, - sourceConfigPath: path.join(workspacePath, ".devcontainer", "devcontainer.json"), - generatedConfigPath: path.join(workspacePath, ".devcontainer", ".devbox.generated.devcontainer.json"), - labels: { managed: "true" }, - userDataDir: path.join(stateHome, "user-data"), - lastContainerId: "container-123", - updatedAt: "2026-04-23T00:00:00.000Z", - }, null, 2)}\n`, - "utf8", - ); - - await expect(loadWorkspaceState(workspacePath)).resolves.toEqual({ - version: STATE_VERSION, + tempPaths.push(workspacePath); + + const statePath = getWorkspaceStateFile(workspacePath); + expect(statePath).toBe(path.join(workspacePath, ".devbox", "state.json")); + await mkdir(path.dirname(statePath), { recursive: true }); + await writeFile( + statePath, + `${JSON.stringify({ + version: 1, workspacePath, workspaceHash: "hash", port: 5001, - configSource: "repo", sourceConfigPath: path.join(workspacePath, ".devcontainer", "devcontainer.json"), generatedConfigPath: path.join(workspacePath, ".devcontainer", ".devbox.generated.devcontainer.json"), labels: { managed: "true" }, - userDataDir: path.join(stateHome, "user-data"), - template: null, + userDataDir: path.join(workspacePath, ".devbox", "user-data"), lastContainerId: "container-123", updatedAt: "2026-04-23T00:00:00.000Z", - }); - } finally { - if (previousStateHome === undefined) { - delete process.env.XDG_STATE_HOME; - } else { - process.env.XDG_STATE_HOME = previousStateHome; - } - } + }, null, 2)}\n`, + "utf8", + ); + + await expect(loadWorkspaceState(workspacePath)).resolves.toEqual({ + version: STATE_VERSION, + workspacePath, + workspaceHash: "hash", + port: 5001, + configSource: "repo", + sourceConfigPath: path.join(workspacePath, ".devcontainer", "devcontainer.json"), + generatedConfigPath: path.join(workspacePath, ".devcontainer", ".devbox.generated.devcontainer.json"), + labels: { managed: "true" }, + userDataDir: path.join(workspacePath, ".devbox", "user-data"), + template: null, + lastContainerId: "container-123", + updatedAt: "2026-04-23T00:00:00.000Z", + }); }); }); @@ -455,7 +442,7 @@ describe("resolveWorkspaceConfig", () => { }); }); - test("normalizes legacy template generated config paths from saved state", async () => { + test("uses the local template generated config path instead of saved stale paths", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "devbox-test-")); tempPaths.push(tempDir); const template = getTemplateDefinition("ubuntu"); @@ -471,7 +458,7 @@ describe("resolveWorkspaceConfig", () => { port: 5001, configSource: "template", sourceConfigPath: null, - generatedConfigPath: getLegacyTemplateGeneratedConfigPath(tempDir), + generatedConfigPath: path.join(os.tmpdir(), "old-devbox-state", ".devcontainer.json"), labels: {}, userDataDir: path.join(tempDir, ".devbox", "user-data"), template, @@ -485,7 +472,7 @@ describe("resolveWorkspaceConfig", () => { }); expect(resolution.generatedConfigPath).toBe(getTemplateGeneratedConfigPath(tempDir)); - expect(resolution.legacyGeneratedConfigPath).toBe(getLegacyTemplateGeneratedConfigPath(tempDir)); + expect(resolution.legacyGeneratedConfigPath).toBeNull(); }); }); diff --git a/tests/examples.live.test.ts b/tests/examples.live.test.ts index 1a48079..160f2ff 100644 --- a/tests/examples.live.test.ts +++ b/tests/examples.live.test.ts @@ -5,7 +5,6 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { - CLI_NAME, DEVBOX_SSH_METADATA_FILENAME, DOCKER_DESKTOP_SSH_AUTH_SOCK_SOURCE, MANAGED_LABEL_KEY, @@ -433,7 +432,6 @@ async function setupLiveFixture(exampleName: string, options: LiveFixtureOptions env.HOME = homeDir; env.PATH = `${wrappersDir}${path.delimiter}${env.PATH}`; env.SSH_AUTH_SOCK = sshAuthSockPath ?? ""; - env.XDG_STATE_HOME = path.join(tempRoot, "state"); const fixture: LiveFixture = { env, @@ -458,7 +456,7 @@ async function setupLiveFixture(exampleName: string, options: LiveFixtureOptions runnerHostKeyMarker, sampleFilePath: path.join(workspacePath, "sample-file.txt"), sshAuthSockPath, - statePath: getStatePath(homeDir, workspacePath, env.XDG_STATE_HOME), + statePath: getStatePath(workspacePath), workspacePath, }; @@ -996,20 +994,8 @@ function baseEnv(): Record { return env; } -function getStatePath(homeDir: string, workspacePath: string, xdgStateHome?: string): string { - return path.join(getStateRoot(homeDir, xdgStateHome), "workspaces", hashWorkspacePath(workspacePath), "state.json"); -} - -function getStateRoot(homeDir: string, xdgStateHome?: string): string { - if (process.platform === "darwin") { - return path.join(homeDir, "Library", "Application Support", CLI_NAME); - } - - if (xdgStateHome) { - return path.join(xdgStateHome, CLI_NAME); - } - - return path.join(homeDir, ".local", "state", CLI_NAME); +function getStatePath(workspacePath: string): string { + return path.join(workspacePath, ".devbox", "state.json"); } function findExecutable(command: string): string | null { diff --git a/tests/examples.test.ts b/tests/examples.test.ts index 75b56b4..e112105 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 { getGeneratedConfigPath, hashWorkspacePath } from "../src/core"; +import { getGeneratedConfigPath } from "../src/core"; import { buildInteractiveShellScript } from "../src/runtime"; const repoRoot = process.cwd(); @@ -618,7 +618,6 @@ async function setupExampleFixture(exampleName: string, options: ExampleFixtureO await writeFile(sshAuthSockPath, "fake-agent\n", "utf8"); } - const xdgStateHome = path.join(tempDir, "state"); const env = baseEnv(); env.DEVBOX_FAKE_GH_MODE = options.ghToken ? "token" : "unauthenticated"; env.DEVBOX_FAKE_GH_TOKEN = options.ghToken ?? ""; @@ -627,9 +626,8 @@ async function setupExampleFixture(exampleName: string, options: ExampleFixtureO env.HOME = homeDir; env.PATH = `${path.join(fakeHostDir, "bin")}${path.delimiter}${env.PATH}`; env.SSH_AUTH_SOCK = sshAuthSockPath ?? ""; - env.XDG_STATE_HOME = xdgStateHome; - const stateDir = getStateDir(homeDir, workspacePath, xdgStateHome); + const stateDir = getStateDir(workspacePath); const sourceConfigPath = resolveExampleSourceConfigPath(workspacePath); return { commandLogPath, @@ -713,16 +711,8 @@ function baseEnv(): Record { return env; } -function getStateDir(homeDir: string, workspacePath: string, xdgStateHome?: string): string { - let root: string; - if (process.platform === "darwin") { - root = path.join(homeDir, "Library", "Application Support", "devbox"); - } else if (xdgStateHome) { - root = path.join(xdgStateHome, "devbox"); - } else { - root = path.join(homeDir, ".local", "state", "devbox"); - } - return path.join(root, "workspaces", hashWorkspacePath(workspacePath)); +function getStateDir(workspacePath: string): string { + return path.join(workspacePath, ".devbox"); } async function readJson(filePath: string): Promise {