mikser-io 9.101.0 → 10.0.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/app.js CHANGED
@@ -74,8 +74,7 @@ function locate(argv) {
74
74
  // rebuilt whatever the gates let through, which on a settled tree
75
75
  // is nothing, and a caller asking for a full re-render got a no-op
76
76
  // reported as success.
77
- force: has('--force', '-f'),
78
- renderPresets: has('--render-presets') ? (value('--render-presets') ?? true) : undefined }
77
+ force: has('--force', '-f') }
79
78
 
80
79
  return {
81
80
  longRunning,
package/index.js CHANGED
@@ -6,6 +6,10 @@ export * from './src/roles.js'
6
6
  export * from './src/inventory.js'
7
7
  export * from './src/report.js'
8
8
  export * from './src/cli.js'
9
+ // What one plugin offers another. A plugin publishes an API under a name
10
+ // (provideService) or adds to an extension point (contribute); consumers ask
11
+ // core, never a sibling. Replaces reaching into runtime.options.<plugin>.
12
+ export * from './src/services.js'
9
13
  // The diagnostics behind --explain. Exported so a transport — the MCP tool
10
14
  // surface, the api plugin's routes — can serve the same structured report the
11
15
  // CLI formats, rather than each one reimplementing the question.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.101.0",
3
+ "version": "10.0.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/cli.js CHANGED
@@ -40,12 +40,25 @@ const declared = new Map()
40
40
  //
41
41
  // cliOption('--lighthouse', 'audit the built pages with Lighthouse, and exit 0')
42
42
  //
43
- // Reads back from `runtime.options` under commander's usual camel-cased name.
43
+ // Reads back from `runtime.options` under commander's usual camel-cased name —
44
+ // but NOT at construction time. Declaring happens during the load phase and
45
+ // the table is not parsed until after it, so an option read while the plugin
46
+ // is being built is always undefined. Read it in a hook: onLoaded and later
47
+ // all run after stage two. documents() read its folder at construction first,
48
+ // and `--documents content` silently did nothing.
44
49
  export function cliOption(flags, description, defaultValue) {
50
+ // No CLI at all — a plugin constructed by a test harness, or embedded
51
+ // programmatically through setup({ ... }) rather than run from a terminal.
52
+ // There is nothing to register the option on and nothing about that is a
53
+ // mistake, so it returns quietly. Throwing here made every plugin that
54
+ // declares an option unusable in its own unit tests, which is how this was
55
+ // found.
56
+ //
57
+ // Declaring LATE is still an error — see below — because that one really
58
+ // is a mistake: the option would never be parsed and the flag would look
59
+ // ignored.
45
60
  const commander = runtime.engine?.commander
46
- if (!commander) {
47
- throw new Error('cliOption: no commander yet — call it while the plugin is being constructed')
48
- }
61
+ if (!commander) return undefined
49
62
  if (runtime.engine.cliSealed) {
50
63
  throw new Error(
51
64
  `cliOption(${JSON.stringify(flags)}): the option table was already parsed. `
@@ -97,7 +110,28 @@ export function completeCliParse() {
97
110
  }
98
111
 
99
112
  commander.parse(process.argv)
100
- Object.assign(runtime.options, commander.opts())
113
+
114
+ // ONLY the options a plugin declared.
115
+ //
116
+ // Assigning the whole opts() blob put core's own options back to their
117
+ // parsed values — and by this point the engine has already NORMALISED
118
+ // several of them. `--working-folder` defaults to './' and onInitialize
119
+ // resolves it to an absolute path; re-assigning made it './' again, after
120
+ // the load phase, so every plugin that resolves a folder in onLoaded got a
121
+ // relative one. `path.join('./', 'schemas')` is `schemas`, which reaches
122
+ // import() as a BARE SPECIFIER — so schemas, presets and layout sidecars
123
+ // all failed with "Cannot find package 'schemas'" on a clean build that
124
+ // was otherwise green.
125
+ //
126
+ // Stage two exists to pick up what stage one could not know about. It has
127
+ // no business restating what stage one already produced and the engine has
128
+ // since corrected.
129
+ const parsed = commander.opts()
130
+ for (const option of commander.options) {
131
+ if (!declared.has(option.flags)) continue
132
+ const name = option.attributeName()
133
+ if (parsed[name] !== undefined) runtime.options[name] = parsed[name]
134
+ }
101
135
  }
102
136
 
103
137
  // The plugin-declared options carried by a client's argv.
package/src/engine.js CHANGED
@@ -32,7 +32,7 @@ import { queryContext } from './database/query-context.js'
32
32
  // Build a structuredClone-safe copy of runtime.options for WORKER
33
33
  // dispatch. Plugin surfaces live under `runtime.options.<plugin>` per
34
34
  // the engine's namespacing convention and routinely hold functions
35
- // (e.g. `runtime.options.layouts.inspect`, `runtime.options.preview.get`)
35
+ // (e.g. the `layouts` service's inspect(), the `preview` service's get())
36
36
  // — those don't cross thread boundaries via Piscina's structured clone.
37
37
  //
38
38
  // Per-key probe: anything that survives `structuredClone(value)` passes
@@ -539,7 +539,6 @@ export async function setup(options) {
539
539
  .option('-c --config <file>', 'set mikser mikser.config.js location', './mikser.config.js')
540
540
  .option('-m --mode <mode>', 'set mikser runtime mode', 'development')
541
541
  .option('-r --clear', 'clear current state before execution', false)
542
- .option('--render-presets [name]', 're-render preset derivatives whose sources and revisions are unchanged; with a name, only that preset')
543
542
  .option('-o --output-folder <folder>', 'set mikser output folder relative to working folder', 'out')
544
543
  .option('-w --watch', 'watch entities for changes', false)
545
544
  .option('-f --force', 'rebuild everything; disable incremental dispatch', false)
@@ -600,7 +599,10 @@ Which check answers which question:
600
599
  Are the derivatives current?
601
600
  --render-presets [n] re-derives every preset, or one by name, without
602
601
  touching anything else. For a preset edited without
603
- bumping its revision.
602
+ bumping its revision. Declared by the assets
603
+ plugin, so it exists only in a project that loads
604
+ one — and a config without assets() refuses it as
605
+ unknown rather than accepting it and doing nothing.
604
606
 
605
607
  Start over.
606
608
  --clear removes the output folder and reopens the cache.
@@ -625,11 +627,6 @@ Which check answers which question:
625
627
  instance is ALWAYS in watch mode, so watch alone
626
628
  answers the wrong question.
627
629
 
628
- Is the page fast, and can it be read?
629
- --lighthouse from mikser-io-lighthouse, if installed. A plugin
630
- declares its own options now, and they appear here
631
- beside core's — see cliOption().
632
-
633
630
  What did this build do, and cost?
634
631
  --json the whole report as one document on stdout, with
635
632
  every warning carrying a stable code, and per-phase
@@ -845,8 +842,18 @@ The full version, with what each code means: docs/diagnostics.md`)
845
842
  // The same three commands the instance answers over the socket —
846
843
  // one implementation, so a forwarded --audit-output cannot disagree with a
847
844
  // local one about what it checked.
848
- const code = await runReportOnly()
849
- if (code !== null) process.exit(code)
845
+ //
846
+ // --tools and --tool are NOT among them: they are dispatched at `import`
847
+ // instead, because the tool registry is not complete until every
848
+ // plugin's onLoaded has run and this hook runs ahead of all of them.
849
+ // The import dispatch existed already and was documented as
850
+ // load-bearing, but this call reached the same code first and exited,
851
+ // so it never ran — and every tool a plugin registers was missing from
852
+ // the CLI while the listing looked healthy, just short.
853
+ if (!runtime.options.tools && !runtime.options.tool) {
854
+ const code = await runReportOnly()
855
+ if (code !== null) process.exit(code)
856
+ }
850
857
  })
851
858
 
852
859
  onRender(async (signal) => {
@@ -1054,7 +1061,7 @@ The full version, with what each code means: docs/diagnostics.md`)
1054
1061
  mc.port2.unref()
1055
1062
  renderOptions.port = mc.port1
1056
1063
  // Strip plugin-surface functions
1057
- // (runtime.options.layouts.inspect, etc.) so
1064
+ // (the layouts service's inspect, etc.) so
1058
1065
  // Piscina's structured clone doesn't choke on
1059
1066
  // them. Engine-side primitives pass through;
1060
1067
  // plugin surfaces are reachable via the
@@ -1501,30 +1508,17 @@ The full version, with what each code means: docs/diagnostics.md`)
1501
1508
  if (failed) logger.error('Mikser completed with %d render error%s', failed, failed === 1 ? '' : 's')
1502
1509
  else logger.notice('Mikser completed')
1503
1510
 
1504
- // Is everything the templates linked to actually there?
1511
+ // The `render-presets-unhandled` guard is gone, and so is the state it
1512
+ // read.
1505
1513
  //
1506
- // The URL helpers build paths; they do not resolve them. So a preset
1507
- // that never ran, a library that was not copied, or a template naming
1508
- // an extension the preset stopped producing all yield a well-formed
1509
- // link to a file that does not exist — and the only symptom is a
1510
- // missing image on the deployed site, found by a person.
1511
- //
1512
- // Checked at the end of the cycle because that is the first moment the
1513
- // answer is stable: derivatives are produced during the cycle, so
1514
- // asking any earlier would report files that were about to appear.
1515
- // --render-presets with nothing to consume it.
1516
- //
1517
- // The flag is implemented by the assets plugin, so without that plugin
1518
- // it reaches nobody: the build runs normally, nothing is re-derived,
1519
- // and the operator is left to notice. Checked here rather than at
1520
- // onLoaded because the engine's own onLoaded is registered first and
1521
- // runs before any plugin has set itself up.
1522
- if (runtime.options.renderPresets && !runtime.state?.assets?.renderPresetsHandled) {
1523
- useLogger().error({ code: 'render-presets-unhandled' },
1524
- '--render-presets was passed, but no assets plugin is loaded to act on it. '
1525
- + 'Nothing was re-derived. Add assets() to the plugins array, or drop the flag.')
1526
- process.exitCode = 1
1527
- }
1514
+ // It existed because core declared `--render-presets` while the assets
1515
+ // plugin implemented it, so the flag could be passed at a build that
1516
+ // had no plugin to act on it — accepted, ignored, and reported after
1517
+ // the fact. The plugin declares the option itself now, so a config
1518
+ // without assets() does not have the flag at all and a misspelling is
1519
+ // refused by name before anything is built. There is nothing left to
1520
+ // warn about after the event.
1521
+
1528
1522
 
1529
1523
  const brokenTargets = await reportBrokenReferences(useLogger())
1530
1524
  await reportMissingAssets(useLogger(), brokenTargets)
package/src/instance.js CHANGED
@@ -73,6 +73,11 @@ export function socketPath(workingFolder) {
73
73
  //
74
74
  // → { type: 'build', config, clear, renderPresets }
75
75
  // → { type: 'report', config, tool, tools, toolArgs, explain, auditOutput, fingerprint, json }
76
+ //
77
+ // A PLUGIN's options are not listed here and are not hand-carried: they travel
78
+ // on `argv` and are applied by withRequestOutput against the instance's own
79
+ // option table. --render-presets used to be named explicitly, which is what a
80
+ // core-declared plugin flag costs — one more thing to remember in two files.
76
81
  // ← { type: 'log', chunk } (zero or more, in order)
77
82
  // ← { type: 'done', code }
78
83
  // ← { type: 'refused', reason, detail }
@@ -460,8 +465,6 @@ async function serveBuild(socket, request, logger) {
460
465
  // silent no-op, which is the failure this surface exists to remove —
461
466
  // and restoring it after keeps the watcher from forcing every later
462
467
  // rebuild.
463
- const priorRenderPresets = runtime.options.renderPresets
464
- if (request.renderPresets !== undefined) runtime.options.renderPresets = request.renderPresets
465
468
  // Applied for THIS cycle only, like renderPresets. An instance left in
466
469
  // force mode would re-render the whole site on every later save.
467
470
  const priorForce = runtime.options.force
@@ -486,7 +489,6 @@ async function serveBuild(socket, request, logger) {
486
489
  emitReport()
487
490
  })
488
491
  } finally {
489
- runtime.options.renderPresets = priorRenderPresets
490
492
  runtime.options.force = priorForce
491
493
  }
492
494
 
@@ -1,5 +1,6 @@
1
1
  import { reportEvaluated } from '../report.js'
2
2
  import { countEntities } from '../catalog.js'
3
+ import { cliOption } from '../cli.js'
3
4
  import path from 'node:path'
4
5
  import { mkdir, writeFile, unlink, rm, readFile, symlink, } from 'fs/promises'
5
6
  import { existsSync } from 'node:fs'
@@ -86,6 +87,33 @@ export function assets(options = {}) {
86
87
  changeExtension,
87
88
  constants: { ACTION, OPERATION },
88
89
  }) => {
90
+ // Declared HERE, not in core.
91
+ //
92
+ // Everything this flag does happens in this plugin, and core carried it
93
+ // only because a plugin could not declare an option until 9.100.0. The
94
+ // cost of that was a flag a build could accept with nothing loaded to act
95
+ // on it: passed, ignored, and reported afterwards by a `render-presets-
96
+ // unhandled` guard in the engine. Declared here, a config without assets()
97
+ // simply does not have the option, so the mistake is refused by name
98
+ // before anything is built rather than explained after.
99
+ // The folders this plugin owns, on the command line.
100
+ //
101
+ // They were config-only, because a plugin could not declare an option
102
+ // before 9.100.0 — so overriding one for a single run meant editing the
103
+ // config, which is the file whose checksum decides whether the whole
104
+ // catalog is still valid. A flag is the difference between "try this
105
+ // once" and "invalidate everything".
106
+ //
107
+ // CLI beats config beats default, which is the order every other option
108
+ // here already follows.
109
+ cliOption('--assets <folder>',
110
+ 'folder for derived assets, relative to the working folder (default: assets)')
111
+ cliOption('--presets <folder>',
112
+ 'folder holding the preset modules, relative to the working folder (default: presets)')
113
+ cliOption('--render-presets [name]',
114
+ 're-render preset derivatives whose sources and revisions are unchanged; '
115
+ + 'with a name, only that preset')
116
+
89
117
  const collection = 'presets'
90
118
  const type = 'preset'
91
119
  const checksumMap = new Set()
@@ -425,12 +453,16 @@ export function assets(options = {}) {
425
453
  runtime.engine ??= {}
426
454
  runtime.engine.assets = { explainMissing }
427
455
 
428
- runtime.options.presets = options.presetsFolder || collection
456
+ // `??`, not `||`: an option commander did not see is undefined, and
457
+ // falling through to the config is the point. `||` would also fall
458
+ // through for an intentional empty string, which is a different
459
+ // answer than "not given".
460
+ runtime.options.presets = runtime.options.presets ?? options.presetsFolder ?? collection
429
461
  runtime.options.presetsFolder = path.join(runtime.options.workingFolder, runtime.options.presets)
430
462
  logger.debug('Presets folder: %s', runtime.options.presetsFolder)
431
463
  await mkdir(runtime.options.presetsFolder, { recursive: true })
432
464
 
433
- runtime.options.assets = options.assetsFolder || 'assets'
465
+ runtime.options.assets = runtime.options.assets ?? options.assetsFolder ?? 'assets'
434
466
  runtime.options.assetsFolder = path.join(runtime.options.workingFolder, runtime.options.assets)
435
467
  logger.debug('Assets folder: %s', runtime.options.assetsFolder)
436
468
 
@@ -641,7 +673,6 @@ export function assets(options = {}) {
641
673
  // end of the cycle: a flag that reaches no plugin has to say so,
642
674
  // rather than building normally and leaving the operator to
643
675
  // notice nothing was re-derived.
644
- runtime.state.assets.renderPresetsHandled = true
645
676
 
646
677
  const known = Object.keys(runtime.state.assets.presets)
647
678
  const names = wanted === true ? known : [wanted]
@@ -1,4 +1,5 @@
1
1
  import path from 'path'
2
+ import { cliOption } from '../cli.js'
2
3
  import { mkdir, writeFile, unlink, open } from 'fs/promises'
3
4
  import _ from 'lodash'
4
5
  import sift from 'sift'
@@ -17,9 +18,23 @@ export function data(options = {}) {
17
18
  onBeforeRender,
18
19
  constants: { OPERATION },
19
20
  }) => {
21
+ // The folder this plugin owns, on the command line.
22
+ //
23
+ // Config-only until 9.100.0, because a plugin could not declare an option
24
+ // — so overriding it for one run meant editing the config, which is the
25
+ // file whose checksum decides whether the catalog is still valid. A flag
26
+ // is the difference between trying something once and invalidating
27
+ // everything.
28
+ //
29
+ // CLI beats config beats default. `??` rather than `||` below: an option
30
+ // commander did not see is undefined, and an intentional empty string is a
31
+ // different answer than "not given".
32
+ cliOption('--data <folder>',
33
+ 'folder for emitted data files, relative to the OUTPUT folder (default: data)')
34
+
20
35
  onLoaded(async () => {
21
36
  const logger = useLogger()
22
- runtime.options.data = options.dataFolder || 'data'
37
+ runtime.options.data = runtime.options.data ?? options.dataFolder ?? 'data'
23
38
  runtime.options.dataFolder = path.join(runtime.options.outputFolder, runtime.options.data)
24
39
 
25
40
  logger.debug('Data folder: %s', runtime.options.dataFolder)
@@ -1,4 +1,5 @@
1
1
  import { useSource } from '../source.js'
2
+ import { cliOption } from '../cli.js'
2
3
 
3
4
  // documents — the canonical content source plugin.
4
5
  //
@@ -13,6 +14,13 @@ export function documents(options = {}) {
13
14
  const collection = 'documents'
14
15
  const type = 'document'
15
16
 
17
+ // The folder this plugin owns, on the command line. Config-only until
18
+ // 9.100.0, because a plugin could not declare an option — and editing
19
+ // the config to try another folder once invalidates the catalog,
20
+ // whose checksum covers it. CLI beats config beats default.
21
+ cliOption('--documents <folder>',
22
+ 'folder of source documents, relative to the working folder (default: documents)')
23
+
16
24
  useSource(core, {
17
25
  collection,
18
26
  type,
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path'
2
+ import { cliOption } from '../cli.js'
2
3
  import { mkdir, symlink, unlink, lstat, realpath } from 'fs/promises'
3
4
  import { globby } from 'globby'
4
5
  import pMap from 'p-map'
@@ -23,6 +24,20 @@ export function files(options = {}) {
23
24
  constants: { ACTION },
24
25
  }) => {
25
26
  const collection = 'files'
27
+ // The folder this plugin owns, on the command line.
28
+ //
29
+ // Config-only until 9.100.0, because a plugin could not declare an option
30
+ // — so overriding it for one run meant editing the config, which is the
31
+ // file whose checksum decides whether the catalog is still valid. A flag
32
+ // is the difference between trying something once and invalidating
33
+ // everything.
34
+ //
35
+ // CLI beats config beats default. `??` rather than `||` below: an option
36
+ // commander did not see is undefined, and an intentional empty string is a
37
+ // different answer than "not given".
38
+ cliOption('--files <folder>',
39
+ 'folder of static files copied into the output by symlink, relative to the working folder (default: files)')
40
+
26
41
  const type = 'file'
27
42
 
28
43
  async function ensureLink(relativePath) {
@@ -156,7 +171,7 @@ export function files(options = {}) {
156
171
 
157
172
  onLoaded(async () => {
158
173
  const logger = useLogger()
159
- runtime.options.files = options.filesFolder || collection
174
+ runtime.options.files = runtime.options.files ?? options.filesFolder ?? collection
160
175
  runtime.options.filesFolder = path.join(runtime.options.workingFolder, runtime.options.files)
161
176
 
162
177
  logger.debug('Files folder: %s', runtime.options.filesFolder)
@@ -6,7 +6,7 @@
6
6
  // 1. An in-memory cache (Map<filename, { bytes, mime, expiresAt,
7
7
  // size, deps }>) with LRU eviction past a configurable byte cap.
8
8
  // 2. An Express GET /preview/:filename route that serves cache entries.
9
- // 3. Exposes the cache surface at runtime.options.preview = { store,
9
+ // 3. Offers the cache surface as the `preview` service = { store,
10
10
  // get, stats, config } so other plugins (mikser-io-mcp's
11
11
  // mikser_preview_render tool, library-mode callers) can stash bytes
12
12
  // and retrieve them by URL without going through MCP.
@@ -29,6 +29,7 @@
29
29
  // isn't mounted.
30
30
 
31
31
  import sift from 'sift'
32
+ import { provideService } from '../services.js'
32
33
 
33
34
  export function preview(options = {}) {
34
35
  return ({ runtime, onLoaded, onPersist, useJournal, useLogger, registerRoute, constants: { OPERATION } }) => {
@@ -98,13 +99,14 @@ export function preview(options = {}) {
98
99
  }
99
100
  }
100
101
 
101
- // Expose the cache surface at runtime.options.preview so other
102
- // plugins / library callers can use it without going through MCP.
103
- // `config` is part of the surface too — mikser-io-mcp's
104
- // mikser_preview_render reads it to derive URL paths + TTL clamps.
105
- // Done at factory-eval time (before any onLoaded fires) so a
106
- // later plugin's onLoad / onLoaded can already see it.
107
- runtime.options.preview = { store, get, stats, config }
102
+ // Offer the cache surface as a service, so a plugin that wants it asks
103
+ // core rather than reaching into runtime.options.preview. `config` is
104
+ // part of the surface too — mikser-io-mcp's mikser_preview_render reads
105
+ // it to derive URL paths + TTL clamps.
106
+ //
107
+ // Provided at factory-eval time, before any hook runs, so a consumer's
108
+ // onLoad / onLoaded can already see it whatever the plugin order.
109
+ provideService('preview', { store, get, stats, config }, { plugin: 'mikser-io' })
108
110
 
109
111
  // Per-cycle invalidation. For every preview entry that has `deps`,
110
112
  // walk this cycle's catalog mutations and evict the entry if any
@@ -144,7 +146,7 @@ export function preview(options = {}) {
144
146
 
145
147
  // HTTP route: served regardless of whether MCP is on, so previews
146
148
  // are also reachable from library-mode callers that stored bytes
147
- // via runtime.options.preview.store() directly.
149
+ // via the preview service's store() directly.
148
150
  onLoaded(async () => {
149
151
  const logger = useLogger()
150
152
  const app = runtime.options.app
@@ -1,4 +1,5 @@
1
1
  import { mkdir, symlink, rename, unlink } from 'node:fs/promises'
2
+ import { cliOption } from '../cli.js'
2
3
  import { createWriteStream } from 'node:fs'
3
4
  import lodash from 'lodash'
4
5
  import deepdash from 'deepdash'
@@ -27,6 +28,20 @@ export function resources(options = {}) {
27
28
  updateProgress,
28
29
  constants: { OPERATION },
29
30
  }) => {
31
+ // The folder this plugin owns, on the command line.
32
+ //
33
+ // Config-only until 9.100.0, because a plugin could not declare an option
34
+ // — so overriding it for one run meant editing the config, which is the
35
+ // file whose checksum decides whether the catalog is still valid. A flag
36
+ // is the difference between trying something once and invalidating
37
+ // everything.
38
+ //
39
+ // CLI beats config beats default. `??` rather than `||` below: an option
40
+ // commander did not see is undefined, and an intentional empty string is a
41
+ // different answer than "not given".
42
+ cliOption('--resources <folder>',
43
+ 'folder of library resources linked into the output, relative to the working folder (default: resources)')
44
+
30
45
  const collection = 'resources'
31
46
  const type = 'resource'
32
47
 
@@ -46,7 +61,7 @@ export function resources(options = {}) {
46
61
  : resourcesName,
47
62
  }
48
63
 
49
- runtime.options.resources = options.resourcesFolder || collection
64
+ runtime.options.resources = runtime.options.resources ?? options.resourcesFolder ?? collection
50
65
  runtime.options.resourcesFolder = path.join(runtime.options.workingFolder, runtime.options.resources)
51
66
  logger.debug('Resources folder: %s', runtime.options.resourcesFolder)
52
67
 
package/src/plugins.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { useLogger } from './engine.js'
2
2
  import { onLoad } from './lifecycle.js'
3
+ import { resetServices } from './services.js'
3
4
  import runtime from './runtime.js'
4
5
 
5
6
  import * as core from '../index.js'
@@ -23,6 +24,13 @@ import * as core from '../index.js'
23
24
  onLoad(() => {
24
25
  const logger = useLogger()
25
26
 
27
+ // The service and extension-point registries are module state, which
28
+ // outlives one load phase — a watcher re-reads its config in the same
29
+ // process. Clearing here, before any factory runs, means a reload
30
+ // rebuilds them from scratch instead of a provider colliding with the
31
+ // copy of itself it registered last time.
32
+ resetServices()
33
+
26
34
  runtime.options.plugins = (runtime.options.plugins ?? [])
27
35
  .concat(runtime.config.plugins ?? [])
28
36
  .filter(Boolean)
@@ -0,0 +1,88 @@
1
+ // What one plugin offers another, without either importing the other.
2
+ //
3
+ // Plugins reached into each other through `runtime.options`:
4
+ //
5
+ // runtime.options.layouts.inspect read by core
6
+ // runtime.options.schemas.lookup read by forms, layouts, ocr
7
+ // runtime.options.preview.get read by core, layouts, mcp
8
+ // runtime.options.roles read by mcp
9
+ //
10
+ // It worked, and it was wrong in a specific way: mikser-io-mcp and
11
+ // mikser-io-layouts were coupled through a shared mutable object neither of
12
+ // them owns. Nothing declared the relationship, so nothing could check it —
13
+ // a consumer's `if (!runtime.options.layouts) return` reads as a null check
14
+ // but is really an is-that-plugin-installed check wearing a disguise.
15
+ //
16
+ // It also cost the two best names. Commander derives an option's property
17
+ // from its long flag, so `--layouts <folder>` writes runtime.options.layouts
18
+ // — on top of the inspect API, after the load phase, with the failure
19
+ // surfacing three plugins away.
20
+ //
21
+ // This covers ONE direction: one plugin provides, others consume. The other
22
+ // direction — many plugins adding to a surface one plugin serves — already
23
+ // has a home for each surface it applies to: tools.js, routes.js, roles.js
24
+ // and cli.js. A generic contribution registry would be a fifth way to do
25
+ // what those four already do.
26
+ //
27
+ // So: core holds the registry, and neither side names the other.
28
+
29
+ const services = new Map() // name -> { api, plugin }
30
+
31
+ // Publish an API under a name other plugins can ask for.
32
+ //
33
+ // provideService('layouts', { inspect }, { plugin: 'mikser-io-layouts' })
34
+ //
35
+ // Call at factory-eval time, before any hook runs, so that a consumer's
36
+ // onLoaded can already see it. Same rule as cliOption: declare during
37
+ // construction, read during a hook.
38
+ export function provideService(name, api, { plugin } = {}) {
39
+ if (!name || !api) return
40
+ const existing = services.get(name)
41
+ if (existing) {
42
+ // Two providers for one name is a configuration mistake, not a
43
+ // precedence question. Last-write-wins would hand consumers whichever
44
+ // plugin happened to be constructed later — the exact order
45
+ // dependence this registry exists to remove. Thrown rather than
46
+ // logged: this runs at factory-eval time, where plugins.js already
47
+ // catches, names the plugin and carries on with the rest.
48
+ throw new Error(
49
+ `Service "${name}" is already provided by ${existing.plugin ?? 'another plugin'}` +
50
+ `${plugin ? `; ${plugin} tried to provide it too` : ''}. Only one plugin may provide a service.`)
51
+ }
52
+ services.set(name, { api, plugin })
53
+ }
54
+
55
+ // Ask for a service. Returns undefined when nothing provides it, so a
56
+ // consumer degrades on purpose:
57
+ //
58
+ // const layouts = useService('layouts')
59
+ // if (!layouts) return // layouts plugin isn't installed
60
+ //
61
+ // This replaces `if (!runtime.options.mcp) return`, which read as a null
62
+ // check but was really an is-that-plugin-loaded check wearing a disguise.
63
+ export function useService(name) {
64
+ return services.get(name)?.api
65
+ }
66
+
67
+ // Ask for a service the caller cannot work without. Throws naming the
68
+ // package that provides it, because "cannot read properties of undefined"
69
+ // does not tell anyone which npm install they are missing.
70
+ export function requireService(name, { from } = {}) {
71
+ const found = services.get(name)
72
+ if (found) return found.api
73
+ throw new Error(
74
+ `No plugin provides the "${name}" service` +
75
+ (from ? `. Install ${from} and add it to the plugins array.` : '.'))
76
+ }
77
+
78
+ // Drop everything. The registries are module state, which outlives a single
79
+ // run inside one process — tests, and the watcher's repeated config reloads.
80
+ export function resetServices() {
81
+ services.clear()
82
+ }
83
+
84
+ // What is registered right now. For --explain and the MCP inventory tool:
85
+ // "which plugin provides what" is a question people ask.
86
+ export function serviceInventory() {
87
+ return [...services].map(([name, { plugin }]) => ({ name, plugin }))
88
+ }
package/src/source.js CHANGED
@@ -337,9 +337,19 @@ export function useSource(core, options) {
337
337
  // have already passed. We do folder resolution here — by which
338
338
  // point engine has set workingFolder and journal + catalog
339
339
  // have initialized.
340
- absFolder = path.isAbsolute(folder)
341
- ? folder
342
- : path.join(runtime.options.workingFolder, folder)
340
+ // A `--<collection> <folder>` flag, if the plugin declared one, wins
341
+ // over the configured folder.
342
+ //
343
+ // Resolved HERE rather than where useSource was called, and that is
344
+ // the whole point: plugin factories run during the load phase, and the
345
+ // CLI table is not complete until after it — so an option read at
346
+ // construction is always undefined. documents() read it there first
347
+ // and `--documents content` silently did nothing, which is the shape
348
+ // of bug this option mechanism exists to prevent rather than create.
349
+ const configured = runtime.options[collection] ?? folder
350
+ absFolder = path.isAbsolute(configured)
351
+ ? configured
352
+ : path.join(runtime.options.workingFolder, configured)
343
353
  runtime.options[`${collection}Folder`] = absFolder
344
354
  // The authoritative set of folders whose files become entities.
345
355
  //
package/src/tools.js CHANGED
@@ -7,13 +7,17 @@
7
7
  // the first. Closing that by adding a CLI flag per tool would drift the moment
8
8
  // anyone added a tool.
9
9
  //
10
- // One direction, today. The mcp plugin mirrors its registrations into here, so
11
- // every tool it registers is reachable from the CLI. The reverse is NOT true: a
12
- // tool registered directly against this registry does not appear in an MCP
13
- // session, because the substrate binds sessions from its own list and MCP wants
14
- // a zod shape for `inputSchema` while the engine is deliberately zod-free.
15
- // Registering through `runtime.options.mcp` therefore remains the way to reach
16
- // both surfaces, and that is what every tool does now.
10
+ // Both directions. The mcp plugin mirrors its own registrations into here, and
11
+ // binds every tool registered here into each session — prefixing on the way
12
+ // out, since `mikser_` belongs to MCP's flat namespace and not to the engine.
13
+ // So a plugin registers once, against this registry, and reaches both surfaces
14
+ // without depending on the mcp plugin being installed, or on where it sits in
15
+ // the plugins array.
16
+ //
17
+ // `inputSchema` is stored opaquely, which is what makes that possible: a
18
+ // plugin may describe its parameters in the neutral vocabulary below or hand
19
+ // over a zod shape, and the engine passes either through untouched rather than
20
+ // taking a dependency on zod to hold it.
17
21
  //
18
22
  // So the REGISTRY is substrate and the transports are consumers. ADR-0006's
19
23
  // five tests, which MCP itself failed on release cadence:
@@ -50,6 +54,13 @@ export function registerTool(name, definition = {}, handler) {
50
54
  runtime.engine?.logger?.debug('Tool %s re-registered, replacing the previous handler', name)
51
55
  }
52
56
  tools.set(name, {
57
+ // The whole definition, not the three fields core happens to read.
58
+ // A transport needs more than core does — `mutates: true` is how a
59
+ // tool says it changes something, and MCP wraps those differently.
60
+ // Dropping unknown keys here meant a plugin could only reach that
61
+ // behaviour by registering with the mcp plugin directly, which is
62
+ // exactly the coupling this registry exists to remove.
63
+ ...definition,
53
64
  name,
54
65
  description: definition.description ?? '',
55
66
  inputSchema: definition.inputSchema ?? {},
@@ -7,6 +7,7 @@
7
7
 
8
8
  import _ from 'lodash'
9
9
  import realRuntime from '../src/runtime.js'
10
+ import { resetServices } from '../src/services.js'
10
11
  import { matchEntity, normalize, changeExtension, getFormatInfo, checksum, AbortError } from '../src/utils.js'
11
12
 
12
13
  const OPERATION = {
@@ -31,6 +32,11 @@ export function createHarness({
31
32
  entities = [],
32
33
  journal = [],
33
34
  } = {}) {
35
+ // A fresh harness is a fresh world. The service registry is module state,
36
+ // so without this the previous test's provider is still registered and
37
+ // constructing the plugin again is (correctly) an error — every package
38
+ // building a plugin per test would otherwise have to remember to clear it.
39
+ resetServices()
34
40
  const hookNames = [
35
41
  'load', 'loaded', 'import', 'imported',
36
42
  'process', 'processed', 'persist', 'persisted',