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 +1 -2
- package/index.js +4 -0
- package/package.json +1 -1
- package/src/cli.js +39 -5
- package/src/engine.js +28 -34
- package/src/instance.js +5 -3
- package/src/plugins/assets.js +34 -3
- package/src/plugins/data.js +16 -1
- package/src/plugins/documents.js +8 -0
- package/src/plugins/files.js +16 -1
- package/src/plugins/preview.js +11 -9
- package/src/plugins/resources.js +16 -1
- package/src/plugins.js +8 -0
- package/src/services.js +88 -0
- package/src/source.js +13 -3
- package/src/tools.js +18 -7
- package/testing/harness.js +6 -0
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
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
|
-
|
|
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. `
|
|
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
|
-
|
|
849
|
-
|
|
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
|
-
// (
|
|
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
|
-
//
|
|
1511
|
+
// The `render-presets-unhandled` guard is gone, and so is the state it
|
|
1512
|
+
// read.
|
|
1505
1513
|
//
|
|
1506
|
-
//
|
|
1507
|
-
//
|
|
1508
|
-
//
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
//
|
|
1512
|
-
//
|
|
1513
|
-
|
|
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
|
|
package/src/plugins/assets.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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]
|
package/src/plugins/data.js
CHANGED
|
@@ -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
|
|
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)
|
package/src/plugins/documents.js
CHANGED
|
@@ -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,
|
package/src/plugins/files.js
CHANGED
|
@@ -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
|
|
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)
|
package/src/plugins/preview.js
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
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
|
|
149
|
+
// via the preview service's store() directly.
|
|
148
150
|
onLoaded(async () => {
|
|
149
151
|
const logger = useLogger()
|
|
150
152
|
const app = runtime.options.app
|
package/src/plugins/resources.js
CHANGED
|
@@ -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
|
|
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)
|
package/src/services.js
ADDED
|
@@ -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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
//
|
|
11
|
-
// every tool
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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 ?? {},
|
package/testing/harness.js
CHANGED
|
@@ -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',
|