diff --git a/README.md b/README.md index ec8ca35..6232fc9 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ devbox up --ssh-public-key ~/.ssh/work-devbox.pub # 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 +devbox up --template python + +# List the built-in templates as JSON +devbox templates + # Rebuild/recreate the managed devcontainer devbox rebuild @@ -92,10 +98,25 @@ 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. + `devbox shell` requires an already running managed container for the current workspace. If none is running, use `devbox up` first. `devbox status` always prints JSON so it can be used directly from scripts and automation. +`devbox templates` always prints JSON. Each entry includes the template name, description, pinned image/reference, runtime version, language tags, and whether the template is compatible with `ssh-server-runner`. + +Built-in templates: + +- `ubuntu` +- `dotnet` +- `node-typescript` +- `bun` +- `python` +- `go` +- `rust` +- `java` + `devbox arise` is a global recovery command. It scans existing stopped devbox-managed containers, recovers each host workspace from the bind mount targeting `/workspaces/...`, checks that the workspace still has devbox leftovers from a previous `up` run, and then reruns `devbox up` for that workspace. It logs each discovery, skip, restart, and failure, and continues with the remaining workspaces if one restart fails. Example: @@ -171,14 +192,16 @@ The complex example uses several devcontainer features, so the first `up` or `re ## Notes -- 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 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. - `--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 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 does not delete the workspace `.sshcred`, `.devbox-ssh.json`, or `.devbox-ssh-host-keys/`, so the SSH password, SSH metadata, and SSH host identity survive rebuilds. +- `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. - 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/arise.ts b/src/arise.ts index e0cdc32..b3fcfef 100644 --- a/src/arise.ts +++ b/src/arise.ts @@ -335,7 +335,7 @@ export async function ariseManagedWorkspaces(deps: AriseDependencies): Promise { const parsed = parseArgs(process.argv.slice(2)); @@ -81,6 +79,11 @@ async function main(): Promise { return; } + if (parsed.command === "templates") { + console.log(JSON.stringify(listTemplateSummaries(), null, 2)); + return; + } + const workspacePath = await realpath(process.cwd()); const state = await loadWorkspaceState(workspacePath); @@ -107,6 +110,7 @@ async function main(): Promise { parsed.allowMissingSsh, parsed.devcontainerSubpath, parsed.sshPublicKeyPath, + parsed.templateName, ); } @@ -118,6 +122,7 @@ async function handleUpLike( allowMissingSsh: boolean, devcontainerSubpath: string | undefined, sshPublicKeyPath?: string, + templateName?: string, ): Promise { const environment = await ensureHostEnvironment({ allowMissingSsh, workspacePath }); const resolvedSshPublicKey = await resolveSshPublicKey({ overridePath: sshPublicKeyPath }); @@ -143,14 +148,19 @@ async function handleUpLike( : resolvePort(command, explicitPort, state); console.log(`Using port ${port}. ${command === "up" ? describeUpPortStrategy() : ""}`.trim()); - const discovered = await discoverDevcontainerConfig(workspacePath, devcontainerSubpath); - const generatedConfigPath = getGeneratedConfigPath(discovered.path); - const legacyGeneratedConfigPath = getLegacyGeneratedConfigPath(discovered.path); + const resolvedConfig = await resolveWorkspaceConfig({ + workspacePath, + devcontainerSubpath, + templateName, + state, + preferStateSource: command === "rebuild", + }); + const generatedConfigPath = resolvedConfig.generatedConfigPath; const userDataDir = getWorkspaceUserDataDir(workspacePath); const preparedKnownHosts = await prepareKnownHostsMount({ userDataDir }); const containerName = getManagedContainerName(workspacePath, port); - const managedConfig = buildManagedConfig(discovered.config, { + const managedConfig = buildManagedConfig(resolvedConfig.config, { port, containerName, sshAuthSock: environment.sshAuthSock, @@ -186,8 +196,12 @@ async function handleUpLike( console.log("Using host GitHub authentication from gh."); } - await ensureGeneratedConfigIgnored(workspacePath, generatedConfigPath); - await removeGeneratedConfig(legacyGeneratedConfigPath); + if (resolvedConfig.configSource === "repo" && resolvedConfig.sourceConfigPath) { + await ensureGeneratedConfigIgnored(workspacePath, generatedConfigPath); + } + if (resolvedConfig.legacyGeneratedConfigPath) { + await removeGeneratedConfig(resolvedConfig.legacyGeneratedConfigPath); + } await writeManagedConfig(generatedConfigPath, managedConfig); if (command === "rebuild") { @@ -290,10 +304,12 @@ async function handleUpLike( createWorkspaceState({ workspacePath, port, - sourceConfigPath: discovered.path, + configSource: resolvedConfig.configSource, + sourceConfigPath: resolvedConfig.sourceConfigPath, generatedConfigPath, userDataDir, labels, + template: resolvedConfig.template, containerId: upResult.containerId, }), ); @@ -347,19 +363,33 @@ async function handleDown( generatedConfigPaths.add(state.generatedConfigPath); } - try { - const discovered = await discoverDevcontainerConfig(workspacePath, devcontainerSubpath); - generatedConfigPaths.add(getGeneratedConfigPath(discovered.path)); - generatedConfigPaths.add(getLegacyGeneratedConfigPath(discovered.path)); - } catch { - // Workspace may no longer contain a devcontainer definition; cleanup still continues. + if (state?.configSource === "repo") { + try { + const resolvedConfig = await resolveWorkspaceConfig({ + workspacePath, + devcontainerSubpath, + state, + }); + generatedConfigPaths.add(resolvedConfig.generatedConfigPath); + if (resolvedConfig.legacyGeneratedConfigPath) { + generatedConfigPaths.add(resolvedConfig.legacyGeneratedConfigPath); + } + } catch { + // Workspace may no longer contain a devcontainer definition; cleanup still continues. + } } for (const generatedConfigPath of generatedConfigPaths) { await removeGeneratedConfig(generatedConfigPath); } - await deleteWorkspaceState(workspacePath); + if (state) { + await saveWorkspaceState({ + ...state, + lastContainerId: undefined, + updatedAt: new Date().toISOString(), + }); + } if (containerIds.length === 0) { console.log("No managed container was running for this workspace."); diff --git a/src/constants.ts b/src/constants.ts index 8d802da..6408fdc 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,4 +12,4 @@ export const DEVBOX_SSH_METADATA_FILENAME = ".devbox-ssh.json"; export const RUNNER_HOST_KEYS_DIRNAME = ".devbox-ssh-host-keys"; export const RUNNER_URL = "https://raw.githubusercontent.com/PabloZaiden/ssh-server-runner/main/ssh-server.sh"; -export const STATE_VERSION = 1; +export const STATE_VERSION = 2; diff --git a/src/core.ts b/src/core.ts index 68cc98b..94463f7 100644 --- a/src/core.ts +++ b/src/core.ts @@ -17,8 +17,9 @@ import { STATE_VERSION, WORKSPACE_LABEL_KEY, } from "./constants"; +import { getTemplateDefinition } from "./templates"; -export type CommandName = "up" | "down" | "rebuild" | "shell" | "status" | "arise" | "help"; +export type CommandName = "up" | "down" | "rebuild" | "shell" | "status" | "arise" | "templates" | "help"; export interface ParsedArgs { command: CommandName; @@ -26,6 +27,7 @@ export interface ParsedArgs { allowMissingSsh: boolean; devcontainerSubpath?: string; sshPublicKeyPath?: string; + templateName?: string; } export type DevcontainerConfig = Record; @@ -54,14 +56,38 @@ export interface WorkspaceState { workspacePath: string; workspaceHash: string; port: number; - sourceConfigPath: string; + configSource: "repo" | "template"; + sourceConfigPath: string | null; generatedConfigPath: string; labels: Record; userDataDir: string; + template: WorkspaceTemplateState | null; lastContainerId?: string; updatedAt: string; } +export interface WorkspaceTemplateState { + name: string; + description: string; + source: "built-in"; + base: string; + image: string | null; + pinnedReference: string; + runtimeVersion: string; + languages: string[]; + runnerCompatible: boolean; + config: DevcontainerConfig; +} + +export interface ResolvedWorkspaceConfig { + config: DevcontainerConfig; + configSource: "repo" | "template"; + sourceConfigPath: string | null; + generatedConfigPath: string; + legacyGeneratedConfigPath: string | null; + template: WorkspaceTemplateState | null; +} + export interface UpResult { outcome: string; containerId: string; @@ -98,7 +124,7 @@ export class UserError extends Error { } export function helpText(): string { - return `${CLI_NAME} v${pkg.version} - manage a devcontainer plus ssh-server-runner\n\nUsage:\n ${CLI_NAME}\n ${CLI_NAME} up [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ]\n ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ]\n ${CLI_NAME} shell\n ${CLI_NAME} status\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 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 -h, --help Show this help.`; + return `${CLI_NAME} v${pkg.version} - manage a devcontainer plus ssh-server-runner\n\nUsage:\n ${CLI_NAME}\n ${CLI_NAME} up [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ] [--template ]\n ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ]\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 -h, --help Show this help.`; } export function parseArgs(argv: string[]): ParsedArgs { @@ -111,7 +137,15 @@ export function parseArgs(argv: string[]): ParsedArgs { let command: CommandName; const first = args[0]; - if (first === "up" || first === "down" || first === "rebuild" || first === "shell" || first === "status" || first === "arise") { + if ( + first === "up" || + first === "down" || + first === "rebuild" || + first === "shell" || + first === "status" || + first === "arise" || + first === "templates" + ) { command = first; args.shift(); } else if (first === "help") { @@ -126,6 +160,7 @@ export function parseArgs(argv: string[]): ParsedArgs { let allowMissingSsh = false; let devcontainerSubpath: string | undefined; let sshPublicKeyPath: string | undefined; + let templateName: string | undefined; const positionals: string[] = []; for (let index = 0; index < args.length; index += 1) { @@ -165,6 +200,21 @@ export function parseArgs(argv: string[]): ParsedArgs { continue; } + if (arg === "--template") { + const value = args[index + 1]; + if (!value) { + throw new UserError("Expected a value after --template."); + } + templateName = parseTemplateName(value); + index += 1; + continue; + } + + if (arg.startsWith("--template=")) { + templateName = parseTemplateName(arg.slice("--template=".length)); + continue; + } + if (arg.startsWith("--ssh-public-key=")) { sshPublicKeyPath = parseCliPathOption(arg.slice("--ssh-public-key=".length), "--ssh-public-key"); continue; @@ -228,6 +278,10 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError("The arise command does not accept a port."); } + if (command === "templates" && port !== undefined) { + throw new UserError("The templates command does not accept a port."); + } + if (command === "shell" && devcontainerSubpath !== undefined) { throw new UserError("The shell command does not accept --devcontainer-subpath."); } @@ -240,6 +294,10 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError("The arise command does not accept --devcontainer-subpath."); } + if (command === "templates" && devcontainerSubpath !== undefined) { + throw new UserError("The templates command does not accept --devcontainer-subpath."); + } + if (command === "down" && sshPublicKeyPath !== undefined) { throw new UserError("The down command does not accept --ssh-public-key."); } @@ -256,6 +314,10 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError("The arise command does not accept --ssh-public-key."); } + if (command === "templates" && sshPublicKeyPath !== undefined) { + throw new UserError("The templates command does not accept --ssh-public-key."); + } + if (command === "status" && allowMissingSsh) { throw new UserError("The status command does not accept --allow-missing-ssh."); } @@ -264,13 +326,46 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError("The arise command does not accept --allow-missing-ssh."); } - if (devcontainerSubpath || sshPublicKeyPath) { + if (command === "templates" && allowMissingSsh) { + throw new UserError("The templates command does not accept --allow-missing-ssh."); + } + + if (templateName !== undefined && devcontainerSubpath !== undefined) { + throw new UserError("--template cannot be combined with --devcontainer-subpath."); + } + + if (command === "down" && templateName !== undefined) { + throw new UserError("The down command does not accept --template."); + } + + if (command === "rebuild" && templateName !== undefined) { + throw new UserError("The rebuild command does not accept --template. Start fresh with `devbox up --template `."); + } + + if (command === "shell" && templateName !== undefined) { + throw new UserError("The shell command does not accept --template."); + } + + if (command === "status" && templateName !== undefined) { + throw new UserError("The status command does not accept --template."); + } + + if (command === "arise" && templateName !== undefined) { + throw new UserError("The arise command does not accept --template."); + } + + if (command === "templates" && templateName !== undefined) { + throw new UserError("The templates command does not accept --template."); + } + + if (devcontainerSubpath || sshPublicKeyPath || templateName) { return { command, port, allowMissingSsh, ...(devcontainerSubpath ? { devcontainerSubpath } : {}), ...(sshPublicKeyPath ? { sshPublicKeyPath } : {}), + ...(templateName ? { templateName } : {}), }; } @@ -326,6 +421,10 @@ export function getWorkspaceUserDataDir(workspacePath: string): string { return path.join(getWorkspaceStateDir(workspacePath), "user-data"); } +export function getTemplateGeneratedConfigPath(workspacePath: string): string { + return path.join(getWorkspaceStateDir(workspacePath), "template.devcontainer.json"); +} + export function getDefaultRemoteWorkspaceFolder(workspacePath: string): string { return path.posix.join("/workspaces", path.basename(workspacePath)); } @@ -377,23 +476,14 @@ export async function loadWorkspaceState(workspacePath: string): Promise; + const parsed = JSON.parse(raw) as unknown; + const migrated = migrateWorkspaceState(parsed); - if ( - parsed.version !== STATE_VERSION || - typeof parsed.workspacePath !== "string" || - typeof parsed.workspaceHash !== "string" || - typeof parsed.port !== "number" || - typeof parsed.generatedConfigPath !== "string" || - typeof parsed.sourceConfigPath !== "string" || - typeof parsed.userDataDir !== "string" || - !parsed.labels || - typeof parsed.labels !== "object" - ) { + if (!migrated) { throw new UserError(`State file is invalid: ${statePath}`); } - return parsed as WorkspaceState; + return migrated; } export async function saveWorkspaceState(state: WorkspaceState): Promise { @@ -488,7 +578,7 @@ export async function discoverDevcontainerConfig( export function validateSupportedDevcontainerConfig(config: DevcontainerConfig): void { if (config.dockerComposeFile !== undefined) { - throw new UserError("dockerComposeFile-based devcontainers are not supported in v1."); + throw new UserError("dockerComposeFile-based devcontainers are not supported."); } const hasImage = typeof config.image === "string" && config.image.trim().length > 0; @@ -497,7 +587,7 @@ export function validateSupportedDevcontainerConfig(config: DevcontainerConfig): const hasBuildDockerfile = typeof build?.dockerfile === "string" && build.dockerfile.trim().length > 0; if (!hasImage && !hasDockerFile && !hasBuildDockerfile) { - throw new UserError("Only image- or Dockerfile-based devcontainers are supported in v1."); + throw new UserError("Only image- or Dockerfile-based devcontainers are supported."); } } @@ -565,10 +655,12 @@ export function buildManagedConfig(baseConfig: DevcontainerConfig, options: Mana export function createWorkspaceState(input: { workspacePath: string; port: number; - sourceConfigPath: string; + configSource: "repo" | "template"; + sourceConfigPath: string | null; generatedConfigPath: string; userDataDir: string; labels: Record; + template: WorkspaceTemplateState | null; containerId?: string; }): WorkspaceState { return { @@ -576,15 +668,88 @@ export function createWorkspaceState(input: { workspacePath: input.workspacePath, workspaceHash: hashWorkspacePath(input.workspacePath), port: input.port, + configSource: input.configSource, sourceConfigPath: input.sourceConfigPath, generatedConfigPath: input.generatedConfigPath, labels: input.labels, userDataDir: input.userDataDir, + template: input.template ? cloneTemplateState(input.template) : null, lastContainerId: input.containerId, updatedAt: new Date().toISOString(), }; } +export async function resolveWorkspaceConfig(input: { + workspacePath: string; + devcontainerSubpath?: string; + templateName?: string; + state: WorkspaceState | null; + preferStateSource?: boolean; +}): Promise { + if (input.templateName) { + const template = resolveBuiltInTemplate(input.templateName); + return { + config: structuredClone(template.config), + configSource: "template", + sourceConfigPath: null, + generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath), + legacyGeneratedConfigPath: null, + template, + }; + } + + if (input.devcontainerSubpath) { + const discovered = await discoverDevcontainerConfig(input.workspacePath, input.devcontainerSubpath); + return { + config: discovered.config, + configSource: "repo", + sourceConfigPath: discovered.path, + generatedConfigPath: getGeneratedConfigPath(discovered.path), + legacyGeneratedConfigPath: getLegacyGeneratedConfigPath(discovered.path), + template: null, + }; + } + + if (input.preferStateSource && input.state?.configSource === "template" && input.state.template) { + return { + config: structuredClone(input.state.template.config), + configSource: "template", + sourceConfigPath: null, + generatedConfigPath: input.state.generatedConfigPath || getTemplateGeneratedConfigPath(input.workspacePath), + legacyGeneratedConfigPath: null, + template: cloneTemplateState(input.state.template), + }; + } + + const discovered = await discoverDevcontainerConfigIfPresent(input.workspacePath); + if (discovered) { + return { + config: discovered.config, + configSource: "repo", + sourceConfigPath: discovered.path, + generatedConfigPath: getGeneratedConfigPath(discovered.path), + legacyGeneratedConfigPath: getLegacyGeneratedConfigPath(discovered.path), + template: null, + }; + } + + if (input.state?.configSource === "template" && input.state.template) { + return { + config: structuredClone(input.state.template.config), + configSource: "template", + sourceConfigPath: null, + generatedConfigPath: input.state.generatedConfigPath || getTemplateGeneratedConfigPath(input.workspacePath), + legacyGeneratedConfigPath: null, + template: cloneTemplateState(input.state.template), + }; + } + + 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 \`.`, + ); +} + export async function prepareKnownHostsMount(input: { userDataDir: string; homeDir?: string; @@ -695,6 +860,14 @@ function parseCliPathOption(raw: string, optionName: string): string { return trimmed; } +function parseTemplateName(raw: string): string { + const trimmed = raw.trim(); + if (!/^[a-z0-9][a-z0-9-]*$/.test(trimmed)) { + throw new UserError(`Invalid template name: ${raw}`); + } + return trimmed; +} + function getDevcontainerCandidates(workspacePath: string, devcontainerSubpath?: string): string[] { if (devcontainerSubpath) { return [path.join(workspacePath, ".devcontainer", devcontainerSubpath, "devcontainer.json")]; @@ -706,6 +879,21 @@ function getDevcontainerCandidates(workspacePath: string, devcontainerSubpath?: ]; } +async function discoverDevcontainerConfigIfPresent(workspacePath: string): Promise { + try { + return await discoverDevcontainerConfig(workspacePath); + } catch (error) { + if ( + error instanceof UserError && + error.message.startsWith("No devcontainer definition was found in ") + ) { + return null; + } + + throw error; + } +} + function formatDevcontainerSubpath(subpath: string): string { return subpath.split(path.sep).join("/"); } @@ -795,3 +983,133 @@ function asRecord(value: unknown): Record | null { return value as Record; } + +function resolveBuiltInTemplate(name: string): WorkspaceTemplateState { + const definition = getTemplateDefinition(name); + if (!definition) { + throw new UserError(`Unknown template: ${name}. Run \`${CLI_NAME} templates\` to list available templates.`); + } + + if (!definition.runnerCompatible) { + throw new UserError(`Template ${name} is not compatible with ssh-server-runner.`); + } + + validateSupportedDevcontainerConfig(definition.config); + + return { + name: definition.name, + description: definition.description, + source: definition.source, + base: definition.base, + image: definition.image, + pinnedReference: definition.pinnedReference, + runtimeVersion: definition.runtimeVersion, + languages: [...definition.languages], + runnerCompatible: definition.runnerCompatible, + config: structuredClone(definition.config), + }; +} + +function assertValidTemplateState(value: unknown): asserts value is WorkspaceTemplateState { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new UserError("State file template entry is invalid."); + } + + const record = value as Record; + if ( + typeof record.name !== "string" || + typeof record.description !== "string" || + record.source !== "built-in" || + typeof record.base !== "string" || + (record.image !== null && typeof record.image !== "string") || + typeof record.pinnedReference !== "string" || + typeof record.runtimeVersion !== "string" || + !Array.isArray(record.languages) || + record.languages.some((entry) => typeof entry !== "string") || + typeof record.runnerCompatible !== "boolean" || + !record.config || + typeof record.config !== "object" || + Array.isArray(record.config) + ) { + throw new UserError("State file template entry is invalid."); + } +} + +function migrateWorkspaceState(value: unknown): WorkspaceState | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const record = value as Record; + if ( + typeof record.workspacePath !== "string" || + typeof record.workspaceHash !== "string" || + typeof record.port !== "number" || + typeof record.generatedConfigPath !== "string" || + typeof record.userDataDir !== "string" || + !record.labels || + typeof record.labels !== "object" || + Array.isArray(record.labels) + ) { + return null; + } + + const updatedAt = typeof record.updatedAt === "string" ? record.updatedAt : new Date().toISOString(); + const lastContainerId = typeof record.lastContainerId === "string" ? record.lastContainerId : undefined; + + if (record.version === 1) { + if (typeof record.sourceConfigPath !== "string") { + return null; + } + + return { + version: STATE_VERSION, + workspacePath: record.workspacePath, + workspaceHash: record.workspaceHash, + port: record.port, + configSource: "repo", + sourceConfigPath: record.sourceConfigPath, + generatedConfigPath: record.generatedConfigPath, + labels: record.labels as Record, + userDataDir: record.userDataDir, + template: null, + lastContainerId, + updatedAt, + }; + } + + if ( + record.version !== STATE_VERSION || + (record.configSource !== "repo" && record.configSource !== "template") || + (record.sourceConfigPath !== null && typeof record.sourceConfigPath !== "string") + ) { + return null; + } + + if (record.template !== null && record.template !== undefined) { + assertValidTemplateState(record.template); + } + + return { + version: STATE_VERSION, + workspacePath: record.workspacePath, + workspaceHash: record.workspaceHash, + port: record.port, + configSource: record.configSource, + sourceConfigPath: record.sourceConfigPath, + generatedConfigPath: record.generatedConfigPath, + labels: record.labels as Record, + userDataDir: record.userDataDir, + template: (record.template as WorkspaceTemplateState | null | undefined) ?? null, + lastContainerId, + updatedAt, + }; +} + +function cloneTemplateState(template: WorkspaceTemplateState): WorkspaceTemplateState { + return { + ...template, + languages: [...template.languages], + config: structuredClone(template.config), + }; +} diff --git a/src/status.ts b/src/status.ts index 93b7784..24e3a29 100644 --- a/src/status.ts +++ b/src/status.ts @@ -48,6 +48,11 @@ export interface DevboxStatus { credentialPath: string; hasSshMetadataFile: boolean; sshMetadataPath: string; + configSource: "repo" | "template" | null; + templateName: string | null; + templateDescription: string | null; + templatePinnedReference: string | null; + templateRuntimeVersion: string | null; sourceConfigPath: string | null; generatedConfigPath: string | null; userDataDir: string | null; @@ -110,12 +115,19 @@ export async function getDevboxStatus( const credentialFile = await readRunnerCredentialsFile(credentialPath, readFile); const sshMetadataPath = path.join(input.workspacePath, DEVBOX_SSH_METADATA_FILENAME); const sshMetadataFile = await readRunnerMetadataFile(sshMetadataPath, readFile, warnings); - const configHints = await readConfigHints({ - readFile, - sourceConfigPath: state?.sourceConfigPath ?? null, - warnings, - workspacePath: input.workspacePath, - }); + const configHints = state?.template + ? readConfigHintsFromConfig({ + config: state.template.config, + sourceConfigPath: state.sourceConfigPath, + workspacePath: input.workspacePath, + }) + : await readConfigHints({ + readFile, + sourceConfigPath: state?.sourceConfigPath ?? null, + generatedConfigPath: state?.generatedConfigPath ?? null, + warnings, + workspacePath: input.workspacePath, + }); const publishedPorts = getPublishedPorts(primaryContainer); const configuredSshPort = state?.port @@ -173,6 +185,11 @@ export async function getDevboxStatus( credentialPath, hasSshMetadataFile: sshMetadataFile.exists, sshMetadataPath, + configSource: state?.configSource ?? null, + templateName: state?.template?.name ?? null, + templateDescription: state?.template?.description ?? null, + templatePinnedReference: state?.template?.pinnedReference ?? null, + templateRuntimeVersion: state?.template?.runtimeVersion ?? null, sourceConfigPath: state?.sourceConfigPath ?? configHints.sourceConfigPath, generatedConfigPath: state?.generatedConfigPath ?? null, userDataDir: state?.userDataDir ?? null, @@ -227,12 +244,14 @@ async function readRunnerMetadataFile( async function readConfigHints(input: { workspacePath: string; sourceConfigPath: string | null; + generatedConfigPath: string | null; readFile: (filePath: string) => Promise; warnings: string[]; }): Promise { const defaultWorkdir = getDefaultRemoteWorkspaceFolder(input.workspacePath); const candidates = [ input.sourceConfigPath, + input.generatedConfigPath, path.join(input.workspacePath, ".devcontainer", "devcontainer.json"), path.join(input.workspacePath, ".devcontainer.json"), ].filter((candidate, index, values): candidate is string => Boolean(candidate) && values.indexOf(candidate) === index); @@ -259,22 +278,11 @@ async function readConfigHints(input: { continue; } - const config = parsed as Record; - const configuredWorkdir = typeof config.workspaceFolder === "string" && config.workspaceFolder.trim().length > 0 - ? config.workspaceFolder - : null; - const remoteUser = typeof config.remoteUser === "string" && config.remoteUser.trim().length > 0 - ? config.remoteUser - : typeof config.containerUser === "string" && config.containerUser.trim().length > 0 - ? config.containerUser - : null; - - return { - remoteUser, - sourceConfigPath: candidate, - workdir: configuredWorkdir ?? defaultWorkdir, - workdirSource: configuredWorkdir ? "config" : "default", - }; + return readConfigHintsFromConfig({ + config: parsed as Record, + sourceConfigPath: candidate === input.generatedConfigPath ? input.sourceConfigPath : candidate, + workspacePath: input.workspacePath, + }); } return { @@ -285,6 +293,29 @@ async function readConfigHints(input: { }; } +function readConfigHintsFromConfig(input: { + workspacePath: string; + sourceConfigPath: string | null; + config: Record; +}): ConfigHints { + const defaultWorkdir = getDefaultRemoteWorkspaceFolder(input.workspacePath); + const configuredWorkdir = typeof input.config.workspaceFolder === "string" && input.config.workspaceFolder.trim().length > 0 + ? input.config.workspaceFolder + : null; + const remoteUser = typeof input.config.remoteUser === "string" && input.config.remoteUser.trim().length > 0 + ? input.config.remoteUser + : typeof input.config.containerUser === "string" && input.config.containerUser.trim().length > 0 + ? input.config.containerUser + : null; + + return { + remoteUser, + sourceConfigPath: input.sourceConfigPath, + workdir: configuredWorkdir ?? defaultWorkdir, + workdirSource: configuredWorkdir ? "config" : "default", + }; +} + function appendMissingDataWarnings(input: { warnings: string[]; credentialFile: OptionalParsedFile; diff --git a/src/templates.ts b/src/templates.ts new file mode 100644 index 0000000..1bc66ec --- /dev/null +++ b/src/templates.ts @@ -0,0 +1,176 @@ +type DevcontainerConfig = Record; + +export interface DevboxTemplateDefinition { + name: string; + description: string; + source: "built-in"; + base: string; + image: string | null; + pinnedReference: string; + runtimeVersion: string; + languages: string[]; + runnerCompatible: boolean; + config: DevcontainerConfig; +} + +export interface DevboxTemplateSummary { + name: string; + description: string; + source: "built-in"; + base: string; + image: string | null; + pinnedReference: string; + runtimeVersion: string; + languages: string[]; + runnerCompatible: boolean; +} + +const BUN_VERSION = "1.3.13"; +const BASE_IMAGE = "mcr.microsoft.com/devcontainers/base:2.1.8-ubuntu24.04"; +const BUN_IMAGE = `oven/bun:${BUN_VERSION}`; + +const TEMPLATE_DEFINITIONS: Record = { + ubuntu: { + name: "ubuntu", + description: "Ubuntu 24.04 base image with common devcontainer tooling.", + source: "built-in", + base: "ubuntu24.04", + image: BASE_IMAGE, + pinnedReference: BASE_IMAGE, + runtimeVersion: "Ubuntu 24.04", + languages: [], + runnerCompatible: true, + config: { + image: BASE_IMAGE, + }, + }, + dotnet: { + name: "dotnet", + description: ".NET 10 SDK on Ubuntu 24.04.", + source: "built-in", + base: "ubuntu24.04", + image: "mcr.microsoft.com/devcontainers/dotnet:2.0.7-10.0-noble", + pinnedReference: "mcr.microsoft.com/devcontainers/dotnet:2.0.7-10.0-noble", + runtimeVersion: ".NET 10.0", + languages: ["dotnet", "csharp", "fsharp"], + runnerCompatible: true, + config: { + image: "mcr.microsoft.com/devcontainers/dotnet:2.0.7-10.0-noble", + }, + }, + "node-typescript": { + name: "node-typescript", + description: "Node.js 24 with TypeScript tooling on Debian bookworm.", + source: "built-in", + base: "bookworm", + image: "mcr.microsoft.com/devcontainers/typescript-node:4.0.8-24-bookworm", + pinnedReference: "mcr.microsoft.com/devcontainers/typescript-node:4.0.8-24-bookworm", + runtimeVersion: "Node.js 24", + languages: ["node", "typescript", "javascript"], + runnerCompatible: true, + config: { + image: "mcr.microsoft.com/devcontainers/typescript-node:4.0.8-24-bookworm", + }, + }, + bun: { + name: "bun", + description: `Official Bun ${BUN_VERSION} image on Debian trixie.`, + source: "built-in", + base: "trixie", + image: BUN_IMAGE, + pinnedReference: BUN_IMAGE, + runtimeVersion: `Bun ${BUN_VERSION}`, + languages: ["bun", "javascript", "typescript"], + runnerCompatible: true, + config: { + image: BUN_IMAGE, + }, + }, + python: { + name: "python", + description: "Python 3.14 on Debian bookworm.", + source: "built-in", + base: "bookworm", + image: "mcr.microsoft.com/devcontainers/python:3.0.7-3.14-bookworm", + pinnedReference: "mcr.microsoft.com/devcontainers/python:3.0.7-3.14-bookworm", + runtimeVersion: "Python 3.14", + languages: ["python"], + runnerCompatible: true, + config: { + image: "mcr.microsoft.com/devcontainers/python:3.0.7-3.14-bookworm", + }, + }, + go: { + name: "go", + description: "Go 1.26 on Debian bookworm.", + source: "built-in", + base: "bookworm", + image: "mcr.microsoft.com/devcontainers/go:2.1.2-1.26-bookworm", + pinnedReference: "mcr.microsoft.com/devcontainers/go:2.1.2-1.26-bookworm", + runtimeVersion: "Go 1.26", + languages: ["go"], + runnerCompatible: true, + config: { + image: "mcr.microsoft.com/devcontainers/go:2.1.2-1.26-bookworm", + }, + }, + rust: { + name: "rust", + description: "Rust stable toolchain image on Debian bookworm.", + source: "built-in", + base: "bookworm", + image: "mcr.microsoft.com/devcontainers/rust:2.0.10-1-bookworm", + pinnedReference: "mcr.microsoft.com/devcontainers/rust:2.0.10-1-bookworm", + runtimeVersion: "Rust stable (image release 2.0.10)", + languages: ["rust"], + runnerCompatible: true, + config: { + image: "mcr.microsoft.com/devcontainers/rust:2.0.10-1-bookworm", + }, + }, + java: { + name: "java", + description: "Java 25 LTS on Debian bookworm.", + source: "built-in", + base: "bookworm", + image: "mcr.microsoft.com/devcontainers/java:3.0.7-25-bookworm", + pinnedReference: "mcr.microsoft.com/devcontainers/java:3.0.7-25-bookworm", + runtimeVersion: "Java 25 LTS", + languages: ["java"], + runnerCompatible: true, + config: { + image: "mcr.microsoft.com/devcontainers/java:3.0.7-25-bookworm", + }, + }, +}; + +export function listTemplateDefinitions(): DevboxTemplateDefinition[] { + return Object.values(TEMPLATE_DEFINITIONS).map(cloneTemplateDefinition); +} + +export function listTemplateSummaries(): DevboxTemplateSummary[] { + return listTemplateDefinitions().map((definition) => ({ + name: definition.name, + description: definition.description, + source: definition.source, + base: definition.base, + image: definition.image, + pinnedReference: definition.pinnedReference, + runtimeVersion: definition.runtimeVersion, + languages: [...definition.languages], + runnerCompatible: definition.runnerCompatible, + })); +} + +export function getTemplateDefinition(name: string): DevboxTemplateDefinition | null { + const definition = TEMPLATE_DEFINITIONS[name]; + return definition ? cloneTemplateDefinition(definition) : null; +} + +function cloneTemplateDefinition(definition: DevboxTemplateDefinition): DevboxTemplateDefinition { + return { + ...definition, + languages: [...definition.languages], + config: structuredClone(definition.config), + }; +} diff --git a/tests/arise.test.ts b/tests/arise.test.ts index 9c3af79..83e305d 100644 --- a/tests/arise.test.ts +++ b/tests/arise.test.ts @@ -235,14 +235,16 @@ describe("ariseManagedWorkspaces", () => { [ "/tmp/ok", { - version: 1, + version: 2, workspacePath: "/tmp/ok", workspaceHash: "hash-ok", port: 5001, + configSource: "repo", sourceConfigPath: "/tmp/ok/.devcontainer/services/api/devcontainer.json", generatedConfigPath: "/tmp/ok/.devcontainer/.devcontainer.json", labels: { "devbox.managed": "true", "devbox.workspace": "hash-ok" }, userDataDir: "/tmp/state-ok", + template: null, updatedAt: "2026-03-20T00:00:00.000Z", }, ], diff --git a/tests/core.test.ts b/tests/core.test.ts index 9f56dfa..383c797 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1,9 +1,10 @@ +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"; import { afterEach, describe, expect, test } from "bun:test"; import pkg from "../package.json"; -import { DOCKER_DESKTOP_SSH_AUTH_SOCK_SOURCE } from "../src/constants"; +import { DOCKER_DESKTOP_SSH_AUTH_SOCK_SOURCE, STATE_VERSION } from "../src/constants"; import { buildManagedConfig, describeUpPortStrategy, @@ -19,10 +20,16 @@ import { helpText, parseArgs, prepareKnownHostsMount, + loadWorkspaceState, + resolveWorkspaceConfig, resolvePort, resolveUpPortPreference, + validateSupportedDevcontainerConfig, + getWorkspaceStateFile, type DevcontainerConfig, + type WorkspaceState, } from "../src/core"; +import { getTemplateDefinition } from "../src/templates"; const tempPaths: string[] = []; @@ -63,6 +70,10 @@ describe("parseArgs", () => { expect(parseArgs(["arise"])).toEqual({ command: "arise", allowMissingSsh: false }); }); + test("supports the templates subcommand", () => { + expect(parseArgs(["templates"])).toEqual({ command: "templates", allowMissingSsh: false }); + }); + test("supports selecting a devcontainer subpath", () => { expect(parseArgs(["up", "5001", "--devcontainer-subpath", "services/api"])).toEqual({ command: "up", @@ -90,6 +101,14 @@ describe("parseArgs", () => { }); }); + test("supports selecting a built-in template for up", () => { + expect(parseArgs(["up", "--template", "python"])).toEqual({ + command: "up", + allowMissingSsh: false, + templateName: "python", + }); + }); + test("requires an explicit command for options or ports", () => { expect(() => parseArgs(["5001"])).toThrow("A command is required."); expect(() => parseArgs(["--devcontainer-subpath=python"])).toThrow("A command is required."); @@ -140,6 +159,16 @@ describe("parseArgs", () => { "The arise command does not accept --ssh-public-key.", ); }); + + test("rebuild rejects --template", () => { + expect(() => parseArgs(["rebuild", "--template", "go"])).toThrow("The rebuild command does not accept --template."); + }); + + test("rejects combining --template with --devcontainer-subpath", () => { + expect(() => parseArgs(["up", "--template", "python", "--devcontainer-subpath", "services/api"])).toThrow( + "--template cannot be combined with --devcontainer-subpath.", + ); + }); }); describe("helpText", () => { @@ -164,6 +193,7 @@ describe("helpText", () => { expect(text).toContain("rebuild"); expect(text).toContain("shell"); expect(text).toContain("status"); + expect(text).toContain("templates"); expect(text).toContain("arise"); expect(text).toContain("down"); expect(text).toContain("help"); @@ -174,30 +204,87 @@ describe("resolvePort", () => { test("reuses stored port", () => { expect( resolvePort("up", undefined, { - version: 1, + version: 2, workspacePath: "/tmp/ws", workspaceHash: "hash", port: 5003, + configSource: "repo", sourceConfigPath: "/tmp/ws/.devcontainer/devcontainer.json", generatedConfigPath: "/tmp/ws/.devcontainer/.devbox.generated.devcontainer.json", labels: { managed: "true" }, userDataDir: "/tmp/state", + template: null, updatedAt: new Date().toISOString(), }), ).toBe(5003); }); }); +describe("loadWorkspaceState", () => { + test("migrates a version 1 workspace state file", 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, + 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, + 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; + } + } + }); +}); + describe("resolveUpPortPreference", () => { const state = { - version: 1, + version: 2, workspacePath: "/tmp/ws", workspaceHash: "hash", port: 5003, + configSource: "repo", sourceConfigPath: "/tmp/ws/.devcontainer/devcontainer.json", generatedConfigPath: "/tmp/ws/.devcontainer/.devbox.generated.devcontainer.json", labels: { managed: "true" }, userDataDir: "/tmp/state", + template: null, updatedAt: new Date().toISOString(), }; @@ -297,6 +384,70 @@ describe("discoverDevcontainerConfig", () => { }); }); +describe("validateSupportedDevcontainerConfig", () => { + test("uses version-agnostic wording for unsupported docker-compose configs", () => { + expect(() => validateSupportedDevcontainerConfig({ dockerComposeFile: "docker-compose.yml" })).toThrow( + "dockerComposeFile-based devcontainers are not supported.", + ); + }); + + test("uses version-agnostic wording for unsupported config shapes", () => { + expect(() => validateSupportedDevcontainerConfig({ features: {} })).toThrow( + "Only image- or Dockerfile-based devcontainers are supported.", + ); + }); +}); + +describe("resolveWorkspaceConfig", () => { + test("prefers the saved template source for rebuild-style resolution without changing up precedence", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "devbox-test-")); + tempPaths.push(tempDir); + await mkdir(path.join(tempDir, ".devcontainer"), { recursive: true }); + await writeFile( + path.join(tempDir, ".devcontainer", "devcontainer.json"), + `{ "image": "mcr.microsoft.com/devcontainers/base:ubuntu" }`, + ); + + const template = getTemplateDefinition("python"); + expect(template).not.toBeNull(); + if (!template) { + throw new Error("Expected the built-in python template to exist."); + } + + const state: WorkspaceState = { + version: STATE_VERSION, + workspacePath: tempDir, + workspaceHash: "workspace-hash", + port: 5001, + configSource: "template", + sourceConfigPath: null, + generatedConfigPath: path.join(tempDir, ".devbox", "generated-template-devcontainer.json"), + labels: {}, + userDataDir: path.join(tempDir, ".devbox", "user-data"), + template, + updatedAt: new Date().toISOString(), + }; + + const upStyleResolution = await resolveWorkspaceConfig({ + workspacePath: tempDir, + state, + }); + expect(upStyleResolution.configSource).toBe("repo"); + expect(upStyleResolution.sourceConfigPath).toBe(path.join(tempDir, ".devcontainer", "devcontainer.json")); + + const rebuildStyleResolution = await resolveWorkspaceConfig({ + workspacePath: tempDir, + state, + preferStateSource: true, + }); + expect(rebuildStyleResolution.configSource).toBe("template"); + expect(rebuildStyleResolution.sourceConfigPath).toBeNull(); + expect(rebuildStyleResolution.generatedConfigPath).toBe(state.generatedConfigPath); + expect(rebuildStyleResolution.template?.name).toBe("python"); + expect(rebuildStyleResolution.config.image).toBe("mcr.microsoft.com/devcontainers/python:3.0.7-3.14-bookworm"); + }); +}); + describe("buildManagedConfig", () => { test("adds port publish, mounts and env", () => { const baseConfig: DevcontainerConfig = { diff --git a/tests/examples.live.test.ts b/tests/examples.live.test.ts index 4b88e9c..9463226 100644 --- a/tests/examples.live.test.ts +++ b/tests/examples.live.test.ts @@ -149,7 +149,7 @@ describe("example workspaces (real devcontainers)", () => { expect(existsSync(fixture.runnerCredPath)).toBe(true); expect(existsSync(fixture.runnerMetadataPath)).toBe(true); expect(await readTrimmedFile(fixture.runnerArtifacts.hostKey)).toBe(fixture.runnerHostKeyMarker); - expect(existsSync(fixture.statePath)).toBe(false); + expect(existsSync(fixture.statePath)).toBe(true); }, { timeout: 8 * 60_000 }, ); @@ -266,7 +266,7 @@ describe("example workspaces (real devcontainers)", () => { expect(existsSync(fixture.runnerCredPath)).toBe(true); expect(existsSync(fixture.runnerMetadataPath)).toBe(true); expect(await readTrimmedFile(fixture.runnerArtifacts.hostKey)).toBe(fixture.runnerHostKeyMarker); - expect(existsSync(fixture.statePath)).toBe(false); + expect(existsSync(fixture.statePath)).toBe(true); }, { timeout: 12 * 60_000 }, ); diff --git a/tests/examples.test.ts b/tests/examples.test.ts index bfdcbc7..4197b25 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -19,6 +19,7 @@ interface ExampleFixtureOptions { }; knownHosts?: string; sshAuthSock?: boolean; + withoutDevcontainer?: boolean; } interface ExampleFixture { @@ -26,7 +27,7 @@ interface ExampleFixture { env: Record; generatedConfigPath: string; homeDir: string; - sourceConfigPath: string; + sourceConfigPath: string | null; sshAuthSockPath: string | null; statePath: string; userDataDir: string; @@ -373,7 +374,7 @@ afterEach(async () => { describe("example workspaces (simulated host tools)", () => { test("smoke workspace exercises up, shell, and down through the CLI", async () => { const fixture = await setupExampleFixture("smoke-workspace"); - const sourceBefore = await readFile(fixture.sourceConfigPath, "utf8"); + const sourceBefore = await readFile(String(fixture.sourceConfigPath), "utf8"); const up = runCli(fixture, ["up", "--allow-missing-ssh"]); expect(up.exitCode).toBe(0); @@ -383,7 +384,7 @@ describe("example workspaces (simulated host tools)", () => { expect(up.stdout).toContain("Ready."); expect(up.stderr).toContain("Continuing without SSH agent sharing."); expect(existsSync(fixture.generatedConfigPath)).toBe(true); - expect(await readFile(fixture.sourceConfigPath, "utf8")).toBe(sourceBefore); + expect(await readFile(String(fixture.sourceConfigPath), "utf8")).toBe(sourceBefore); const generatedConfig = await readJson(fixture.generatedConfigPath); expect(generatedConfig.image).toBe("mcr.microsoft.com/devcontainers/base:ubuntu"); @@ -397,6 +398,7 @@ describe("example workspaces (simulated host tools)", () => { const state = await readJson(fixture.statePath); expect(state.port).toBe(5001); expect(state.sourceConfigPath).toBe(fixture.sourceConfigPath); + expect(state.configSource).toBe("repo"); expect(state.generatedConfigPath).toBe(fixture.generatedConfigPath); const commandsAfterUp = await readCommandLog(fixture.commandLogPath); @@ -443,7 +445,7 @@ describe("example workspaces (simulated host tools)", () => { expect(down.exitCode).toBe(0); expect(down.stdout).toContain("Removed 1 managed container(s)."); expect(existsSync(fixture.generatedConfigPath)).toBe(false); - expect(existsSync(fixture.statePath)).toBe(false); + expect(existsSync(fixture.statePath)).toBe(true); const statusAfterDown = runCli(fixture, ["status"]); expect(statusAfterDown.exitCode).toBe(0); @@ -451,7 +453,7 @@ describe("example workspaces (simulated host tools)", () => { expect(stoppedStatus.running).toBe(false); expect(stoppedStatus.port).toBe(5001); expect(stoppedStatus.password).toBe("password"); - expect(stoppedStatus.hasStateFile).toBe(false); + expect(stoppedStatus.hasStateFile).toBe(true); expect(stoppedStatus.hasCredentialFile).toBe(true); expect(stoppedStatus.hasSshMetadataFile).toBe(true); }); @@ -466,7 +468,7 @@ describe("example workspaces (simulated host tools)", () => { knownHosts: "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeExampleKnownHost\n", sshAuthSock: true, }); - const sourceBefore = await readFile(fixture.sourceConfigPath, "utf8"); + const sourceBefore = await readFile(String(fixture.sourceConfigPath), "utf8"); const up = runCli(fixture, ["up"]); expect(up.exitCode).toBe(0); @@ -479,7 +481,7 @@ describe("example workspaces (simulated host tools)", () => { const knownHostsSnapshotPath = path.join(fixture.userDataDir, KNOWN_HOSTS_SNAPSHOT_FILENAME); expect(existsSync(knownHostsSnapshotPath)).toBe(true); - expect(await readFile(fixture.sourceConfigPath, "utf8")).toBe(sourceBefore); + expect(await readFile(String(fixture.sourceConfigPath), "utf8")).toBe(sourceBefore); const generatedConfig = await readJson(fixture.generatedConfigPath); expect(generatedConfig.features).toEqual({ @@ -507,7 +509,7 @@ describe("example workspaces (simulated host tools)", () => { ), ).toBe(true); - const rebuild = runCli(fixture, ["rebuild"]); + const rebuild = runCli(fixture, ["rebuild", "--allow-missing-ssh"]); expect(rebuild.exitCode).toBe(0); expect(rebuild.stdout).toContain("Using port 5001."); expect(rebuild.stdout).toContain("Ready."); @@ -520,11 +522,67 @@ describe("example workspaces (simulated host tools)", () => { expect(down.exitCode).toBe(0); expect(down.stdout).toContain("Removed 1 managed container(s)."); expect(existsSync(fixture.generatedConfigPath)).toBe(false); - expect(existsSync(fixture.statePath)).toBe(false); + expect(existsSync(fixture.statePath)).toBe(true); const commandsAfterDown = await readCommandLog(fixture.commandLogPath); 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 () => { + const fixture = await setupExampleFixture("smoke-workspace", { + withoutDevcontainer: true, + }); + + const templates = runCli(fixture, ["templates"]); + expect(templates.exitCode).toBe(0); + const templateList = JSON.parse(templates.stdout); + 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"]); + 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.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"); + + 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"); + + const rebuild = runCli(fixture, ["rebuild", "--allow-missing-ssh"]); + expect(rebuild.exitCode).toBe(0); + expect(rebuild.stdout).toContain("Using port 5001."); + expect(rebuild.stdout).toContain("Ready."); + + const rebuildWithTemplate = runCli(fixture, ["rebuild", "--template", "python"]); + expect(rebuildWithTemplate.exitCode).toBe(1); + expect(rebuildWithTemplate.stderr).toContain("The rebuild command does not accept --template."); + + const down = runCli(fixture, ["down"]); + expect(down.exitCode).toBe(0); + expect(existsSync(fixture.generatedConfigPath)).toBe(false); + expect(existsSync(fixture.statePath)).toBe(true); + + const statusAfterDown = runCli(fixture, ["status"]); + expect(statusAfterDown.exitCode).toBe(0); + const stoppedStatus = JSON.parse(statusAfterDown.stdout); + expect(stoppedStatus.hasStateFile).toBe(true); + expect(stoppedStatus.configSource).toBe("template"); + expect(stoppedStatus.templateName).toBe("bun"); + }); }); async function setupExampleFixture(exampleName: string, options: ExampleFixtureOptions = {}): Promise { @@ -533,6 +591,10 @@ 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) { @@ -573,9 +635,11 @@ async function setupExampleFixture(exampleName: string, options: ExampleFixtureO return { commandLogPath, env, - generatedConfigPath: path.join(workspacePath, ".devcontainer", ".devcontainer.json"), + generatedConfigPath: options.withoutDevcontainer + ? path.join(stateDir, "template.devcontainer.json") + : path.join(workspacePath, ".devcontainer", ".devcontainer.json"), homeDir, - sourceConfigPath: path.join(workspacePath, ".devcontainer", "devcontainer.json"), + sourceConfigPath: options.withoutDevcontainer ? null : path.join(workspacePath, ".devcontainer", "devcontainer.json"), sshAuthSockPath, statePath: path.join(stateDir, "state.json"), userDataDir: path.join(stateDir, "user-data"), diff --git a/tests/status.test.ts b/tests/status.test.ts index 80f87cf..f054469 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -43,14 +43,16 @@ describe("parseRunnerCredentials", () => { describe("getDevboxStatus", () => { test("prefers live container data and config hints when available", async () => { const state: WorkspaceState = { - version: 1, + version: 2, workspacePath: "/tmp/ws", workspaceHash: "workspace-hash", port: 5001, + configSource: "repo", sourceConfigPath: "/tmp/ws/.devcontainer/devcontainer.json", generatedConfigPath: "/tmp/ws/.devcontainer/.devcontainer.json", labels: { "devbox.managed": "true", "devbox.workspace": "workspace-hash" }, userDataDir: "/tmp/ws-state", + template: null, lastContainerId: "container-2", updatedAt: "2026-03-16T00:00:00.000Z", }; @@ -177,14 +179,16 @@ describe("getDevboxStatus", () => { { workspacePath: "/tmp/parse-fallback", state: { - version: 1, + version: 2, workspacePath: "/tmp/parse-fallback", workspaceHash: "workspace-hash", port: 5001, + configSource: "repo", sourceConfigPath: "/tmp/parse-fallback/custom/devcontainer.json", generatedConfigPath: "/tmp/parse-fallback/.devcontainer/.devcontainer.json", labels: { "devbox.managed": "true", "devbox.workspace": "workspace-hash" }, userDataDir: "/tmp/state", + template: null, lastContainerId: null, updatedAt: "2026-03-16T00:00:00.000Z", }, @@ -217,14 +221,16 @@ describe("getDevboxStatus", () => { { workspacePath: "/tmp/object-fallback", state: { - version: 1, + version: 2, workspacePath: "/tmp/object-fallback", workspaceHash: "workspace-hash", port: 5001, + configSource: "repo", sourceConfigPath: "/tmp/object-fallback/custom/devcontainer.json", generatedConfigPath: "/tmp/object-fallback/.devcontainer/.devcontainer.json", labels: { "devbox.managed": "true", "devbox.workspace": "workspace-hash" }, userDataDir: "/tmp/state", + template: null, lastContainerId: null, updatedAt: "2026-03-16T00:00:00.000Z", }, @@ -257,14 +263,16 @@ describe("getDevboxStatus", () => { { workspacePath: "/tmp/no-container", state: { - version: 1, + version: 2, workspacePath: "/tmp/no-container", workspaceHash: "workspace-hash", port: 5001, + configSource: "repo", sourceConfigPath: "/tmp/no-container/.devcontainer/devcontainer.json", generatedConfigPath: "/tmp/no-container/.devcontainer/.devcontainer.json", labels: { "devbox.managed": "true", "devbox.workspace": "workspace-hash" }, userDataDir: "/tmp/state", + template: null, lastContainerId: "container-stale", updatedAt: "2026-03-16T00:00:00.000Z", }, @@ -290,14 +298,16 @@ describe("getDevboxStatus", () => { { workspacePath: "/tmp/password-only", state: { - version: 1, + version: 2, workspacePath: "/tmp/password-only", workspaceHash: "workspace-hash", port: 5005, + configSource: "repo", sourceConfigPath: "/tmp/password-only/.devcontainer/devcontainer.json", generatedConfigPath: "/tmp/password-only/.devcontainer/.devcontainer.json", labels: { "devbox.managed": "true", "devbox.workspace": "workspace-hash" }, userDataDir: "/tmp/state", + template: null, lastContainerId: "container-1", updatedAt: "2026-03-16T00:00:00.000Z", }, @@ -348,14 +358,16 @@ describe("getDevboxStatus", () => { { workspacePath: "/tmp/port-selection", state: { - version: 1, + version: 2, workspacePath: "/tmp/port-selection", workspaceHash: "workspace-hash", port: 5001, + configSource: "repo", sourceConfigPath: "/tmp/port-selection/.devcontainer/devcontainer.json", generatedConfigPath: "/tmp/port-selection/.devcontainer/.devcontainer.json", labels: { "devbox.managed": "true", "devbox.workspace": "workspace-hash" }, userDataDir: "/tmp/state", + template: null, lastContainerId: "container-1", updatedAt: "2026-03-16T00:00:00.000Z", }, diff --git a/tests/templates.test.ts b/tests/templates.test.ts new file mode 100644 index 0000000..7969bcc --- /dev/null +++ b/tests/templates.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { getTemplateDefinition, listTemplateSummaries } from "../src/templates"; + +describe("built-in templates", () => { + test("uses the official Bun image instead of an installer script", () => { + const template = getTemplateDefinition("bun"); + expect(template).not.toBeNull(); + if (!template) { + throw new Error("Expected the built-in bun template to exist."); + } + + expect(template.description).toBe("Official Bun 1.3.13 image on Debian trixie."); + expect(template.base).toBe("trixie"); + expect(template.image).toBe("oven/bun:1.3.13"); + expect(template.pinnedReference).toBe("oven/bun:1.3.13"); + expect(template.runnerCompatible).toBe(true); + expect(template.config).toEqual({ + image: "oven/bun:1.3.13", + }); + }); + + test("exposes the pinned Bun image in template summaries", () => { + const bunTemplate = listTemplateSummaries().find((template) => template.name === "bun"); + expect(bunTemplate).toEqual({ + name: "bun", + description: "Official Bun 1.3.13 image on Debian trixie.", + source: "built-in", + base: "trixie", + image: "oven/bun:1.3.13", + pinnedReference: "oven/bun:1.3.13", + runtimeVersion: "Bun 1.3.13", + languages: ["bun", "javascript", "typescript"], + runnerCompatible: true, + }); + }); +});