diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fffcedb..9db018c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,7 @@ jobs: - windows-latest env: - DEVSPACE_ALLOWED_ROOTS: ${{ github.workspace }} DEVSPACE_OAUTH_OWNER_TOKEN: ci-owner-token-that-is-long-enough - DEVSPACE_PUBLIC_BASE_URL: http://127.0.0.1:7676 steps: - name: Checkout diff --git a/docs/artifact-exchange.md b/docs/artifact-exchange.md index 6a6f5c75..f4728eb0 100644 --- a/docs/artifact-exchange.md +++ b/docs/artifact-exchange.md @@ -1,7 +1,8 @@ # Download a native file DevSpace can save a file attached or generated by an MCP host, such as ChatGPT, -directly into an open workspace. Enable the tool with `DEVSPACE_ARTIFACTS=1`. +directly into an open workspace. Enable the tool with +`artifacts.enabled` in `~/.devspace/config.jsonc`. ## Workflow @@ -36,7 +37,7 @@ file-object shape, trusted OpenAI download hosts, and redirects before streaming Malformed references, unknown fields, absolute paths, traversal, and symlinked parents are rejected. -Downloads are streamed under `DEVSPACE_ARTIFACT_MAX_FILE_BYTES` and published as +Downloads are streamed under `artifacts.maxFileBytes` and published as owner-only files without overwriting an existing destination. The tool is currently available on Linux. It is not registered on macOS, Windows, or BSD because Node.js does not expose the required descriptor-relative filesystem diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index a2296fa8..1cb43e7a 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -117,8 +117,8 @@ DevSpace discovers standard Agent Skills from: It also keeps compatibility with: - the bundled `subagents` skill when Subagents are enabled, unless `~/.devspace/skills/subagents/SKILL.md` exists -- `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` -- additional paths from `DEVSPACE_SKILL_PATHS` +- `skills.agentDir/skills`, defaulting to `~/.codex/skills` +- additional paths from `skills.paths` When Subagents are enabled, DevSpace discovers agent profiles from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`. @@ -130,7 +130,7 @@ Example profiles are packaged under `examples/agents/` for users who want starter templates. Copy or adapt them into one of the active profile directories before use. -Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. +Legacy project paths such as `.pi/skills` can be added to `skills.paths` when needed. When `open_workspace` returns matching skills, the model should read the advertised `SKILL.md` before following that skill. @@ -140,8 +140,8 @@ Skill paths may be outside the workspace. DevSpace only permits reading: - advertised `SKILL.md` files - files under a skill directory after that skill's `SKILL.md` has been read -Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. Enable Subagents -and choose providers through `devspace init` or the persisted provider +Set `skills.enabled` to `false` to hide skills from workspace output. Enable +Subagents and choose providers through `devspace init` or the persisted provider configuration. The bundled `subagents` skill teaches the minimal `devspace agents targets`, `devspace agents ls`, `devspace agents run`, `devspace agents continue`, and `devspace agents show` workflow. The catalog @@ -173,7 +173,7 @@ returns a process session ID when a command is still running after its yield window. Use `write_stdin` to poll it, send input, resize a PTY, or send Ctrl-C. Set `tty: true` only for commands that need a terminal. -Set `tools.mode` to `claude` in `~/.devspace/config.json` to expose `write`, +Set `tools.mode` to `claude` in `~/.devspace/config.jsonc` to expose `write`, `edit`, and `bash` instead of the Codex mutation and command tools. Dedicated MCP tools for `grep`, `glob`, and `ls` are not registered in either mode; use the configured shell tool with command-line tools such as `rg`, `find`, and @@ -184,7 +184,7 @@ the configured shell tool with command-line tools such as `rg`, `find`, and DevSpace exposes `show_changes` in both tool modes and attaches widget UI only to `open_workspace` and `show_changes`. Reads, edits, and commands return normal MCP results without creating an iframe for each call. Set `ui.enabled` to -`false` in `~/.devspace/config.json` to disable UI metadata while keeping the +`false` in `~/.devspace/config.jsonc` to disable UI metadata while keeping the aggregate review tool available. Call `show_changes` exactly once after the final file modification in any turn diff --git a/docs/configuration.md b/docs/configuration.md index d246f4f1..6a6f607b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,171 +1,112 @@ # Configuration Reference -DevSpace can be configured through `devspace init`, persisted config files, or -environment variables. +DevSpace stores durable settings in `~/.devspace/config.jsonc`. The file accepts +comments and trailing commas and is validated before the server starts. Editor +completion is provided by the versioned [JSON Schema](../schema/v1/devspace.schema.json), +also hosted at the URL in the file's `$schema` property. -The default files are: +Authentication stays separate because it contains a secret: ```text -~/.devspace/config.json +~/.devspace/config.jsonc ~/.devspace/auth.json ``` -Use another config directory with: +Run `devspace init` to create both files. `devspace config set publicBaseUrl +` updates the JSONC document without discarding its comments. -```bash -DEVSPACE_CONFIG_DIR=/path/to/config npx @waishnav/devspace serve -``` - -## Commands - -```bash -npx @waishnav/devspace init -npx @waishnav/devspace serve -npx @waishnav/devspace doctor -npx @waishnav/devspace config get -npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com -``` - -## Core Environment Variables - -| Variable | Purpose | -| --- | --- | -| `HOST` | Local bind host. Defaults to `127.0.0.1`. | -| `PORT` | Local port. Defaults to `7676`. | -| `DEVSPACE_ALLOWED_ROOTS` | Comma-separated local roots that workspaces may open. | -| `DEVSPACE_PUBLIC_BASE_URL` | Public origin for the server, without `/mcp`. | -| `DEVSPACE_ALLOWED_HOSTS` | Optional Host header allowlist override. | -| `DEVSPACE_OAUTH_OWNER_TOKEN` | Owner password for OAuth approval. Must be at least 16 characters. | -| `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. | -| `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. | - -## Native Artifact Download - -Native-file download is disabled by default. Enable it when ChatGPT needs to hand -an attached or generated file into an already-open workspace: - -```bash -DEVSPACE_ARTIFACTS=1 npx @waishnav/devspace serve -``` - -This feature currently supports Linux. It is not registered on macOS, Windows, -or BSD because the secure publication path depends on traversable, -descriptor-anchored directory paths provided by Linux procfs. - -| Variable | Default | Purpose | -| --- | --- | --- | -| `DEVSPACE_ARTIFACTS` | `0` | Expose `download_artifact` for trusted native files. | -| `DEVSPACE_ARTIFACT_MAX_FILE_BYTES` | `104857600` | Maximum streamed size of one file (100 MiB). | - -The same settings may be persisted in `~/.devspace/config.json` as -`artifactsEnabled` and `artifactMaxFileBytes`. - -`download_artifact` accepts the native file object supplied by the MCP connector, -a `workspaceId` returned by `open_workspace`, and a relative workspace `path`. -DevSpace safely creates missing parent directories, refuses to overwrite an -existing destination, and returns only the normalized workspace-relative path. -It does not accept conflict modes, expected hashes, arbitrary URL strings, local -paths, embedded credentials, or extra object fields. +## Complete example -There is no artifact root, total quota, TTL, pinning, persistent database record, -or background artifact cleanup service. See [Native File Download](artifact-exchange.md) -for the supported connector shape and security boundaries. - -## OAuth - -DevSpace uses a single-user OAuth approval flow. - -| Variable | Default | -| --- | --- | -| `DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS` | `3600` | -| `DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | -| `DEVSPACE_OAUTH_SCOPES` | `devspace` | -| `DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS` | `chatgpt.com,localhost,127.0.0.1` | - -MCP clients discover metadata from: - -```text -/.well-known/oauth-protected-resource/mcp -/.well-known/oauth-authorization-server -``` - -## Tool Modes - -`tools.mode` in `~/.devspace/config.json` controls the tool surface: - -```json +```jsonc { + "$schema": "https://raw.githubusercontent.com/Waishnav/devspace/main/schema/v1/devspace.schema.json", + "configVersion": 1, + + "server": { + "host": "127.0.0.1", + "port": 7676, + // Use the public origin only; do not append /mcp. + "publicBaseUrl": "https://devspace.example.com", + "allowedHosts": [], + "trustProxy": false, + }, + "workspaces": { + "allowedRoots": ["~/personal", "~/work"], + "worktreeRoot": "~/.devspace/worktrees", + }, + "storage": { + "stateDir": "~/.local/share/devspace", + }, "tools": { - "mode": "codex" - } -} -``` - -`DEVSPACE_TOOL_MODE` and `DEVSPACE_MINIMAL_TOOLS` are no longer read. Set -`tools.mode` in the configuration file when selecting the Claude surface; -omitting it selects Codex. - -| Value | Behavior | -| --- | --- | -| `codex` | Default. Exposes `open_workspace`, `read`, `apply_patch`, `exec_command`, and `write_stdin`. | -| `claude` | Exposes `open_workspace`, `read`, `write`, `edit`, and `bash`. Clients use `bash` with tools such as `rg`, `find`, and `ls` for inspection. | - -The dedicated MCP tools `grep`, `glob`, and `ls` are no longer exposed. Both -modes use their shell tool for search, file discovery, and directory inspection. - -Codex-mode commands run without a PTY by default. Set `tty: true` on -`exec_command` for interactive terminal programs. PTY support uses the optional -`node-pty` dependency; `write_stdin` can send input, poll output, and resize PTY -sessions. - -## UI - -DevSpace attaches ChatGPT Apps UI metadata only to `open_workspace` and -`show_changes`. This avoids creating an iframe for every read, edit, or command -tool call. The aggregate `show_changes` tool remains available to every MCP -host, including hosts that ignore UI metadata. - -UI is enabled by default. Disable it without removing `show_changes`: - -```json -{ + "mode": "codex", + }, "ui": { - "enabled": false - } + "enabled": true, + }, + "artifacts": { + "enabled": false, + "maxFileBytes": 104857600, + }, + "skills": { + "enabled": true, + "paths": [], + "agentDir": "~/.codex", + }, + "subagents": { + "enabled": false, + "providers": [], + }, + "logging": { + "level": "info", + "format": "json", + "requests": true, + "assets": false, + "toolCalls": true, + "shellCommands": false, + }, + "oauth": { + "accessTokenTtlSeconds": 3600, + "refreshTokenTtlSeconds": 2592000, + "scopes": ["devspace"], + "allowedRedirectHosts": ["chatgpt.com", "localhost", "127.0.0.1"], + }, } ``` -## Skills +Omitted sections and keys use the defaults shown above. An empty +`workspaces.allowedRoots` uses the current working directory. Unknown keys are +rejected so spelling mistakes cannot silently alter behavior. -| Variable | Purpose | -| --- | --- | -| `DEVSPACE_SKILLS` | Set to `0` to hide skills. Enabled by default. | -| `DEVSPACE_SUBAGENTS` | Optional master override for the persisted Subagents configuration. | -| `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`; its `skills` child is loaded for compatibility. | -| `DEVSPACE_SKILL_PATHS` | Optional comma-separated additional skill directories. | +## Tool modes and UI -DevSpace discovers standard Agent Skills from: +`tools.mode` accepts two values: -- `~/.agents/skills` -- project `.agents/skills` -- `~/.devspace/skills` +| Value | Tool surface | +| --- | --- | +| `codex` | Default. `open_workspace`, `read`, `apply_patch`, `exec_command`, `write_stdin`, and `show_changes`. | +| `claude` | `open_workspace`, `read`, `write`, `edit`, `bash`, and `show_changes`. | -It also keeps compatibility with: +The dedicated MCP tools `grep`, `glob`, and `ls` are not exposed. Each mode uses +its shell tool with programs such as `rg`, `find`, and `ls` when it needs those +operations. -- the bundled `subagents` skill when Subagents are enabled, unless `~/.devspace/skills/subagents/SKILL.md` exists -- `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` -- additional paths from `DEVSPACE_SKILL_PATHS` +DevSpace attaches Apps UI metadata only to `open_workspace` and `show_changes`. +This avoids rendering an iframe for every read, edit, search, or command call. +Setting `ui.enabled` to `false` removes the metadata but does not remove the +`show_changes` tool. -When Subagents are enabled, DevSpace discovers agent profiles -from: +## Skills and subagents -- `~/.devspace/agents/*.md` -- project `.devspace/agents/*.md` +DevSpace discovers standard Agent Skills from `~/.agents/skills`, project +`.agents/skills`, and `~/.devspace/skills`. It also checks +`skills.agentDir/skills` and each path in `skills.paths`. Relative custom paths +are resolved from the active workspace. -Enable providers and set their defaults in `~/.devspace/config.json`: +Subagent providers are explicit. Omitted providers are disabled: -```json +```jsonc { + "configVersion": 1, "subagents": { "enabled": true, "providers": [ @@ -173,101 +114,104 @@ Enable providers and set their defaults in `~/.devspace/config.json`: "id": "codex", "enabled": true, "model": "gpt-5.4", - "effort": "high" + "effort": "high", }, { "id": "claude", "enabled": true, - "model": "sonnet" + "model": "sonnet", }, - { - "id": "grok", - "enabled": true, - "model": "grok-4.5", - "effort": "low" - } - ] - } + ], + }, } ``` -Each entry controls one provider. Providers omitted from the array are disabled. -`model` and `effort` are optional defaults; an invocation override wins over a -profile value, which wins over the provider default. The legacy boolean -`"subagents": true` remains readable and enables every provider, but new -configuration should use the explicit object form. - -`devspace agents targets` shows usable providers and profiles for the current -workspace. Add `--json` for a compact list of exact target names and their -selection metadata. Disabled, unavailable, and unconfigured providers are -omitted. Provider availability is runtime state and never rewrites the -configuration. - -Grok Build is discovered from the `grok` executable. Authenticate it with -`grok login` or `XAI_API_KEY`; DevSpace does not read or store Grok credentials. -Grok supports `grok-build` by default and validates explicit model and effort -values against the ACP session metadata when available. Set `GROK_COMMAND` when -the executable is not on the normal PATH. If your Grok installation selects a -custom agent profile, set `GROK_AGENT_PROFILE` to that profile's path; DevSpace -passes it to `grok agent stdio` without writing to Grok's configuration. - -`open_workspace` returns a compact catalog containing profile names, -descriptions, providers, and optional models/effort levels so the host model can choose an -agent without reading provider-specific launch details. Disabled or unavailable -providers and their profiles are omitted from this model-facing catalog. `devspace agents ls` -lists existing subagent sessions for the current workspace, scoped by the -workspace environment injected into shell commands. The `subagents` -skill teaches the model to use only the minimal `devspace agents ls`, -`devspace agents targets`, `devspace agents run`, `devspace agents continue`, -and `devspace agents show` workflow. - -For Codex, Claude Code, OpenCode, Pi, or another supported Coding Agent, use -the Skills CLI to install the same skill. DevSpace setup prints this command but -does not run it or write into agent skill directories: - -```bash -npx skills add Waishnav/devspace --skill subagents --global -``` +Profiles are loaded from `~/.devspace/agents/*.md` and project +`.devspace/agents/*.md`. `devspace agents targets` prints the configured targets +available in the current workspace. -Starter profile templates are available under `examples/agents/`. Copy or adapt -them into one of the active profile directories before use. +Provider executable discovery remains process-scoped. The supported overrides +are `CODEX_COMMAND`, `CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, +`COPILOT_COMMAND`, `GROK_COMMAND`, and `GROK_AGENT_PROFILE`. DevSpace does not +persist provider credentials. -Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. +## Native artifact download -Example: +Set `artifacts.enabled` to `true` when a host needs to save a native attached or +generated file into an open workspace. `artifacts.maxFileBytes` limits one +streamed file. The secure publication path is currently available only on +Linux; the tool is not registered on macOS, Windows, or BSD. -```bash -DEVSPACE_SKILL_PATHS="$HOME/.claude/skills,$HOME/company/skills" \ -npx @waishnav/devspace serve -``` +## Environment boundary -## Logging +Only two user-facing DevSpace environment variables remain: -| Variable | Default | +| Variable | Purpose | | --- | --- | -| `DEVSPACE_LOG_LEVEL` | `info` | -| `DEVSPACE_LOG_FORMAT` | `json` | -| `DEVSPACE_LOG_REQUESTS` | `1` | -| `DEVSPACE_LOG_ASSETS` | `0` | -| `DEVSPACE_LOG_TOOL_CALLS` | `1` | -| `DEVSPACE_LOG_SHELL_COMMANDS` | `0` | -| `DEVSPACE_TRUST_PROXY` | `0` | - -Set `DEVSPACE_LOG_FORMAT=pretty` for local debugging. - -Set `DEVSPACE_LOG_SHELL_COMMANDS=1` only when you intentionally want command -previews in logs. - -## Env-Only Example - -```bash -DEVSPACE_OAUTH_OWNER_TOKEN="$(openssl rand -base64 32)" \ -DEVSPACE_ALLOWED_ROOTS="$HOME/personal,$HOME/work" \ -DEVSPACE_PUBLIC_BASE_URL="https://devspace.example.com" \ -DEVSPACE_WORKTREE_ROOT="$HOME/.devspace/worktrees" \ -DEVSPACE_ARTIFACTS="1" \ -npx @waishnav/devspace serve -``` +| `DEVSPACE_CONFIG_DIR` | Bootstrap location for `config.jsonc`, `auth.json`, skills, and profiles. | +| `DEVSPACE_OAUTH_OWNER_TOKEN` | Optional secret override for the owner token stored in `auth.json`. | -The environment assignments must be part of the same command invocation, or -exported first. +Durable environment settings were removed in v1.1. Move existing deployment +values to these JSONC keys: + +| Removed setting | JSONC key | +| --- | --- | +| `HOST`, `PORT` | `server.host`, `server.port` | +| `DEVSPACE_PUBLIC_BASE_URL` | `server.publicBaseUrl` | +| `DEVSPACE_ALLOWED_HOSTS` | `server.allowedHosts` | +| `DEVSPACE_TRUST_PROXY` | `server.trustProxy` | +| `DEVSPACE_ALLOWED_ROOTS` | `workspaces.allowedRoots` | +| `DEVSPACE_WORKTREE_ROOT` | `workspaces.worktreeRoot` | +| `DEVSPACE_STATE_DIR` | `storage.stateDir` | +| `DEVSPACE_TOOL_MODE`, `DEVSPACE_MINIMAL_TOOLS` | `tools.mode` | +| `DEVSPACE_WIDGETS` | `ui.enabled` | +| `DEVSPACE_ARTIFACTS` | `artifacts.enabled` | +| `DEVSPACE_ARTIFACT_MAX_FILE_BYTES` | `artifacts.maxFileBytes` | +| `DEVSPACE_SKILLS` | `skills.enabled` | +| `DEVSPACE_SKILL_PATHS` | `skills.paths` | +| `DEVSPACE_AGENT_DIR` | `skills.agentDir` | +| `DEVSPACE_SUBAGENTS` | `subagents.enabled` | +| `DEVSPACE_LOG_LEVEL` | `logging.level` | +| `DEVSPACE_LOG_FORMAT` | `logging.format` | +| `DEVSPACE_LOG_REQUESTS` | `logging.requests` | +| `DEVSPACE_LOG_ASSETS` | `logging.assets` | +| `DEVSPACE_LOG_TOOL_CALLS` | `logging.toolCalls` | +| `DEVSPACE_LOG_SHELL_COMMANDS` | `logging.shellCommands` | +| `DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS` | `oauth.accessTokenTtlSeconds` | +| `DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS` | `oauth.refreshTokenTtlSeconds` | +| `DEVSPACE_OAUTH_SCOPES` | `oauth.scopes` | +| `DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS` | `oauth.allowedRedirectHosts` | + +These environment values are not read or auto-imported in v1.1. Environment is +process state, so there is no reliable file DevSpace can migrate on the user's +behalf. + +## v1.0 file migration + +The first v1.1 load performs one migration when `config.jsonc` is missing and +`config.json` exists: + +1. Validate the old JSON document. +2. Translate its known fields into the versioned JSONC structure. +3. Write and validate a temporary `config.jsonc`. +4. Atomically publish it. +5. Rename the old file to `config.json.v1.0.bak`. + +If `config.jsonc` exists, DevSpace never reads `config.json`. Invalid JSONC also +never falls back to the old file. Unsupported legacy keys stop migration with an +actionable error instead of being silently discarded. + +The persisted fields map as follows: + +| v1.0 JSON field | v1.1 JSONC key | +| --- | --- | +| `host`, `port` | `server.host`, `server.port` | +| `publicBaseUrl`, `allowedHosts` | `server.publicBaseUrl`, `server.allowedHosts` | +| `allowedRoots`, `worktreeRoot` | `workspaces.allowedRoots`, `workspaces.worktreeRoot` | +| `stateDir` | `storage.stateDir` | +| `artifactsEnabled`, `artifactMaxFileBytes` | `artifacts.enabled`, `artifacts.maxFileBytes` | +| `agentDir` | `skills.agentDir` | +| `subagents` | `subagents` | +| `tools.mode`, `ui.enabled` | unchanged nested keys | + +`auth.json` is unchanged. diff --git a/docs/gotchas.md b/docs/gotchas.md index cd536931..2f609355 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -69,10 +69,10 @@ npx @waishnav/devspace config set publicBaseUrl https://your-tunnel-host.example Temporary tunnels often change URLs between runs. -For a one-off run: +Update the configured URL: ```bash -DEVSPACE_PUBLIC_BASE_URL="https://new-tunnel.example.com" npx @waishnav/devspace serve +npx @waishnav/devspace config set publicBaseUrl https://new-tunnel.example.com ``` For a stable URL: @@ -94,11 +94,8 @@ npx @waishnav/devspace doctor Confirm the public URL hostname appears in allowed hosts. If you changed tunnel URLs, update `publicBaseUrl`. -Use this only for intentional local debugging: - -```bash -DEVSPACE_ALLOWED_HOSTS="*" npx @waishnav/devspace serve -``` +For intentional local debugging only, set `server.allowedHosts` to `["*"]` in +`~/.devspace/config.jsonc`. ## OAuth Redirect Host Rejected @@ -110,11 +107,8 @@ localhost 127.0.0.1 ``` -If another MCP client uses a different redirect host, configure: - -```bash -DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS="chatgpt.com,example.com" npx @waishnav/devspace serve -``` +If another MCP client uses a different redirect host, add it to +`oauth.allowedRedirectHosts` in `~/.devspace/config.jsonc`. ## Owner Password Not Accepted @@ -204,11 +198,8 @@ Confirm Bash is detected. ## Skills Do Not Appear -Skills are enabled by default. Check: - -```bash -DEVSPACE_SKILLS=1 npx @waishnav/devspace serve -``` +Skills are enabled by default. Confirm `skills.enabled` is `true` in +`~/.devspace/config.jsonc`. DevSpace looks in standard Agent Skills locations: @@ -219,8 +210,8 @@ DevSpace looks in standard Agent Skills locations: It also checks compatibility and custom paths: - the bundled `subagents` skill when Subagents are enabled, unless `~/.devspace/skills/subagents/SKILL.md` exists -- `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` -- additional paths from `DEVSPACE_SKILL_PATHS` +- `skills.agentDir/skills`, defaulting to `~/.codex/skills` +- additional paths from `skills.paths` When Subagents are enabled, DevSpace loads agent profiles from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`, then exposes a @@ -246,7 +237,7 @@ not copy files into agent skill directories. Packaged agent profile examples under `examples/agents/` are starter templates. Copy or adapt them into one of the active profile directories before use. -Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. +Legacy project paths such as `.pi/skills` can be added to `skills.paths` when needed. If a skill appears in `open_workspace`, the model must read that skill's `SKILL.md` before reading other files inside the skill directory. @@ -259,4 +250,4 @@ to avoid one iframe per call. Plain MCP clients may ignore ChatGPT Apps widget metadata and only show text results; `show_changes` remains available there. If both cards are missing in ChatGPT, confirm that `ui.enabled` is not `false` -in `~/.devspace/config.json` and reconnect the MCP server. +in `~/.devspace/config.jsonc` and reconnect the MCP server. diff --git a/docs/security.md b/docs/security.md index d7ec0e1d..69bbc130 100644 --- a/docs/security.md +++ b/docs/security.md @@ -51,8 +51,8 @@ DEVSPACE_OAUTH_OWNER_TOKEN="$(openssl rand -base64 32)" ## Public URL And Host Allowlist -DevSpace needs `DEVSPACE_PUBLIC_BASE_URL` so MCP clients can discover OAuth -metadata and connect to the correct resource. +DevSpace needs `server.publicBaseUrl` in `config.jsonc` so MCP clients can +discover OAuth metadata and connect to the correct resource. The value should be the origin only: @@ -60,10 +60,10 @@ The value should be the origin only: https://your-tunnel-host.example.com ``` -Do not include `/mcp` in `DEVSPACE_PUBLIC_BASE_URL`. +Do not include `/mcp` in `server.publicBaseUrl`. By default, DevSpace derives allowed Host headers from the local host and public -URL. Use `DEVSPACE_ALLOWED_HOSTS=*` only for intentional local debugging. +URL. Put `"*"` in `server.allowedHosts` only for intentional local debugging. ## Tunnels @@ -112,7 +112,7 @@ execute transferred content. ## Logs By default, DevSpace logs requests and tool calls. Shell command previews are -disabled unless `DEVSPACE_LOG_SHELL_COMMANDS=1`. +disabled unless `logging.shellCommands` is `true`. Do not enable shell command logging if commands may contain secrets. diff --git a/docs/setup.md b/docs/setup.md index 934b0b8c..e5e76d8f 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -56,7 +56,7 @@ remain limited to the roots configured for ChatGPT. Setup detects supported Coding Agents and asks which ones DevSpace may use. These choices are stored as provider objects under `subagents` in -`~/.devspace/config.json`. +`~/.devspace/config.jsonc`. If you selected Coding Agents, setup prints: @@ -99,13 +99,7 @@ Run: npx @waishnav/devspace serve ``` -If your tunnel URL changes for one run, override it without rewriting config: - -```bash -DEVSPACE_PUBLIC_BASE_URL="https://new-tunnel.example.com" npx @waishnav/devspace serve -``` - -For a stable public URL, persist it: +If your tunnel URL changes, update the persisted value before starting: ```bash npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com @@ -120,7 +114,7 @@ password approval page. Enter the Owner password printed during setup. The default config files are: ```text -~/.devspace/config.json +~/.devspace/config.jsonc ~/.devspace/auth.json ``` diff --git a/package-lock.json b/package-lock.json index 5b5c247a..c0563885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "diff": "^8.0.3", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "jsonc-parser": "^3.3.1", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -782,7 +783,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -1087,7 +1088,7 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { - "version": "1.0.3", + "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" @@ -4450,6 +4451,12 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", diff --git a/package.json b/package.json index 301edf4c..dfa5cf55 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dist", "docs", "examples", + "schema", "scripts", "skills", "README.md" @@ -28,9 +29,10 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", + "schema:config": "tsx scripts/generate-config-schema.ts", "start": "node dist/cli.js serve", "test": "tsx src/user-config.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsx src/config-schema.test.ts && tsc -p tsconfig.json --noEmit" }, "keywords": [], "author": "", @@ -51,6 +53,7 @@ "diff": "^8.0.3", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "jsonc-parser": "^3.3.1", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json new file mode 100644 index 00000000..e7c18466 --- /dev/null +++ b/schema/v1/devspace.schema.json @@ -0,0 +1,301 @@ +{ + "$id": "https://raw.githubusercontent.com/Waishnav/devspace/main/schema/v1/devspace.schema.json", + "title": "DevSpace configuration", + "description": "Versioned configuration for a local DevSpace MCP server.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "$schema": { + "default": "https://raw.githubusercontent.com/Waishnav/devspace/main/schema/v1/devspace.schema.json", + "type": "string", + "format": "uri" + }, + "configVersion": { + "type": "number", + "const": 1 + }, + "server": { + "default": {}, + "type": "object", + "properties": { + "host": { + "default": "127.0.0.1", + "type": "string", + "minLength": 1 + }, + "port": { + "default": 7676, + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "publicBaseUrl": { + "default": null, + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + }, + "allowedHosts": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "trustProxy": { + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "workspaces": { + "default": {}, + "type": "object", + "properties": { + "allowedRoots": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "worktreeRoot": { + "default": "~/.devspace/worktrees", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "storage": { + "default": {}, + "type": "object", + "properties": { + "stateDir": { + "default": "~/.local/share/devspace", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "tools": { + "default": {}, + "type": "object", + "properties": { + "mode": { + "default": "codex", + "type": "string", + "enum": [ + "claude", + "codex" + ] + } + }, + "additionalProperties": false + }, + "ui": { + "default": {}, + "type": "object", + "properties": { + "enabled": { + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "artifacts": { + "default": {}, + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "maxFileBytes": { + "default": 104857600, + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "skills": { + "default": {}, + "type": "object", + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "paths": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "agentDir": { + "default": "~/.codex", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "subagents": { + "default": { + "enabled": false, + "providers": [] + }, + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": [ + "codex", + "claude", + "opencode", + "pi", + "cursor", + "copilot", + "grok" + ] + }, + "enabled": { + "type": "boolean" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "effort": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "enabled" + ], + "additionalProperties": false + } + } + }, + "required": [ + "enabled", + "providers" + ], + "additionalProperties": false + }, + "logging": { + "default": {}, + "type": "object", + "properties": { + "level": { + "default": "info", + "type": "string", + "enum": [ + "silent", + "error", + "warn", + "info", + "debug" + ] + }, + "format": { + "default": "json", + "type": "string", + "enum": [ + "json", + "pretty" + ] + }, + "requests": { + "default": true, + "type": "boolean" + }, + "assets": { + "default": false, + "type": "boolean" + }, + "toolCalls": { + "default": true, + "type": "boolean" + }, + "shellCommands": { + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "oauth": { + "default": {}, + "type": "object", + "properties": { + "accessTokenTtlSeconds": { + "default": 3600, + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "refreshTokenTtlSeconds": { + "default": 2592000, + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "scopes": { + "default": [ + "devspace" + ], + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "allowedRedirectHosts": { + "default": [ + "chatgpt.com", + "localhost", + "127.0.0.1" + ], + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + } + }, + "required": [ + "configVersion" + ], + "additionalProperties": false +} diff --git a/scripts/generate-config-schema.ts b/scripts/generate-config-schema.ts new file mode 100644 index 00000000..324b611b --- /dev/null +++ b/scripts/generate-config-schema.ts @@ -0,0 +1,9 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { + devspaceConfigJsonSchema, +} from "../src/config-schema.js"; + +const outputPath = new URL("../schema/v1/devspace.schema.json", import.meta.url); + +mkdirSync(new URL(".", outputPath), { recursive: true }); +writeFileSync(outputPath, `${JSON.stringify(devspaceConfigJsonSchema(), null, 2)}\n`); diff --git a/src/cli.test.ts b/src/cli.test.ts index 9a2022ef..27c91da4 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -11,6 +11,7 @@ import { loadConfig } from "./config.js"; import { localAgentDaemonPaths } from "./local-agent-daemon-lifecycle.js"; import { encodeLocalAgentDaemonResponse } from "./local-agent-daemon-protocol.js"; import { LocalAgentStore } from "./local-agent-store.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; const execFileAsync = promisify(execFile); const require = createRequire(import.meta.url); @@ -38,6 +39,11 @@ try { mkdirSync(stateDir, { recursive: true }); mkdirSync(join(configDir, "agents"), { recursive: true }); mkdirSync(projectRoot, { recursive: true }); + const cliConfigEnv = writeTestDevspaceConfig(configDir, { + workspaces: { allowedRoots: [projectRoot] }, + storage: { stateDir }, + subagents: { enabled: true, providers: [] }, + }); writeFileSync( join(configDir, "agents", "reviewer.md"), [ @@ -138,13 +144,9 @@ try { encoding: "utf8", env: { ...process.env, - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_STATE_DIR: stateDir, + ...cliConfigEnv, DEVSPACE_WORKSPACE_ID: "ws_current", DEVSPACE_WORKSPACE_ROOT: projectRoot, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }, }); @@ -158,13 +160,9 @@ try { encoding: "utf8", env: { ...process.env, - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_STATE_DIR: stateDir, + ...cliConfigEnv, DEVSPACE_WORKSPACE_ID: "ws_current", DEVSPACE_WORKSPACE_ROOT: projectRoot, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }, }, ); @@ -181,11 +179,7 @@ try { encoding: "utf8", env: { ...process.env, - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: stateDir, - DEVSPACE_STATE_DIR: stateDir, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + ...cliConfigEnv, DEVSPACE_WORKSPACE_ID: "", DEVSPACE_WORKSPACE_ROOT: stateDir, }, @@ -205,13 +199,9 @@ try { encoding: "utf8", env: { ...process.env, - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_STATE_DIR: stateDir, + ...cliConfigEnv, DEVSPACE_WORKSPACE_ID: "ws_current", DEVSPACE_WORKSPACE_ROOT: projectRoot, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }, }, ); @@ -247,13 +237,9 @@ try { encoding: "utf8", env: { ...process.env, - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_STATE_DIR: stateDir, + ...cliConfigEnv, DEVSPACE_WORKSPACE_ID: "ws_current", DEVSPACE_WORKSPACE_ROOT: projectRoot, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }, }, ), @@ -268,13 +254,7 @@ try { }); } - assert.equal(loadConfig({ - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_STATE_DIR: stateDir, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - }).subagents.enabled, true); + assert.equal(loadConfig(cliConfigEnv).subagents.enabled, true); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/src/cli.ts b/src/cli.ts index 7cf723f8..92248151 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,7 +8,6 @@ import { getShellConfig } from "@earendil-works/pi-coding-agent"; import { satisfies } from "semver"; import { loadConfig } from "./config.js"; import { resolveCliWorkspaceContext } from "./cli-workspace.js"; -import { resolveSubagentsConfig } from "./local-agent-config.js"; import { getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js"; @@ -46,9 +45,9 @@ import { import { generateOwnerToken, loadDevspaceFiles, + setDevspaceConfigValue, + setDevspaceConfigValues, writeDevspaceAuth, - writeDevspaceConfig, - type DevspaceUserConfig, } from "./user-config.js"; import { expandHomePath } from "./roots.js"; import { shutdownHttpServer } from "./server-shutdown.js"; @@ -99,6 +98,9 @@ function normalizeCommand(command: string | undefined): Command { async function ensureConfigured(): Promise { const files = loadDevspaceFiles(); + if (files.migratedLegacyConfig) { + console.log(`Migrated legacy configuration to ${files.configPath}`); + } if (files.configExists && files.authExists) return; if (process.env.DEVSPACE_OAUTH_OWNER_TOKEN) return; @@ -143,7 +145,7 @@ async function runInit({ force }: { force: boolean }): Promise { hint: "Use DevSpace from Codex, Claude Code, OpenCode, Pi, and similar tools.", }, ], - initialValues: files.config.publicBaseUrl ? ["chatgpt"] : ["coding-agents"], + initialValues: files.config.server.publicBaseUrl ? ["chatgpt"] : ["coding-agents"], required: true, }); if (prompts.isCancel(destinationAnswer)) throw new SetupCancelledError(); @@ -153,7 +155,7 @@ async function runInit({ force }: { force: boolean }): Promise { let allowedRoots: string[] | undefined; if (useChatGpt) { - const defaultRoots = files.config.allowedRoots?.join(", ") || process.cwd(); + const defaultRoots = files.config.workspaces.allowedRoots.join(", ") || process.cwd(); const rootsAnswer = await textPrompt({ message: `Which project folders can DevSpace access? Press Enter to use ${defaultRoots}`, placeholder: defaultRoots, @@ -166,7 +168,7 @@ async function runInit({ force }: { force: boolean }): Promise { .filter(Boolean); } - const port = isValidPort(files.config.port) ? files.config.port : 7676; + const port = files.config.server.port; let publicBaseUrl: string | null = null; if (useChatGpt) { @@ -180,16 +182,16 @@ async function runInit({ force }: { force: boolean }): Promise { "Connect ChatGPT", ); publicBaseUrl = normalizePublicBaseUrl(await textPrompt({ - message: files.config.publicBaseUrl - ? `What public URL will ChatGPT connect to? Press Enter to keep ${files.config.publicBaseUrl}` + message: files.config.server.publicBaseUrl + ? `What public URL will ChatGPT connect to? Press Enter to keep ${files.config.server.publicBaseUrl}` : "What public URL will ChatGPT connect to?", - placeholder: files.config.publicBaseUrl ?? "https://your-tunnel-host.example.com", - defaultValue: files.config.publicBaseUrl ?? "", + placeholder: files.config.server.publicBaseUrl ?? "https://your-tunnel-host.example.com", + defaultValue: files.config.server.publicBaseUrl ?? "", validate: validateRequiredPublicBaseUrl, })); } - const currentSubagents = resolveSubagentsConfig(files.config.subagents, {}); + const currentSubagents = files.config.subagents; const availability = getLocalAgentProviderAvailabilitySnapshot(); const configuredProviders = currentSubagents.providers .filter((provider) => provider.enabled) @@ -218,19 +220,20 @@ async function runInit({ force }: { force: boolean }): Promise { selectedProviders, ); - const config: DevspaceUserConfig = { - ...files.config, - host: files.config.host ?? "127.0.0.1", - port, - ...(allowedRoots ? { allowedRoots } : {}), - publicBaseUrl, - subagents, - }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), }; - writeDevspaceConfig(config); + setDevspaceConfigValues([ + { path: ["server", "port"], value: port }, + ...(useChatGpt + ? [{ path: ["server", "publicBaseUrl"], value: publicBaseUrl }] + : []), + ...(allowedRoots + ? [{ path: ["workspaces", "allowedRoots"], value: allowedRoots }] + : []), + { path: ["subagents"], value: subagents }, + ]); writeDevspaceAuth(auth); const lines = [ @@ -295,7 +298,7 @@ async function serve(): Promise { console.log(`allowed roots: ${config.allowedRoots.join(", ")}`); console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`); if (config.allowedHosts.includes("*")) { - console.warn("warning: Host header allowlist is disabled because DEVSPACE_ALLOWED_HOSTS=*"); + console.warn("warning: Host header allowlist is disabled because server.allowedHosts contains '*'"); } console.log("auth: Owner password approval required"); console.log(`logging: ${config.logging.level} ${config.logging.format}`); @@ -369,10 +372,10 @@ function runConfigCommand(args: string[]): void { throw new Error("Missing publicBaseUrl value."); } - writeDevspaceConfig({ - ...files.config, - publicBaseUrl: normalizeOptionalPublicBaseUrl(value), - }); + setDevspaceConfigValue( + ["server", "publicBaseUrl"], + normalizeOptionalPublicBaseUrl(value), + ); console.log(`Updated ${files.configPath}`); } @@ -384,7 +387,7 @@ function printHelp(): void { "Usage:", " devspace Run first-time setup if needed, then start the server", " devspace serve Start the server", - " devspace init Create or update ~/.devspace/config.json and auth.json", + " devspace init Create or update ~/.devspace/config.jsonc and auth.json", " devspace doctor Show config, runtime, and native dependency status", " devspace config get Print persisted config", " devspace config set publicBaseUrl ", @@ -396,7 +399,8 @@ function printHelp(): void { " devspace -v, --version Print the installed version", "", "For temporary tunnels:", - " DEVSPACE_PUBLIC_BASE_URL=https://example.trycloudflare.com devspace serve", + " devspace config set publicBaseUrl https://example.trycloudflare.com", + " devspace serve", ].join("\n"), ); } @@ -667,10 +671,6 @@ async function textPrompt(options: TextPromptOptions): Promise { return value || options.defaultValue; } -function isValidPort(value: unknown): value is number { - return Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 65535; -} - function validateRequiredPublicBaseUrl(value: string | undefined): string | undefined { const trimmed = value?.trim() ?? ""; if (!trimmed) return "Enter the public URL from your tunnel or reverse proxy."; diff --git a/src/config-migration.ts b/src/config-migration.ts new file mode 100644 index 00000000..f24851ad --- /dev/null +++ b/src/config-migration.ts @@ -0,0 +1,96 @@ +import * as z from "zod/v4"; +import { + DEVSPACE_CONFIG_VERSION, + devspaceConfigSchema, + type DevspaceConfig, +} from "./config-schema.js"; +import { storedSubagentsConfigSchema } from "./local-agent-config.js"; +import { LOCAL_AGENT_PROVIDERS } from "./local-agent-profiles.js"; + +const legacyConfigSchema = z.object({ + host: z.string().optional(), + port: z.number().optional(), + allowedRoots: z.array(z.string()).optional(), + publicBaseUrl: z.string().nullable().optional(), + allowedHosts: z.array(z.string()).optional(), + stateDir: z.string().optional(), + worktreeRoot: z.string().optional(), + artifactsEnabled: z.boolean().optional(), + artifactMaxFileBytes: z.number().optional(), + agentDir: z.string().optional(), + subagents: storedSubagentsConfigSchema.optional(), + tools: z.object({ + mode: z.enum(["claude", "codex"]).optional(), + }).strict().optional(), + ui: z.object({ + enabled: z.boolean().optional(), + }).strict().optional(), +}).passthrough(); + +const LEGACY_CONFIG_KEYS = new Set([ + "host", + "port", + "allowedRoots", + "publicBaseUrl", + "allowedHosts", + "stateDir", + "worktreeRoot", + "artifactsEnabled", + "artifactMaxFileBytes", + "agentDir", + "subagents", + "tools", + "ui", +]); + +export function migrateLegacyConfig(value: unknown): DevspaceConfig { + const legacy = legacyConfigSchema.parse(value); + const unsupportedKeys = Object.keys(legacy).filter((key) => !LEGACY_CONFIG_KEYS.has(key)); + if (unsupportedKeys.length > 0) { + throw new Error( + `Unsupported legacy configuration keys: ${unsupportedKeys.sort().join(", ")}`, + ); + } + + return devspaceConfigSchema.parse({ + configVersion: DEVSPACE_CONFIG_VERSION, + server: definedEntries({ + host: legacy.host, + port: legacy.port, + publicBaseUrl: legacy.publicBaseUrl, + allowedHosts: legacy.allowedHosts, + }), + workspaces: definedEntries({ + allowedRoots: legacy.allowedRoots, + worktreeRoot: legacy.worktreeRoot, + }), + storage: definedEntries({ stateDir: legacy.stateDir }), + tools: definedEntries({ mode: legacy.tools?.mode }), + ui: definedEntries({ enabled: legacy.ui?.enabled }), + artifacts: definedEntries({ + enabled: legacy.artifactsEnabled, + maxFileBytes: legacy.artifactMaxFileBytes, + }), + skills: definedEntries({ agentDir: legacy.agentDir }), + subagents: migrateLegacySubagents(legacy.subagents), + }); +} + +function definedEntries>(value: T): Partial { + return Object.fromEntries( + Object.entries(value).filter((entry) => entry[1] !== undefined), + ) as Partial; +} + +function migrateLegacySubagents( + value: z.infer | undefined, +): unknown { + if (value === undefined) return undefined; + if (typeof value !== "boolean") return value; + return { + enabled: value, + providers: value + ? LOCAL_AGENT_PROVIDERS.map((id) => ({ id, enabled: true })) + : [], + }; +} diff --git a/src/config-schema.test.ts b/src/config-schema.test.ts new file mode 100644 index 00000000..78438f63 --- /dev/null +++ b/src/config-schema.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { + defaultDevspaceConfig, + devspaceConfigJsonSchema, + devspaceConfigSchema, +} from "./config-schema.js"; + +const defaults = defaultDevspaceConfig(); +assert.equal(defaults.configVersion, 1); +assert.equal(defaults.tools.mode, "codex"); +assert.equal(defaults.ui.enabled, true); + +assert.throws( + () => devspaceConfigSchema.parse({ configVersion: 1, typo: true }), + /Unrecognized key/, +); + +const generatedSchema = `${JSON.stringify(devspaceConfigJsonSchema(), null, 2)}\n`; +const committedSchema = readFileSync( + new URL("../schema/v1/devspace.schema.json", import.meta.url), + "utf8", +).replace(/\r\n/g, "\n"); +assert.equal(committedSchema, generatedSchema, "run `npm run schema:config` after changing config-schema.ts"); + +console.log("config schema tests passed"); diff --git a/src/config-schema.ts b/src/config-schema.ts new file mode 100644 index 00000000..c30bb361 --- /dev/null +++ b/src/config-schema.ts @@ -0,0 +1,97 @@ +import * as z from "zod/v4"; +import { subagentsConfigSchema } from "./local-agent-config.js"; + +export const DEVSPACE_CONFIG_VERSION = 1 as const; +export const DEVSPACE_CONFIG_SCHEMA_URL = + "https://raw.githubusercontent.com/Waishnav/devspace/main/schema/v1/devspace.schema.json"; + +const serverConfigSchema = z.object({ + host: z.string().trim().min(1).default("127.0.0.1"), + port: z.number().int().min(1).max(65_535).default(7676), + publicBaseUrl: z.string().url().nullable().default(null), + allowedHosts: z.array(z.string().trim().min(1)).default([]), + trustProxy: z.boolean().default(false), +}).strict().prefault({}); + +const workspacesConfigSchema = z.object({ + allowedRoots: z.array(z.string().trim().min(1)).default([]), + worktreeRoot: z.string().trim().min(1).default("~/.devspace/worktrees"), +}).strict().prefault({}); + +const storageConfigSchema = z.object({ + stateDir: z.string().trim().min(1).default("~/.local/share/devspace"), +}).strict().prefault({}); + +const toolsConfigSchema = z.object({ + mode: z.enum(["claude", "codex"]).default("codex"), +}).strict().prefault({}); + +const uiConfigSchema = z.object({ + enabled: z.boolean().default(true), +}).strict().prefault({}); + +const artifactsConfigSchema = z.object({ + enabled: z.boolean().default(false), + maxFileBytes: z.number().int().positive().default(100 * 1024 * 1024), +}).strict().prefault({}); + +const skillsConfigSchema = z.object({ + enabled: z.boolean().default(true), + paths: z.array(z.string().trim().min(1)).default([]), + agentDir: z.string().trim().min(1).default("~/.codex"), +}).strict().prefault({}); + +const loggingConfigSchema = z.object({ + level: z.enum(["silent", "error", "warn", "info", "debug"]).default("info"), + format: z.enum(["json", "pretty"]).default("json"), + requests: z.boolean().default(true), + assets: z.boolean().default(false), + toolCalls: z.boolean().default(true), + shellCommands: z.boolean().default(false), +}).strict().prefault({}); + +const oauthConfigSchema = z.object({ + accessTokenTtlSeconds: z.number().int().positive().default(60 * 60), + refreshTokenTtlSeconds: z.number().int().positive().default(30 * 24 * 60 * 60), + scopes: z.array(z.string().trim().min(1)).min(1).default(["devspace"]), + allowedRedirectHosts: z.array(z.string().trim().min(1)).min(1).default([ + "chatgpt.com", + "localhost", + "127.0.0.1", + ]), +}).strict().prefault({}); + +export const devspaceConfigSchema = z.object({ + $schema: z.string().url().default(DEVSPACE_CONFIG_SCHEMA_URL), + configVersion: z.literal(DEVSPACE_CONFIG_VERSION), + server: serverConfigSchema, + workspaces: workspacesConfigSchema, + storage: storageConfigSchema, + tools: toolsConfigSchema, + ui: uiConfigSchema, + artifacts: artifactsConfigSchema, + skills: skillsConfigSchema, + subagents: subagentsConfigSchema.default({ enabled: false, providers: [] }), + logging: loggingConfigSchema, + oauth: oauthConfigSchema, +}).strict(); + +export type DevspaceConfig = z.output; +export type DevspaceConfigInput = z.input; +export type ToolMode = DevspaceConfig["tools"]["mode"]; + +export function defaultDevspaceConfig(): DevspaceConfig { + return devspaceConfigSchema.parse({ configVersion: DEVSPACE_CONFIG_VERSION }); +} + +export function devspaceConfigJsonSchema(): object { + return { + $id: DEVSPACE_CONFIG_SCHEMA_URL, + title: "DevSpace configuration", + description: "Versioned configuration for a local DevSpace MCP server.", + ...z.toJSONSchema(devspaceConfigSchema, { + target: "draft-2020-12", + io: "input", + }), + }; +} diff --git a/src/config.test.ts b/src/config.test.ts index bb464d84..47a39652 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,167 +1,127 @@ import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { loadConfig } from "./config.js"; +import { writeDevspaceAuth, writeDevspaceConfig } from "./user-config.js"; -const emptyConfigDir = mkdtempSync(join(tmpdir(), "devspace-empty-config-test-")); -const baseEnv = { - DEVSPACE_CONFIG_DIR: emptyConfigDir, - DEVSPACE_ALLOWED_ROOTS: process.cwd(), +const configDir = mkdtempSync(join(tmpdir(), "devspace-config-test-")); +const env = { + DEVSPACE_CONFIG_DIR: configDir, DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }; -assert.equal(loadConfig(baseEnv).uiEnabled, true); -assert.equal(loadConfig(baseEnv).toolMode, "codex"); -assert.equal(loadConfig(baseEnv).skillsEnabled, true); -assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); -assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); -assert.deepEqual(loadConfig(baseEnv).subagents, { enabled: false, providers: [] }); -assert.equal(loadConfig(baseEnv).artifactsEnabled, false); -assert.equal(loadConfig(baseEnv).artifactMaxFileBytes, 100 * 1024 * 1024); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_ARTIFACTS: "1" }).artifactsEnabled, true); -assert.equal( - loadConfig({ ...baseEnv, DEVSPACE_ARTIFACT_MAX_FILE_BYTES: "123" }).artifactMaxFileBytes, - 123, -); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); -assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "1" }).subagents, { - enabled: true, - providers: [], -}); -assert.deepEqual(loadConfig(baseEnv).logging, { - level: "info", - format: "json", - requests: true, - assets: false, - toolCalls: true, - shellCommands: false, - trustProxy: false, -}); - -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "silent" }).logging.level, "silent"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "error" }).logging.level, "error"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "warn" }).logging.level, "warn"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "info" }).logging.level, "info"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "debug" }).logging.level, "debug"); - -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_FORMAT: "json" }).logging.format, "json"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_FORMAT: "pretty" }).logging.format, "pretty"); - -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_REQUESTS: "0" }).logging.requests, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_ASSETS: "1" }).logging.assets, true); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_TOOL_CALLS: "0" }).logging.toolCalls, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_SHELL_COMMANDS: "1" }).logging.shellCommands, true); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TRUST_PROXY: "1" }).logging.trustProxy, true); - -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "trace" }), - /Invalid DEVSPACE_LOG_LEVEL: trace/, -); - -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_LOG_FORMAT: "color" }), - /Invalid DEVSPACE_LOG_FORMAT: color/, -); - -assert.equal(loadConfig(baseEnv).oauth.ownerToken, "test-owner-token-that-is-long-enough"); -assert.deepEqual(loadConfig(baseEnv).oauth.scopes, ["devspace"]); -assert.deepEqual(loadConfig(baseEnv).oauth.allowedRedirectHosts, [ - "chatgpt.com", - "localhost", - "127.0.0.1", -]); -assert.equal(loadConfig(baseEnv).oauth.accessTokenTtlSeconds, 3600); -assert.equal(loadConfig(baseEnv).oauth.refreshTokenTtlSeconds, 2592000); +try { + const defaults = loadConfig(env); + assert.equal(defaults.host, "127.0.0.1"); + assert.equal(defaults.port, 7676); + assert.equal(defaults.publicBaseUrl, "http://127.0.0.1:7676"); + assert.deepEqual(defaults.allowedRoots, [process.cwd()]); + assert.deepEqual(defaults.allowedHosts, ["localhost", "127.0.0.1", "::1"]); + assert.equal(defaults.toolMode, "codex"); + assert.equal(defaults.uiEnabled, true); + assert.equal(defaults.skillsEnabled, true); + assert.equal(defaults.artifactsEnabled, false); + assert.deepEqual(defaults.subagents, { enabled: false, providers: [] }); + assert.deepEqual(defaults.logging, { + level: "info", + format: "json", + requests: true, + assets: false, + toolCalls: true, + shellCommands: false, + trustProxy: false, + }); -assert.deepEqual( - loadConfig({ ...baseEnv, DEVSPACE_OAUTH_SCOPES: "devspace,admin" }).oauth.scopes, - ["devspace", "admin"], -); -assert.deepEqual( - loadConfig({ ...baseEnv, DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS: "chatgpt.com,example.com" }).oauth - .allowedRedirectHosts, - ["chatgpt.com", "example.com"], -); -assert.equal( - loadConfig({ ...baseEnv, DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: "120" }).oauth - .accessTokenTtlSeconds, - 120, -); -assert.equal( - loadConfig({ ...baseEnv, DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS: "240" }).oauth - .refreshTokenTtlSeconds, - 240, -); - -assert.throws( - () => loadConfig({ DEVSPACE_CONFIG_DIR: emptyConfigDir, DEVSPACE_ALLOWED_ROOTS: process.cwd() }), - /DEVSPACE_OAUTH_OWNER_TOKEN is required/, -); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_OAUTH_OWNER_TOKEN: "too-short" }), - /DEVSPACE_OAUTH_OWNER_TOKEN must be at least 16 characters long/, -); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: "0" }), - /Invalid DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: 0/, -); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_ARTIFACT_MAX_FILE_BYTES: "0" }), - /Invalid DEVSPACE_ARTIFACT_MAX_FILE_BYTES: 0/, -); + writeDevspaceConfig({ + configVersion: 1, + server: { + host: "0.0.0.0", + port: 8787, + publicBaseUrl: "https://devspace.example.com/", + allowedHosts: ["example.internal"], + trustProxy: true, + }, + workspaces: { + allowedRoots: ["~/work"], + worktreeRoot: "~/trees", + }, + storage: { stateDir: "~/state" }, + tools: { mode: "claude" }, + ui: { enabled: false }, + artifacts: { enabled: true, maxFileBytes: 321 }, + skills: { enabled: false, paths: ["~/skills"], agentDir: "~/agent" }, + subagents: { + enabled: true, + providers: [{ id: "codex", enabled: true }], + }, + logging: { + level: "debug", + format: "pretty", + requests: false, + assets: true, + toolCalls: false, + shellCommands: true, + }, + oauth: { + accessTokenTtlSeconds: 120, + refreshTokenTtlSeconds: 240, + scopes: ["devspace", "admin"], + allowedRedirectHosts: ["chatgpt.com", "example.com"], + }, + }, env); + writeDevspaceAuth({ ownerToken: "persisted-owner-token-long-enough" }, env); -assert.equal(loadConfig(baseEnv).publicBaseUrl, "http://127.0.0.1:7676"); -assert.deepEqual(loadConfig(baseEnv).allowedHosts, ["localhost", "127.0.0.1", "::1"]); + const configured = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); + assert.equal(configured.configDir, configDir); + assert.equal(configured.host, "0.0.0.0"); + assert.equal(configured.port, 8787); + assert.equal(configured.publicBaseUrl, "https://devspace.example.com"); + assert.deepEqual(configured.allowedRoots, [resolve("~/work".replace("~", process.env.HOME!))]); + assert.deepEqual(configured.allowedHosts, [ + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", + "devspace.example.com", + "example.internal", + ]); + assert.equal(configured.toolMode, "claude"); + assert.equal(configured.uiEnabled, false); + assert.equal(configured.stateDir, resolve(process.env.HOME!, "state")); + assert.equal(configured.worktreeRoot, resolve(process.env.HOME!, "trees")); + assert.equal(configured.artifactsEnabled, true); + assert.equal(configured.artifactMaxFileBytes, 321); + assert.equal(configured.skillsEnabled, false); + assert.deepEqual(configured.skillPaths, ["~/skills"]); + assert.equal(configured.agentDir, resolve(process.env.HOME!, "agent")); + assert.equal(configured.subagents.enabled, true); + assert.equal(configured.oauth.ownerToken, "persisted-owner-token-long-enough"); + assert.equal(configured.oauth.accessTokenTtlSeconds, 120); + assert.deepEqual(configured.oauth.scopes, ["devspace", "admin"]); + assert.deepEqual(configured.logging, { + level: "debug", + format: "pretty", + requests: false, + assets: true, + toolCalls: false, + shellCommands: true, + trustProxy: true, + }); -assert.equal( - loadConfig({ ...baseEnv, DEVSPACE_PUBLIC_BASE_URL: "https://abc.trycloudflare.com/" }).publicBaseUrl, - "https://abc.trycloudflare.com", -); -assert.deepEqual( - loadConfig({ ...baseEnv, DEVSPACE_PUBLIC_BASE_URL: "https://abc.trycloudflare.com/" }).allowedHosts, - ["localhost", "127.0.0.1", "::1", "abc.trycloudflare.com"], -); -assert.deepEqual( - loadConfig({ ...baseEnv, DEVSPACE_ALLOWED_HOSTS: "*" }).allowedHosts, - ["*"], -); + assert.equal(loadConfig(env).oauth.ownerToken, env.DEVSPACE_OAUTH_OWNER_TOKEN); +} finally { + rmSync(configDir, { recursive: true, force: true }); +} -const configDir = mkdtempSync(join(tmpdir(), "devspace-config-test-")); -writeFileSync( - join(configDir, "config.json"), - JSON.stringify({ - port: 8787, - allowedRoots: [process.cwd()], - publicBaseUrl: "https://devspace.example.com", - subagents: true, - artifactsEnabled: true, - artifactMaxFileBytes: 321, - tools: { mode: "claude" }, - ui: { enabled: false }, - }), -); -writeFileSync( - join(configDir, "auth.json"), - JSON.stringify({ - ownerToken: "persisted-owner-token-long-enough", - }), -); +const missingAuthDir = mkdtempSync(join(tmpdir(), "devspace-config-no-auth-test-")); +try { + assert.throws( + () => loadConfig({ DEVSPACE_CONFIG_DIR: missingAuthDir }), + /OAuth owner token is required/, + ); +} finally { + rmSync(missingAuthDir, { recursive: true, force: true }); +} -const fileConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); -assert.equal(fileConfig.port, 8787); -assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); -assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); -assert.equal(fileConfig.subagents.enabled, true); -assert.equal(fileConfig.subagents.providers.length, 7); -assert.equal(fileConfig.artifactsEnabled, true); -assert.equal(fileConfig.artifactMaxFileBytes, 321); -assert.equal(fileConfig.toolMode, "claude"); -assert.equal(fileConfig.uiEnabled, false); -assert.deepEqual(fileConfig.allowedHosts, [ - "localhost", - "127.0.0.1", - "::1", - "devspace.example.com", -]); +console.log("config tests passed"); diff --git a/src/config.ts b/src/config.ts index e4236557..e5330526 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,17 +1,15 @@ -import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { resolve } from "node:path"; +import type { ToolMode } from "./config-schema.js"; import { expandHomePath } from "./roots.js"; -import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js"; +import type { LoggingConfig } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; -import { resolveSubagentsConfig, type SubagentsConfig } from "./local-agent-config.js"; +import type { SubagentsConfig } from "./local-agent-config.js"; -export type ToolMode = "claude" | "codex"; -const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; -const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; -const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024; +export type { ToolMode } from "./config-schema.js"; export interface ServerConfig { + configDir: string; host: string; port: number; oauth: OAuthConfig; @@ -33,168 +31,13 @@ export interface ServerConfig { logging: LoggingConfig; } -function parsePort(value: string | number | undefined): number { - if (value === undefined || value === "") return 7676; - - const port = Number(value); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error(`Invalid PORT: ${value}`); - } - - return port; -} - -function parseAllowedRoots(value: string | string[] | undefined): string[] { - if (Array.isArray(value)) { - const roots = value.map((entry) => entry.trim()).filter(Boolean); - return (roots.length > 0 ? roots : [process.cwd()]).map((root) => resolve(expandHomePath(root))); - } - - const rawRoots = - value - ?.split(",") - .map((entry) => entry.trim()) - .filter(Boolean) ?? []; - - const roots = rawRoots.length > 0 ? rawRoots : [process.cwd()]; - return roots.map((root) => resolve(expandHomePath(root))); -} - -function parseAllowedHosts(value: string | string[] | undefined, derivedHosts: string[]): string[] { - if (Array.isArray(value)) { - return normalizeAllowedHosts(value, derivedHosts); - } - - const rawHosts = - value - ?.split(",") - .map((entry) => entry.trim()) - .filter(Boolean) ?? []; - - return normalizeAllowedHosts(rawHosts, derivedHosts); -} - -function normalizeAllowedHosts(rawHosts: string[], derivedHosts: string[]): string[] { - const hosts = rawHosts.length > 0 ? rawHosts : derivedHosts; - if (hosts.includes("*")) return ["*"]; - return Array.from(new Set(hosts.map((host) => host.trim()).filter(Boolean))); -} - -function parseBoolean(value: string | undefined): boolean { - return ["1", "true", "yes", "on"].includes(value?.toLowerCase() ?? ""); -} - -function parseLogLevel(value: string | undefined): LogLevel { - if (!value || value === "info") return "info"; - if (["silent", "error", "warn", "debug"].includes(value)) return value as LogLevel; - - throw new Error(`Invalid DEVSPACE_LOG_LEVEL: ${value}`); -} - -function parseLogFormat(value: string | undefined): LogFormat { - if (!value || value === "json") return "json"; - if (value === "pretty") return "pretty"; - - throw new Error(`Invalid DEVSPACE_LOG_FORMAT: ${value}`); -} - -function parsePathList(value: string | undefined): string[] { - return ( - value - ?.split(",") - .map((entry) => entry.trim()) - .filter(Boolean) ?? [] - ); -} - -function parseStringList(value: string | undefined, fallback: string[]): string[] { - const entries = value - ?.split(",") - .map((entry) => entry.trim()) - .filter(Boolean); - - return entries && entries.length > 0 ? entries : fallback; -} - -function parsePositiveInteger( - value: string | undefined, - fallback: number, - name: string, - max = Number.MAX_SAFE_INTEGER, -): number { - if (!value) return fallback; - - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) { - throw new Error(`Invalid ${name}: ${value}`); - } - - return parsed; -} - -function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { - return { - level: parseLogLevel(env.DEVSPACE_LOG_LEVEL), - format: parseLogFormat(env.DEVSPACE_LOG_FORMAT), - requests: env.DEVSPACE_LOG_REQUESTS === undefined ? true : parseBoolean(env.DEVSPACE_LOG_REQUESTS), - assets: parseBoolean(env.DEVSPACE_LOG_ASSETS), - toolCalls: env.DEVSPACE_LOG_TOOL_CALLS === undefined ? true : parseBoolean(env.DEVSPACE_LOG_TOOL_CALLS), - shellCommands: parseBoolean(env.DEVSPACE_LOG_SHELL_COMMANDS), - trustProxy: parseBoolean(env.DEVSPACE_TRUST_PROXY), - }; -} - -function parseRequiredSecret(value: string | undefined, name: string): string { - const secret = value?.trim(); - if (!secret) { - throw new Error(`${name} is required for DevSpace OAuth. Run: devspace init`); - } - if (secret.length < 16) { - throw new Error(`${name} must be at least 16 characters long.`); - } - return secret; -} - -function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined): OAuthConfig { - return { - ownerToken: parseRequiredSecret(env.DEVSPACE_OAUTH_OWNER_TOKEN ?? ownerToken, "DEVSPACE_OAUTH_OWNER_TOKEN"), - accessTokenTtlSeconds: parsePositiveInteger( - env.DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS, - DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS, - "DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS", - ), - refreshTokenTtlSeconds: parsePositiveInteger( - env.DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS, - DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS, - "DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS", - ), - scopes: parseStringList(env.DEVSPACE_OAUTH_SCOPES, ["devspace"]), - allowedRedirectHosts: parseStringList(env.DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS, [ - "chatgpt.com", - "localhost", - "127.0.0.1", - ]), - }; -} - -function defaultStateDir(): string { - return join(homedir(), ".local", "share", "devspace"); -} - -function defaultWorktreeRoot(): string { - return join(homedir(), ".devspace", "worktrees"); -} - -function defaultAgentDir(): string { - return join(homedir(), ".codex"); -} - export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { const files = loadDevspaceFiles(env); - const host = env.HOST ?? files.config.host ?? "127.0.0.1"; - const port = parsePort(env.PORT ?? files.config.port); + const stored = files.config; + const host = stored.server.host; + const port = stored.server.port; const publicBaseUrl = parsePublicBaseUrl( - env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port), + stored.server.publicBaseUrl ?? localPublicBaseUrl(host, port), ); const derivedAllowedHosts = [ "localhost", @@ -202,41 +45,66 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { "::1", host, new URL(publicBaseUrl).hostname, - ...(files.config.allowedHosts ?? []), + ...stored.server.allowedHosts, ]; return { + configDir: files.dir, host, port, - oauth: parseOAuthConfig(env, files.auth.ownerToken), - allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), - allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), + oauth: { + ownerToken: parseRequiredSecret( + env.DEVSPACE_OAUTH_OWNER_TOKEN ?? files.auth.ownerToken, + ), + accessTokenTtlSeconds: stored.oauth.accessTokenTtlSeconds, + refreshTokenTtlSeconds: stored.oauth.refreshTokenTtlSeconds, + scopes: stored.oauth.scopes, + allowedRedirectHosts: stored.oauth.allowedRedirectHosts, + }, + allowedRoots: normalizePaths(stored.workspaces.allowedRoots, [process.cwd()]), + allowedHosts: normalizeAllowedHosts(derivedAllowedHosts), publicBaseUrl, - toolMode: files.config.tools?.mode ?? "codex", - uiEnabled: files.config.ui?.enabled ?? true, - stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), - worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), - artifactsEnabled: - env.DEVSPACE_ARTIFACTS === undefined - ? files.config.artifactsEnabled === true - : parseBoolean(env.DEVSPACE_ARTIFACTS), - artifactMaxFileBytes: parsePositiveInteger( - env.DEVSPACE_ARTIFACT_MAX_FILE_BYTES ?? numberConfigValue(files.config.artifactMaxFileBytes), - DEFAULT_ARTIFACT_MAX_FILE_BYTES, - "DEVSPACE_ARTIFACT_MAX_FILE_BYTES", - ), - skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), - skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), + toolMode: stored.tools.mode, + uiEnabled: stored.ui.enabled, + stateDir: normalizePath(stored.storage.stateDir), + worktreeRoot: normalizePath(stored.workspaces.worktreeRoot), + artifactsEnabled: stored.artifacts.enabled, + artifactMaxFileBytes: stored.artifacts.maxFileBytes, + skillsEnabled: stored.skills.enabled, + skillPaths: stored.skills.paths, devspaceSkillsDir: devspaceSkillsDir(env), devspaceAgentsDir: devspaceAgentsDir(env), - subagents: resolveSubagentsConfig(files.config.subagents, env), - agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), - logging: parseLoggingConfig(env), + subagents: stored.subagents, + agentDir: normalizePath(stored.skills.agentDir), + logging: { + ...stored.logging, + trustProxy: stored.server.trustProxy, + }, }; } -function numberConfigValue(value: number | undefined): string | undefined { - return value === undefined ? undefined : String(value); +function normalizePaths(paths: string[], fallback: string[] = []): string[] { + return (paths.length > 0 ? paths : fallback).map(normalizePath); +} + +function normalizePath(path: string): string { + return resolve(expandHomePath(path)); +} + +function normalizeAllowedHosts(hosts: string[]): string[] { + if (hosts.includes("*")) return ["*"]; + return Array.from(new Set(hosts.map((host) => host.trim()).filter(Boolean))); +} + +function parseRequiredSecret(value: string | undefined): string { + const secret = value?.trim(); + if (!secret) { + throw new Error("OAuth owner token is required. Run: devspace init"); + } + if (secret.length < 16) { + throw new Error("OAuth owner token must be at least 16 characters long."); + } + return secret; } function parsePublicBaseUrl(value: string): string { diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index eb47ebaf..01f8c1cd 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -49,6 +49,7 @@ import type { StartLocalAgentInput, } from "./local-agent-manager.js"; import type { LocalAgentRecord, LocalAgentWorkspaceScope } from "./local-agent-store.js"; +import { devspaceConfigDir } from "./user-config.js"; const DEFAULT_STARTUP_TIMEOUT_MS = 8_000; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; @@ -63,6 +64,7 @@ type RequestError = export interface LocalAgentClientOptions { stateDir: string; + configDir?: string; startupTimeoutMs?: number; requestTimeoutMs?: number; spawnDaemon?: () => void; @@ -84,7 +86,9 @@ export class LocalAgentClient { this.endpoint = options.endpoint ?? this.paths.endpoint; this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; - this.spawnDaemon = options.spawnDaemon ?? (() => spawnLocalAgentDaemon(options.stateDir)); + this.spawnDaemon = options.spawnDaemon ?? (() => spawnLocalAgentDaemon( + options.configDir ?? devspaceConfigDir(), + )); } async run( @@ -404,21 +408,33 @@ export class LocalAgentClient { } } -export function createLocalAgentClient(config: Pick): LocalAgentClient { - return new LocalAgentClient({ stateDir: config.stateDir }); +export function createLocalAgentClient( + config: Pick, +): LocalAgentClient { + return new LocalAgentClient({ configDir: config.configDir, stateDir: config.stateDir }); } -export function spawnLocalAgentDaemon(stateDir: string, env: NodeJS.ProcessEnv = process.env): void { +export function spawnLocalAgentDaemon( + configDir: string, + env: NodeJS.ProcessEnv = process.env, +): void { const entrypoint = resolveDaemonEntrypoint(); const child = spawn(process.execPath, [...daemonExecArgv(process.execArgv), entrypoint], { detached: true, stdio: "ignore", windowsHide: true, - env: { ...env, DEVSPACE_STATE_DIR: stateDir }, + env: localAgentDaemonEnvironment(configDir, env), }); child.unref(); } +export function localAgentDaemonEnvironment( + configDir: string, + env: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + return { ...env, DEVSPACE_CONFIG_DIR: configDir }; +} + export function daemonExecArgv(execArgv: readonly string[]): string[] { const result: string[] = []; for (let index = 0; index < execArgv.length; index += 1) { diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 713fed65..1751d063 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -11,7 +11,7 @@ const config = resolveSubagentsConfig({ { id: "codex", enabled: true, model: " gpt-5.4 ", effort: " high " }, { id: "claude", enabled: false, model: "sonnet" }, ], -}, {}); +}); assert.deepEqual(config, { enabled: true, providers: [ @@ -24,31 +24,26 @@ assert.equal(isSubagentProviderEnabled(config, "claude"), false); assert.equal(isSubagentProviderEnabled(config, "pi"), false); assert.equal(subagentProviderConfig(config, "codex")?.model, "gpt-5.4"); -assert.equal(resolveSubagentsConfig(config, { DEVSPACE_SUBAGENTS: "0" }).enabled, false); -assert.equal(resolveSubagentsConfig({ ...config, enabled: false }, { - DEVSPACE_SUBAGENTS: "1", -}).enabled, true); -assert.equal(resolveSubagentsConfig(undefined, {}).providers.length, 0); -assert.equal(resolveSubagentsConfig(true, {}).providers.length, 7); +assert.equal(resolveSubagentsConfig(undefined).providers.length, 0); assert.throws( () => resolveSubagentsConfig({ enabled: true, providers: [{ id: "codex", enabled: true }, { id: "codex", enabled: false }], - }, {}), + }), /Duplicate subagent provider: codex/, ); assert.throws( () => resolveSubagentsConfig({ enabled: true, providers: [{ id: "unknown", enabled: true }], - }, {}), + }), /Invalid option/, ); assert.throws( () => resolveSubagentsConfig({ enabled: true, providers: [{ id: "codex", enabled: true, effort: " " }], - }, {}), + }), /Too small/, ); diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 538355d9..e008788c 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -39,19 +39,10 @@ export type StoredSubagentsConfig = z.infer; export function resolveSubagentsConfig( value: unknown, - env: NodeJS.ProcessEnv = process.env, ): SubagentsConfig { - const stored = value === undefined + return value === undefined ? { enabled: false, providers: [] } - : typeof value === "boolean" - ? legacySubagentsConfig(value) - : subagentsConfigSchema.parse(value); - return { - ...stored, - enabled: env.DEVSPACE_SUBAGENTS === undefined - ? stored.enabled - : parseBoolean(env.DEVSPACE_SUBAGENTS), - }; + : subagentsConfigSchema.parse(value); } export function subagentProviderConfig( @@ -67,16 +58,3 @@ export function isSubagentProviderEnabled( ): boolean { return config.enabled && subagentProviderConfig(config, provider)?.enabled === true; } - -function legacySubagentsConfig(enabled: boolean): SubagentsConfig { - return { - enabled, - providers: enabled - ? LOCAL_AGENT_PROVIDERS.map((id) => ({ id, enabled: true })) - : [], - }; -} - -function parseBoolean(value: string): boolean { - return ["1", "true", "yes", "on"].includes(value.toLowerCase()); -} diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 3b5abef4..6ea66e65 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -5,7 +5,11 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { createConnection, createServer as createNetServer } from "node:net"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { daemonExecArgv, LocalAgentClient } from "./local-agent-client.js"; +import { + daemonExecArgv, + localAgentDaemonEnvironment, + LocalAgentClient, +} from "./local-agent-client.js"; import { LocalAgentDaemon, type LocalAgentDaemonManager } from "./local-agent-daemon.js"; import { ensureLocalAgentDaemonSecret, @@ -112,6 +116,12 @@ assert.deepEqual( "detached daemon startup must not inherit inspector flags", ); +assert.deepEqual( + localAgentDaemonEnvironment("/alternate/config", { PATH: "/bin" }), + { PATH: "/bin", DEVSPACE_CONFIG_DIR: "/alternate/config" }, + "the daemon must reload the same persisted configuration as its client", +); + let shutdownSocket: ReturnType | undefined; try { const started = unwrap(await client.run({ diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index 6868e140..d3c5705f 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; import { loadLocalAgentProfiles, summarizeLocalAgentProfile } from "./local-agent-profiles.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; const root = await mkdtemp(join(tmpdir(), "devspace-agent-profiles-test-")); @@ -57,12 +58,10 @@ try { ].join("\n"), ); - const enabledConfig = loadConfig({ - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: workspaceRoot, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - }); + const enabledConfig = loadConfig(writeTestDevspaceConfig(configDir, { + workspaces: { allowedRoots: [workspaceRoot] }, + subagents: { enabled: true, providers: [] }, + })); const profiles = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); assert.equal(profiles.length, 1); @@ -96,12 +95,10 @@ try { const profilesWithInvalid = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); assert.deepEqual(profilesWithInvalid.map((profile) => profile.name), ["reviewer"]); - const disabledConfig = loadConfig({ - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: workspaceRoot, - DEVSPACE_SUBAGENTS: "0", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - }); + const disabledConfig = loadConfig(writeTestDevspaceConfig(configDir, { + workspaces: { allowedRoots: [workspaceRoot] }, + subagents: { enabled: false, providers: [] }, + })); assert.deepEqual(await loadLocalAgentProfiles(disabledConfig, workspaceRoot), []); } finally { await rm(root, { recursive: true, force: true }); diff --git a/src/server.test.ts b/src/server.test.ts index 4f1215c1..b7d8d351 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -16,6 +16,7 @@ import { ProcessSessionManager } from "./process-sessions.js"; import { createMcpServer } from "./server.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; const execFileAsync = promisify(execFile); @@ -373,15 +374,12 @@ async function fixture( const initialProviderAvailability = typeof options.localAgentProviders === "function" ? options.localAgentProviders() : options.localAgentProviders ?? []; - const loadedConfig = loadConfig({ - DEVSPACE_CONFIG_DIR: join(root, ".config"), - DEVSPACE_ALLOWED_ROOTS: root, - DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SUBAGENTS: options.localAgentProviders ? "1" : "0", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const loadedConfig = loadConfig(writeTestDevspaceConfig(join(root, ".config"), { + server: { port: 1 }, + workspaces: { allowedRoots: [root], worktreeRoot: join(root, ".worktrees") }, + skills: { agentDir }, + subagents: { enabled: options.localAgentProviders !== undefined, providers: [] }, + })); const modeConfig: ServerConfig = { ...loadedConfig, toolMode: options.toolMode ?? loadedConfig.toolMode, diff --git a/src/skills.test.ts b/src/skills.test.ts index 9db16a10..41556b3a 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -9,6 +9,7 @@ import { loadWorkspaceSkills, resolveSkillReadPath, } from "./skills.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; const root = await mkdtemp(join(tmpdir(), "devspace-skills-test-")); const originalHome = process.env.HOME; @@ -160,23 +161,22 @@ try { ].join("\n"), ); - const disabledConfig = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SKILL_PATHS: explicitSkills, - DEVSPACE_SKILLS: "0", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const configDir = join(root, ".devspace"); + const disabledConfig = loadConfig(writeTestDevspaceConfig(configDir, { + server: { port: 1 }, + workspaces: { allowedRoots: [projectRoot] }, + skills: { agentDir, paths: [explicitSkills], enabled: false }, + })); assert.deepEqual(loadWorkspaceSkills(disabledConfig, projectRoot).skills, []); - const config = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SKILL_PATHS: [explicitSkills, "~/.claude/skills", "./.claude/skills"].join(","), - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const config = loadConfig(writeTestDevspaceConfig(configDir, { + server: { port: 1 }, + workspaces: { allowedRoots: [projectRoot] }, + skills: { + agentDir, + paths: [explicitSkills, "~/.claude/skills", "./.claude/skills"], + }, + })); const loaded = loadWorkspaceSkills(config, projectRoot); assert.equal(loaded.skills.some((skill) => skill.name === "agent-global-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "agent-project-skill"), true); @@ -195,13 +195,12 @@ try { false, ); - const experimentalConfig = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const experimentalConfig = loadConfig(writeTestDevspaceConfig(configDir, { + server: { port: 1 }, + workspaces: { allowedRoots: [projectRoot] }, + skills: { agentDir }, + subagents: { enabled: true, providers: [] }, + })); assert.equal( loadWorkspaceSkills(experimentalConfig, projectRoot).skills.some( (skill) => skill.name === "subagents", @@ -209,25 +208,21 @@ try { true, ); - const duplicateConfig = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SKILL_PATHS: [explicitSkills, "./.agents/skills"].join(","), - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const duplicateConfig = loadConfig(writeTestDevspaceConfig(configDir, { + server: { port: 1 }, + workspaces: { allowedRoots: [projectRoot] }, + skills: { agentDir, paths: [explicitSkills, "./.agents/skills"] }, + })); assert.equal( effectiveSkillPaths(duplicateConfig, projectRoot).filter((path) => path === projectAgentsSkills).length, 1, ); - const legacyPiConfig = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SKILL_PATHS: [explicitSkills, join(projectRoot, ".pi", "skills")].join(","), - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const legacyPiConfig = loadConfig(writeTestDevspaceConfig(configDir, { + server: { port: 1 }, + workspaces: { allowedRoots: [projectRoot] }, + skills: { agentDir, paths: [explicitSkills, join(projectRoot, ".pi", "skills")] }, + })); assert.equal( loadWorkspaceSkills(legacyPiConfig, projectRoot).skills.some((skill) => skill.name === "project-skill"), true, diff --git a/src/test-support/config.test.ts b/src/test-support/config.test.ts new file mode 100644 index 00000000..2c97bc4e --- /dev/null +++ b/src/test-support/config.test.ts @@ -0,0 +1,43 @@ +import { + defaultDevspaceConfig, + type DevspaceConfig, +} from "../config-schema.js"; +import { writeDevspaceConfig } from "../user-config.js"; + +type SectionOverrides = { + server?: Partial; + workspaces?: Partial; + storage?: Partial; + tools?: Partial; + ui?: Partial; + artifacts?: Partial; + skills?: Partial; + subagents?: DevspaceConfig["subagents"]; + logging?: Partial; + oauth?: Partial; +}; + +export function writeTestDevspaceConfig( + configDir: string, + overrides: SectionOverrides = {}, +): NodeJS.ProcessEnv { + const defaults = defaultDevspaceConfig(); + const env = { DEVSPACE_CONFIG_DIR: configDir }; + writeDevspaceConfig({ + ...defaults, + server: { ...defaults.server, ...overrides.server }, + workspaces: { ...defaults.workspaces, ...overrides.workspaces }, + storage: { ...defaults.storage, ...overrides.storage }, + tools: { ...defaults.tools, ...overrides.tools }, + ui: { ...defaults.ui, ...overrides.ui }, + artifacts: { ...defaults.artifacts, ...overrides.artifacts }, + skills: { ...defaults.skills, ...overrides.skills }, + subagents: overrides.subagents ?? defaults.subagents, + logging: { ...defaults.logging, ...overrides.logging }, + oauth: { ...defaults.oauth, ...overrides.oauth }, + }, env); + return { + ...env, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }; +} diff --git a/src/user-config.test.ts b/src/user-config.test.ts index f13ca46f..8e09b912 100644 --- a/src/user-config.test.ts +++ b/src/user-config.test.ts @@ -1,41 +1,195 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { loadDevspaceFiles } from "./user-config.js"; +import { + loadDevspaceFiles, + setDevspaceConfigValue, + setDevspaceConfigValues, +} from "./user-config.js"; -const configDir = mkdtempSync(join(tmpdir(), "devspace-user-config-test-")); -const env = { DEVSPACE_CONFIG_DIR: configDir }; - -try { +withConfigDir((configDir, env) => { writeFileSync(join(configDir, "config.json"), JSON.stringify({ + host: "0.0.0.0", port: 8787, - subagents: { - enabled: true, - providers: [{ id: "codex", enabled: true }], - }, + allowedRoots: ["/work"], + publicBaseUrl: "https://devspace.example.com", + artifactsEnabled: true, + subagents: true, })); writeFileSync(join(configDir, "auth.json"), JSON.stringify({ ownerToken: "test-owner-token", })); - assert.deepEqual(loadDevspaceFiles(env).config, { + const files = loadDevspaceFiles(env); + assert.equal(files.migratedLegacyConfig, true); + assert.equal(files.config.server.host, "0.0.0.0"); + assert.equal(files.config.server.port, 8787); + assert.deepEqual(files.config.workspaces.allowedRoots, ["/work"]); + assert.equal(files.config.artifacts.enabled, true); + assert.equal(files.config.subagents.enabled, true); + assert.equal(files.config.tools.mode, "codex"); + assert.equal(files.config.ui.enabled, true); + assert.equal(files.auth.ownerToken, "test-owner-token"); + assert.equal(existsSync(join(configDir, "config.json")), false); + assert.equal(existsSync(join(configDir, "config.jsonc")), true); + assert.equal(existsSync(join(configDir, "config.json.v1.0.bak")), true); + + const nextLoad = loadDevspaceFiles(env); + assert.equal(nextLoad.migratedLegacyConfig, false); +}); + +await withConfigDirAsync(async (configDir) => { + writeFileSync(join(configDir, "config.json"), JSON.stringify({ port: 8787, - subagents: { - enabled: true, - providers: [{ id: "codex", enabled: true }], + allowedRoots: ["/work"], + })); + + const results = await Promise.all([ + migrateInChildProcess(configDir), + migrateInChildProcess(configDir), + ]); + assert.equal(results.filter((result) => result.migrated).length, 1); + assert.equal(results.filter((result) => !result.migrated).length, 1); + assert.equal(existsSync(join(configDir, "config.json")), false); + assert.equal(existsSync(join(configDir, "config.jsonc")), true); + assert.equal(existsSync(join(configDir, "config.json.v1.0.bak")), true); + assert.equal(loadDevspaceFiles({ DEVSPACE_CONFIG_DIR: configDir }).config.server.port, 8787); +}); + +withConfigDir((configDir, env) => { + writeFileSync(join(configDir, "config.jsonc"), `{ + // This comment must survive config updates. + "configVersion": 1, + "server": { + "port": 8787, }, - }); - assert.equal(loadDevspaceFiles(env).auth.ownerToken, "test-owner-token"); + }\n`); - writeFileSync(join(configDir, "config.json"), JSON.stringify({ port: "8787" })); - assert.throws(() => loadDevspaceFiles(env), /expected number/i); + const files = loadDevspaceFiles(env); + assert.equal(files.config.server.port, 8787); + assert.equal(files.config.tools.mode, "codex"); - writeFileSync(join(configDir, "config.json"), JSON.stringify({ unknownSetting: true })); - assert.equal(loadDevspaceFiles(env).config.unknownSetting, true); + setDevspaceConfigValue(["server", "publicBaseUrl"], "https://new.example.com", env); + const updated = readFileSync(join(configDir, "config.jsonc"), "utf8"); + assert.match(updated, /This comment must survive config updates/); + assert.equal(loadDevspaceFiles(env).config.server.publicBaseUrl, "https://new.example.com"); + + setDevspaceConfigValues([ + { path: ["server", "port"], value: 7676 }, + { path: ["tools", "mode"], value: "claude" }, + ], env); + const multiUpdated = readFileSync(join(configDir, "config.jsonc"), "utf8"); + assert.match(multiUpdated, /This comment must survive config updates/); + assert.equal(loadDevspaceFiles(env).config.server.port, 7676); + assert.equal(loadDevspaceFiles(env).config.tools.mode, "claude"); +}); +withConfigDir((configDir, env) => { + writeFileSync(join(configDir, "config.jsonc"), JSON.stringify({ configVersion: 1 })); writeFileSync(join(configDir, "config.json"), "{"); - assert.throws(() => loadDevspaceFiles(env), /Unable to read .*config\.json/); -} finally { - rmSync(configDir, { recursive: true, force: true }); + assert.equal(loadDevspaceFiles(env).config.server.port, 7676); + assert.equal(existsSync(join(configDir, "config.json")), true); +}); + +withConfigDir((configDir, env) => { + writeFileSync(join(configDir, "config.jsonc"), "{"); + writeFileSync(join(configDir, "config.json"), JSON.stringify({ port: 8787 })); + assert.throws(() => loadDevspaceFiles(env), /Unable to read .*config\.jsonc/); + assert.equal(existsSync(join(configDir, "config.json")), true); +}); + +withConfigDir((configDir, env) => { + writeFileSync(join(configDir, "config.json"), JSON.stringify({ unknownSetting: true })); + assert.throws( + () => loadDevspaceFiles(env), + /Unsupported legacy configuration keys: unknownSetting/, + ); + assert.equal(existsSync(join(configDir, "config.json")), true); + assert.equal(existsSync(join(configDir, "config.jsonc")), false); + assert.equal(existsSync(join(configDir, "config.json.v1.0.bak")), false); +}); + +withConfigDir((configDir, env) => { + const legacyPath = join(configDir, "config.json"); + const backupPath = join(configDir, "config.json.v1.0.bak"); + writeFileSync(legacyPath, JSON.stringify({ port: 8787 })); + writeFileSync(backupPath, JSON.stringify({ port: 7676 })); + + assert.throws( + () => loadDevspaceFiles(env), + (error: unknown) => error instanceof Error + && error.message.includes(`backup already exists at ${backupPath}`) + && error.message.includes(`Move ${backupPath} out of the way, then run DevSpace again.`), + ); +}); + +console.log("user config tests passed"); + +function withConfigDir( + test: (configDir: string, env: NodeJS.ProcessEnv) => void, +): void { + const configDir = mkdtempSync(join(tmpdir(), "devspace-user-config-test-")); + const env = { DEVSPACE_CONFIG_DIR: configDir }; + try { + test(configDir, env); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } +} + +async function withConfigDirAsync( + test: (configDir: string) => Promise, +): Promise { + const configDir = mkdtempSync(join(tmpdir(), "devspace-user-config-test-")); + try { + await test(configDir); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } +} + +async function migrateInChildProcess( + configDir: string, +): Promise<{ migrated: boolean }> { + const moduleUrl = new URL("./user-config.ts", import.meta.url).href; + const source = [ + `import { loadDevspaceFiles } from ${JSON.stringify(moduleUrl)};`, + "const files = loadDevspaceFiles();", + "process.stdout.write(JSON.stringify({ migrated: files.migratedLegacyConfig }));", + ].join("\n"); + + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", source], + { + env: { ...process.env, DEVSPACE_CONFIG_DIR: configDir }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (code) => { + if (code !== 0) { + reject(new Error(`migration child exited with ${code}: ${stderr}`)); + return; + } + resolve(JSON.parse(stdout) as { migrated: boolean }); + }); + }); } diff --git a/src/user-config.ts b/src/user-config.ts index 00535be2..6775e4b7 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -1,41 +1,37 @@ import { randomBytes } from "node:crypto"; import { existsSync, + linkSync, mkdirSync, readFileSync, + renameSync, + rmSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; +import { + applyEdits, + modify, + parse, + printParseErrorCode, + type ParseError, +} from "jsonc-parser"; import * as z from "zod/v4"; +import { + defaultDevspaceConfig, + devspaceConfigSchema, + type DevspaceConfig, + type DevspaceConfigInput, +} from "./config-schema.js"; +import { migrateLegacyConfig } from "./config-migration.js"; import { expandHomePath } from "./roots.js"; -import { storedSubagentsConfigSchema } from "./local-agent-config.js"; - -const devspaceUserConfigSchema = z.object({ - host: z.string().optional(), - port: z.number().optional(), - allowedRoots: z.array(z.string()).optional(), - publicBaseUrl: z.string().nullable().optional(), - allowedHosts: z.array(z.string()).optional(), - stateDir: z.string().optional(), - worktreeRoot: z.string().optional(), - artifactsEnabled: z.boolean().optional(), - artifactMaxFileBytes: z.number().optional(), - agentDir: z.string().optional(), - subagents: storedSubagentsConfigSchema.optional(), - tools: z.object({ - mode: z.enum(["claude", "codex"]).optional(), - }).strict().optional(), - ui: z.object({ - enabled: z.boolean().optional(), - }).strict().optional(), -}).passthrough(); const devspaceAuthConfigSchema = z.object({ ownerToken: z.string().optional(), }).passthrough(); -export type DevspaceUserConfig = z.infer; +export type DevspaceUserConfig = DevspaceConfig; export type DevspaceAuthConfig = z.infer; export interface DevspaceFiles { @@ -44,8 +40,14 @@ export interface DevspaceFiles { authPath: string; configExists: boolean; authExists: boolean; - config: DevspaceUserConfig; + config: DevspaceConfig; auth: DevspaceAuthConfig; + migratedLegacyConfig: boolean; +} + +export interface DevspaceConfigEdit { + path: (string | number)[]; + value: unknown; } export function devspaceConfigDir(env: NodeJS.ProcessEnv = process.env): string { @@ -53,9 +55,17 @@ export function devspaceConfigDir(env: NodeJS.ProcessEnv = process.env): string } export function devspaceConfigPath(env: NodeJS.ProcessEnv = process.env): string { + return join(devspaceConfigDir(env), "config.jsonc"); +} + +export function devspaceLegacyConfigPath(env: NodeJS.ProcessEnv = process.env): string { return join(devspaceConfigDir(env), "config.json"); } +export function devspaceLegacyConfigBackupPath(env: NodeJS.ProcessEnv = process.env): string { + return join(devspaceConfigDir(env), "config.json.v1.0.bak"); +} + export function devspaceAuthPath(env: NodeJS.ProcessEnv = process.env): string { return join(devspaceConfigDir(env), "auth.json"); } @@ -70,8 +80,12 @@ export function devspaceAgentsDir(env: NodeJS.ProcessEnv = process.env): string export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): DevspaceFiles { const dir = devspaceConfigDir(env); - const configPath = join(dir, "config.json"); - const authPath = join(dir, "auth.json"); + const configPath = devspaceConfigPath(env); + const legacyConfigPath = devspaceLegacyConfigPath(env); + const authPath = devspaceAuthPath(env); + const migratedLegacyConfig = !existsSync(configPath) && existsSync(legacyConfigPath) + ? migrateLegacyConfigFile(legacyConfigPath, configPath, devspaceLegacyConfigBackupPath(env)) + : false; const configExists = existsSync(configPath); const authExists = existsSync(authPath); @@ -81,28 +95,56 @@ export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): Devspac authPath, configExists, authExists, - config: configExists ? readJsonFile(configPath, devspaceUserConfigSchema) : {}, + config: configExists ? readJsoncConfig(configPath) : defaultDevspaceConfig(), auth: authExists ? readJsonFile(authPath, devspaceAuthConfigSchema) : {}, + migratedLegacyConfig, }; } export function writeDevspaceConfig( - config: DevspaceUserConfig, + config: DevspaceConfigInput, env: NodeJS.ProcessEnv = process.env, ): string { const filePath = devspaceConfigPath(env); - mkdirSync(devspaceConfigDir(env), { recursive: true }); - writeJsonFile(filePath, config, 0o600); + const parsed = devspaceConfigSchema.parse(config); + atomicWrite(filePath, serializeConfig(parsed), 0o600); return filePath; } +export function setDevspaceConfigValue( + path: (string | number)[], + value: unknown, + env: NodeJS.ProcessEnv = process.env, +): string { + return setDevspaceConfigValues([{ path, value }], env); +} + +export function setDevspaceConfigValues( + edits: DevspaceConfigEdit[], + env: NodeJS.ProcessEnv = process.env, +): string { + const files = loadDevspaceFiles(env); + const source = files.configExists + ? readFileSync(files.configPath, "utf8") + : serializeConfig(files.config); + const updated = edits.reduce( + (document, edit) => applyEdits(document, modify(document, edit.path, edit.value, { + formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, + })), + source, + ); + parseJsoncConfig(updated, files.configPath); + atomicWrite(files.configPath, updated.endsWith("\n") ? updated : `${updated}\n`, 0o600); + return files.configPath; +} + export function writeDevspaceAuth( auth: DevspaceAuthConfig, env: NodeJS.ProcessEnv = process.env, ): string { const filePath = devspaceAuthPath(env); mkdirSync(devspaceConfigDir(env), { recursive: true }); - writeJsonFile(filePath, auth, 0o600); + writeJsonFile(filePath, devspaceAuthConfigSchema.parse(auth), 0o600); return filePath; } @@ -110,15 +152,120 @@ export function generateOwnerToken(): string { return randomBytes(32).toString("base64url"); } +function migrateLegacyConfigFile( + legacyPath: string, + configPath: string, + backupPath: string, +): boolean { + if (existsSync(backupPath)) { + throw new Error( + `Unable to migrate ${legacyPath}: backup already exists at ${backupPath}. ` + + `Move ${backupPath} out of the way, then run DevSpace again.`, + ); + } + + let migrated: DevspaceConfig; + try { + migrated = migrateLegacyConfig(JSON.parse(readFileSync(legacyPath, "utf8")) as unknown); + } catch (error) { + throw fileError("migrate", legacyPath, error); + } + + const temporaryPath = temporaryFilePath(configPath); + let published = false; + try { + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(temporaryPath, serializeConfig(migrated), { mode: 0o600, flag: "wx" }); + readJsoncConfig(temporaryPath); + try { + // A hard link publishes the complete temporary file atomically without + // replacing config.jsonc if another first-start process won the race. + linkSync(temporaryPath, configPath); + } catch (error) { + if (!isErrnoException(error) || error.code !== "EEXIST") throw error; + readJsoncConfig(configPath); + return false; + } + published = true; + renameSync(legacyPath, backupPath); + } catch (error) { + if (published && existsSync(legacyPath)) { + rmSync(configPath, { force: true }); + } + throw fileError("migrate", legacyPath, error); + } finally { + rmSync(temporaryPath, { force: true }); + } + return true; +} + +function readJsoncConfig(filePath: string): DevspaceConfig { + try { + return parseJsoncConfig(readFileSync(filePath, "utf8"), filePath); + } catch (error) { + if (error instanceof DevspaceConfigFileError) throw error; + throw fileError("read", filePath, error); + } +} + +function parseJsoncConfig(source: string, filePath: string): DevspaceConfig { + const errors: ParseError[] = []; + const value = parse(source, errors, { allowTrailingComma: true }); + if (errors.length > 0) { + const first = errors[0]!; + throw new DevspaceConfigFileError( + `Unable to read ${filePath}: ${printParseErrorCode(first.error)} at offset ${first.offset}`, + ); + } + try { + return devspaceConfigSchema.parse(value); + } catch (error) { + throw fileError("read", filePath, error); + } +} + +function serializeConfig(config: DevspaceConfig): string { + return `${JSON.stringify(config, null, 2)}\n`; +} + +function atomicWrite(filePath: string, source: string, mode: number): void { + mkdirSync(dirname(filePath), { recursive: true }); + const temporaryPath = temporaryFilePath(filePath); + try { + writeFileSync(temporaryPath, source, { mode, flag: "wx" }); + renameSync(temporaryPath, filePath); + } catch (error) { + rmSync(temporaryPath, { force: true }); + throw error; + } +} + +function temporaryFilePath(filePath: string): string { + return join( + dirname(filePath), + `.${basename(filePath)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`, + ); +} + function readJsonFile(filePath: string, schema: z.ZodType): T { try { return schema.parse(JSON.parse(readFileSync(filePath, "utf8")) as unknown); } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - throw new Error(`Unable to read ${filePath}: ${reason}`); + throw fileError("read", filePath, error); } } function writeJsonFile(filePath: string, value: unknown, mode: number): void { - writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", { mode }); + writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode }); +} + +function fileError(action: "read" | "migrate", filePath: string, error: unknown): Error { + const reason = error instanceof Error ? error.message : String(error); + return new DevspaceConfigFileError(`Unable to ${action} ${filePath}: ${reason}`); } + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +class DevspaceConfigFileError extends Error {} diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 5af9f991..45c45a14 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -9,6 +9,7 @@ import { loadConfig, type ServerConfig } from "./config.js"; import { openDatabase } from "./db/client.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; const execFileAsync = promisify(execFile); @@ -242,14 +243,14 @@ test("canonical checkout identity survives macOS var path aliases", { skip: plat return; } - const aliasConfig = loadConfig({ - DEVSPACE_CONFIG_DIR: join(context.root, ".alias-config"), - DEVSPACE_ALLOWED_ROOTS: `${context.root},${macAlias}`, - DEVSPACE_WORKTREE_ROOT: join(context.root, ".worktrees"), - DEVSPACE_AGENT_DIR: join(context.root, "agent"), - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const aliasConfig = loadConfig(writeTestDevspaceConfig(join(context.root, ".alias-config"), { + server: { port: 1 }, + workspaces: { + allowedRoots: [context.root, macAlias], + worktreeRoot: join(context.root, ".worktrees"), + }, + skills: { agentDir: join(context.root, "agent") }, + })); const aliasRegistry = new WorkspaceRegistry(aliasConfig, context.store); const direct = await context.registry.openWorkspace(context.project, { @@ -416,15 +417,12 @@ async function fixture( if (options.git) await initializeGitRepository(project); - const config = loadConfig({ - DEVSPACE_CONFIG_DIR: join(root, ".config"), - DEVSPACE_ALLOWED_ROOTS: root, - DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const config = loadConfig(writeTestDevspaceConfig(join(root, ".config"), { + server: { port: 1 }, + workspaces: { allowedRoots: [root], worktreeRoot: join(root, ".worktrees") }, + skills: { agentDir }, + subagents: { enabled: true, providers: [] }, + })); const openStore = () => { const store = new SqliteWorkspaceStore(stateDir); stores.add(store); diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 8584c1b7..3dab1080 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -9,6 +9,7 @@ import { loadConfig, type ServerConfig } from "./config.js"; import { GitWorktreeError } from "./git-worktrees.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; const execFileAsync = promisify(execFile); @@ -47,14 +48,17 @@ test("a checkout exposes initial and nested instruction context while filtering await writeFile(join(context.outsideRoot, "secret.txt"), "outside secret\n"); await symlink(join(context.outsideRoot, "secret.txt"), join(unsafeAgentDir, "AGENTS.md")); - const unsafeConfig = loadConfig({ - DEVSPACE_CONFIG_DIR: join(context.root, ".devspace-unsafe-home"), - DEVSPACE_ALLOWED_ROOTS: context.root, - DEVSPACE_WORKTREE_ROOT: join(context.root, ".devspace", "unsafe-worktrees"), - DEVSPACE_AGENT_DIR: unsafeAgentDir, - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const unsafeConfig = loadConfig(writeTestDevspaceConfig( + join(context.root, ".devspace-unsafe-home"), + { + server: { port: 1 }, + workspaces: { + allowedRoots: [context.root], + worktreeRoot: join(context.root, ".devspace", "unsafe-worktrees"), + }, + skills: { agentDir: unsafeAgentDir }, + }, + )); const unsafeWorkspace = await new WorkspaceRegistry(unsafeConfig).openWorkspace(context.root); assert.deepEqual( @@ -144,13 +148,17 @@ test("a symlinked allowed root preserves checkout and worktree path behavior", { await symlink(context.root, aliasRoot, "dir"); await createGitProject(context.root); - const aliasConfig = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: aliasRoot, - DEVSPACE_WORKTREE_ROOT: join(aliasRoot, ".devspace", "alias-worktrees"), - DEVSPACE_AGENT_DIR: context.agentDir, - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const aliasConfig = loadConfig(writeTestDevspaceConfig( + join(context.root, ".devspace-alias-home"), + { + server: { port: 1 }, + workspaces: { + allowedRoots: [aliasRoot], + worktreeRoot: join(aliasRoot, ".devspace", "alias-worktrees"), + }, + skills: { agentDir: context.agentDir }, + }, + )); const aliasRegistry = new WorkspaceRegistry(aliasConfig); const worktree = await aliasRegistry.openWorkspace({ @@ -207,15 +215,15 @@ async function fixture(t: TestContext): Promise { await writeFile(join(root, "nested", "AGENTS.md"), "nested instructions\n"); await writeFile(join(root, "nested", "file.txt"), "hello\n"); - const config = loadConfig({ - DEVSPACE_CONFIG_DIR: join(root, ".devspace-home"), - DEVSPACE_ALLOWED_ROOTS: root, - DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "worktrees"), - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const config = loadConfig(writeTestDevspaceConfig(join(root, ".devspace-home"), { + server: { port: 1 }, + workspaces: { + allowedRoots: [root], + worktreeRoot: join(root, ".devspace", "worktrees"), + }, + skills: { agentDir }, + subagents: { enabled: true, providers: [] }, + })); t.after(async () => { await rm(root, { recursive: true, force: true });