mikser-io 10.13.0 → 11.0.1
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/docs/api-reference.md +48 -1
- package/docs/plugins.md +1 -1
- package/docs/rendering.md +31 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/auth.js +62 -0
- package/src/engine/workers.js +36 -5
- package/src/inventory.js +43 -33
- package/src/plugins/api.js +5 -0
- package/src/plugins/commands.js +1 -1
- package/src/plugins/data.js +5 -0
- package/src/plugins/documents.js +1 -1
- package/src/plugins/files.js +1 -0
- package/src/plugins/front-matter.js +5 -0
- package/src/plugins/json.js +5 -0
- package/src/plugins/mapper.js +5 -0
- package/src/plugins/observer.js +1 -0
- package/src/plugins/render/file.js +1 -1
- package/src/plugins/render/hbs.js +1 -0
- package/src/plugins/render/href.js +1 -1
- package/src/plugins/render/resource.js +1 -1
- package/src/plugins/resources.js +1 -0
- package/src/plugins/shares.js +5 -0
- package/src/plugins/validator.js +5 -0
- package/src/plugins/yaml.js +5 -0
- package/src/plugins.js +78 -2
- package/src/postprocess.js +5 -2
- package/src/principal.js +33 -0
- package/src/render.js +23 -1
- package/src/utils/entity.js +21 -0
- package/src/utils/index.js +1 -1
- package/src/utils/output.js +144 -29
- package/src/write.js +144 -2
package/docs/api-reference.md
CHANGED
|
@@ -495,7 +495,8 @@ Query types throughout: function, lodash match object, or `undefined` for all.
|
|
|
495
495
|
|
|
496
496
|
`updateEntity` is a catalog operation. `writeEntitySource` writes the FILE, with
|
|
497
497
|
the checks that make a whole-file rewrite safe to perform without having watched
|
|
498
|
-
the file the whole time.
|
|
498
|
+
the file the whole time. `editEntitySource` changes PART of a file, by naming
|
|
499
|
+
the text to change rather than resending the rest of it.
|
|
499
500
|
|
|
500
501
|
### `writeEntitySource(options)`
|
|
501
502
|
|
|
@@ -581,6 +582,52 @@ exactly the one that needs telling.
|
|
|
581
582
|
|
|
582
583
|
`siblingDestinations(folder, relativePath)` reports files differing only by
|
|
583
584
|
extension, which may render to the same destination.
|
|
585
|
+
### `editEntitySource(options)`
|
|
586
|
+
|
|
587
|
+
```js
|
|
588
|
+
import { editEntitySource } from 'mikser-io'
|
|
589
|
+
|
|
590
|
+
const result = await editEntitySource({
|
|
591
|
+
id: '/documents/pricing.md',
|
|
592
|
+
find: 'price: 1200',
|
|
593
|
+
replace: 'price: 1400',
|
|
594
|
+
})
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
| Option | Meaning |
|
|
598
|
+
| --- | --- |
|
|
599
|
+
| `id` or `collection` + `relativePath` | Which file. Same rules as `writeEntitySource`. |
|
|
600
|
+
| `find` | The exact text to replace. Must appear EXACTLY ONCE unless `all`. |
|
|
601
|
+
| `replace` | What to put there. Empty or omitted deletes the matched text. |
|
|
602
|
+
| `all` | Replace every occurrence instead of refusing an ambiguous anchor. Reports `replacements`. |
|
|
603
|
+
| `ifChecksum`, `dryRun`, `awaitCycle`, `changeSet`, `summary`, `principal` | As `writeEntitySource`. |
|
|
604
|
+
|
|
605
|
+
It is layered ON `writeEntitySource` rather than beside it, so containment, the
|
|
606
|
+
change set, the cycle id, sibling destinations and the collection write
|
|
607
|
+
capability are the same code path every other write goes through — a second
|
|
608
|
+
implementation of any of those is a second implementation that can drift.
|
|
609
|
+
|
|
610
|
+
**Why an anchor rather than a whole file.** A model rewriting a file to change
|
|
611
|
+
one line must re-emit every other line, and a line it drops on the way is
|
|
612
|
+
indistinguishable downstream from a line someone deleted on purpose.
|
|
613
|
+
`ifChecksum` catches a stale READ; nothing catches a lossy WRITE. An anchor
|
|
614
|
+
cannot lose what it does not name: the bytes outside the match are not merely
|
|
615
|
+
preserved, they are never rewritten. That also makes the concurrency question
|
|
616
|
+
sharper than a checksum — a checksum refuses when ANY part of the file moved,
|
|
617
|
+
an anchor refuses when the part being edited moved.
|
|
618
|
+
|
|
619
|
+
Its own refusals, on top of `writeEntitySource`'s: `no-anchor` (empty `find`),
|
|
620
|
+
`anchor-not-found`, `anchor-ambiguous` (carrying `occurrences`), `no-such-file`
|
|
621
|
+
(an edit changes something that exists; use the whole-file write to create),
|
|
622
|
+
and `would-not-parse` (carrying the parser's complaint). The last one is the
|
|
623
|
+
guarantee a whole-file write cannot make: the result is checked against the
|
|
624
|
+
file's registered source format — see `validateSource` — and a file that would
|
|
625
|
+
not parse never lands.
|
|
626
|
+
|
|
627
|
+
Between reading the file and writing it back, the checksum it read is forwarded
|
|
628
|
+
as the write's precondition, so a writer landing in that window is refused
|
|
629
|
+
rather than overwritten.
|
|
630
|
+
|
|
584
631
|
`locateEntityFile(id)` resolves a catalog id to its `{ collection, relativePath }`,
|
|
585
632
|
or `{ error }` — taken from the entity rather than by splitting the id, since the
|
|
586
633
|
prefix is configurable and the extension may have been stripped.
|
package/docs/plugins.md
CHANGED
|
@@ -28,7 +28,7 @@ Plugins are standard ESM imports. There's no name-based search path — whatever
|
|
|
28
28
|
- Sibling plugins each export a named factory: `import { vector } from 'mikser-io-vector'`.
|
|
29
29
|
- Project-local plugins live anywhere — drop the file, import by relative path: `import myPlugin from './plugins/my-plugin.js'`.
|
|
30
30
|
|
|
31
|
-
Renderer and postprocessor packages **are** listed in `plugins: []` alongside lifecycle plugins. They return descriptors (`{ name, options, load?, render? }` or `{ name, options, postprocess, ... }`) instead of `(core) => void` closures; the loader stores them in `runtime.renderers` / `runtime.postprocessors` and the dispatcher picks them up by name when a layout requests them.
|
|
31
|
+
Renderer and postprocessor packages **are** listed in `plugins: []` alongside lifecycle plugins. They return descriptors (`{ name, options, load?, render? }` or `{ name, options, postprocess, ... }`) instead of `(core) => void` closures; the loader stores them in `runtime.renderers` / `runtime.postprocessors` and the dispatcher picks them up by name when a layout requests them. If your package is **not** named `mikser-io-render-<name>` / `mikser-io-post-<name>`, add `module: import.meta.url` to the descriptor — a worker has no registry to read and otherwise resolves the name to a package that does not exist. See [rendering.md](rendering.md#declare-module-if-your-package-is-not-named-for-your-plugin).
|
|
32
32
|
|
|
33
33
|
---
|
|
34
34
|
|
package/docs/rendering.md
CHANGED
|
@@ -174,6 +174,37 @@ export async function render({ entity, options, config, context, plugins, runtim
|
|
|
174
174
|
|
|
175
175
|
Only the renderer plugin (named `render-{options.renderer}`) is expected to export `render()`. Other plugins (loaded via `context.plugins`, `entity.meta.plugins`, `options.plugins`) typically only export `load()`.
|
|
176
176
|
|
|
177
|
+
### Declare `module` if your package is not named for your plugin
|
|
178
|
+
|
|
179
|
+
On the main thread a plugin is found in the runtime registry, under the `name`
|
|
180
|
+
its descriptor carries. A **worker** is another thread with another runtime
|
|
181
|
+
singleton, so its registry is empty and it resolves by name instead: plugin
|
|
182
|
+
`render-preset` is looked for in a package called `mikser-io-render-preset`.
|
|
183
|
+
|
|
184
|
+
That guess is right whenever the package is named for the plugin. It is wrong
|
|
185
|
+
whenever one package ships a plugin under some other name — and then the
|
|
186
|
+
failure is asymmetric, which is what makes it expensive: the main thread works,
|
|
187
|
+
and only entities dispatched with `task: worker` break. A missing helper throws
|
|
188
|
+
`Missing helper: "..."` and the page is never written; a missing renderer is a
|
|
189
|
+
render fault naming the renderer.
|
|
190
|
+
|
|
191
|
+
A descriptor can say where it lives:
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
export function renderPreset(options = {}) {
|
|
195
|
+
return { name: options.name ?? 'preset', options, load, render, module: import.meta.url }
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`import.meta.url` and not the package name, because it points at the file that
|
|
200
|
+
exports `load` / `render` at module level — which is what the worker imports —
|
|
201
|
+
so nothing has to be re-exported from the package index to make it reachable.
|
|
202
|
+
|
|
203
|
+
The field is optional and only consulted on a worker. A package named
|
|
204
|
+
`mikser-io-render-<name>` or `mikser-io-post-<name>` needs nothing. Everything
|
|
205
|
+
else should declare it; `mikser-io-assets` ships two such plugins (`preset` and
|
|
206
|
+
`asset`) and declares both.
|
|
207
|
+
|
|
177
208
|
---
|
|
178
209
|
|
|
179
210
|
## Renderer Plugins
|
package/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export * as constants from './src/constants.js'
|
|
|
3
3
|
export * from './src/utils/index.js'
|
|
4
4
|
export * from './src/invalidation.js'
|
|
5
5
|
export * from './src/auth.js'
|
|
6
|
+
export * from './src/principal.js'
|
|
6
7
|
export * from './src/roles.js'
|
|
7
8
|
export * from './src/inventory.js'
|
|
8
9
|
export * from './src/report.js'
|
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
2
|
import { isLoopback } from './utils/index.js'
|
|
3
|
+
import { currentPrincipal } from './principal.js'
|
|
4
|
+
import runtime from './runtime.js'
|
|
3
5
|
|
|
4
6
|
// Authentication seam (ADR-0012).
|
|
5
7
|
//
|
|
@@ -286,6 +288,66 @@ export function anyOf(...verifiers) {
|
|
|
286
288
|
// four — the same partial answer, one layer down.
|
|
287
289
|
export const CAPABILITY_WILDCARD = '*'
|
|
288
290
|
|
|
291
|
+
// Writing to a collection is scoped to that collection.
|
|
292
|
+
//
|
|
293
|
+
// write:<collection> may change what is in it
|
|
294
|
+
//
|
|
295
|
+
// One name, asked by every write surface, so `write:documents` means the same
|
|
296
|
+
// thing whether the write arrives over MCP, over the api, or from a plugin.
|
|
297
|
+
// Reads are not scoped here: the api bounds them with `api:list` and the drive
|
|
298
|
+
// with `drive:<endpoint>`, and inventing a third read rule would give three
|
|
299
|
+
// answers to one question.
|
|
300
|
+
export const writeCapabilityFor = (collection) => `write:${collection}`
|
|
301
|
+
|
|
302
|
+
// Off until an operator turns it on, and turned on by granting.
|
|
303
|
+
//
|
|
304
|
+
// Enforcing immediately would refuse every write on every deployment that
|
|
305
|
+
// exists, since nobody holds a capability that did not exist until now. So
|
|
306
|
+
// the rule is the one the rest of this file already uses for principals,
|
|
307
|
+
// applied to the catalogue: a site that declares no `write:` capability is
|
|
308
|
+
// not using collection scoping, and its writes are bounded by whatever
|
|
309
|
+
// bounded them before. The first `write:` grant turns it on for EVERY
|
|
310
|
+
// collection at once — which is surprising exactly once, and is the only
|
|
311
|
+
// reading that does not leave a half-enforced site.
|
|
312
|
+
function collectionScopingConfigured(catalogue) {
|
|
313
|
+
return Object.values(catalogue ?? {}).flat()
|
|
314
|
+
.some(capability => String(capability).startsWith('write:'))
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// The capability missing to write to this collection, or null when the write
|
|
318
|
+
// may proceed. `catalogue` says whether the site uses collection scoping at
|
|
319
|
+
// all; `principal` defaults to whoever the surface established.
|
|
320
|
+
export function missingCollectionWrite(collection, {
|
|
321
|
+
principal = currentPrincipal(),
|
|
322
|
+
catalogue = runtime.options?.roles?.catalogue,
|
|
323
|
+
} = {}) {
|
|
324
|
+
if (!collection) return null
|
|
325
|
+
if (!collectionScopingConfigured(catalogue)) return null
|
|
326
|
+
return missingCapability(principal, writeCapabilityFor(collection))
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Which of these capabilities the principal does NOT hold — the first one, or
|
|
330
|
+
// null when it holds them all.
|
|
331
|
+
//
|
|
332
|
+
// The plural is the point. Almost every gate in the tree needs a SET, not one:
|
|
333
|
+
// writing to a drive endpoint needs `drive:<name>` to reach it and
|
|
334
|
+
// `drive:<name>:write` to change it, and "may ALSO write" means the base is a
|
|
335
|
+
// prerequisite rather than an alternative. Each surface worked that out for
|
|
336
|
+
// itself and they disagreed — the WebDAV mount required both, the tool over
|
|
337
|
+
// the same endpoint required only the second, so one grant was refused a PUT
|
|
338
|
+
// and allowed the identical write through the other door.
|
|
339
|
+
//
|
|
340
|
+
// Returning the MISSING capability rather than a boolean is what lets a
|
|
341
|
+
// refusal name the one that is actually absent, which is the difference
|
|
342
|
+
// between "you lack drive:documents" and "you lack drive:documents:write" when
|
|
343
|
+
// the operator granted exactly one of them.
|
|
344
|
+
export function missingCapability(principal, capabilities = []) {
|
|
345
|
+
for (const capability of [].concat(capabilities)) {
|
|
346
|
+
if (!hasCapability(principal, capability)) return capability
|
|
347
|
+
}
|
|
348
|
+
return null
|
|
349
|
+
}
|
|
350
|
+
|
|
289
351
|
export function hasCapability(principal, capability) {
|
|
290
352
|
if (!capability) return true
|
|
291
353
|
const caps = principal?.capabilities
|
package/src/engine/workers.js
CHANGED
|
@@ -16,8 +16,38 @@ export function workerMessages() {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// The identifier a descriptor is known by once its closures are gone.
|
|
20
|
+
// Render-shaped first, exactly as before: a descriptor carrying both `load`
|
|
21
|
+
// and `postprocess` has always been treated as a renderer.
|
|
22
|
+
function pluginIdentifier(plugin) {
|
|
23
|
+
if (!plugin || typeof plugin !== 'object' || typeof plugin.name !== 'string') return null
|
|
24
|
+
if (typeof plugin.load === 'function' || typeof plugin.render === 'function') return `render-${plugin.name}`
|
|
25
|
+
if (typeof plugin.postprocess === 'function') return `post-${plugin.name}`
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
export function workerSafeOptions(opts) {
|
|
20
30
|
const result = {}
|
|
31
|
+
// Where the code behind each identifier actually lives.
|
|
32
|
+
//
|
|
33
|
+
// The projection below reduces a descriptor to `render-<name>`, and the
|
|
34
|
+
// worker resolves that name BY CONVENTION: a package called
|
|
35
|
+
// `mikser-io-render-<name>`, or a file core ships. That convention is the
|
|
36
|
+
// whole resolution story on a worker, because the runtime registry the
|
|
37
|
+
// main thread reads is empty in another thread.
|
|
38
|
+
//
|
|
39
|
+
// So a renderer shipped under a package that is not named for it is
|
|
40
|
+
// unresolvable there — and the failure is asymmetric in the worst way:
|
|
41
|
+
// the main thread renders it fine from the registry, and only the
|
|
42
|
+
// worker-dispatched entities break. mikser-io-assets ships two such
|
|
43
|
+
// plugins (`preset` and `asset`), which is how 10.12.0 left every
|
|
44
|
+
// worker render that calls `asset()` failing on a missing helper.
|
|
45
|
+
//
|
|
46
|
+
// A descriptor that carries `module` — the `import.meta.url` of the file
|
|
47
|
+
// defining it — says where it is, and a string is exactly what survives
|
|
48
|
+
// the thread boundary. Optional: a package named by the convention needs
|
|
49
|
+
// nothing, so no existing plugin changes.
|
|
50
|
+
const pluginModules = {}
|
|
21
51
|
for (const [k, v] of Object.entries(opts)) {
|
|
22
52
|
// `plugins` is a mixed array of factory-return values — functions
|
|
23
53
|
// (lifecycle plugins; workers don't need them) and descriptor
|
|
@@ -28,11 +58,9 @@ export function workerSafeOptions(opts) {
|
|
|
28
58
|
if (k === 'plugins' && Array.isArray(v)) {
|
|
29
59
|
result[k] = v
|
|
30
60
|
.map(p => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
&& typeof p.postprocess === 'function') return `post-${p.name}`
|
|
35
|
-
return null
|
|
61
|
+
const identifier = pluginIdentifier(p)
|
|
62
|
+
if (identifier && typeof p.module === 'string') pluginModules[identifier] = p.module
|
|
63
|
+
return identifier
|
|
36
64
|
})
|
|
37
65
|
.filter(Boolean)
|
|
38
66
|
continue
|
|
@@ -42,5 +70,8 @@ export function workerSafeOptions(opts) {
|
|
|
42
70
|
result[k] = v
|
|
43
71
|
} catch { /* not cloneable — skip */ }
|
|
44
72
|
}
|
|
73
|
+
// Only when something declared one, so an options object a test compares
|
|
74
|
+
// whole does not grow an empty key.
|
|
75
|
+
if (Object.keys(pluginModules).length) result.pluginModules = pluginModules
|
|
45
76
|
return result
|
|
46
77
|
}
|
package/src/inventory.js
CHANGED
|
@@ -30,39 +30,29 @@ function repositoryUrl(repository) {
|
|
|
30
30
|
.replace(/\.git$/, '')
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
//
|
|
33
|
+
// Every mikser package INSTALLED beside this one, described.
|
|
34
34
|
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// A lifecycle plugin that mounts nothing is recognised by the surface it
|
|
53
|
-
// publishes on the runtime.
|
|
54
|
-
if (runtime.options?.layouts) active.add('mikser-io-layouts')
|
|
55
|
-
if (runtime.options?.preview) active.add('mikser-io-preview')
|
|
56
|
-
// The engine itself is always running; saying otherwise would be odd.
|
|
57
|
-
active.add('mikser-io')
|
|
58
|
-
return active
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Every mikser package installed beside this one, described.
|
|
35
|
+
// Installed, and nothing more. This list used to carry an `active` flag,
|
|
36
|
+
// derived by probing whatever surfaces a plugin happened to expose — a route
|
|
37
|
+
// here, a CLI flag there. Two things were wrong with it and only the second
|
|
38
|
+
// was dangerous.
|
|
39
|
+
//
|
|
40
|
+
// The probe went stale silently: it tested `runtime.options.layouts`, which
|
|
41
|
+
// stopped being layouts' API object two majors ago and is now the `--layouts`
|
|
42
|
+
// folder flag, so layouts read as inactive on every site that did not pass a
|
|
43
|
+
// flag it has no reason to pass. It also tested `runtime.options.preview` for
|
|
44
|
+
// a package, `mikser-io-preview`, that does not exist.
|
|
45
|
+
//
|
|
46
|
+
// And the flag was three-valued in code and two-valued in its contract:
|
|
47
|
+
// present meant running, absent meant EITHER not running or not detectable,
|
|
48
|
+
// and an agent told to read it "to know what the system can do" could only
|
|
49
|
+
// read absence as off. On a real site that said no schema validation and no
|
|
50
|
+
// git sync while both were running — and git sync is the only route by which
|
|
51
|
+
// an agent's own edit reaches the repository.
|
|
62
52
|
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
53
|
+
// What is running is now answered by the runtime recording what it loads, in
|
|
54
|
+
// plugins.js. This answers a different and still useful question: what is on
|
|
55
|
+
// disk, what version, and where to read about it.
|
|
66
56
|
export function inventory({ workingFolder = runtime.options?.workingFolder } = {}) {
|
|
67
57
|
const root = path.join(workingFolder ?? '.', 'node_modules')
|
|
68
58
|
let names = []
|
|
@@ -72,7 +62,6 @@ export function inventory({ workingFolder = runtime.options?.workingFolder } = {
|
|
|
72
62
|
return []
|
|
73
63
|
}
|
|
74
64
|
|
|
75
|
-
const active = activeNames()
|
|
76
65
|
const plugins = []
|
|
77
66
|
for (const name of names.sort()) {
|
|
78
67
|
try {
|
|
@@ -82,7 +71,6 @@ export function inventory({ workingFolder = runtime.options?.workingFolder } = {
|
|
|
82
71
|
name,
|
|
83
72
|
version: manifest.version ?? null,
|
|
84
73
|
...(manifest.description ? { summary: manifest.description } : {}),
|
|
85
|
-
...(active.has(name) ? { active: true } : {}),
|
|
86
74
|
...(manifest.homepage ? { homepage: manifest.homepage } : {}),
|
|
87
75
|
...(repository ? { repository } : {}),
|
|
88
76
|
npm: `https://www.npmjs.com/package/${name}`,
|
|
@@ -91,3 +79,25 @@ export function inventory({ workingFolder = runtime.options?.workingFolder } = {
|
|
|
91
79
|
}
|
|
92
80
|
return plugins
|
|
93
81
|
}
|
|
82
|
+
|
|
83
|
+
// What this runtime LOADED — the answer to "what is running".
|
|
84
|
+
//
|
|
85
|
+
// Read from the record plugins.js keeps as it loads, not derived from
|
|
86
|
+
// surfaces afterwards. Every loaded plugin appears, including one that named
|
|
87
|
+
// nothing: `package: null` means "running, and did not say what it is", which
|
|
88
|
+
// is a different statement from not running and must never be collapsed into
|
|
89
|
+
// one. Nothing here is ever absent because it could not be detected — a plugin
|
|
90
|
+
// that is loaded is in this list.
|
|
91
|
+
export function loadedPlugins({ workingFolder = runtime.options?.workingFolder } = {}) {
|
|
92
|
+
const root = path.join(workingFolder ?? '.', 'node_modules')
|
|
93
|
+
const versionOf = (name) => {
|
|
94
|
+
if (!name) return null
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(readFileSync(path.join(root, name, 'package.json'), 'utf8')).version ?? null
|
|
97
|
+
} catch { return null }
|
|
98
|
+
}
|
|
99
|
+
return (runtime.plugins ?? []).map(entry => ({
|
|
100
|
+
...entry,
|
|
101
|
+
...(entry.package ? { version: versionOf(entry.package) } : {}),
|
|
102
|
+
}))
|
|
103
|
+
}
|
package/src/plugins/api.js
CHANGED
|
@@ -1209,5 +1209,10 @@ export function api(options = {}) {
|
|
|
1209
1209
|
}
|
|
1210
1210
|
}
|
|
1211
1211
|
})
|
|
1212
|
+
|
|
1213
|
+
// Names this package to the runtime's loaded-plugin record — see
|
|
1214
|
+
// plugins.js. A plugin that declares nothing still reports as loaded,
|
|
1215
|
+
// but as `package: null`.
|
|
1216
|
+
return { module: import.meta.url }
|
|
1212
1217
|
}
|
|
1213
1218
|
}
|
package/src/plugins/commands.js
CHANGED
package/src/plugins/data.js
CHANGED
|
@@ -216,5 +216,10 @@ export function data(options = {}) {
|
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
})
|
|
219
|
+
|
|
220
|
+
// Names this package to the runtime's loaded-plugin record — see
|
|
221
|
+
// plugins.js. A plugin that declares nothing still reports as loaded,
|
|
222
|
+
// but as `package: null`.
|
|
223
|
+
return { module: import.meta.url }
|
|
219
224
|
}
|
|
220
225
|
}
|
package/src/plugins/documents.js
CHANGED
package/src/plugins/files.js
CHANGED
|
@@ -20,5 +20,10 @@ export function frontMatter(options = {}) {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
})
|
|
23
|
+
|
|
24
|
+
// Names this package to the runtime's loaded-plugin record — see
|
|
25
|
+
// plugins.js. A plugin that declares nothing still reports as loaded,
|
|
26
|
+
// but as `package: null`.
|
|
27
|
+
return { module: import.meta.url }
|
|
23
28
|
}
|
|
24
29
|
}
|
package/src/plugins/json.js
CHANGED
|
@@ -16,5 +16,10 @@ export function json(options = {}) {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
})
|
|
19
|
+
|
|
20
|
+
// Names this package to the runtime's loaded-plugin record — see
|
|
21
|
+
// plugins.js. A plugin that declares nothing still reports as loaded,
|
|
22
|
+
// but as `package: null`.
|
|
23
|
+
return { module: import.meta.url }
|
|
19
24
|
}
|
|
20
25
|
}
|
package/src/plugins/mapper.js
CHANGED
|
@@ -22,5 +22,10 @@ export function mapper(options = {}) {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
})
|
|
25
|
+
|
|
26
|
+
// Names this package to the runtime's loaded-plugin record — see
|
|
27
|
+
// plugins.js. A plugin that declares nothing still reports as loaded,
|
|
28
|
+
// but as `package: null`.
|
|
29
|
+
return { module: import.meta.url }
|
|
25
30
|
}
|
|
26
31
|
}
|
package/src/plugins/observer.js
CHANGED
|
@@ -164,5 +164,5 @@ export function load({ runtime, options, track, logger }) {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
export function fileHelpers(options = {}) {
|
|
167
|
-
return { name: options.name ?? 'file', options, load }
|
|
167
|
+
return { name: options.name ?? 'file', options, load, module: import.meta.url }
|
|
168
168
|
}
|
|
@@ -24,5 +24,5 @@ export function load({ runtime, entity, state, options, track }) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function resourceUrlHelper(options = {}) {
|
|
27
|
-
return { name: options.name ?? 'resource', options, load }
|
|
27
|
+
return { name: options.name ?? 'resource', options, load, module: import.meta.url }
|
|
28
28
|
}
|
package/src/plugins/resources.js
CHANGED
package/src/plugins/shares.js
CHANGED
|
@@ -38,5 +38,10 @@ export function shares(options = {}) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
})
|
|
41
|
+
|
|
42
|
+
// Names this package to the runtime's loaded-plugin record — see
|
|
43
|
+
// plugins.js. A plugin that declares nothing still reports as loaded,
|
|
44
|
+
// but as `package: null`.
|
|
45
|
+
return { module: import.meta.url }
|
|
41
46
|
}
|
|
42
47
|
}
|
package/src/plugins/validator.js
CHANGED
|
@@ -14,5 +14,10 @@ export function validator(options = {}) {
|
|
|
14
14
|
})
|
|
15
15
|
}
|
|
16
16
|
})
|
|
17
|
+
|
|
18
|
+
// Names this package to the runtime's loaded-plugin record — see
|
|
19
|
+
// plugins.js. A plugin that declares nothing still reports as loaded,
|
|
20
|
+
// but as `package: null`.
|
|
21
|
+
return { module: import.meta.url }
|
|
17
22
|
}
|
|
18
23
|
}
|
package/src/plugins/yaml.js
CHANGED
|
@@ -22,5 +22,10 @@ export function yaml(options = {}) {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
})
|
|
25
|
+
|
|
26
|
+
// Names this package to the runtime's loaded-plugin record — see
|
|
27
|
+
// plugins.js. A plugin that declares nothing still reports as loaded,
|
|
28
|
+
// but as `package: null`.
|
|
29
|
+
return { module: import.meta.url }
|
|
25
30
|
}
|
|
26
31
|
}
|
package/src/plugins.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { useLogger } from './engine/index.js'
|
|
2
2
|
import { onLoad } from './lifecycle.js'
|
|
3
3
|
import { resetServices } from './services.js'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
4
7
|
import runtime from './runtime.js'
|
|
5
8
|
|
|
6
9
|
import * as core from '../index.js'
|
|
@@ -40,6 +43,9 @@ onLoad(() => {
|
|
|
40
43
|
// always sees a Map, never undefined.
|
|
41
44
|
runtime.renderers = runtime.renderers ?? new Map()
|
|
42
45
|
runtime.postprocessors = runtime.postprocessors ?? new Map()
|
|
46
|
+
// Rebuilt, not appended to: loadPlugins runs again on a config change, and
|
|
47
|
+
// a list that accumulated would report every plugin twice.
|
|
48
|
+
runtime.plugins = []
|
|
43
49
|
|
|
44
50
|
const factoryEntries = []
|
|
45
51
|
let registeredRenderers = 0
|
|
@@ -62,6 +68,8 @@ onLoad(() => {
|
|
|
62
68
|
continue
|
|
63
69
|
}
|
|
64
70
|
runtime.renderers.set(name, entry)
|
|
71
|
+
recordPlugin({ kind: 'renderer', name,
|
|
72
|
+
package: packageOfModule(entry.module), module: entry.module ?? null })
|
|
65
73
|
registeredRenderers++
|
|
66
74
|
continue
|
|
67
75
|
}
|
|
@@ -72,6 +80,8 @@ onLoad(() => {
|
|
|
72
80
|
continue
|
|
73
81
|
}
|
|
74
82
|
runtime.postprocessors.set(name, entry)
|
|
83
|
+
recordPlugin({ kind: 'postprocessor', name,
|
|
84
|
+
package: packageOfModule(entry.module), module: entry.module ?? null })
|
|
75
85
|
registeredPostprocessors++
|
|
76
86
|
continue
|
|
77
87
|
}
|
|
@@ -140,17 +150,83 @@ onLoad(() => {
|
|
|
140
150
|
logger.error('Plugin factory threw on registration: %s', err.message)
|
|
141
151
|
continue
|
|
142
152
|
}
|
|
143
|
-
|
|
153
|
+
// `plugin-7` names nothing a reader can act on. A plugin that
|
|
154
|
+
// declared its module has already said where it is, so the file's own
|
|
155
|
+
// stem is a better name than its position in an array.
|
|
156
|
+
const declaredName = descriptor?.module
|
|
157
|
+
? path.basename(String(descriptor.module).split('?')[0]).replace(/\.[cm]?js$/, '')
|
|
158
|
+
: null
|
|
159
|
+
const label = descriptor?.collection ?? descriptor?.type ?? declaredName ?? `plugin-${index + 1}`
|
|
160
|
+
const registered = []
|
|
144
161
|
for (const name of hookNames) {
|
|
145
|
-
|
|
162
|
+
const added = runtime.hooks[name].slice(before.get(name))
|
|
163
|
+
if (added.length) registered.push(name)
|
|
164
|
+
for (const hook of added) {
|
|
146
165
|
// A plugin registering the same function twice keeps its first
|
|
147
166
|
// label rather than being renamed by a later registration.
|
|
148
167
|
if (typeof hook === 'function' && !hook.mikserPlugin) hook.mikserPlugin = label
|
|
149
168
|
}
|
|
150
169
|
}
|
|
170
|
+
// Recorded whether or not it named itself. A plugin that declares
|
|
171
|
+
// nothing is still loaded, and the list must say so — that is the
|
|
172
|
+
// whole difference between this and the probe it replaces.
|
|
173
|
+
recordPlugin({
|
|
174
|
+
kind: 'lifecycle',
|
|
175
|
+
label,
|
|
176
|
+
package: packageOfModule(descriptor?.module),
|
|
177
|
+
module: descriptor?.module ?? null,
|
|
178
|
+
...(descriptor?.collection ? { collection: descriptor.collection } : {}),
|
|
179
|
+
hooks: registered,
|
|
180
|
+
})
|
|
151
181
|
}
|
|
152
182
|
})
|
|
153
183
|
|
|
184
|
+
|
|
185
|
+
// The package a module belongs to, read from the nearest package.json.
|
|
186
|
+
//
|
|
187
|
+
// Walked up from the file rather than pattern-matched on the path: a plugin
|
|
188
|
+
// developed in a workspace sits at `~/Projects/mikser/mikser-io-git`, and the
|
|
189
|
+
// same plugin installed sits at `<site>/node_modules/mikser-io-git`. Only the
|
|
190
|
+
// manifest is true in both, and only the manifest is true for a plugin whose
|
|
191
|
+
// package is not named for it.
|
|
192
|
+
function packageOfModule(moduleUrl) {
|
|
193
|
+
if (typeof moduleUrl !== 'string') return null
|
|
194
|
+
let dir
|
|
195
|
+
try {
|
|
196
|
+
dir = path.dirname(moduleUrl.startsWith('file:') ? fileURLToPath(moduleUrl) : moduleUrl)
|
|
197
|
+
} catch { return null }
|
|
198
|
+
for (let depth = 0; depth < 12; depth++) {
|
|
199
|
+
const manifest = path.join(dir, 'package.json')
|
|
200
|
+
if (existsSync(manifest)) {
|
|
201
|
+
try { return JSON.parse(readFileSync(manifest, 'utf8')).name ?? null } catch { return null }
|
|
202
|
+
}
|
|
203
|
+
const parent = path.dirname(dir)
|
|
204
|
+
if (parent === dir) break
|
|
205
|
+
dir = parent
|
|
206
|
+
}
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// What this runtime LOADED, recorded as it loads it.
|
|
211
|
+
//
|
|
212
|
+
// Not derived afterwards. The previous answer was assembled by inventory.js
|
|
213
|
+
// from whatever surfaces happened to be observable — a route here, a CLI flag
|
|
214
|
+
// there — which made "is this plugin running" a question about how well the
|
|
215
|
+
// probe was maintained rather than about the plugin. It reported layouts as
|
|
216
|
+
// not running on every site that did not pass `--layouts`, because the flag it
|
|
217
|
+
// probed had stopped being an API object two majors earlier and nothing failed
|
|
218
|
+
// when it went stale.
|
|
219
|
+
//
|
|
220
|
+
// A plugin names itself by returning `module: import.meta.url` from its
|
|
221
|
+
// factory, the same self-naming `registerRoute` and `provideService` already
|
|
222
|
+
// require. One that names nothing is still recorded and still reported as
|
|
223
|
+
// LOADED — its `package` is null, which says "this is running and did not say
|
|
224
|
+
// what it is", never "this is not running".
|
|
225
|
+
function recordPlugin(entry) {
|
|
226
|
+
runtime.plugins = runtime.plugins ?? []
|
|
227
|
+
runtime.plugins.push(entry)
|
|
228
|
+
}
|
|
229
|
+
|
|
154
230
|
function kebabToCamel(s) {
|
|
155
231
|
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
|
|
156
232
|
}
|
package/src/postprocess.js
CHANGED
|
@@ -7,7 +7,7 @@ import _ from 'lodash'
|
|
|
7
7
|
import { useLogger } from './engine/index.js'
|
|
8
8
|
import engineRuntime from './runtime.js'
|
|
9
9
|
|
|
10
|
-
export async function loadPlugin(pluginName, workingFolder, loggerOverride) {
|
|
10
|
+
export async function loadPlugin(pluginName, workingFolder, loggerOverride, pluginModules) {
|
|
11
11
|
// Worker contexts don't have access to the engine's pino instance
|
|
12
12
|
// via useLogger() — callers from inside the Piscina dispatch pass
|
|
13
13
|
// their port-forwarded logger explicitly so plugin-load failures
|
|
@@ -28,6 +28,9 @@ export async function loadPlugin(pluginName, workingFolder, loggerOverride) {
|
|
|
28
28
|
} catch { /* package not installed at this level — fine, try next */ }
|
|
29
29
|
|
|
30
30
|
const resolveLocations = [
|
|
31
|
+
// See render.js: what the descriptor declared, before anything that
|
|
32
|
+
// guesses a package name from the plugin name.
|
|
33
|
+
pluginModules?.[pluginName],
|
|
31
34
|
path.join(workingFolder, 'node_modules', `mikser-io-${pluginName}/index.js`),
|
|
32
35
|
nodeModulesResolved,
|
|
33
36
|
path.join(workingFolder, 'plugins', `${pluginName}.js`),
|
|
@@ -110,7 +113,7 @@ export default async ({ entity, options, config, context, state, logger, port })
|
|
|
110
113
|
.filter(p => p && p.indexOf('post-') == 0))
|
|
111
114
|
|
|
112
115
|
for (let pluginName of pluginsToLoad) {
|
|
113
|
-
const plugin = await loadPlugin(pluginName, options.workingFolder, logger)
|
|
116
|
+
const plugin = await loadPlugin(pluginName, options.workingFolder, logger, options.pluginModules)
|
|
114
117
|
if (!plugin) continue
|
|
115
118
|
plugins[pluginName] = plugin
|
|
116
119
|
}
|
package/src/principal.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Who is acting, for the length of one request.
|
|
2
|
+
//
|
|
3
|
+
// A leaf, and an AsyncLocalStorage for the same reason changeSetContext is
|
|
4
|
+
// one: the surfaces that KNOW the caller (an HTTP route, an MCP session) are
|
|
5
|
+
// nowhere near the primitives that need to ask about them, and threading a
|
|
6
|
+
// principal through every intervening call is how one of them comes to be
|
|
7
|
+
// missed.
|
|
8
|
+
//
|
|
9
|
+
// This exists because the thing already flowing to the write primitives was
|
|
10
|
+
// not an identity. `writeEntitySource` takes a `principal`, and what MCP puts
|
|
11
|
+
// there is a display string — "dk@almero.bg (admins)" — built for the
|
|
12
|
+
// change-set log. Asked `hasCapability(thatString, 'write:documents')` it
|
|
13
|
+
// reads `undefined.capabilities`, takes the "not capability-scoped" branch,
|
|
14
|
+
// and returns TRUE. An authorization check against it would not merely fail
|
|
15
|
+
// to protect anything; it would look like protection while allowing
|
|
16
|
+
// everything.
|
|
17
|
+
//
|
|
18
|
+
// `null` when nothing established a context, and that means UNKNOWN rather
|
|
19
|
+
// than "nobody". Callers decide what unknown implies — the write gate treats
|
|
20
|
+
// it the way it treats a credential that carries no capabilities, which is
|
|
21
|
+
// how every static token and library caller already behaves.
|
|
22
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
23
|
+
|
|
24
|
+
const principalContext = new AsyncLocalStorage()
|
|
25
|
+
|
|
26
|
+
export function withPrincipal(principal, fn) {
|
|
27
|
+
if (!principal || typeof principal !== 'object') return fn()
|
|
28
|
+
return principalContext.run({ principal }, fn)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function currentPrincipal() {
|
|
32
|
+
return principalContext.getStore()?.principal ?? null
|
|
33
|
+
}
|
package/src/render.js
CHANGED
|
@@ -149,6 +149,13 @@ export default async ({ entity, options, config, context, state, logger, port, t
|
|
|
149
149
|
} catch { /* package not installed at this level — fine, try next */ }
|
|
150
150
|
|
|
151
151
|
const resolveLocations = [
|
|
152
|
+
// What the descriptor itself said, first: it is the only entry
|
|
153
|
+
// that does not guess from the name. Everything below assumes a
|
|
154
|
+
// package called `mikser-io-<pluginName>`, which is true of every
|
|
155
|
+
// plugin named for its renderer and false of every one that ships
|
|
156
|
+
// alongside other things — mikser-io-assets ships two. See
|
|
157
|
+
// workerSafeOptions.
|
|
158
|
+
options.pluginModules?.[pluginName],
|
|
152
159
|
path.join(options.workingFolder, 'node_modules', `mikser-io-${pluginName}/index.js`),
|
|
153
160
|
nodeModulesResolved,
|
|
154
161
|
path.join(options.workingFolder, 'plugins', `${pluginName}.js`),
|
|
@@ -322,7 +329,22 @@ export default async ({ entity, options, config, context, state, logger, port, t
|
|
|
322
329
|
}
|
|
323
330
|
|
|
324
331
|
const rendererPlugin = plugins[`render-${renderer}`]
|
|
325
|
-
|
|
332
|
+
// A missing HELPER announces itself — the template asks for something that
|
|
333
|
+
// is not there and the engine reports a render error. A missing RENDERER
|
|
334
|
+
// did not: the optional call returned undefined, the entity produced no
|
|
335
|
+
// output, and the only trace was one log line above. That is how
|
|
336
|
+
// `render-preset` went unresolvable on workers for two releases while
|
|
337
|
+
// every build stayed green.
|
|
338
|
+
//
|
|
339
|
+
// An entity was asked for and nothing rendered it. That is a fault, and it
|
|
340
|
+
// reads as one now.
|
|
341
|
+
if (typeof rendererPlugin?.render !== 'function') {
|
|
342
|
+
throw new Error(`Renderer "${renderer}" is not loaded, so ${entity?.id ?? 'this entity'} `
|
|
343
|
+
+ `cannot be rendered. Its plugin resolved to nothing: either it is missing from the `
|
|
344
|
+
+ `config, or it is a plugin a worker cannot find by name — see the "Render plugin `
|
|
345
|
+
+ `render-${renderer} not found" line above.`)
|
|
346
|
+
}
|
|
347
|
+
const output = await rendererPlugin.render({ entity, options, config: rendererPlugin.options, context, plugins, runtime, state, logger, track })
|
|
326
348
|
|
|
327
349
|
// One shape for both dispatch modes, so the engine unpacks in one place
|
|
328
350
|
// rather than branching on how the render happened. An inline render folds
|
package/src/utils/entity.js
CHANGED
|
@@ -8,6 +8,7 @@ import { contentType } from 'mime-types'
|
|
|
8
8
|
import { minimatch } from 'minimatch'
|
|
9
9
|
import { mkdir, open, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
10
10
|
import { createRequire } from 'node:module'
|
|
11
|
+
import { missingCollectionWrite } from '../auth.js'
|
|
11
12
|
|
|
12
13
|
// Extension → mime type lookup for rendered outputs. Used anywhere an
|
|
13
14
|
// entity's destination is being served over HTTP (the api plugin's
|
|
@@ -371,6 +372,24 @@ export function projectMeta(meta) {
|
|
|
371
372
|
* remove(relativePath: string): Promise<void>,
|
|
372
373
|
* }}
|
|
373
374
|
*/
|
|
375
|
+
// Refuse a write the acting principal may not make.
|
|
376
|
+
//
|
|
377
|
+
// Here rather than at each caller, and for the reason the change-set hook one
|
|
378
|
+
// line below is here: this is the lowest write primitive, so a plugin that
|
|
379
|
+
// has never heard of capabilities is still bounded by them. The alternative
|
|
380
|
+
// is every writer remembering, and the one that forgets is the one that
|
|
381
|
+
// leaks.
|
|
382
|
+
//
|
|
383
|
+
// Throws rather than returning a refusal because `write` has no refusal
|
|
384
|
+
// channel — it returns a uri — and a caller that ignores a falsy return would
|
|
385
|
+
// report success for a write that never happened.
|
|
386
|
+
function refuseUnlessMayWrite(collection) {
|
|
387
|
+
const missing = missingCollectionWrite(collection)
|
|
388
|
+
if (!missing) return
|
|
389
|
+
throw new Error(`Refused: writing to ${collection} needs ${missing}, `
|
|
390
|
+
+ 'which this credential does not carry.')
|
|
391
|
+
}
|
|
392
|
+
|
|
374
393
|
export function useCollection(runtime, name) {
|
|
375
394
|
function resolveFolder() {
|
|
376
395
|
const folder = runtime?.options?.[`${name}Folder`]
|
|
@@ -402,6 +421,7 @@ export function useCollection(runtime, name) {
|
|
|
402
421
|
resolveWithin,
|
|
403
422
|
|
|
404
423
|
async write(relativePath, content = '') {
|
|
424
|
+
refuseUnlessMayWrite(name)
|
|
405
425
|
const uri = resolveWithin(relativePath)
|
|
406
426
|
await mkdir(path.dirname(uri), { recursive: true })
|
|
407
427
|
await writeFile(uri, content, 'utf8')
|
|
@@ -415,6 +435,7 @@ export function useCollection(runtime, name) {
|
|
|
415
435
|
},
|
|
416
436
|
|
|
417
437
|
async remove(relativePath) {
|
|
438
|
+
refuseUnlessMayWrite(name)
|
|
418
439
|
const uri = resolveWithin(relativePath)
|
|
419
440
|
await unlink(uri)
|
|
420
441
|
runtime.recordChangeSetWrite?.({ uri, operation: 'delete' })
|
package/src/utils/index.js
CHANGED
|
@@ -15,5 +15,5 @@ export { checksum, checksumOf, diffInputParts, inputHashOf, inputPartsOf, normal
|
|
|
15
15
|
export { JUNK_IGNORE, isJunkPath, junkFilter, junkIgnore, registerJunk } from './junk.js'
|
|
16
16
|
export { matchesLibrary } from './library.js'
|
|
17
17
|
export { isLoopback, loopbackOnly } from './net.js'
|
|
18
|
-
export { siteRelativeUrl, siteRootFor, writeEntity, writeOutput } from './output.js'
|
|
18
|
+
export { siteRelativeUrl, siteRootFor, writeEntity, writeOutput, registerSourceFormat, sourceFormatFor, validateSource } from './output.js'
|
|
19
19
|
export { extractRefs, isRefKey, lookupKeys, matchesRef, refFilter } from './refs.js'
|
package/src/utils/output.js
CHANGED
|
@@ -37,44 +37,159 @@ import { lstat, mkdir, open, readFile, stat, unlink, writeFile } from 'node:fs/p
|
|
|
37
37
|
// Returns the absolute path that was written. The watcher will see the
|
|
38
38
|
// change just like any external edit — the entity re-enters the lifecycle
|
|
39
39
|
// naturally on the next cycle.
|
|
40
|
+
// How a source file carries its meta, and how to put it back.
|
|
41
|
+
//
|
|
42
|
+
// Front matter is one answer, not the answer. A `.yml` or `.json` entity IS
|
|
43
|
+
// its meta — there is no body — and treating every file as front-matter did
|
|
44
|
+
// not merely format it oddly, it destroyed it: with no `---` to find, the
|
|
45
|
+
// whole document was taken as the BODY and the patch written above it as
|
|
46
|
+
// fresh front matter. A one-key rename turned a price list into a two-key
|
|
47
|
+
// document with the real one quoted underneath as inert text, and nothing
|
|
48
|
+
// threw. refs.rename reaches this for every referring entity, which is the
|
|
49
|
+
// largest fan-out any single request has.
|
|
50
|
+
//
|
|
51
|
+
// Registered and dispatched the way provenance registers its readers: last
|
|
52
|
+
// registered is checked first, and the catch-all goes in first so it is
|
|
53
|
+
// consulted last. A format plugin adds its own without touching this file.
|
|
54
|
+
//
|
|
55
|
+
// test(entity, raw) does this handler own this source?
|
|
56
|
+
// write({ raw, patch }) the complete new file contents
|
|
57
|
+
// validate(text) null if it parses, else why not — so an edit can
|
|
58
|
+
// be refused before it lands rather than found at
|
|
59
|
+
// the next build
|
|
60
|
+
//
|
|
61
|
+
// `patch` rather than a merged meta, deliberately: a handler that can edit its
|
|
62
|
+
// source in place should be allowed to. The yaml one does, so comments and
|
|
63
|
+
// untouched values survive an edit that names one key.
|
|
64
|
+
const sourceFormats = []
|
|
65
|
+
|
|
66
|
+
export function registerSourceFormat(name, { test, write, validate = null }) {
|
|
67
|
+
if (!name || typeof test !== 'function' || typeof write !== 'function') {
|
|
68
|
+
throw new Error('registerSourceFormat(name, { test, write }) requires all three')
|
|
69
|
+
}
|
|
70
|
+
sourceFormats.unshift({ name, test, write, validate })
|
|
71
|
+
return () => {
|
|
72
|
+
const at = sourceFormats.findIndex(h => h.name === name)
|
|
73
|
+
if (at >= 0) sourceFormats.splice(at, 1)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Does this text still parse as what the file is? `null` when it does, and
|
|
78
|
+
// when the format has no parser to ask — the answer to "is this valid" for a
|
|
79
|
+
// stylesheet is not this module's to give.
|
|
80
|
+
export function validateSource(entity, text) {
|
|
81
|
+
const handler = sourceFormatFor(entity, text)
|
|
82
|
+
if (typeof handler?.validate !== 'function') return null
|
|
83
|
+
try { return handler.validate(text) } catch (err) { return err.message }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function sourceFormatFor(entity, raw = '') {
|
|
87
|
+
for (const handler of sourceFormats) {
|
|
88
|
+
try {
|
|
89
|
+
if (handler.test(entity, raw)) return handler
|
|
90
|
+
} catch { /* a handler that throws is a handler that declines */ }
|
|
91
|
+
}
|
|
92
|
+
return sourceFormats[sourceFormats.length - 1]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The format an entity is in, by what the catalog recorded and, failing that,
|
|
96
|
+
// by its extension — a caller holding only a uri is one this must still answer
|
|
97
|
+
// for.
|
|
98
|
+
function formatOf(entity) {
|
|
99
|
+
if (entity?.format) return String(entity.format).toLowerCase()
|
|
100
|
+
return path.extname(entity?.uri ?? '').replace(/^\./, '').toLowerCase()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function applyPatch(target, patch) {
|
|
104
|
+
const next = { ...target }
|
|
105
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
106
|
+
if (value === null) delete next[key]
|
|
107
|
+
else next[key] = value
|
|
108
|
+
}
|
|
109
|
+
return next
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A text document with a `---` block, or one that should grow one. Registered
|
|
113
|
+
// first, so it is checked last: this is the catch-all.
|
|
114
|
+
registerSourceFormat('front-matter', {
|
|
115
|
+
test: () => true,
|
|
116
|
+
// Only the block is parseable; the body is whatever the renderer makes of
|
|
117
|
+
// it. A file with no front matter has nothing here to be wrong.
|
|
118
|
+
validate(text) {
|
|
119
|
+
if (!fm.test(text)) return null
|
|
120
|
+
try { fm(text); return null } catch (err) { return err.message }
|
|
121
|
+
},
|
|
122
|
+
write({ raw, patch }) {
|
|
123
|
+
const parsed = fm.test(raw) ? fm(raw) : null
|
|
124
|
+
const body = parsed ? (parsed.body ?? '') : raw
|
|
125
|
+
const meta = applyPatch(parsed?.attributes ?? {}, patch)
|
|
126
|
+
// No meta left — write just the body, rather than a `---`/`---` shell
|
|
127
|
+
// that some tooling reads as broken front matter.
|
|
128
|
+
if (Object.keys(meta).length === 0) return body
|
|
129
|
+
return `---\n${yaml.stringify(meta)}---\n${body}`
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// A YAML document is meta all the way down.
|
|
134
|
+
//
|
|
135
|
+
// Patched through the document AST rather than re-serialized, so comments and
|
|
136
|
+
// every value the patch does not name survive. Not byte-perfect: the emitter
|
|
137
|
+
// re-flows block scalars to its own width, so hand-wrapped prose moves. That
|
|
138
|
+
// is a formatting change, where re-serializing would have been a content one.
|
|
139
|
+
registerSourceFormat('yaml', {
|
|
140
|
+
validate(text) {
|
|
141
|
+
const errors = yaml.parseAllDocuments(text).flatMap(document => document.errors)
|
|
142
|
+
return errors.length ? errors[0].message : null
|
|
143
|
+
},
|
|
144
|
+
// On the format alone. `fm.test` is not a safe second opinion here: a
|
|
145
|
+
// multi-document YAML file has a `---` between its documents and reads as
|
|
146
|
+
// front matter to it, so consulting it sent exactly the files with the
|
|
147
|
+
// most to lose down the wrong path.
|
|
148
|
+
test: (entity) => ['yml', 'yaml'].includes(formatOf(entity)),
|
|
149
|
+
write({ raw, patch }) {
|
|
150
|
+
// Every document, not the first. parseDocument on a multi-document
|
|
151
|
+
// source returns one carrying errors, and stringifying it throws —
|
|
152
|
+
// loud, but it refuses a file it could have patched. The meta belongs
|
|
153
|
+
// to the first document; the rest are copied through untouched.
|
|
154
|
+
const documents = raw.trim() ? yaml.parseAllDocuments(raw) : [yaml.parseDocument('')]
|
|
155
|
+
const target = documents[0]
|
|
156
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
157
|
+
if (value === null) target.deleteIn([key])
|
|
158
|
+
else target.setIn([key], value)
|
|
159
|
+
}
|
|
160
|
+
return documents.map(document => document.toString()).join('')
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// JSON has no comments to keep and no body to preserve, so this is a plain
|
|
165
|
+
// round-trip — but the indent is read off the file rather than imposed, so a
|
|
166
|
+
// one-key patch does not reformat every line of a 4-space document.
|
|
167
|
+
registerSourceFormat('json', {
|
|
168
|
+
validate(text) {
|
|
169
|
+
if (!text.trim()) return null
|
|
170
|
+
try { JSON.parse(text); return null } catch (err) { return err.message }
|
|
171
|
+
},
|
|
172
|
+
test: (entity) => formatOf(entity) === 'json',
|
|
173
|
+
write({ raw, patch }) {
|
|
174
|
+
const current = raw.trim() ? JSON.parse(raw) : {}
|
|
175
|
+
const indent = raw.match(/^[ \t]+/m)?.[0] ?? ' '
|
|
176
|
+
return JSON.stringify(applyPatch(current, patch), null, indent) + '\n'
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
|
|
40
180
|
export async function writeEntity(entity, patch = {}) {
|
|
41
181
|
if (!entity?.uri) {
|
|
42
182
|
throw new Error('writeEntity: entity.uri is required')
|
|
43
183
|
}
|
|
44
184
|
|
|
45
|
-
let
|
|
46
|
-
let body = ''
|
|
185
|
+
let raw = ''
|
|
47
186
|
try {
|
|
48
|
-
|
|
49
|
-
if (fm.test(content)) {
|
|
50
|
-
const parsed = fm(content)
|
|
51
|
-
currentMeta = parsed.attributes ?? {}
|
|
52
|
-
body = parsed.body ?? ''
|
|
53
|
-
} else {
|
|
54
|
-
body = content
|
|
55
|
-
}
|
|
187
|
+
raw = await readFile(entity.uri, 'utf8')
|
|
56
188
|
} catch (err) {
|
|
57
189
|
if (err.code !== 'ENOENT') throw err
|
|
58
|
-
// Fresh file —
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const newMeta = { ...currentMeta }
|
|
62
|
-
for (const [k, v] of Object.entries(patch)) {
|
|
63
|
-
if (v === null) delete newMeta[k]
|
|
64
|
-
else newMeta[k] = v
|
|
190
|
+
// Fresh file — the handler starts from empty source.
|
|
65
191
|
}
|
|
66
|
-
|
|
67
|
-
let newContent
|
|
68
|
-
if (Object.keys(newMeta).length > 0) {
|
|
69
|
-
// yaml.stringify always emits a trailing newline.
|
|
70
|
-
const yamlStr = yaml.stringify(newMeta)
|
|
71
|
-
newContent = `---\n${yamlStr}---\n${body}`
|
|
72
|
-
} else {
|
|
73
|
-
// No meta left — write just the body. Avoids `---\n---\n<body>`
|
|
74
|
-
// shells that some tooling treats as "broken frontmatter."
|
|
75
|
-
newContent = body
|
|
76
|
-
}
|
|
77
|
-
|
|
192
|
+
const newContent = sourceFormatFor(entity, raw).write({ raw, patch, entity })
|
|
78
193
|
await mkdir(path.dirname(entity.uri), { recursive: true })
|
|
79
194
|
await writeFile(entity.uri, newContent, 'utf8')
|
|
80
195
|
// The other file-writing primitive. A rename cascade rewrites every
|
package/src/write.js
CHANGED
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
// gap this closes: the safety belongs to the write, not to one transport.
|
|
19
19
|
|
|
20
20
|
import path from 'node:path'
|
|
21
|
-
import { readdir } from 'node:fs/promises'
|
|
21
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
22
22
|
|
|
23
23
|
import runtime from './runtime.js'
|
|
24
24
|
import { readEntity, findEntities } from './catalog.js'
|
|
25
|
-
import { useCollection, checksum, readEntityContent, lookupKeys } from './utils/index.js'
|
|
25
|
+
import { useCollection, checksum, readEntityContent, lookupKeys, validateSource } from './utils/index.js'
|
|
26
26
|
import { nextCycleId, whenCycleCompletes } from './report.js'
|
|
27
27
|
import { recordChangeSetWrite, currentChangeSet } from './changeset.js'
|
|
28
28
|
|
|
@@ -431,3 +431,145 @@ function referrersOf(entity) {
|
|
|
431
431
|
}
|
|
432
432
|
return [...found.values()]
|
|
433
433
|
}
|
|
434
|
+
|
|
435
|
+
// Change a source file by naming the text to change, not by resending it.
|
|
436
|
+
//
|
|
437
|
+
// The alternative — and until now the only one — is a whole-file rewrite. For
|
|
438
|
+
// a model that is a silent-corruption machine: to change one price in a
|
|
439
|
+
// 900-line price list it must re-emit all 900 lines, and a line it quietly
|
|
440
|
+
// drops is indistinguishable downstream from a line someone meant to delete.
|
|
441
|
+
// `ifChecksum` catches a stale READ; nothing catches a lossy WRITE.
|
|
442
|
+
//
|
|
443
|
+
// An anchor cannot lose what it does not name. The only bytes that change are
|
|
444
|
+
// the ones matched, so everything else is not merely preserved — it is never
|
|
445
|
+
// rewritten. That also makes the concurrency check sharper than a checksum: a
|
|
446
|
+
// checksum refuses when ANY part of the file moved, an anchor refuses when the
|
|
447
|
+
// part you are editing moved, and those are different questions.
|
|
448
|
+
//
|
|
449
|
+
// Three refusals, and all of them are the good kind — stop and look, never
|
|
450
|
+
// "wrote the wrong thing":
|
|
451
|
+
//
|
|
452
|
+
// anchor-not-found the text is not there; the file is not what you read
|
|
453
|
+
// anchor-ambiguous it is there more than once; say more, or pass `all`
|
|
454
|
+
// would-not-parse the result is not valid in this file's format
|
|
455
|
+
//
|
|
456
|
+
// The last is the one a whole-file write can never offer. Today a model can
|
|
457
|
+
// emit invalid YAML and the build finds out; here the file never lands.
|
|
458
|
+
export async function editEntitySource({
|
|
459
|
+
id,
|
|
460
|
+
collection,
|
|
461
|
+
relativePath,
|
|
462
|
+
find,
|
|
463
|
+
replace = '',
|
|
464
|
+
all = false,
|
|
465
|
+
ifChecksum,
|
|
466
|
+
dryRun = false,
|
|
467
|
+
awaitCycle = false,
|
|
468
|
+
changeSet,
|
|
469
|
+
summary,
|
|
470
|
+
principal,
|
|
471
|
+
} = {}) {
|
|
472
|
+
if (typeof find !== 'string' || find === '') {
|
|
473
|
+
return { ok: false, refused: 'no-anchor', error: '`find` must be a non-empty string.' }
|
|
474
|
+
}
|
|
475
|
+
if (id) {
|
|
476
|
+
const located = await locateEntityFile(id)
|
|
477
|
+
if (located.error) return { ok: false, refused: 'unresolvable-id', error: located.error }
|
|
478
|
+
if (collection && collection !== located.collection) {
|
|
479
|
+
return {
|
|
480
|
+
ok: false,
|
|
481
|
+
refused: 'collection-mismatch',
|
|
482
|
+
error: `id ${id} is in collection ${located.collection}, not ${collection}. Pass one or the other.`,
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
collection ??= located.collection
|
|
486
|
+
relativePath ??= located.relativePath
|
|
487
|
+
}
|
|
488
|
+
if (!collection || !relativePath) {
|
|
489
|
+
return {
|
|
490
|
+
ok: false,
|
|
491
|
+
refused: 'incomplete-target',
|
|
492
|
+
error: 'Pass either `id` (for an existing entity) or both `collection` and `relativePath`.',
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
let handle
|
|
497
|
+
let uri
|
|
498
|
+
try {
|
|
499
|
+
handle = useCollection(runtime, collection)
|
|
500
|
+
uri = handle.resolveWithin(relativePath)
|
|
501
|
+
} catch (err) {
|
|
502
|
+
return { ok: false, refused: 'invalid-target', collection, relativePath, error: err.message }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const before = await fileChecksum(uri)
|
|
506
|
+
if (before === null) {
|
|
507
|
+
return {
|
|
508
|
+
ok: false,
|
|
509
|
+
refused: 'no-such-file',
|
|
510
|
+
collection, relativePath,
|
|
511
|
+
error: 'There is nothing here to edit. Use the whole-file write to create a file.',
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (ifChecksum !== undefined && ifChecksum !== before) {
|
|
515
|
+
return {
|
|
516
|
+
ok: false,
|
|
517
|
+
refused: 'checksum-mismatch',
|
|
518
|
+
collection, relativePath,
|
|
519
|
+
expectedChecksum: ifChecksum, currentChecksum: before,
|
|
520
|
+
hint: 'The file changed since you read it. Re-read it and check your anchor still says what you meant.',
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const current = await readFile(uri, 'utf8')
|
|
525
|
+
const occurrences = current.split(find).length - 1
|
|
526
|
+
if (occurrences === 0) {
|
|
527
|
+
return {
|
|
528
|
+
ok: false,
|
|
529
|
+
refused: 'anchor-not-found',
|
|
530
|
+
collection, relativePath, currentChecksum: before,
|
|
531
|
+
error: 'That text is not in the file.',
|
|
532
|
+
hint: 'Re-read the entity: either it changed, or the anchor carries whitespace or a line break it does not have.',
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (occurrences > 1 && !all) {
|
|
536
|
+
return {
|
|
537
|
+
ok: false,
|
|
538
|
+
refused: 'anchor-ambiguous',
|
|
539
|
+
collection, relativePath, occurrences, currentChecksum: before,
|
|
540
|
+
error: `That text appears ${occurrences} times.`,
|
|
541
|
+
hint: 'Extend `find` with surrounding lines until it is unique, or pass `all: true` to change every one.',
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// One expression for both, because the refusals above have already made
|
|
546
|
+
// the counts exact: `all` means "however many there are", and without it
|
|
547
|
+
// there is exactly one. (split/join replaces each match in the ORIGINAL,
|
|
548
|
+
// so a replacement containing the anchor does not compound.)
|
|
549
|
+
const content = current.split(find).join(replace)
|
|
550
|
+
|
|
551
|
+
// Refused before it lands, which is the guarantee a whole-file write has
|
|
552
|
+
// no way to make. Formats with no parser say nothing and pass.
|
|
553
|
+
const invalid = validateSource({ uri, collection }, content)
|
|
554
|
+
if (invalid) {
|
|
555
|
+
return {
|
|
556
|
+
ok: false,
|
|
557
|
+
refused: 'would-not-parse',
|
|
558
|
+
collection, relativePath, currentChecksum: before,
|
|
559
|
+
error: `The result is not valid ${path.extname(uri).replace('.', '') || 'content'}: ${invalid}`,
|
|
560
|
+
hint: 'Nothing was written. Check the replacement for indentation or quoting the format needs.',
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Through the whole-file write, so containment, the change set, the cycle
|
|
565
|
+
// id, sibling destinations and the collection capability gate are the same
|
|
566
|
+
// ones every other write goes through rather than a second set that can
|
|
567
|
+
// drift. `before` as the checksum closes the window between the read above
|
|
568
|
+
// and the write below.
|
|
569
|
+
const written = await writeEntitySource({
|
|
570
|
+
collection, relativePath, content,
|
|
571
|
+
ifChecksum: before,
|
|
572
|
+
dryRun, awaitCycle, changeSet, summary, principal,
|
|
573
|
+
})
|
|
574
|
+
return written.ok ? { ...written, replacements: all ? occurrences : 1 } : written
|
|
575
|
+
}
|