mikser-io 6.27.0 → 7.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  **Mikser is the content layer of your application.** Business logic, user accounts, transactions live in their own services; mikser handles the parts that *are* content — pages, docs, the published catalog, multi-format outputs. [Vue](https://github.com/almero-digital-marketing/mikser-io-sdk-vue), [React](https://github.com/almero-digital-marketing/mikser-io-sdk-react), and [Svelte](https://github.com/almero-digital-marketing/mikser-io-sdk-svelte) SDKs are the seam between them — same surface across all three (`useDocument`, `useDocuments`, multilingual `useHref`, live SSE updates), each in its framework's idiomatic shape.
8
8
 
9
- Built for Node.js around a strict lifecycle, a composable plugin system, and direct control over every output. Every document, asset, and template flows through the same deterministic pipeline. Plugins hook in at any phase; nothing runs outside the cycle. It scales from a single markdown blog to a multi-language, multi-format publishing platform with image / video / AI pipelines, live SSE-driven editors, semantic search, and typed frontend contracts and stays predictable in both directions.
9
+ Built for Node.js around a strict lifecycle and a composable plugin system. Every document, asset, and template flows through the same pipeline; plugins hook in at any phase. The same engine runs a single markdown blog and a multi-language publishing platform with PDF / email / AI-augmented asset pipelinessame lifecycle, more plugins.
10
10
 
11
11
  It's MIT-licensed, runs on Node 18+, has zero hosted dependencies, and the entire content tree it manages is a folder of `.md` and `.yml` files you can copy, diff, and version-control. **The portability promise is the architecture, not a feature.**
12
12
 
@@ -30,25 +30,27 @@ Build mikser into the parts of your application that are content-shaped. Keep th
30
30
 
31
31
  **Your content stays yours.** Source files live on disk as `.md`, `.yml`, `.html` with YAML front-matter. The build output is plain static files. No database lock-in, no proprietary export format. The whole content tree is copyable, diffable, and version-controllable with git — your site is portable on day one and on year ten.
32
32
 
33
+ **Static-first with a built-in live channel.** Most content engines pick one side: static-site generators (Hugo, Eleventy, Jekyll) are fast but rebuild-only; headless CMSes (Sanity, Contentful, Strapi) are live but every page is an API round-trip. Mikser composes both — content publishes as static files by default (fast first paint, no API on the happy path), and the live channel arrives on top, so edits show up in connected clients without a refresh and without losing the static advantage.
34
+
33
35
  **Incremental builds that scale.** Mikser tracks every entity in a journal. When a file changes, only the affected entities re-process — not the whole site graph. On 10k+ documents this dramatically outpaces tools that rebuild more on every change.
34
36
 
35
37
  **Concurrent rendering.** Renders fan out across a worker pool that keeps every CPU core hot. Multi-format outputs (HTML, PDF, MJML email, etc.) generate in parallel from the same source.
36
38
 
37
- **Image, video, and AI pipelines authored as pluginsnot configured.** The `assets` plugin runs user-written preset modules over binary inputs. A preset is a plain Node module: ~10 lines around `sharp` resize an image, ~10 lines around `fluent-ffmpeg` transcode a video, ~30 lines around the Replicate API run an AI upscaler. The pipeline isn't a fixed menu of operations — it's whatever Node can call. Most SSGs cap your asset processing at "resize and convert format." Mikser caps it at "what can Node do." Compose with the `resources` plugin and your DAM, CDN, or company content server becomes an upstream input — uploaded files end up transcoded, watermarked, AI-enhanced, and deployed without manual handoff. See [the assets / resources docs](./documentation/plugins.md#assets) for end-to-end examples.
39
+ **Asset pipelines are whatever Node can do.** Most static frameworks (Astro, Next.js, Hugo) ship image optimization and stop there video transcoding, AI upscaling, watermarking all need a separate service. Mikser runs user-written modules over binary inputs: ~10 lines around `sharp` resize an image, ~10 around `fluent-ffmpeg` transcode a video, ~30 around the Replicate API upscale with AI. Anything an npm package can do, your pipeline can do including pulling uploads from a DAM or CDN through the same flow.
38
40
 
39
41
  **One lifecycle, everything composes.** Plugins hook into 20+ named lifecycle phases. A search-indexing plugin shares the same journal iteration as an email-rendering plugin and a PDF-postprocessing plugin — no glue code, no orchestration layer. The engine doesn't know which plugins are loaded; plugins don't have to know about each other.
40
42
 
41
43
  **Run anywhere.** The same CLI handles one-shot builds, watch-mode dev loops, and a long-running HTTP server with a shared Express app. `npx mikser` ships a static site; `mikser --watch` is the dev loop; `mikser --server` exposes a live admin/API.
42
44
 
43
- **Survives backend outages.** The `api` plugin's [per-query disk cache](./documentation/caching.md) writes every cacheable list response to `out/` keyed by the request URL. A stock-nginx reverse proxy can fail over to the cached file when mikser is unreachable same URL, transparent to the client, no Lua or extra modules required. Production frontends keep rendering routes during deploys, brief outages, and the upstream-blip-of-the-week. Opt in per endpoint with `cache: true`; see [Caching and reverse-proxy failover](./documentation/caching.md) for the working config.
45
+ **Outages don't take you down.** Headless CMSes (Contentful, Sanity, Strapi) treat the API as the source of truth when it blinks, every frontend errors out. Mikser inverts that: reads become static files on disk, the live channel layers on top. A reverse proxy keeps serving the files when mikser blips. Visitors don't notice; live updates pause until mikser returns.
44
46
 
45
- **Library mode.** Mikser is also a library. `useRenderer`, `useCollection`, `findSimilar`, and direct lifecycle hooks let you embed the engine inside an existing Node app instead of running it as a CLI.
47
+ **Library mode.** Mikser is also a library. `useRenderer`, `useCollection`, and direct lifecycle hooks let you embed the engine inside an existing Node app instead of running it as a CLI — plugins like `vector` add their own primitives the same way.
46
48
 
47
49
  **Open source.** MIT-licensed, on GitHub, no telemetry, no auth wall, no SaaS dependency. What you see is what runs.
48
50
 
49
51
  ## Built for AI-assisted development
50
52
 
51
- Files-as-source isn't just a portability story — it makes the project unusually friendly to AI coding agents. Most setups lose time configuring an agent's access to the data: tokens, schemas, MCP servers, sandboxed query environments. Mikser sidesteps all of it because the content is text files the agent can already read with the tools it already has.
53
+ Files-as-source isn't just a portability story — it makes the project unusually friendly to AI coding agents. There's a static-time half (the agent reads your tree the way it reads any repo — no DB connection, no schema upload, no sandboxed query layer to learn) and a runtime half (when the agent needs to write or render, mikser ships its own MCP server in core, so it talks to the live engine instead of a parallel REST shim you have to maintain).
52
54
 
53
55
  **Zero infra friction for discovery.** An agent can `rg "type: product"` across the content tree to find every product doc in a second. No DB connection, no API token, no schema file to parse.
54
56
 
@@ -60,10 +62,39 @@ Files-as-source isn't just a portability story — it makes the project unusuall
60
62
 
61
63
  **Plugin-by-example.** Authoring a new plugin? There are 15+ existing ones in the same shape to pattern-match against. Convention is dense enough that new plugins look like the old ones without coaching.
62
64
 
63
- **One-shot bootstrap via Claude Code.** The [`mikser-io-claude-plugin`](https://github.com/almero-digital-marketing/mikser-io-claude-plugin) wraps the setup story above into a single skill. Register the repo as a Claude Code marketplace and install the plugin (`/plugin marketplace add almero-digital-marketing/mikser-io-claude-plugin` then `/plugin install mikser-io-claude-plugin@mikser-io`), then in any Vue 3, React, or SvelteKit project — or in a blank directory — say "add mikser to this app." It detects the framework (or scaffolds a fresh starter via `create-vite` / `sv create`), wires the matching framework SDK, composes with your existing router rather than replacing it, and optionally lays down a `mikser-content/` sibling folder with Zod schemas and starter documents so the backend works on first run.
65
+ **One-shot bootstrap via Claude Code.** The [`mikser-io-claude-plugin`](https://github.com/almero-digital-marketing/mikser-io-claude-plugin) wraps the whole setup into a single skill. Register the marketplace, install once:
66
+
67
+ ```
68
+ /plugin marketplace add almero-digital-marketing/mikser-io-claude-plugin
69
+ /plugin install mikser-io-claude-plugin@mikser-io
70
+ ```
71
+
72
+ …then in any Vue 3, React, or SvelteKit project — or in a blank directory — say *"add mikser to this app."* It detects the framework (or scaffolds a fresh starter via `create-vite` / `sv create`), wires the matching framework SDK without replacing your router, and optionally drops a `mikser-content/` sibling folder with Zod schemas and starter documents so the backend works on first run.
73
+
74
+ The runtime half — the agent driving the live engine, not just reading the tree — gets its own section below.
64
75
 
65
76
  The honest caveat: this advantage is real on **content-shaped work** — adding pages, restructuring collections, generating new layouts, building frontends. It doesn't make mikser better for non-content tasks (concurrency bugs in the worker pool, database tuning elsewhere in your stack); those are plain Node debugging like anywhere else. The visibility advantage also degrades past ~10k documents — at that scale the agent queries via the SDK instead of grepping the tree, which is still good but less "see everything at once."
66
77
 
78
+ ## Control mikser from your AI agent
79
+
80
+ Add `--mcp` to your mikser command and any MCP-speaking client — Claude Desktop, Claude Code, ChatGPT, custom agents — connects to the running engine. From inside a chat, your AI can:
81
+
82
+ - read every entity in the catalog
83
+ - write new content files (markdown, layouts, configuration) — writes land on disk and the next cycle picks them up
84
+ - render any layout for preview without touching the output folder
85
+ - watch every build log as it streams past
86
+ - introspect engine state — current lifecycle phase, effective config, recent log buffer
87
+
88
+ Plugins extend the tool surface the same way they mount HTTP routes; install the plugin, the agent gets new verbs. No glue code, no per-project agent wiring.
89
+
90
+ ```bash
91
+ mikser --server --mcp # mounts MCP at /mcp on the same port as --server
92
+ ```
93
+
94
+ What that feels like in practice: *"draft three hero-section variants and show me previews"* — three layouts written, three previews returned inline, one chat turn. *"Why did the build break?"* — the agent reads the rolling log buffer and answers from the same view your terminal sees. Operator, AI, and any observer dashboard share the same engine because mikser is single-tenant by design.
95
+
96
+ Full tool reference and twelve worked scenarios in [MCP — talking to mikser from AI](./documentation/mcp.md).
97
+
67
98
  ## Plugins on top of the engine
68
99
 
69
100
  The engine is what stays stable — the lifecycle, the catalog, the file-based content model. Plugins are independent npm packages sitting on the plugin API: some are essential to the SSG workflow, some give external systems HTTP access to the catalog, some are integrations that earn their keep on real projects, and some are probes that test how far the lifecycle stretches without touching the core. Install what a project needs; drop what it doesn't.
@@ -85,6 +116,7 @@ The engine is what stays stable — the lifecycle, the catalog, the file-based c
85
116
  |---|---|
86
117
  | `data` | JSON snapshots of entities / context / catalog, written to disk for static serving |
87
118
  | `api` | REST endpoints with sift-backed queries, per-endpoint tokens, optional render, opt-in [per-query disk cache](./documentation/caching.md) for reverse-proxy failover |
119
+ | `preview` | In-memory render cache + `GET /preview/:filename` route. Companion to the [`mikser_preview`](./documentation/mcp.md) MCP tool — transient render bytes served at a clickable URL, no filesystem footprint |
88
120
 
89
121
  **Integrations:**
90
122
 
@@ -92,7 +124,10 @@ The engine is what stays stable — the lifecycle, the catalog, the file-based c
92
124
  |---|---|
93
125
  | [`mikser-io-vector`](https://github.com/almero-digital-marketing/mikser-io-vector) | OpenAI embeddings + semantic search (sqlite-vec or pgvector) |
94
126
  | [`mikser-io-plugin-schemas`](https://github.com/almero-digital-marketing/mikser-io-plugin-schemas) | Zod-backed entity validation + auto-generated TypeScript declarations for the SDK |
95
- | `archive`, `mapper`, `live`, `aml` | Specialty integrations |
127
+ | [`mikser-io-archive`](https://github.com/almero-digital-marketing/mikser-io-archive) | Persist matching entities to YAML — audit trail, versioned content history, downstream export |
128
+ | `mapper` | Run config-supplied transforms over matched entities each cycle (in-core, generic transformation layer) |
129
+ | [`mikser-io-live`](https://github.com/almero-digital-marketing/mikser-io-live) | Lightweight dev server with browser auto-refresh — pair with `--watch` for the classic save→reload loop |
130
+ | [`mikser-io-aml`](https://github.com/almero-digital-marketing/mikser-io-aml) | Parse [ArchieML](https://archieml.org/) (the NYT/ProPublica format) into `entity.meta` for non-technical authors |
96
131
 
97
132
  **Integration probes** — wrap a substantial external project as a plugin to confirm the lifecycle is open enough to host it without core changes. Treat these as feasibility evidence, not as a statement about where mikser is heading:
98
133
 
@@ -127,22 +162,14 @@ Each SDK ships TypeScript declarations so client projects get autocomplete on fi
127
162
  npm install mikser-io
128
163
  ```
129
164
 
130
- ```js
131
- // mikser.config.js
132
- export default {
133
- plugins: ['documents', 'layouts'],
134
- layouts: {
135
- cleanUrls: true,
136
- },
137
- }
138
- ```
139
-
140
165
  ```bash
141
166
  npx mikser # one-shot build
142
167
  npx mikser --watch # incremental dev loop
143
168
  npx mikser --server # build + serve at :3001
144
169
  ```
145
170
 
171
+ For a working starter — config with a real plugin set, sample `documents/`, expected output — see [Getting Started](./documentation/getting-started.md). Or skip straight to "add mikser to this app" via the [Claude Code plugin](#built-for-ai-assisted-development) above.
172
+
146
173
  ## Core Concepts
147
174
 
148
175
  - **Lifecycle** — Processing runs through fixed phases: initialize → load → import → process → persist → render → finalize. Plugins hook into any phase.
@@ -166,12 +193,18 @@ The shape mikser **doesn't** fit cleanly: anything with non-technical content au
166
193
 
167
194
  ## Engineering discipline
168
195
 
169
- A few things this project takes seriously:
196
+ What you get from how this project is built:
197
+
198
+ - **Every load-bearing decision has an ADR.** The [`decisions/`](./documentation/decisions/) folder names which choices are structural — files-as-source, journal+catalog split, plugin-as-factory, when something goes in core vs. ships as a plugin — and explains what protects them. When you push against one, there's a written answer waiting instead of folklore.
199
+ - **Engine stays small; capability ships in plugins.** The 15+ plugin ecosystem adds features without core changes, so your upgrade cost stays low. Probes like `decap` (a full third-party CMS mounted in ~150 lines, zero engine changes) are deliberate evidence the extension model holds where it counts.
200
+ - **Builds are deterministic; no async middleware layer.** The journal is the only synchronization primitive — no event bus, no IoC container, no orchestrator running plugins in surprising order. The lifecycle is a list of named phases; "what ran when?" has an answer you can read off the source.
201
+ - **The whole engine is one read.** The [Architecture Overview](./documentation/overview.md) walks the full pipeline top to bottom. Onboarding a new engineer is an afternoon, not a tour through fifteen reference docs.
202
+
203
+ ## Acknowledgments
204
+
205
+ The earliest version of mikser was inspired by [DocPad](https://github.com/docpad/docpad) (Benjamin Lupton, with Michael Duane Mooring and Rob Loach). DocPad's "freeway, not a box" philosophy — files on disk, any pre-processor or template engine, plugin-by-convention extension — shaped how mikser started.
170
206
 
171
- - **ADRs for load-bearing decisions.** The [`decisions/`](./documentation/decisions/) folder names which choices are structural files-as-source, journal+catalog split, plugin-as-factory, compose-via-protocols and explains what protects them. Read those before proposing a feature that pushes against one.
172
- - **Engine stability, plugin churn.** The 15+ plugins in the ecosystem add capability without core changes. The integration probes (e.g. `decap`, mounting a third-party CMS in ~150 lines) are deliberate evidence that the extension model holds.
173
- - **Deterministic builds.** The journal is the synchronization primitive. There's no event-passing layer, no IoC container, no plugin orchestrator. The engine knows how to fire phases in order; everything else falls out of that.
174
- - **The mental model is one document.** The [Architecture Overview](./documentation/overview.md) is one read for the full top-to-bottom picture. The reference docs exist for lookup; the overview exists for comprehension.
207
+ Mikser itself has a previous chapter: the [legacy 7.x line](https://github.com/almero-digital-marketing/mikser) (last release 2022) introduced the real-time SSG model the current engine still carries forward. The redesign dropped MongoDB (the catalog lives in-process now, not in a database), modernized to Node 18+ ESM with a structured 20-phase lifecycle, added the live SSE channel that powers the framework SDKs, and replaced cluster-based rendering with an async worker pool. Same intent content as files, real-time previews, multi-format output at scale — clearer foundations.
175
208
 
176
209
  ## Documentation Index
177
210
 
@@ -185,6 +218,7 @@ A few things this project takes seriously:
185
218
  | [Entities](./documentation/entities.md) | Users & Developers | Entity model, operations, journal, catalog |
186
219
  | [Rendering](./documentation/rendering.md) | Users & Developers | Render pipeline, render plugins, render modes |
187
220
  | [Watch Mode](./documentation/watch-mode.md) | Users | File watching, scheduled tasks, incremental builds |
221
+ | [MCP](./documentation/mcp.md) | Users | The `--mcp` server — tool surface, `mikser://` resources, twelve worked AI-driven scenarios |
188
222
  | [Caching](./documentation/caching.md) | Users (production) | The `cache: true` disk cache + working nginx config for reverse-proxy failover |
189
223
  | [Architecture](./documentation/architecture.md) | Developers | Module-level reference — what's in each file |
190
224
  | [API Reference](./documentation/api-reference.md) | Developers | Complete public API reference |
package/index.js CHANGED
@@ -9,4 +9,6 @@ export * from './src/plugins.js'
9
9
  export * from './src/manager.js'
10
10
  export * from './src/tracking.js'
11
11
  export * from './src/engine.js'
12
- export * from './src/api.js'
12
+ export * from './src/api.js'
13
+ export * from './src/source.js'
14
+ export * from './src/mcp.js'
package/ngrok.ymlt ADDED
@@ -0,0 +1,3 @@
1
+ version: 3
2
+ agent:
3
+ authtoken: 3EcICPaDtSN54h63Y5v8g2zKOJ9_5oMnCdXyPmMmKVXH9ba1c
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "6.27.0",
3
+ "version": "7.6.0",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -20,13 +20,14 @@
20
20
  "author": "",
21
21
  "license": "ISC",
22
22
  "dependencies": {
23
- "@budibase/handlebars-helpers": "^0.14.2",
23
+ "@budibase/handlebars-helpers": "^0.14.3",
24
+ "@modelcontextprotocol/sdk": "^1.29.0",
24
25
  "await-semaphore": "^0.1.3",
25
- "axios": "^1.16.0",
26
+ "axios": "^1.17.0",
26
27
  "chokidar": "^5.0.0",
27
28
  "cli-progress": "^3.12.0",
28
- "commander": "^14.0.3",
29
- "dayjs": "^1.11.20",
29
+ "commander": "^15.0.0",
30
+ "dayjs": "^1.11.21",
30
31
  "deepdash": "^5.3.9",
31
32
  "escape-string-regexp": "^5.0.0",
32
33
  "execa": "^9.6.1",
@@ -42,18 +43,18 @@
42
43
  "minimatch": "^10.2.5",
43
44
  "node-cron": "^4.2.1",
44
45
  "p-map": "^7.0.4",
45
- "p-queue": "^9.2.0",
46
+ "p-queue": "^9.3.0",
46
47
  "pino": "^10.3.1",
47
48
  "pino-pretty": "^13.1.3",
48
49
  "piscina": "^5.1.4",
49
50
  "sift": "^17.1.3",
50
51
  "sqlite3": "^6.0.1",
51
52
  "truncate-stream": "^1.0.2",
52
- "yaml": "^2.8.4"
53
+ "yaml": "^2.9.0"
53
54
  },
54
55
  "optionalDependencies": {
55
56
  "express": "^5.2.1",
56
- "puppeteer": "^24.43.0"
57
+ "puppeteer": "^25.1.0"
57
58
  },
58
59
  "devDependencies": {
59
60
  "fluent-ffmpeg": "^2.1.3",
@@ -62,8 +63,10 @@
62
63
  "mikser-io-render-eta": "file:../mikser-io-render-eta",
63
64
  "mikser-io-render-liquid": "file:../mikser-io-render-liquid",
64
65
  "mikser-io-render-markdown": "file:../mikser-io-render-markdown",
66
+ "mikser-io-schemas": "file:../mikser-io-schemas",
65
67
  "mikser-io-vector": "file:../mikser-io-vector",
66
- "sharp": "^0.34.5"
68
+ "sharp": "^0.34.5",
69
+ "zod": "^4.4.3"
67
70
  },
68
71
  "directories": {
69
72
  "test": "test"
package/src/api.js CHANGED
@@ -37,10 +37,20 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
37
37
  const remaining = new Map(batch.map(b => [b.correlationId, b]))
38
38
  const completedHooks = runtime.hooks.completed
39
39
  const hook = async (entry) => {
40
- const cid = entry.entity?._correlationId
40
+ const cid = entry.entity?.options?.correlationId
41
41
  if (!cid) return
42
42
  const item = remaining.get(cid)
43
43
  if (!item) return
44
+ // Only resolve on the FINAL completion. For postprocessor-
45
+ // equipped entities the engine fires runtime.complete twice:
46
+ // once after render (intermediate bytes, entity.origin not
47
+ // set) and once after postprocess (final bytes, entity.origin
48
+ // set to the intermediate destination). useRenderer's
49
+ // contract is "return the pipeline's final output", so we
50
+ // skip the intermediate fire and wait for the final.
51
+ const hasPostprocessor = entry.entity?.layout?.postprocessor
52
+ const isFinal = !hasPostprocessor || entry.entity?.origin != null
53
+ if (!isFinal) return
44
54
  remaining.delete(cid)
45
55
  clearTimeout(item.timer)
46
56
  item.resolve({ output: entry.output, entity: entry.entity })
@@ -99,13 +109,24 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
99
109
  * skip the final disk write; the bytes still come back via
100
110
  * `output.result` for you to pipe wherever you want (HTTP
101
111
  * response, S3, …). For layouts with a postprocessor (e.g.
102
- * `*.html-pdf.*`), the intermediate file is still written so the
103
- * postprocessor can consume it; only the FINAL output is skipped.
112
+ * `*.html-pdf.*`), the intermediate is written to a scratch path
113
+ * under `runtime.options.previewFolder` (engine-owned, never
114
+ * in outputFolder) so the postprocessor can consume it; only
115
+ * the FINAL output is skipped from disk. `output.result` is
116
+ * always the FINAL pipeline output — PDF bytes for a
117
+ * `*.html-pdf.*` layout, MJML-derived HTML for
118
+ * `*.html-mjml.*`, etc., not the intermediate.
104
119
  *
105
120
  * The rendered output's bytes are always returned in `output.result`
106
121
  * regardless of either flag — `save` only affects whether they also
107
122
  * end up on disk.
108
123
  *
124
+ * Per-entity engine state (correlation id, control flags) lives at
125
+ * `entity.options.*` — same noun mikser uses for engine config
126
+ * (`runtime.options`) and plugin params, scoped to one entity's
127
+ * pass through the lifecycle. Consumers should not set
128
+ * `entity.options.correlationId` themselves; useRenderer owns it.
129
+ *
109
130
  * @param {object} entity - any entity-shaped object
110
131
  * @param {object} [opts]
111
132
  * @param {number} [opts.timeout] - override the default timeout
@@ -116,12 +137,25 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
116
137
  async function render(entity, { timeout = defaultTimeout, catalog = true, save = true } = {}) {
117
138
  const result = await new Promise((resolve, reject) => {
118
139
  const correlationId = randomUUID()
119
- const stamped = { ...entity, _correlationId: correlationId }
120
- // Only stamp _save when explicitly opting out — keeps the
121
- // entity object clean for the common case.
122
- if (save === false) stamped._save = false
140
+ // Engine-set fields live under entity.options. The caller's
141
+ // render(entity, { save: false }) becomes
142
+ // entity.options.save = false here same noun mikser uses
143
+ // for engine config (runtime.options) and plugin params,
144
+ // just scoped to one entity's pass through the lifecycle.
145
+ //
146
+ // Only set `save` when explicitly opting out — leaves the
147
+ // entity.options as a clean { correlationId } in the common
148
+ // case rather than carrying a redundant save:true.
149
+ const prepared = {
150
+ ...entity,
151
+ options: {
152
+ ...entity.options,
153
+ correlationId,
154
+ ...(save === false ? { save: false } : {}),
155
+ },
156
+ }
123
157
  pending.push({
124
- entity: stamped,
158
+ entity: prepared,
125
159
  correlationId,
126
160
  timeout,
127
161
  resolve,
package/src/catalog.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import runtime from './runtime.js'
2
2
  import { useLogger } from './engine.js'
3
- import { onLoaded, onPersist, onFinalized } from './lifecycle.js'
3
+ import { onInitialized, onPersist, onFinalized } from './lifecycle.js'
4
4
  import { useJournal } from './journal.js'
5
5
  import { OPERATION } from './constants.js'
6
6
  import { Low } from 'lowdb'
@@ -10,7 +10,13 @@ import _ from 'lodash'
10
10
 
11
11
  let catalog
12
12
 
13
- onLoaded(async () => {
13
+ // Same reasoning as journal.js — initialize the catalog in
14
+ // onInitialized so every plugin hook from onLoad onwards can safely
15
+ // call findEntity / findEntities and write through the journal.
16
+ // catalog.js imports useJournal from journal.js, so journal.js's
17
+ // onInitialized registers first within the phase — its hook runs
18
+ // before this one.
19
+ onInitialized(async () => {
14
20
  const adapter = new JSONFile(path.join(runtime.options.runtimeFolder, `catalog.json`))
15
21
  catalog = new Low(adapter, {
16
22
  entities: [],
@@ -27,10 +33,21 @@ onPersist(async () => {
27
33
  logger.trace('Database %s %s: %s', entity.collection, operation, entity.id)
28
34
  catalog.data.entities.push(entity)
29
35
  break
30
- case OPERATION.UPDATE:
36
+ case OPERATION.UPDATE: {
31
37
  logger.trace('Database %s %s: %s', entity.collection, operation, entity.id)
32
- catalog.chain.get('entities').find({ id: entity.id }).assign(entity).value()
38
+ // Upsert semantics: if the entity doesn't already exist,
39
+ // treat UPDATE as CREATE. Plugins that "ensure an entity
40
+ // is in the catalog" can call runtime.update without a
41
+ // findEntity-then-branch dance. Previously a no-op when
42
+ // the id was new, which was a silent footgun.
43
+ const existing = catalog.chain.get('entities').find({ id: entity.id }).value()
44
+ if (existing) {
45
+ catalog.chain.get('entities').find({ id: entity.id }).assign(entity).value()
46
+ } else {
47
+ catalog.data.entities.push(entity)
48
+ }
33
49
  break
50
+ }
34
51
  case OPERATION.DELETE:
35
52
  logger.trace('Database %s %s: %s', entity.collection, operation, entity.id)
36
53
  catalog.chain.get('entities').remove({ id: entity.id }).value()
package/src/engine.js CHANGED
@@ -49,6 +49,7 @@ export async function setup(options) {
49
49
  .option('-s --server [port]', 'start an Express server on the given port (defaults to 3001)')
50
50
  .option('--cors [origin]', 'restrict server CORS to a specific origin (default *)')
51
51
  .option('--no-cors', 'disable server CORS headers')
52
+ .option('--mcp [path]', 'enable MCP server (mounts at <path>, default /mcp)')
52
53
 
53
54
  Object.assign(runtime.options, options || runtime.engine.commander.parse(process.argv).opts())
54
55
  runtime.options.info = true
@@ -62,23 +63,28 @@ export async function setup(options) {
62
63
  runtime.options.info = false
63
64
  }
64
65
  runtime.engine.logger.notice = runtime.engine.logger.info
65
- })
66
-
67
- onInitialized(async () => {
68
- const logger = useLogger()
69
66
 
67
+ // Resolve folders inside onInitialize so journal.js and
68
+ // catalog.js (which initialize in onInitialized) see absolute
69
+ // paths and a guaranteed-existing runtimeFolder. The split is
70
+ // deliberate: engine's onInitialize does setup that the rest of
71
+ // the engine infrastructure depends on; onInitialized does
72
+ // things plugins may need.
70
73
  runtime.options.workingFolder = path.resolve(runtime.options.workingFolder)
71
74
  process.chdir(runtime.options.workingFolder)
72
75
 
73
76
  runtime.options.runtimeFolder = path.join(runtime.options.workingFolder, runtime.options.runtimeFolder || 'runtime')
74
77
  runtime.options.outputFolder = path.join(runtime.options.workingFolder, runtime.options.outputFolder || 'out')
75
-
76
- logger.info('Working folder: %s', runtime.options.workingFolder)
77
- logger.info('Output folder: %s', runtime.options.outputFolder)
78
+ // Scratch path for intermediate render artifacts when a caller
79
+ // opted out of disk writes via render({ save: false }) but the
80
+ // layout has a postprocessor that still needs to read the
81
+ // intermediate. Lives under runtimeFolder so it's engine-owned
82
+ // and never appears in outputFolder.
83
+ runtime.options.previewFolder = path.join(runtime.options.runtimeFolder, 'preview')
78
84
 
79
85
  if (runtime.options.clear) {
80
86
  try {
81
- logger.info('Clearing folders')
87
+ runtime.engine.logger.info('Clearing folders')
82
88
  await rm(runtime.options.outputFolder, { recursive: true })
83
89
  await rm(runtime.options.runtimeFolder, { recursive: true })
84
90
  } catch (err) {
@@ -87,6 +93,13 @@ export async function setup(options) {
87
93
  }
88
94
  }
89
95
  await mkdir(runtime.options.runtimeFolder, { recursive: true })
96
+ })
97
+
98
+ onInitialized(async () => {
99
+ const logger = useLogger()
100
+
101
+ logger.info('Working folder: %s', runtime.options.workingFolder)
102
+ logger.info('Output folder: %s', runtime.options.outputFolder)
90
103
 
91
104
  // Server bring-up: two paths, controlled by which of these are set.
92
105
  //
@@ -146,6 +159,35 @@ export async function setup(options) {
146
159
  logger.info('CORS enabled: %s', origin)
147
160
  }
148
161
  }
162
+
163
+ // MCP substrate — same engine-provides-transport, plugins-
164
+ // register-tools shape as Express. See
165
+ // documentation/decisions/0006-when-to-add-to-core.md for
166
+ // the justification. The substrate object is exposed at
167
+ // runtime.options.mcp; plugins use it directly via the SDK
168
+ // (server.registerTool / server.registerResource). The
169
+ // transport is mounted later, after plugin routes have had
170
+ // a chance to register (see the onLoad below that handles
171
+ // static serving + listen).
172
+ if (runtime.options.mcp) {
173
+ try {
174
+ const { createMcpSubstrate, wireLoggerToMcp } = await import('./mcp.js')
175
+ runtime.options.mcpPath = typeof runtime.options.mcp === 'string'
176
+ ? runtime.options.mcp
177
+ : '/mcp'
178
+ runtime.options.mcp = createMcpSubstrate()
179
+
180
+ // Fan every engine log call out to MCP clients as
181
+ // `notifications/message`. Wraps in-place so the
182
+ // existing logger reference (held by plugins, render
183
+ // workers, useLogger consumers) gains the side-channel
184
+ // automatically — no second logger to thread through.
185
+ wireLoggerToMcp(runtime.engine.logger, runtime.options.mcp)
186
+ logger.info('MCP substrate ready (mounts at %s when server is up)', runtime.options.mcpPath)
187
+ } catch (err) {
188
+ logger.error('Failed to enable MCP: %s', err.message)
189
+ }
190
+ }
149
191
  })
150
192
 
151
193
  // Registered here (inside setup) so it runs AFTER plugins.js's onLoad
@@ -165,6 +207,15 @@ export async function setup(options) {
165
207
  const logger = useLogger()
166
208
  const { default: express } = await import('express')
167
209
 
210
+ // Mount MCP transport (if active) BEFORE the static
211
+ // catch-all so /mcp isn't swallowed. After this runs the
212
+ // server is fully reachable over HTTP for AI clients.
213
+ if (runtime.options.mcp && runtime.options.mcpPath) {
214
+ const { mountMcpOnExpress } = await import('./mcp.js')
215
+ await mountMcpOnExpress(runtime.options.app, runtime.options.mcp, runtime.options.mcpPath)
216
+ logger.info('MCP mounted: %s', runtime.options.mcpPath)
217
+ }
218
+
168
219
  // Serve the output folder as the catch-all static route.
169
220
  // Mounted LAST in the middleware chain so plugin routes
170
221
  // (e.g. /api/*) match first; anything that didn't match a
@@ -370,7 +421,22 @@ export async function setup(options) {
370
421
  origin: entity.destination,
371
422
  destination
372
423
  },
373
- options: { postprocessor: options.postprocessor, tasks: options.tasks },
424
+ options: {
425
+ postprocessor: options.postprocessor,
426
+ tasks: options.tasks,
427
+ // When the originating render call passed
428
+ // `save: false`, the layouts plugin wrote the
429
+ // intermediate into runtime.options.previewFolder
430
+ // rather than outputFolder. Postprocess plugins
431
+ // resolve `entity.origin` against
432
+ // `options.outputFolder`, so swap it here so
433
+ // they look in the right place. No change for
434
+ // normal builds (entity.options.save unset →
435
+ // outputFolder).
436
+ ...(entity.options?.save === false
437
+ ? { outputFolder: runtime.options.previewFolder }
438
+ : {}),
439
+ },
374
440
  context
375
441
  })
376
442
  }
package/src/journal.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import runtime from './runtime.js'
2
- import { onLoaded, onCancelled, onFinalized } from './lifecycle.js'
2
+ import { onInitialized, onCancelled, onFinalized } from './lifecycle.js'
3
3
  import { unlink } from 'fs/promises'
4
4
  import knex from 'knex'
5
5
  import path from 'path'
@@ -81,7 +81,15 @@ export async function clearJournal(aborted) {
81
81
  }
82
82
  }
83
83
 
84
- onLoaded(async () => {
84
+ // Initialize the journal in onInitialized rather than onLoaded.
85
+ // The engine resolves runtime.options.runtimeFolder in its own
86
+ // onInitialized hook; ours runs after (engine.js is imported before
87
+ // any plugin loads, so its onInitialized registers first). This way
88
+ // every plugin hook from onLoad onwards — including plugin onLoaded
89
+ // — can safely call runtime.create / runtime.update without
90
+ // depending on the order in which journal.js's module registered
91
+ // relative to theirs.
92
+ onInitialized(async () => {
85
93
  const filename = path.join(runtime.options.runtimeFolder, `journal.db`)
86
94
  try {
87
95
  await unlink(filename)