✨ Highlights

This release is a developer-experience pass on the CLI after the 0.8.8 hardening and 0.8.9 plugin-loader work. The most user-visible change is that workspace configuration errors now point at the actual problem with a working example, instead of a single bare sentence. Several long-standing CLI gaps are also closed: the deploy --force flag is honoured (no more silent overwrites), migrate --dry-run is now a real option, migrate --upgrade covers the full legacy key map, the dev server shuts down gracefully on docmd stop, and the plugin installer no longer claims success on a no-op install.

No public API changes. No breaking config changes. Purely a developer-experience release.

🧪 migrate --dry-run for every migration path

docmd migrate --dry-run is now a real flag for every supported source (Docusaurus, MkDocs, VitePress, Starlight) and for the in-place --upgrade path. Dry-run prints what would change and exits 0 without writing anything:

┌─ Dry run: MkDocs migration
│  Would move      3 files → mkdocs-backup/
│                  docs
│                  index.md
│                  mkdocs.yml
│  Would write     docmd.config.js
│  Config          {"title":"My Test Site","src":"docs","out":"dist","theme":{"appearance":"system"}}
└──────────────────────────────────────────────────────────

⬢ No changes made. Re-run without --dry-run to apply.

For --upgrade, the dry-run prints the upgraded config in full so you can diff it against the existing file before committing.

📦 migrate --upgrade covers the full legacy-key map

The --upgrade path used to handle only six legacy keys (projects, siteTitle, siteUrl, baseUrl, srcDir, outputDir, defaultLocale). It now also handles:

Legacy key Becomes
source src
outDir out
nav navigation
top-level search (boolean) plugins.search
top-level sidebar layout.sidebar
theme.defaultMode theme.appearance
theme.enableModeToggle optionsMenu.components.themeSwitch
theme.positionMode optionsMenu.position

Each upgrade emits a [ DONE ] Upgraded "X" to "Y" step, and a config that doesn’t contain any legacy keys still prints Configuration is already up to date with the latest schema.

🩺 Better workspace error messages

When a workspace config is missing a root project (the most common first-time setup mistake), the error used to be a single bare sentence: "Workspace configuration must have a root project with prefix "/"". That gave the user no example and no path forward.

The error now reads:

Workspace configuration must include a root project with prefix "/".
Each workspace needs one project that owns the site root, with the others mounted under their own prefixes.
Example:
  {
    "workspace": {
      "projects": [
        { "name": "main", "src": "./docs",     "prefix": "/" },
        { "name": "api",  "src": "./api-docs", "prefix": "/api/" }
      ]
    }
  }
See https://docs.docmd.io/guides/workspace for the full layout guide.

The same shape applies to every workspace validation error: each one ends with the concrete configuration that would satisfy the validator.

deploy --force now means what it says

The --force flag on docmd deploy was previously accepted but never read by the deployer. Running docmd deploy --docker on a project that already had a Dockerfile would silently overwrite it without warning.

The new behaviour:

  • Without --force, an existing config file is preserved and the TUI line shows [ SKIP ] Dockerfile (already exists, skipped — use --force to overwrite).
  • With --force, the existing file is overwritten as before.

This applies to every deploy target: --docker, --nginx, --caddy, --github-pages, --vercel, and --netlify.

🛰️ Netlify template no longer soft-404s every route

The generated netlify.toml used to include a [[redirects]] block that sent every URL to /index.html with HTTP 200:

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

That meant any missing route returned the home page with a 200 status — a soft-404 that hides errors, hurts SEO, and creates a URL-enumeration surface. The block has been removed: docmd generates a real HTML file for every route, so Netlify serves the right file when one exists and falls back to the bundled 404.html (with a 404 status) when it doesn’t.

The docmd build --offline flag used to leave the rendered HTML with absolute paths like <a href="/destination/"> for markdown links. Those work on an HTTP server but break when the user opens site/index.html from the filesystem — file:// cannot resolve absolute paths that have no host.

The fix post-processes the rendered markdown HTML in offline mode and rewrites every internal <a href> and <img src> to a relative .html path using the same logic the button container (:::) already used. The result:

Source build (HTTP) build --offline (file://)
Button container ::: "/destination" ./destination/ ./destination/index.html
Markdown link [text](/destination) /destination/ ./destination/index.html
Markdown image ![alt](/assets/x.png) /assets/x.png ./assets/x.png
External https://example.com unchanged unchanged
Anchor #section unchanged unchanged

Non-offline builds are completely unchanged (clean URLs preserved for SEO). The output works in every hosting shape: file://, HTTP servers, custom domains, the dev server, the live editor. Nested pages get the correct ../ prefix to navigate up from subfolders.

🔌 docmd-search peer dependencies auto-install together

When plugins.search.semantic: true is set and either docmd-search itself or one of its peers (@huggingface/transformers, onnxruntime-node) is missing, the plugin now:

  1. Restricts its module-resolution scope to the user’s project (process.cwd()/node_modules only) — previously it could walk up to the monorepo root via __dirname and find a copy there, leading to confusing behaviour.
  2. Auto-installs just the peers when only those are missing (previously: warn and give up).
  3. Surfaces the actual reason in the fallback message — previously the message always said “docmd-search not installed” even when only a peer was missing.

🛰️ Open Knowledge Format is on by default

OKF has shipped in 0.8.8 and is fully on by default:

  • Auto-loaded for every build unless explicitly disabled via plugins.okf: false.
  • plugins.okf.typeField defaults to type (frontmatter > path > fallback).
  • Missing type fields get ONE summary TUI line plus a per-page entry in okf/_meta/lint-report.txt so detail isn’t lost.
  • Orphan concepts and broken internal links are listed in the same lint report.
  • Graph viewer is opt-in via plugins.okf.graph: true (a graph/index.html, graph.js, and graph.css get emitted under okf/graph/).
  • plugins.okf.i18n: true emits OKF bundles for every configured locale (default-only otherwise).

No public API changes.

🛑 Graceful shutdown on docmd stop

docmd stop used to send SIGTERM and, if that failed, immediately send SIGKILL. The dev server’s SIGTERM handler was a one-liner process.exit(0) that bypassed the watchers, HTTP server, WebSocket server, and worker-pool cleanup. After a stop, child processes and live sockets could be left hanging.

The new flow:

  1. docmd stop sends SIGTERM.
  2. The dev server runs the same graceful shutdown as Ctrl+C (close watchers → close wss → close http server → terminate worker pool → exit 0).
  3. docmd stop polls every 100ms for up to 5 seconds for the process to exit on its own.
  4. Only if the process is still alive after the grace period does docmd stop escalate to SIGKILL.

The TUI line shows Stopped ... PID gracefully for a clean exit, or Killed ... PID (SIGKILL after grace) if the escalation was needed.

🐛 Bug fixes

  • Workspace validator: actionable error message when no root project is configured. The single-sentence error is replaced with a multi-line message that explains the requirement, shows a valid JSON example, and links to the workspace layout guide.
  • Plugin installer: accurate final message for already-installed plugins. docmd add <plugin> for a plugin that’s already in the config used to print “Plugin successfully installed and activated” even though no install happened. The final TUI line now reflects the actual outcome: new install prints the success message; already-configured prints ⬢ Plugin was already configured. Nothing changed.
  • Deployer: --force no longer silently clobbers existing configs. Every deploy target now honours --force: default behaviour is to skip existing files; --force opts in to overwriting.
  • Deployer: Netlify [[redirects]] block removed to fix the soft-404. Generated netlify.toml no longer rewrites every URL to /index.html with status 200. Missing routes now serve the bundled 404.html with a 404 status.
  • Offline build: markdown links and images are now rewritten to relative .html paths. Previously only the button container was offline-aware; markdown [text](url) and ![alt](src) emitted absolute paths that file:// couldn’t resolve. They now go through the same fixHtmlLinks rewrite as the button container (#167).
  • Dev server: SIGTERM runs the same graceful shutdown as SIGINT. The dev server now closes watchers, the WebSocket server, the HTTP server, and the worker pool on SIGTERM before exiting. docmd stop polls for the process to exit and only escalates to SIGKILL after a 5-second grace period.
  • docmd-search: peer dependencies auto-install when missing. When plugins.search.semantic: true is set and the peer packages (@huggingface/transformers, onnxruntime-node) are not installed, the plugin now installs them rather than warning and falling back to keyword search (#163 deeper fix).
  • Versions: config.versions.list accepted as alias for all. Users who wrote list (a common shape for “list of versions”) saw silent 0-page builds because the schema only recognised all. Aliasing listall restores their config without changing the canonical key (M-6).
  • Versions: missing current-version directory is a hard error. The build now aborts with a clear pointer to the missing path and the version id. Previously it silently produced a 0-page site with no actionable message. Old (non-current) versions remain soft-warns (T-Z6).
  • llms.txt / llms.json titles are sanitised. Frontmatter titles containing markdown injection chars (`, [, ], newlines) no longer break the link form or render as raw HTML, and titles starting with =, +, -, or @ are prefixed with a single-quote so opening the file in a spreadsheet does not execute a formula. CSV formula neutralisation (T-Z11) and markdown injection prevention (T-Z10) in one helper.
  • NO_COLOR and DOCMD_NO_BANNER suppress the build banner. Setting NO_COLOR=1 (or the docmd-specific DOCMD_NO_BANNER=1) now hides the ASCII art and version line at the top of the build output. NO_COLOR is the de-facto standard for CLIs; DOCMD_NO_BANNER is a docmd-specific escape hatch for users who want colour but no banner (N-13 + N-16).
  • Migration polish. moveFilesToBackup now keeps lockfiles and package.json in place so a recovery doesn’t have to re-resolve every dependency (N-10). The Docusaurus and MkDocs migrators preserve the original staticDir / site_dir (N-22) and MkDocs nav: blocks are translated to docmd’s navigation format with multi-level sections preserved via children (N-9).

Changelog

  1. Workspace validator: validateProjects in packages/core/src/engine/workspace.ts now throws a multi-line Error with a JSON example and a docs link when no project has prefix: "/" (M-2).
  2. Plugin installer: addPlugin in packages/plugins/installer/src/index.ts branches on the injected flag returned by the config editor and emits TUI.info for the already-configured case, instead of the misleading TUI.success (M-14).
  3. Deployer: write() in packages/deployer/src/index.ts now takes a force argument and skips with a [ SKIP ] TUI line when the target file exists and --force was not passed (N-2). All six target generators (docker, nginx, caddy, github-pages, vercel, netlify) pass opts.force through.
  4. Deployer (Netlify): generateNetlify in packages/deployer/src/providers/netlify.ts no longer emits a [[redirects]] from = "/*" status = 200 block. docmd generates real HTML per route, so the catch-all was both unnecessary and the source of the soft-404 (T-Z9).
  5. Stop: stopServer in packages/core/src/commands/stop.ts exports a new waitForExit(pid, timeoutMs) helper and uses it to poll for the target process to exit after SIGTERM. The escalation to SIGKILL only happens after the 5-second grace period (M-11).
  6. Dev server: startDevServer in packages/core/src/commands/dev.ts extracts the SIGINT shutdown path into a gracefulShutdown() function used by both SIGINT and SIGTERM handlers (M-11).
  7. Migrate: migrateProject in packages/core/src/commands/migrate.ts accepts a new dryRun option (N-3). For every source path (Docusaurus, MkDocs, VitePress, Starlight) the dry-run prints the file move list and the new docmd.config.js before any side effects; for --upgrade it prints the upgraded config. packages/core/src/bin/docmd.ts adds --dry-run to the migrate CLI.
  8. Migrate: the --upgrade path in packages/core/src/commands/migrate.ts now handles eight additional legacy keys: source, outDir, nav, top-level search, top-level sidebar, theme.defaultMode, theme.enableModeToggle, and theme.positionMode (N-4).
  9. Tests: tests/cli-contracts/validate-workspace.test.js adds 5 new M-2 assertions covering the exit code and the four message-content invariants.
  10. Tests: tests/cli-contracts/plugin-add-remove.test.js adds 3 new M-14 assertions covering the already-installed message, the absence of the false success message, and the regression guard for fresh installs.
  11. Tests: new tests/cli-contracts/deploy.test.js (5 assertions, registered in tests/runner.js as deploy) covers the N-2 skip / overwrite / fresh paths.
  12. Tests: new tests/cli-contracts/stop.test.js (3 assertions, registered as stop) covers the M-11 waitForExit helper against live child processes.
  13. Tests: new tests/cli-contracts/migrate.test.js (33 assertions, registered as migrate) covers N-3 dry-run for both source migrations and upgrade, and N-4 coverage of all 13 legacy upgrade paths.