✨ 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.
🔗 Offline-mode links work in every hosting shape
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  |
/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:
- Restricts its module-resolution scope to the user’s project (
process.cwd()/node_modulesonly) — previously it could walk up to the monorepo root via__dirnameand find a copy there, leading to confusing behaviour. - Auto-installs just the peers when only those are missing (previously: warn and give up).
- 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.typeFielddefaults totype(frontmatter > path > fallback).- Missing
typefields get ONE summaryTUIline plus a per-page entry inokf/_meta/lint-report.txtso 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(agraph/index.html,graph.js, andgraph.cssget emitted underokf/graph/). plugins.okf.i18n: trueemits 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:
docmd stopsends SIGTERM.- The dev server runs the same graceful shutdown as Ctrl+C (close watchers → close wss → close http server → terminate worker pool → exit 0).
docmd stoppolls every 100ms for up to 5 seconds for the process to exit on its own.- Only if the process is still alive after the grace period does
docmd stopescalate 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:
--forceno longer silently clobbers existing configs. Every deploy target now honours--force: default behaviour is to skip existing files;--forceopts in to overwriting. - Deployer: Netlify
[[redirects]]block removed to fix the soft-404. Generatednetlify.tomlno longer rewrites every URL to/index.htmlwith status 200. Missing routes now serve the bundled404.htmlwith a 404 status. - Offline build: markdown links and images are now rewritten to relative
.htmlpaths. Previously only the button container was offline-aware; markdown[text](url)andemitted absolute paths thatfile://couldn’t resolve. They now go through the samefixHtmlLinksrewrite 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 stoppolls 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: trueis 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.listaccepted as alias forall. Users who wrotelist(a common shape for “list of versions”) saw silent 0-page builds because the schema only recognisedall. Aliasinglist→allrestores 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_COLORandDOCMD_NO_BANNERsuppress the build banner. SettingNO_COLOR=1(or the docmd-specificDOCMD_NO_BANNER=1) now hides the ASCII art and version line at the top of the build output.NO_COLORis the de-facto standard for CLIs;DOCMD_NO_BANNERis a docmd-specific escape hatch for users who want colour but no banner (N-13 + N-16).- Migration polish.
moveFilesToBackupnow keeps lockfiles andpackage.jsonin place so a recovery doesn’t have to re-resolve every dependency (N-10). The Docusaurus and MkDocs migrators preserve the originalstaticDir/site_dir(N-22) and MkDocsnav:blocks are translated to docmd’snavigationformat with multi-level sections preserved viachildren(N-9).
Changelog
- Workspace validator:
validateProjectsinpackages/core/src/engine/workspace.tsnow throws a multi-lineErrorwith a JSON example and a docs link when no project hasprefix: "/"(M-2). - Plugin installer:
addPlugininpackages/plugins/installer/src/index.tsbranches on theinjectedflag returned by the config editor and emitsTUI.infofor the already-configured case, instead of the misleadingTUI.success(M-14). - Deployer:
write()inpackages/deployer/src/index.tsnow takes aforceargument and skips with a[ SKIP ]TUI line when the target file exists and--forcewas not passed (N-2). All six target generators (docker, nginx, caddy, github-pages, vercel, netlify) passopts.forcethrough. - Deployer (Netlify):
generateNetlifyinpackages/deployer/src/providers/netlify.tsno longer emits a[[redirects]] from = "/*" status = 200block. docmd generates real HTML per route, so the catch-all was both unnecessary and the source of the soft-404 (T-Z9). - Stop:
stopServerinpackages/core/src/commands/stop.tsexports a newwaitForExit(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). - Dev server:
startDevServerinpackages/core/src/commands/dev.tsextracts the SIGINT shutdown path into agracefulShutdown()function used by bothSIGINTandSIGTERMhandlers (M-11). - Migrate:
migrateProjectinpackages/core/src/commands/migrate.tsaccepts a newdryRunoption (N-3). For every source path (Docusaurus, MkDocs, VitePress, Starlight) the dry-run prints the file move list and the newdocmd.config.jsbefore any side effects; for--upgradeit prints the upgraded config.packages/core/src/bin/docmd.tsadds--dry-runto the migrate CLI. - Migrate: the
--upgradepath inpackages/core/src/commands/migrate.tsnow handles eight additional legacy keys:source,outDir,nav, top-levelsearch, top-levelsidebar,theme.defaultMode,theme.enableModeToggle, andtheme.positionMode(N-4). - Tests:
tests/cli-contracts/validate-workspace.test.jsadds 5 new M-2 assertions covering the exit code and the four message-content invariants. - Tests:
tests/cli-contracts/plugin-add-remove.test.jsadds 3 new M-14 assertions covering the already-installed message, the absence of the false success message, and the regression guard for fresh installs. - Tests: new
tests/cli-contracts/deploy.test.js(5 assertions, registered intests/runner.jsasdeploy) covers the N-2 skip / overwrite / fresh paths. - Tests: new
tests/cli-contracts/stop.test.js(3 assertions, registered asstop) covers the M-11waitForExithelper against live child processes. - Tests: new
tests/cli-contracts/migrate.test.js(33 assertions, registered asmigrate) covers N-3 dry-run for both source migrations and upgrade, and N-4 coverage of all 13 legacy upgrade paths.