mikser-io 9.66.0 → 9.67.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/CLAUDE.md CHANGED
@@ -242,7 +242,11 @@ brevity.
242
242
  EINVAL`. chmod 0600, so the filesystem permission is the access
243
243
  decision. A forwarded build RESCANS (`runtime.rebuild()`), never
244
244
  drains: a client can beat the inotify event for the file it just
245
- wrote. Exit code comes from `renderErrorCount()`, not
245
+ wrote. Report-only commands (`--tool`, `--tools`, `--verify`,
246
+ `--explain`) forward too — they read, so a local run damaged nothing,
247
+ but a catalogue another process is mid-write in is not one anyone can
248
+ answer from. `runReportOnly()` in engine.js is the one implementation
249
+ both paths call. Exit code comes from `renderErrorCount()`, not
246
250
  `process.exitCode` — the engine suppresses that in watch mode by
247
251
  design. Config mismatch is refused by resolved PATH; config drift
248
252
  under a running instance is detected by stat over `configCoverage`.
package/app.js CHANGED
@@ -24,27 +24,37 @@ function locate(argv) {
24
24
  }
25
25
  return null
26
26
  }
27
- const has = (...names) => names.some(n => argv.includes(n))
27
+ const has = (...names) => names.some(n => argv.includes(n) || argv.some(a => names.some(x => a.startsWith(`${x}=`))))
28
+
29
+ // What to ask the instance for. Report-only commands go over the same
30
+ // socket as a build: they read, so running them locally never damaged
31
+ // anything, but a catalogue being written by another process is not a
32
+ // catalogue anyone can answer from — a --verify against a half-finished
33
+ // cycle reports drift that is not there.
34
+ const tool = value('--tool')
35
+ const explain = value('--explain')
36
+ const request = has('--tools') ? { type: 'report', tools: true, json: has('--json') }
37
+ : tool ? { type: 'report', tool, toolArgs: value('--tool-args'), json: has('--json') }
38
+ : explain ? { type: 'report', explain, json: has('--json') }
39
+ : has('--verify') ? { type: 'report', verify: true, json: has('--json') }
40
+ : { type: 'build', clear: has('--clear') }
41
+
28
42
  return {
29
43
  workingFolder: value('--working-folder', '-i') ?? '.',
30
44
  config: value('--config', '-c') ?? 'mikser.config.js',
31
- clear: has('--clear'),
32
45
  // Commander's negated form: `attach` is true unless --no-attach said so.
33
46
  attach: has('--no-attach') ? false : true,
34
- // Report-only runs read; they do not write the catalogue or the output
35
- // tree, and their handlers exit the process themselves. Left local —
36
- // the guard in setup() still says an instance is there.
37
- reportOnly: has('--tool', '--tools', '--verify', '--explain'),
47
+ request,
38
48
  }
39
49
  }
40
50
 
41
51
  async function main() {
42
52
  const where = locate(process.argv.slice(2))
43
- if (where.attach !== false && !where.reportOnly) {
53
+ if (where.attach !== false) {
44
54
  const code = await forward({
45
55
  workingFolder: path.resolve(where.workingFolder),
46
56
  config: path.resolve(where.workingFolder, where.config),
47
- clear: where.clear,
57
+ request: where.request,
48
58
  })
49
59
  // null means nobody was listening — carry on exactly as before.
50
60
  if (code !== null) process.exit(code)
@@ -759,6 +759,14 @@ Three things it refuses or reports rather than guessing:
759
759
  - **A folder held by someone else.** `--no-attach` runs a private engine
760
760
  anyway — for checking that a cold start works — and says the folder is held.
761
761
 
762
+ `--tool`, `--tools`, `--verify` and `--explain` forward as well, and for a
763
+ different reason than builds do. They only read, so running one locally never
764
+ damaged anything — it just could not be trusted: on a large site a local
765
+ `--verify` reads a catalogue the instance is halfway through writing and
766
+ reports drift that is a cycle in progress. The instance has the settled state
767
+ and the config that produced it. Exit codes cross the socket unchanged, so
768
+ `--explain` still answers 3 for an entity that is not there.
769
+
762
770
  A forwarded build **rescans**; it does not drain what the watcher happened to
763
771
  queue. A client that writes a file and immediately asks can beat the file
764
772
  event, and draining would then build without the change that prompted the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.66.0",
3
+ "version": "9.67.0",
4
4
  "description": "A mixer for content: entities in, configurable render pipelines, outputs of any kind. Static sites are the canonical recipe, not the definition — the same engine renders PDFs, emails and whatever a renderer plugin produces. Files are the source of truth, every lifecycle phase is observable, and the build graph is queryable by an agent.",
5
5
  "main": "index.js",
6
6
  "exports": {
package/src/engine.js CHANGED
@@ -89,6 +89,127 @@ function workerSafeOptions(opts) {
89
89
  return result
90
90
  }
91
91
 
92
+ // The report-only commands, as functions that RETURN their exit code.
93
+ //
94
+ // They used to be inline here and call process.exit, which is fine for a
95
+ // process whose only job is to answer one question and stop. It is not fine
96
+ // for the instance that has to answer the same question on behalf of a client
97
+ // and stay alive — and answering it there is the point, because a local run
98
+ // reads a catalogue another process is in the middle of writing.
99
+ //
100
+ // `request` carries the CLIENT's arguments. Reading runtime.options here would
101
+ // answer with the instance's own flags, which are whatever it happened to be
102
+ // started with.
103
+ export async function runReportOnly(request = {}) {
104
+ const logger = useLogger()
105
+ const {
106
+ tools = runtime.options.tools,
107
+ tool = runtime.options.tool,
108
+ toolArgs = runtime.options.toolArgs,
109
+ json = runtime.options.json,
110
+ explain = runtime.options.explain,
111
+ verify = runtime.options.verify,
112
+ } = request
113
+
114
+ if (tools) {
115
+ const schemas = toolSchemas()
116
+ if (json) {
117
+ process.stdout.write(JSON.stringify(schemas, null, 2) + '\n')
118
+ } else if (!schemas.length) {
119
+ logger.warn('No tools registered. The mcp plugin registers the standard set; '
120
+ + 'this flag only lists and invokes what is registered.')
121
+ } else {
122
+ for (const schema of schemas) {
123
+ process.stdout.write(`${schema.name}\n ${String(schema.description).split('\n')[0]}\n`)
124
+ }
125
+ }
126
+ return 0
127
+ }
128
+
129
+ if (tool) {
130
+ // An empty catalog answers every question with a confident nothing —
131
+ // `null`, `total: 0`, "no render claims this destination" — all of
132
+ // which read as "the thing you asked about does not exist" when the
133
+ // truth is "nothing has been built here yet". Said once, before the
134
+ // answer, so it cannot be missed.
135
+ const entityCount = (() => {
136
+ try {
137
+ return useDatabase().handle
138
+ .prepare('SELECT count(*) AS n FROM mikser_entities').get()?.n ?? 0
139
+ } catch { return null }
140
+ })()
141
+ if (entityCount === 0 && !runtime.manifest?.size?.()) {
142
+ logger.warn('The catalog and manifest are empty — no build has run in this working '
143
+ + 'folder. Tools answer from what the last build recorded, so this one will '
144
+ + 'report nothing found. Run a build first.')
145
+ }
146
+
147
+ let args = {}
148
+ if (toolArgs) {
149
+ try {
150
+ args = JSON.parse(toolArgs)
151
+ } catch (err) {
152
+ logger.error('--tool-args is not valid JSON: %s', err.message)
153
+ return 3
154
+ }
155
+ }
156
+ let result
157
+ try {
158
+ result = await invokeTool(tool, args)
159
+ } catch (err) {
160
+ logger.error('%s', err.message)
161
+ return 3
162
+ }
163
+ process.stdout.write(toolResultText(result) + '\n')
164
+ // A tool that reports failure must not exit 0 — an agent reading CLI
165
+ // output has only the exit code to branch on.
166
+ return toolResultFailed(result) ? 1 : 0
167
+ }
168
+
169
+ if (explain) {
170
+ // Exit codes:
171
+ // 0 — the entity was found and described
172
+ // 3 — not in the catalog (distinct from --verify's 1/2, which are
173
+ // about output drift; "no such entity" is neither clean nor
174
+ // corrupt, it is a question that could not be answered)
175
+ const { explain: explainEntity, formatExplain } = await import('./explain.js')
176
+ const report = await explainEntity(explain)
177
+ process.stdout.write((json ? JSON.stringify(report, null, 2) : formatExplain(report)) + '\n')
178
+ return report.found ? 0 : 3
179
+ }
180
+
181
+ if (verify) {
182
+ if (!runtime.manifest) {
183
+ logger.error('Verify: no manifest available — nothing to check against')
184
+ return 2
185
+ }
186
+ const { verdict, missing, mismatched, unverifiable, orphaned, collisions } =
187
+ await runtime.manifest.verify()
188
+ const total = runtime.manifest.size()
189
+
190
+ for (const e of missing) logger.error('Missing: %s (entity %s)', e.destination, e.id)
191
+ for (const e of mismatched) logger.error('Mismatched: %s (entity %s)%s', e.destination, e.id,
192
+ e.writtenBy ? ` — the bytes on disk are ${e.writtenBy}'s` : '')
193
+ for (const e of unverifiable) logger.warn('No hash: %s (entity %s)', e.destination, e.id)
194
+ for (const e of orphaned) logger.warn('Orphan: %s', e.path)
195
+ // Named per destination: "two entities write here" is only actionable
196
+ // if you know which two.
197
+ for (const c of collisions) logger.warn('Collision: %s ← %s', c.destination, c.entities.join(', '))
198
+
199
+ // Level picked from the verdict, because the level IS the marker in
200
+ // pino-pretty's messageFormat: notice renders 🟢, warn 🟡, error 🔴. A
201
+ // fixed `notice` prints a green tick next to the word FAIL, which
202
+ // reads as success at a glance even though the exit code is right.
203
+ const report = verdict === 'FAIL' ? logger.error : verdict === 'WARN' ? logger.warn : logger.notice
204
+ report.call(logger,
205
+ 'Verify %s: %d snapshots, %d missing, %d mismatched, %d unverifiable, %d orphaned, %d collisions',
206
+ verdict, total, missing.length, mismatched.length, unverifiable.length, orphaned.length, collisions.length)
207
+ return verdict === 'FAIL' ? 2 : verdict === 'WARN' ? 1 : 0
208
+ }
209
+
210
+ return null // not a report-only request
211
+ }
212
+
92
213
  export async function setup(options) {
93
214
  runtime.options.threads = options?.threads !== undefined ? options.threads : 4
94
215
  runtime.engine = {
@@ -291,57 +412,8 @@ export async function setup(options) {
291
412
  // registry is complete. Nothing is imported, because this exits first,
292
413
  // the same way --explain and --verify do.
293
414
  if (runtime.options.tools || runtime.options.tool) {
294
- if (runtime.options.tools) {
295
- const schemas = toolSchemas()
296
- if (runtime.options.json) {
297
- process.stdout.write(JSON.stringify(schemas, null, 2) + '\n')
298
- } else if (!schemas.length) {
299
- logger.warn('No tools registered. The mcp plugin registers the standard set; '
300
- + 'this flag only lists and invokes what is registered.')
301
- } else {
302
- for (const schema of schemas) {
303
- process.stdout.write(`${schema.name}\n ${String(schema.description).split('\n')[0]}\n`)
304
- }
305
- }
306
- process.exit(0)
307
- }
308
- // An empty catalog answers every question with a confident
309
- // nothing — `null`, `total: 0`, "no render claims this
310
- // destination" — all of which read as "the thing you asked about
311
- // does not exist" when the truth is "nothing has been built here
312
- // yet". Said once, before the answer, so it cannot be missed.
313
- const entityCount = (() => {
314
- try {
315
- return useDatabase().handle
316
- .prepare('SELECT count(*) AS n FROM mikser_entities').get()?.n ?? 0
317
- } catch { return null }
318
- })()
319
- if (entityCount === 0 && !runtime.manifest?.size?.()) {
320
- logger.warn('The catalog and manifest are empty — no build has run in this working '
321
- + 'folder. Tools answer from what the last build recorded, so this one will '
322
- + 'report nothing found. Run a build first.')
323
- }
324
-
325
- let args = {}
326
- if (runtime.options.toolArgs) {
327
- try {
328
- args = JSON.parse(runtime.options.toolArgs)
329
- } catch (err) {
330
- logger.error('--tool-args is not valid JSON: %s', err.message)
331
- process.exit(3)
332
- }
333
- }
334
- let result
335
- try {
336
- result = await invokeTool(runtime.options.tool, args)
337
- } catch (err) {
338
- logger.error('%s', err.message)
339
- process.exit(3)
340
- }
341
- process.stdout.write(toolResultText(result) + '\n')
342
- // A tool that reports failure must not exit 0 — an agent reading
343
- // CLI output has only the exit code to branch on.
344
- process.exit(toolResultFailed(result) ? 1 : 0)
415
+ const code = await runReportOnly()
416
+ if (code !== null) process.exit(code)
345
417
  }
346
418
  })
347
419
 
@@ -368,58 +440,11 @@ export async function setup(options) {
368
440
  // 3 — not in the catalog (distinct from --verify's 1/2, which are
369
441
  // about output drift; "no such entity" is neither clean nor
370
442
  // corrupt, it is a question that could not be answered)
371
- if (runtime.options.explain) {
372
- const { explain, formatExplain } = await import('./explain.js')
373
- const report = await explain(runtime.options.explain)
374
- if (runtime.options.json) {
375
- process.stdout.write(JSON.stringify(report, null, 2) + '\n')
376
- } else {
377
- process.stdout.write(formatExplain(report) + '\n')
378
- }
379
- process.exit(report.found ? 0 : 3)
380
- }
381
-
382
- if (runtime.options.explain) {
383
- const { explain, formatExplain } = await import('./explain.js')
384
- const report = await explain(runtime.options.explain)
385
- if (runtime.options.json) {
386
- process.stdout.write(JSON.stringify(report, null, 2) + '\n')
387
- } else {
388
- process.stdout.write(formatExplain(report) + '\n')
389
- }
390
- process.exit(report.found ? 0 : 3)
391
- }
392
-
393
-
394
- if (runtime.options.verify) {
395
- if (!runtime.manifest) {
396
- logger.error('Verify: no manifest available — nothing to check against')
397
- process.exit(2)
398
- }
399
- const { verdict, missing, mismatched, unverifiable, orphaned, collisions } =
400
- await runtime.manifest.verify()
401
- const total = runtime.manifest.size()
402
-
403
- for (const e of missing) logger.error('Missing: %s (entity %s)', e.destination, e.id)
404
- for (const e of mismatched) logger.error('Mismatched: %s (entity %s)%s', e.destination, e.id,
405
- e.writtenBy ? ` — the bytes on disk are ${e.writtenBy}'s` : '')
406
- for (const e of unverifiable) logger.warn('No hash: %s (entity %s)', e.destination, e.id)
407
- for (const e of orphaned) logger.warn('Orphan: %s', e.path)
408
- // Named per destination: "two entities write here" is only
409
- // actionable if you know which two.
410
- for (const c of collisions) logger.warn('Collision: %s ← %s', c.destination, c.entities.join(', '))
411
-
412
- // Level picked from the verdict, because the level IS the marker
413
- // in pino-pretty's messageFormat: notice renders 🟢, warn 🟡,
414
- // error 🔴. A fixed `notice` prints a green tick next to the word
415
- // FAIL, which reads as success at a glance even though the exit
416
- // code is right.
417
- const report = verdict === 'FAIL' ? logger.error : verdict === 'WARN' ? logger.warn : logger.notice
418
- report.call(logger,
419
- 'Verify %s: %d snapshots, %d missing, %d mismatched, %d unverifiable, %d orphaned, %d collisions',
420
- verdict, total, missing.length, mismatched.length, unverifiable.length, orphaned.length, collisions.length)
421
- process.exit(verdict === 'FAIL' ? 2 : verdict === 'WARN' ? 1 : 0)
422
- }
443
+ // The same three commands the instance answers over the socket —
444
+ // one implementation, so a forwarded --verify cannot disagree with a
445
+ // local one about what it checked.
446
+ const code = await runReportOnly()
447
+ if (code !== null) process.exit(code)
423
448
  })
424
449
 
425
450
  onRender(async (signal) => {
package/src/instance.js CHANGED
@@ -34,6 +34,7 @@ import { chmod } from 'node:fs/promises'
34
34
  import runtime from './runtime.js'
35
35
  import { onLoaded } from './lifecycle.js'
36
36
  import { renderErrorCount } from './report.js'
37
+ import { runReportOnly } from './engine.js'
37
38
 
38
39
  // Where the endpoint lives.
39
40
  //
@@ -66,7 +67,8 @@ export function socketPath(workingFolder) {
66
67
  // Newline-delimited JSON, one object per line. Deliberately boring: both ends
67
68
  // ship together, so there is nothing to negotiate and no version to carry.
68
69
  //
69
- // → { type: 'build', config, clear }
70
+ // → { type: 'build', config, clear }
71
+ // → { type: 'report', config, tool, tools, toolArgs, explain, verify, json }
70
72
  // ← { type: 'log', chunk } (zero or more, in order)
71
73
  // ← { type: 'done', code }
72
74
  // ← { type: 'refused', reason, detail }
@@ -97,7 +99,7 @@ function readFrames(socket, onFrame) {
97
99
  // which case the caller proceeds exactly as it always did. Called before
98
100
  // setup(), so a forwarded command never pays for importing the config or the
99
101
  // plugin graph, which is most of what a one-shot spends its time on.
100
- export function forward({ workingFolder, config, clear }) {
102
+ export function forward({ workingFolder, config, request }) {
101
103
  const endpoint = socketPath(workingFolder)
102
104
 
103
105
  return new Promise((resolve) => {
@@ -116,7 +118,7 @@ export function forward({ workingFolder, config, clear }) {
116
118
  resolve(null)
117
119
  })
118
120
 
119
- socket.on('connect', () => frame(socket, { type: 'build', config, clear }))
121
+ socket.on('connect', () => frame(socket, { ...request, config }))
120
122
 
121
123
  readFrames(socket, (message) => {
122
124
  if (message.type === 'log') {
@@ -219,29 +221,50 @@ async function configStale() {
219
221
  return null
220
222
  }
221
223
 
222
- async function serveBuild(socket, request, logger) {
223
- const wrongConfig = configMismatch(request.config)
224
- if (wrongConfig) {
225
- frame(socket, {
226
- type: 'refused',
227
- reason: `this instance is running ${wrongConfig}, and you asked for ${path.resolve(request.config)}.`,
228
- detail: 'Forwarding would build with the wrong config the accident this refusal exists to prevent. '
229
- + 'Stop that instance, or pass --no-attach to run your own.',
230
- })
231
- return
224
+ // Report-only commands, answered from the live catalogue.
225
+ //
226
+ // These read; they do not write, so running them locally was safe for the
227
+ // FILES. It was not safe for the ANSWER. A local --verify at ten thousand
228
+ // pages reads a catalogue the instance is in the middle of writing and reports
229
+ // drift that is a half-finished cycle, and a local --tool answers from
230
+ // whatever the last build left rather than from what is true now.
231
+ //
232
+ // The instance has the settled state and the config that produced it, so it is
233
+ // the only process that can answer correctly. Same guards as a build: wrong
234
+ // config refuses, drifted config refuses.
235
+ async function serveReport(socket, request, logger) {
236
+ const restore = captureOutput((chunk) => frame(socket, { type: 'log', chunk }))
237
+ let code = 0
238
+ try {
239
+ code = await runReportOnly(request) ?? 0
240
+ } catch (err) {
241
+ logger?.error('instance: forwarded report failed — %s', err.message)
242
+ code = 3
243
+ } finally {
244
+ restore()
232
245
  }
246
+ frame(socket, { type: 'done', code })
247
+ }
233
248
 
234
- const movedFile = await configStale()
235
- if (movedFile) {
236
- frame(socket, {
237
- type: 'refused',
238
- reason: `this instance's config changed on disk since it started (${movedFile}).`,
239
- detail: 'It is still building with the old one. Restart it, and this command will reach an '
240
- + 'instance that matches what you edited.',
241
- })
242
- return
243
- }
249
+ function refuseConfig(socket, request, wrongConfig) {
250
+ frame(socket, {
251
+ type: 'refused',
252
+ reason: `this instance is running ${wrongConfig}, and you asked for ${path.resolve(request.config)}.`,
253
+ detail: 'Answering would use the wrong config the accident this refusal exists to prevent. '
254
+ + 'Stop that instance, or pass --no-attach to run your own.',
255
+ })
256
+ }
244
257
 
258
+ function refuseStale(socket, movedFile) {
259
+ frame(socket, {
260
+ type: 'refused',
261
+ reason: `this instance's config changed on disk since it started (${movedFile}).`,
262
+ detail: 'It is still running the old one. Restart it, and this command will reach an instance that '
263
+ + 'matches what you edited.',
264
+ })
265
+ }
266
+
267
+ async function serveBuild(socket, request, logger) {
245
268
  const restore = captureOutput((chunk) => frame(socket, { type: 'log', chunk }))
246
269
  let code = 0
247
270
  try {
@@ -304,8 +327,20 @@ export function serveInstance() {
304
327
  server = net.createServer((socket) => {
305
328
  socket.on('error', () => { /* client vanished mid-request */ })
306
329
  readFrames(socket, (request) => {
307
- if (request.type !== 'build') return
308
- chain = chain.then(() => serveBuild(socket, request, logger)).catch(() => {})
330
+ if (request.type !== 'build' && request.type !== 'report') return
331
+ chain = chain.then(async () => {
332
+ // Both kinds answer for the client's config, not the
333
+ // instance's — a report against the wrong config is the
334
+ // original incident, and it is wrong whether or not it
335
+ // writes anything.
336
+ const wrongConfig = configMismatch(request.config)
337
+ if (wrongConfig) return refuseConfig(socket, request, wrongConfig)
338
+ const movedFile = await configStale()
339
+ if (movedFile) return refuseStale(socket, movedFile)
340
+ return request.type === 'build'
341
+ ? serveBuild(socket, request, logger)
342
+ : serveReport(socket, request, logger)
343
+ }).catch(() => {})
309
344
  })
310
345
  })
311
346
  server.on('error', (err) => {