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:
- The plugin and template ecosystem gets a single source of truth — every official
@docmd/*package now carries adocmdnamespace in itspackage.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 singlepackage.jsonedit plus a directory underpackages/{plugins,templates,engines}/. - The plugin auto-installer is now resilient to packages that ship
import-onlyexportsfields, and a newdocmd doctorpre-flight command catches configuration drift before a build. - Two previously-silent bugs in published tarballs are fixed:
@docmd/ui’stranslations/and@docmd/template-summer’stemplates/+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:118 — filePath.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.ts — server.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.ts — scriptLiteral/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
docmdNamespace” section documenting the build-time contract every official plugin must satisfy, plus the “Bundled registry removal in 0.9.0” callout. New “ESM Exports — thedefaultCondition” 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 → mainfallback chain and the manualnode_moduleswalk are explained, plus the--foreground-scriptsnote for thedocmd-searchauto-install. - Reference → CLI Commands — new entry for
docmd doctorwith 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.5users: nothing required. The plugin’s tightened peer dep>=0.1.0will pick updocmd-search@0.1.0automatically. - For
@docmd/plugin-search@0.8.5-0.8.8users: bump the plugin to>=0.8.9to get thedefault-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-searchconsumers: update yourpackage.jsonfrom^0.1.0-alpha.1to^0.1.0. The two alphas are deprecated;npm install docmd-searchresolves to0.1.0and lands onlatest. - For plugin authors (new or updated official plugins): add a
docmdnamespace to yourpackage.jsonas documented in the Building Plugins guide. Without it, the build-time registry generator will fail loudly. - For downstream
@docmd/plugin-installerusers without@docmd/api: the bundledregistry/plugins.jsonis still included as a deprecated fallback in 0.8.9. It is removed in 0.9.0 — install@docmd/apias a peer dep. - For
pnpmusers in the monorepo: drop thepnpm.overrides["docmd-search"]workaround (file:../docmd-search) oncedocmd-search@0.1.0is on npm. The tightened>=0.1.0peer 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
/tmpinstall:docmd buildsucceeds;docmd doctor --jsonreports correctly; the dev server returns200for legitimate paths and403for traversal attempts; the 404 page renders with the summer template and translated strings.
📋 Release checklist (for the publisher)
- Confirm
pnpm prepis green locally. - Tag the release:
git tag 0.8.9 && git push origin 0.8.9. - The publish workflow runs the Rust matrix build, then publishes all 30 packages in dependency order.
- Verify on npm:
npm view @docmd/core dist-tagsshows{ latest: '0.8.9' }. - Drop the
file:../docmd-searchoverride inpackage.json#pnpm.overrides(the root devDep can stay at^0.1.0-alpha.1or move to^0.1.0).