title: “v0.8.9 - Plugin Loader Hardening” description: “Release notes for docmd v0.8.9 - new build-time plugin registry, docmd doctor command, docmd namespace standardisation, 404-page and template assets fixes, and security hardening.” date: “2026-06-26”

A focused hardening release. Three themes:

  1. The plugin and template ecosystem gets a single source of truth — every official @docmd/* package now carries a docmd namespace in its package.json, and a build-time registry generator reads those namespaces to produce the catalog the runtime loader consumes. Adding a new official plugin is now a single package.json edit plus a directory under packages/{plugins,templates,engines}/.
  2. The plugin auto-installer is now resilient to packages that ship import-only exports fields, and a new docmd doctor pre-flight command catches configuration drift before a build.
  3. Two previously-silent bugs in published tarballs are fixed: @docmd/ui’s translations/ and @docmd/template-summer’s templates/+assets/ are now in the published files. The 404 page renders with the summer template and translated strings; previously it fell back to the default template and showed raw translation keys.

No public API changes. No breaking config changes. Purely a hardening release.

✨ New

Build-time plugin registry (single source of truth)

A new workspace-level script — scripts/build-plugin-registry.mjs — walks packages/{plugins,templates,engines}/*, reads each package’s package.json#docmd namespace, and emits a generated JSON catalog (packages/api/registry/plugins.generated.json). Wired as the prebuild step of @docmd/api, so the registry is regenerated on every build.

The runtime loader (packages/api/src/hooks.ts getPluginRegistry) now reads from the generated file, with two resolution paths (published layout <pkg>/registry/... and monorepo dev layout <repo>/packages/api/registry/...). The hand-maintained packages/plugins/installer/registry/plugins.json that lived in the installer is no longer consulted at runtime. It is retained as a deprecated fallback for users who install @docmd/plugin-installer without @docmd/api, and is removed in 0.9.0 (see Migration below).

docmd namespace standardisation

Every official @docmd/* package now carries a docmd namespace in its package.json. The namespace is the contract that the registry generator reads and the loader cross-checks against the JS descriptor at load time. Fields:

Field Required Description
key Recommended The user-facing identifier (config.plugins.<key>). Derived from the npm name if omitted.
kind Recommended One of plugin, template, engine. Derived from the directory layout if omitted.
displayName Recommended Human-readable name shown in catalogs and docmd doctor output.
tagline Recommended One-line description; falls back to the npm description if omitted.
capabilities Required for plugins and templates The hook capabilities the JS descriptor declares. The build-time cross-check warns on drift.
preview Optional (template only) Path to a preview asset.

Engines get the same shape but no capabilities — they don’t participate in the hook system, only in the engine loader. The auto-installer checks kind === 'engine' and refuses to install them, so engines appear in the catalog but are never auto-installable.

docmd doctor — pre-flight check

A new CLI subcommand for diagnostics. No filesystem writes, no build side-effects — purely diagnostic.

npx @docmd/core doctor [options]
Option Description
--config <path> Path to a non-default docmd.config.json (or .ts/.js/.mjs).
--fix Auto-install missing official plugins or templates.
--json Emit the report as machine-readable JSON.

By default, doctor prints a human-readable summary covering: the installed @docmd/core version, every configured plugin (with version and ✓ installed / ⚠ missing status), the active template, the requested engines (js always-on, rust opt-in), and a list of auto-install candidates. With --fix, it shells out to the project’s package manager (pnpm add, npm install --save, yarn add, or bun add) to install the candidates, then exits with code 0 if everything resolved. With --json, the same data is emitted as a single JSON object — useful for pre-commit hooks and CI gates.

Exit code 0 means the project is healthy; non-zero means at least one issue remains after any --fix run.

Also available as pnpm doctor (which routes through the workspace docmd script in the monorepo).

New pnpm scripts in the monorepo

The monorepo’s package.json gets a batch of new pnpm scripts, all pointing at the playground via the existing --cwd flag (same pattern as pnpm dev and pnpm live):

pnpm doctor           # → docmd doctor
pnpm validate         # → docmd validate
pnpm migrate          # → docmd migrate
pnpm gen:deploy       # → docmd deploy
pnpm mcp              # → docmd mcp
pnpm plugin:add foo   # → docmd add foo
pnpm plugin:remove foo # → docmd remove foo
pnpm build:playground # → docmd build (separate from `pnpm build` which is the monorepo-wide build)

🐛 Bug fixes

Plugin auto-install: resilient to import-only exports

The auto-installer in @docmd/api previously resolved installed packages with Node’s CommonJS require.resolve, then loaded the resolved path with await import(file://path). For packages that ship an exports field with only an import condition, require.resolve throws ERR_PACKAGE_PATH_NOT_EXPORTED and the build prints:

⬢ Could not load @docmd/template-summer after auto-install

even though the package is correctly installed. The retry path now uses await import(name) directly, which honours the exports field natively and works for any condition.

A defense-in-depth registry re-check is performed inside the retry path. The set of names that can be auto-installed is unchanged — only the set of exports conditions they can carry is larger.

Plugin loader: capability cache and manifest drift check

Two follow-on hardenings to the loader:

  • Per-key capability set cache. Avoids re-walking the registry on every dev-server rebuild. Negligible for the first build, noticeable in a hot-reload loop with a 15-entry registry.
  • Manifest / descriptor capability drift check. When the registry entry has capabilities and the JS descriptor has capabilities, the loader warns if either side declares a hook the other doesn’t. This closes the silent-hook-drop bug where a plugin that implemented a hook (e.g. onPostBuild) but forgot to declare the corresponding capability in the JS descriptor would have its hook silently skipped.

Better “Could not load X after auto-install” error messages

The post-install retry’s catch block now surfaces err.code (e.g. ERR_PACKAGE_PATH_NOT_EXPORTED, ERR_MODULE_NOT_FOUND) and the first line of err.message. The previous “Could not load X after auto-install” message looked like a docmd bug when the real cause was a bad package.json in the dependency. The autoInstallPlugin catch block also surfaces the underlying stderr from the package manager and prints a hint for the most common cases (template → add to dependencies; plugin → docmd add <name>).

@docmd/ui: include translations/ in the published tarball

The server-side translation loader in packages/ui/src/index.ts:loadTranslations looks for translation JSON files at __dirname/../translations/. The package.json#files was ["dist", "templates", "assets"] — missing "translations", so npm packed the tarball without the translation files. The server-side readFileSync threw, the catch set translationsCache[locale] to {}, and the t() function fell back to returning the key literal.

The bug was invisible locally because the user’s --offline build (monorepo dev) had access to the source tree. It was only visible on the deployed docs site, where @docmd/ui@0.8.x is installed from npm and the translation folder is missing from the tarball.

The 404 page was the most visible symptom: it’s rendered lazily by the deployed site’s static-file fallback when a user hits a missing path. The 404 page calls t() for its title and body, and at that point the translation cache is empty. The keys leak.

Fix: add "translations" to packages/ui/package.json#files. Now the published tarball ships all 7 locale files (de, en, es, fr, hi, ja, zh).

@docmd/template-summer: include templates/ and assets/ in the published tarball

The summer template’s runtime uses new URL('../templates/...', import.meta.url) inside dist/index.js, which resolves to <package-root>/templates/... (not <package-root>/dist/templates/...). The package.json#files was just ["dist"], so the published tarball only contained dist/. The resolver couldn’t find any of the template partials and silently fell back to the default template.

The 404 page symptom (on top of the translation issue): on the deployed site, the user saw the default 404 template (still branded as docmd, but with the default look), not the summer-themed 404.

Fix: add "templates" and "assets" to packages/templates/summer/package.json#files. Now the published tarball matches the monorepo dev layout and the summer template renders correctly.

Dev server: strip leading slash before safePath()

The CWE-22 fix from 0.8.9 (replacing filePath.startsWith(rootAbs) with safePath(rootAbs, ...)) had a regression in the dev server: URL pathnames always start with / (e.g. /index.html), and path.resolve('/abs/root', '/index.html') returns /index.html (treating the second arg as absolute) — which always failed the safePath boundary check and produced 403 Forbidden for every legitimate request.

Fix: strip the leading / from the URL pathname before passing to safePath(). The empty case ('/''') is handled with || '.' so it resolves to the root directory itself, which safePath accepts via its resolved !== root exception. Verified: legitimate paths return 200, path traversal is still blocked (403), encoded ../ still resolves to 404 after the path normalisation.

🔒 Security

The cold-email advisory received just before this release is addressed in full:

Issue Status Resolution
packages/core/src/utils/dev-utils.ts:118filePath.startsWith(rootAbs) CWE-22 Fixed (0.8.9) Use safePath() with the strict root + path.sep boundary. The “Forbidden” regression from this change is also fixed (see above).
packages/live/src/index.tsserver.listen(port, '0.0.0.0') CWE-668 Fixed (0.8.9) Default to 127.0.0.1. LAN access opt-in via DOCMD_HOST=0.0.0.0 or --host 0.0.0.0, with a TUI warning when active. The port-probe also binds to 127.0.0.1 instead of 0.0.0.0.
packages/utils/src/html-escape.tsscriptLiteral/jsonInject are bare JSON.stringify Fixed (0.8.9) Both now properly escape </script, <!--, U+2028, and U+2029. The hardening is silent for non-conflicting strings; round-trips through JSON.parse still work because the escape uses JSON-safe sequences.
docker/DOCKER.md — examples show command: dev --host 0.0.0.0 Fixed (0.8.9) Three examples now use the new loopback default. The “Network Issues” troubleshooting section documents the opt-in path with a security note.
@docmd/plugin-openapi CWE-22 (older advisory, in 0.8.5) Closed in 0.8.8 safePath(rootDir, asUserPath(specPath)) in renderSpec. Verified.
Dev-server WebSocket CWE-1385 (older advisory, in 0.8.5) Closed in 0.8.8 verifyClient: createOriginVerify() in WebSocketServer. Verified.

The two older advisories (OpenAPI and WebSocket) can be closed as resolved. They were already fixed in 0.8.8 — this release re-asserts the fix and adds a manifest cross-check in the loader to surface any future drift early.

📚 Documentation

  • Development → Building Plugins and Development → Building Templates — new “The docmd Namespace” section documenting the build-time contract every official plugin must satisfy, plus the “Bundled registry removal in 0.9.0” callout. New “ESM Exports — the default Condition” subsection for both plugins and templates.
  • Plugins → Using Plugins — resilient auto-install callout. The new import(name) retry path is documented as the reason a registry re-check is in place as defense-in-depth.
  • Plugins → Search — resolver robustness callout. The import → default → require → main fallback chain and the manual node_modules walk are explained, plus the --foreground-scripts note for the docmd-search auto-install.
  • Reference → CLI Commands — new entry for docmd doctor with all three flags (--config, --fix, --json) documented.
  • Release Notes → 0.8.9 — this file.

🔌 Compatibility

Surface Status
@docmd/plugin-search@>=0.8.5 ✓ Works. The hardened >=0.1.0 peer dep on docmd-search lands cleanly.
docmd-search@>=0.1.0 ✓ First non-alpha release. Drops the pre-release ^0.1.0-alpha.1 range, lands on latest.
@docmd/engine-js@>=0.8.5 ✓ Optional peer, used for chunking/quantization.
@docmd/engine-rust@>=0.8.5 ✓ Optional peer, accelerated chunking/quantization when present.
@huggingface/transformers@^4.2.0 ✓ Optional peer, required for the embedding model.
onnxruntime-node@^1.26.0 ✓ Optional peer, required for the on-device inference backend.
Node.js >=18 (matches the rest of the @docmd/* family)
Browser (search client) Modern browsers with WebAssembly, Atomics, SharedArrayBuffer (cross-origin isolated)

🔄 Migration notes

  • For @docmd/plugin-search@>=0.8.5 users: nothing required. The plugin’s tightened peer dep >=0.1.0 will pick up docmd-search@0.1.0 automatically.
  • For @docmd/plugin-search@0.8.5-0.8.8 users: bump the plugin to >=0.8.9 to get the default-exports compatibility and the resilient auto-install. Earlier versions continue to install with the alpha, but the auto-install retry will print a warning.
  • For direct docmd-search consumers: update your package.json from ^0.1.0-alpha.1 to ^0.1.0. The two alphas are deprecated; npm install docmd-search resolves to 0.1.0 and lands on latest.
  • For plugin authors (new or updated official plugins): add a docmd namespace to your package.json as documented in the Building Plugins guide. Without it, the build-time registry generator will fail loudly.
  • For downstream @docmd/plugin-installer users without @docmd/api: the bundled registry/plugins.json is still included as a deprecated fallback in 0.8.9. It is removed in 0.9.0 — install @docmd/api as a peer dep.
  • For pnpm users in the monorepo: drop the pnpm.overrides["docmd-search"] workaround (file:../docmd-search) once docmd-search@0.1.0 is on npm. The tightened >=0.1.0 peer dep resolves naturally from the registry.

🛠 Verification

  • pnpm prep — 0 errors, 1 warning (pre-existing lint noise unrelated to this release). All 30 packages build; 372 tests pass.
  • End-to-end smoke test in a clean /tmp install: docmd build succeeds; docmd doctor --json reports correctly; the dev server returns 200 for legitimate paths and 403 for traversal attempts; the 404 page renders with the summer template and translated strings.

📋 Release checklist (for the publisher)

  1. Confirm pnpm prep is green locally.
  2. Tag the release: git tag 0.8.9 && git push origin 0.8.9.
  3. The publish workflow runs the Rust matrix build, then publishes all 30 packages in dependency order.
  4. Verify on npm: npm view @docmd/core dist-tags shows { latest: '0.8.9' }.
  5. Drop the file:../docmd-search override in package.json#pnpm.overrides (the root devDep can stay at ^0.1.0-alpha.1 or move to ^0.1.0).