mikser-io 9.42.0 → 9.43.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 +366 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/utils.js +21 -3
- package/src/write.js +279 -0
package/docs/api-reference.md
CHANGED
|
@@ -483,6 +483,79 @@ Query types throughout: function, lodash match object, or `undefined` for all.
|
|
|
483
483
|
|
|
484
484
|
---
|
|
485
485
|
|
|
486
|
+
## Writing source files
|
|
487
|
+
|
|
488
|
+
`updateEntity` is a catalog operation. `writeEntitySource` writes the FILE, with
|
|
489
|
+
the checks that make a whole-file rewrite safe to perform without having watched
|
|
490
|
+
the file the whole time.
|
|
491
|
+
|
|
492
|
+
### `writeEntitySource(options)`
|
|
493
|
+
|
|
494
|
+
```js
|
|
495
|
+
import { writeEntitySource } from 'mikser-io'
|
|
496
|
+
|
|
497
|
+
const preview = await writeEntitySource({
|
|
498
|
+
id: '/documents/about.md',
|
|
499
|
+
content: next,
|
|
500
|
+
dryRun: true, // writes nothing; reports what it would re-render
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
const result = await writeEntitySource({
|
|
504
|
+
id: '/documents/about.md',
|
|
505
|
+
content: next,
|
|
506
|
+
ifChecksum: preview.currentChecksum,
|
|
507
|
+
})
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
| Option | Meaning |
|
|
511
|
+
| --- | --- |
|
|
512
|
+
| `id` | Catalog id of an existing entity. Alternative to the pair below. |
|
|
513
|
+
| `collection` + `relativePath` | Where to write. Required unless `id` is given. |
|
|
514
|
+
| `content` | The COMPLETE file. Anything omitted is deleted — there is no patch mode. |
|
|
515
|
+
| `ifChecksum` | Only write if the file's current DISK checksum equals this. |
|
|
516
|
+
| `dryRun` | Write nothing; return the blast radius and any advisory. |
|
|
517
|
+
| `awaitCycle` | Resolve once the cycle that picks the write up finishes, with its build report attached as `report`. |
|
|
518
|
+
|
|
519
|
+
**Never throws for an expected outcome.** A bad id, a path that escapes the
|
|
520
|
+
collection, a checksum that no longer matches — each returns
|
|
521
|
+
`{ ok: false, refused }` with the facts needed to retry, because those are
|
|
522
|
+
answers rather than faults. `refused` is one of `unresolvable-id`,
|
|
523
|
+
`collection-mismatch`, `incomplete-target`, `invalid-target`,
|
|
524
|
+
`checksum-mismatch`.
|
|
525
|
+
|
|
526
|
+
**Containment.** `relativePath` cannot leave the collection folder. This
|
|
527
|
+
matters because the path often comes from a request body or a CMS form, and
|
|
528
|
+
`path.join(folder, '../../x')` resolves outside the folder and writes there. It
|
|
529
|
+
is resolved and then contained rather than rejected on a literal `..`, so
|
|
530
|
+
`blog/../about.md` still works. The refusal happens before anything stats the
|
|
531
|
+
file — reporting a checksum for an out-of-tree path is a disclosure on its own.
|
|
532
|
+
|
|
533
|
+
**The precondition is not a lock.** A writer landing between the check and the
|
|
534
|
+
write still wins. It closes the window that matters in practice: read, think,
|
|
535
|
+
write back a whole file built from a copy that is now stale.
|
|
536
|
+
|
|
537
|
+
`ifChecksum` is compared against the DISK. `readEntity`'s `checksum` is the
|
|
538
|
+
catalog's, which lags between builds — pass `diskChecksum`, or the
|
|
539
|
+
`currentChecksum` a refusal hands back.
|
|
540
|
+
|
|
541
|
+
### Advisories
|
|
542
|
+
|
|
543
|
+
`contentAdvisories(entity, content)` names files a caller must not edit blind,
|
|
544
|
+
from `meta.specLocked` / `meta.generated` or from a header in the first 40
|
|
545
|
+
lines. Two kinds, kept apart because the instruction differs: `spec-locked`
|
|
546
|
+
means the bytes answer to a document outside the repo, `generated` means
|
|
547
|
+
editing the file is pointless because the next build overwrites it.
|
|
548
|
+
`advisoryWarning(advisories)` renders one line of prose for a response meant to
|
|
549
|
+
be read rather than parsed. Both are reported by `writeEntitySource` — on the
|
|
550
|
+
dry run and again on the way out, since a caller that never read the file is
|
|
551
|
+
exactly the one that needs telling.
|
|
552
|
+
|
|
553
|
+
`siblingDestinations(folder, relativePath)` reports files differing only by
|
|
554
|
+
extension, which may render to the same destination.
|
|
555
|
+
`locateEntityFile(id)` resolves a catalog id to its `{ collection, relativePath }`,
|
|
556
|
+
or `{ error }` — taken from the entity rather than by splitting the id, since the
|
|
557
|
+
prefix is configurable and the extension may have been stripped.
|
|
558
|
+
|
|
486
559
|
## Search
|
|
487
560
|
|
|
488
561
|
`queryEntities` sifts **meta**. `searchEntities` answers the other question —
|
|
@@ -545,6 +618,299 @@ it, in any text format, with no per-language grammar involved. It returns the
|
|
|
545
618
|
line rather than a verdict about it, so where the heuristic is wrong the
|
|
546
619
|
evidence is in the result.
|
|
547
620
|
|
|
621
|
+
## Content
|
|
622
|
+
|
|
623
|
+
Reading an entity's source, and deciding what "source" even means for it.
|
|
624
|
+
|
|
625
|
+
### `readEntityContent(entity, { reload } = {})`
|
|
626
|
+
|
|
627
|
+
Returns one of `{ content }`, `{ contentError }`, `{ contentSkipped }` — an
|
|
628
|
+
object to `Object.assign` onto the entity, or use directly. Dispatches by URI
|
|
629
|
+
scheme: plain paths and `file://` read from disk, `http(s)://` goes through the
|
|
630
|
+
built-in provider, anything else dynamic-imports `mikser-io-provider-<scheme>`.
|
|
631
|
+
|
|
632
|
+
`entity.content` already being a string short-circuits the whole dispatch, which
|
|
633
|
+
spares re-fetching a remote document a source plugin eagerly pulled in. Pass
|
|
634
|
+
`reload: true` when you want the bytes **as they are now** — between builds the
|
|
635
|
+
catalog copy and the file on disk part ways, and a whole-file rewrite built from
|
|
636
|
+
the catalog copy silently discards whatever changed underneath. An entity with
|
|
637
|
+
no `uri` keeps what it has rather than erroring.
|
|
638
|
+
|
|
639
|
+
### `looksTextual(buffer)` / `isTextEntity(entity)`
|
|
640
|
+
|
|
641
|
+
`looksTextual` answers "is this text?" from the BYTES: no NUL and a clean UTF-8
|
|
642
|
+
decode. This is what decides whether content comes back, and it is why a
|
|
643
|
+
`.liquid`, `.njk`, `.toml` or a format nobody has written yet is readable
|
|
644
|
+
without being added to a list first.
|
|
645
|
+
|
|
646
|
+
`isTextEntity` is a cheap extension guess with no I/O. It is a **hint** — the
|
|
647
|
+
extension list behind it is hand-maintained and therefore wrong about anything
|
|
648
|
+
not yet added. Never gate a read on it.
|
|
649
|
+
|
|
650
|
+
### `mimeForEntity(entity)`
|
|
651
|
+
|
|
652
|
+
Content type for the entity's `destination`, from the IANA registry via
|
|
653
|
+
`mime-types`. Null when the entity has no destination or the extension is
|
|
654
|
+
unregistered.
|
|
655
|
+
|
|
656
|
+
### `checksumOf(content)` / `checksum(uri)`
|
|
657
|
+
|
|
658
|
+
`checksumOf` hashes a string; `checksum` hashes a file, sampling head and tail
|
|
659
|
+
for large ones rather than reading the whole thing.
|
|
660
|
+
|
|
661
|
+
## Collections and sources
|
|
662
|
+
|
|
663
|
+
### `useCollection(runtime, name)`
|
|
664
|
+
|
|
665
|
+
The folder behind a collection, and guarded writes into it.
|
|
666
|
+
|
|
667
|
+
```js
|
|
668
|
+
const documents = useCollection(runtime, 'documents')
|
|
669
|
+
documents.folder // absolute path
|
|
670
|
+
await documents.write('blog/post.md', text)
|
|
671
|
+
await documents.remove('blog/post.md')
|
|
672
|
+
documents.resolveWithin('blog/post.md') // absolute path, or throws
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
`write`, `remove` and `resolveWithin` refuse a path that resolves outside the
|
|
676
|
+
collection folder. This matters whenever the path comes from a request body or
|
|
677
|
+
a form: joining a folder with `../../x` lands outside it. The path is resolved
|
|
678
|
+
and then contained rather than rejected on a literal `..`, so `blog/../post.md`
|
|
679
|
+
still works.
|
|
680
|
+
|
|
681
|
+
### `useSource(core, options)`
|
|
682
|
+
|
|
683
|
+
Codifies the folder-of-files pattern: scan a folder, emit entities, watch for
|
|
684
|
+
changes, sweep deletions.
|
|
685
|
+
|
|
686
|
+
| Option | Meaning |
|
|
687
|
+
| --- | --- |
|
|
688
|
+
| `collection`, `type`, `folder` | Required. |
|
|
689
|
+
| `extensions` | Default `['*']`. |
|
|
690
|
+
| `ignore` | Glob patterns to skip. |
|
|
691
|
+
| `phase` | `'loaded'` (default) or another lifecycle phase. |
|
|
692
|
+
| `content` | Load file content into the entity at sync time. |
|
|
693
|
+
| `load` | `async (entity) => meta` — your parse step. |
|
|
694
|
+
| `idPrefix` | Defaults to `/<collection>`. |
|
|
695
|
+
| `stripExtensionFromId` | Default false (documents style). |
|
|
696
|
+
| `progress` | Progress label. |
|
|
697
|
+
|
|
698
|
+
### `sweepDeleted(collection, scanned, onDelete, ownerPrefix)`
|
|
699
|
+
|
|
700
|
+
Removes catalog entities whose files are gone. **`ownerPrefix` is mandatory and
|
|
701
|
+
load-bearing.** Collections are multi-emitter: the file source scans a folder,
|
|
702
|
+
but a CSV plugin fans rows into the same collection, and a remote sync emits
|
|
703
|
+
there with a `gdrive://` uri. The sweep only considers entities whose `uri` is
|
|
704
|
+
rooted under the prefix — without it, every cycle's file sweep wipes every
|
|
705
|
+
other emitter's entities.
|
|
706
|
+
|
|
707
|
+
### `useRenderer(runtime, { defaultTimeout } = {})`
|
|
708
|
+
|
|
709
|
+
Returns `{ render }` — the batching renderer the engine dispatches through,
|
|
710
|
+
with a per-task timeout (default 30s). A plugin that needs to render something
|
|
711
|
+
outside the normal cycle goes through this rather than importing a renderer
|
|
712
|
+
package directly.
|
|
713
|
+
|
|
714
|
+
## Query context
|
|
715
|
+
|
|
716
|
+
`queryContext` is the `AsyncLocalStorage` that lets a catalog query made during
|
|
717
|
+
a render record itself as a dependency, so an aggregate page invalidates when a
|
|
718
|
+
new matching entity lands.
|
|
719
|
+
|
|
720
|
+
It only works if the whole tree shares ONE module instance of `mikser-io`. In
|
|
721
|
+
the side-by-side dev layout that means the npm workspace at the parent folder
|
|
722
|
+
is not ergonomics but correctness: without it, npm installs a second copy into
|
|
723
|
+
a sibling's own `node_modules`, a plugin's `queryContext` is then a different
|
|
724
|
+
AsyncLocalStorage than the engine's, queries record no edges, and index pages,
|
|
725
|
+
sitemaps and feeds silently stop rebuilding. Production consumers resolve both
|
|
726
|
+
from their own tree, so the problem is local to the dev workspace.
|
|
727
|
+
|
|
728
|
+
## Auth
|
|
729
|
+
|
|
730
|
+
Building a token-gated or loopback-only route.
|
|
731
|
+
|
|
732
|
+
| Export | Does |
|
|
733
|
+
| --- | --- |
|
|
734
|
+
| `resolveAuth(config)` | build a verifier from endpoint config |
|
|
735
|
+
| `requireAuth(verifier, options)` | Express middleware |
|
|
736
|
+
| `authorize(req, verifier, { allowRemote, trustLoopback })` | the check itself |
|
|
737
|
+
| `bearer({ token, name, subject, capabilities, scope })` | a static-token verifier |
|
|
738
|
+
| `loopbackOnly({ message })` | middleware refusing non-loopback callers |
|
|
739
|
+
| `hasCapability(principal, capability)` | test a resolved principal |
|
|
740
|
+
|
|
741
|
+
A principal may carry a `scope` — a sift filter that narrows what it can see.
|
|
742
|
+
Anything reading content on a principal's behalf must apply it; see the warning
|
|
743
|
+
under [Search](#search) for why an unscoped read behind a scoped endpoint is
|
|
744
|
+
the failure mode to watch for.
|
|
745
|
+
|
|
746
|
+
## References
|
|
747
|
+
|
|
748
|
+
The `$`-keyed reference graph (ADR-0007), reachable at `runtime.refs` or via
|
|
749
|
+
`useRefsIndex()`.
|
|
750
|
+
|
|
751
|
+
| Method | Answers |
|
|
752
|
+
| --- | --- |
|
|
753
|
+
| `inboundFor(target)` / `outboundFor(source)` | static `$`-ref edges |
|
|
754
|
+
| `dynamicInboundFor` / `dynamicOutboundFor` | render-time edges (layout, partial, query, lookup) |
|
|
755
|
+
| `inverseClosureOf(seeds)` | everything reachable backwards — what invalidation walks |
|
|
756
|
+
| `resolveRefIds(ref)` | which entities a ref string resolves to |
|
|
757
|
+
| `rename({ from, to })` | rewrite refs across the catalog, as one cascade |
|
|
758
|
+
| `allRefs()` / `size()` | inventory |
|
|
759
|
+
|
|
760
|
+
### `refFilter(ref)` / `matchesRef(entity, ref)` / `lookupKeys(entity)`
|
|
761
|
+
|
|
762
|
+
One relation in three directions — as a catalog query, as a predicate, and in
|
|
763
|
+
reverse. **They must be changed together.** A key present in one and missing
|
|
764
|
+
from the others is silent: `meta.url` once lived only in `refFilter`, which
|
|
765
|
+
made every `$`-ref to a served path non-invalidating without any error
|
|
766
|
+
anywhere.
|
|
767
|
+
|
|
768
|
+
### `extractRefs(meta)` / `isRefKey(key)` / `expandEntity(entity, paths, options)` / `projectMeta(meta)`
|
|
769
|
+
|
|
770
|
+
Find the `$`-keys in a meta tree, test one key, inline referenced entities
|
|
771
|
+
along dotted paths, and drop `$`-keys for output.
|
|
772
|
+
|
|
773
|
+
## Provenance
|
|
774
|
+
|
|
775
|
+
Where a value was **written** — source file, field path, line, column.
|
|
776
|
+
|
|
777
|
+
```js
|
|
778
|
+
const positions = await useProvenance().positionsFor(entity)
|
|
779
|
+
// { 'items[2].label': { line, col }, … }
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
| Method | Answers |
|
|
783
|
+
| --- | --- |
|
|
784
|
+
| `positionsFor(entity)` | every leaf of the entity's meta |
|
|
785
|
+
| `locate(entity, fieldPath)` | one position, or null |
|
|
786
|
+
| `forget(id)` | drop a cached entry |
|
|
787
|
+
| `size()` | how many entries are cached |
|
|
788
|
+
|
|
789
|
+
Field paths are free — they come from walking meta, already in memory. Line and
|
|
790
|
+
column need one parse of the raw source, done **on demand** and cached against
|
|
791
|
+
the entity's checksum, so a build pays nothing.
|
|
792
|
+
|
|
793
|
+
`registerProvenanceFormat(name, { test, positions })` adds a format rather than
|
|
794
|
+
special-casing one. A format whose parser reports no ranges registers a
|
|
795
|
+
`probeFormat(name, { test, parse })` instead, which recovers positions in one
|
|
796
|
+
pass without the parser's help.
|
|
797
|
+
|
|
798
|
+
## Manifest and outputs
|
|
799
|
+
|
|
800
|
+
`runtime.manifest` holds render snapshots. Full treatment lives in
|
|
801
|
+
[diagnostics.md](diagnostics.md) — indexed by the question each surface
|
|
802
|
+
answers — but the ones an application reaches for:
|
|
803
|
+
|
|
804
|
+
| Method | Answers |
|
|
805
|
+
| --- | --- |
|
|
806
|
+
| `affectedBy(entity)` | which destinations would re-render if this changed |
|
|
807
|
+
| `collisions()` | destinations more than one entity writes to |
|
|
808
|
+
| `snapshotsFor(id)` / `snapshotsAt(destination)` | what rendered, and from what |
|
|
809
|
+
| `skipDecision(entity, …)` | the engine's own skip rule, with the reason |
|
|
810
|
+
|
|
811
|
+
`sourcesOf(destination)` is the reverse lookup: what produced this built file,
|
|
812
|
+
each tagged with how it got there. `sourcesBehind(snapshot)` does the same from
|
|
813
|
+
a snapshot you already hold. `resolveOutputPath(destination)` maps a
|
|
814
|
+
destination to a path on disk, and `writeOutput(file, bytes)` writes one.
|
|
815
|
+
|
|
816
|
+
## Tools
|
|
817
|
+
|
|
818
|
+
The tool registry — named, described, invokable capabilities. Two agent
|
|
819
|
+
workflows exist and are equally real: one speaking MCP over HTTP, one running
|
|
820
|
+
the CLI and reading its output. A tool registered here reaches both.
|
|
821
|
+
|
|
822
|
+
```js
|
|
823
|
+
registerTool('audit', {
|
|
824
|
+
description: 'What this answers, in prose an agent will actually read.',
|
|
825
|
+
inputSchema: { path: { type: 'string', required: true } },
|
|
826
|
+
}, async ({ path }) => ok({ … }))
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
`invokeTool(name, args)` runs one — it accepts the bare name or the `mikser_`
|
|
830
|
+
prefixed form. `toolNames()`, `toolSchema(name)` and `toolSchemas()` enumerate.
|
|
831
|
+
`toolResultText(result)` pulls the text back out of a tool result, and
|
|
832
|
+
`toolResultFailed(result)` says whether it failed.
|
|
833
|
+
|
|
834
|
+
The registry is deliberately zod-free: schemas use a neutral
|
|
835
|
+
`{ type, required?, description? }` vocabulary, because it must not depend on
|
|
836
|
+
one transport's schema library. `mikser-io-mcp` converts to zod at bind time.
|
|
837
|
+
|
|
838
|
+
## Routes
|
|
839
|
+
|
|
840
|
+
An Express router stack has the paths but not the intent. Plugins declare each
|
|
841
|
+
mount as they make it, so a facade generator, a healthcheck list or a
|
|
842
|
+
diagnostics view can read one inventory.
|
|
843
|
+
|
|
844
|
+
```js
|
|
845
|
+
registerRoute({
|
|
846
|
+
path: '/api',
|
|
847
|
+
plugin: 'api',
|
|
848
|
+
reachability: 'public', // 'public' | 'token' | 'loopback'
|
|
849
|
+
streaming: false, // true for SSE/WS, which a facade must not buffer
|
|
850
|
+
})
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
`registerRoute` also folds in the origin/URL building and the standard boot
|
|
854
|
+
log. `listRoutes()` returns the inventory; `reachabilityOf` and `routeLocation`
|
|
855
|
+
answer about one route. `isLoopback(ip)` is the check behind
|
|
856
|
+
`reachability: 'loopback'` — note that the server's trust-proxy default is
|
|
857
|
+
`'loopback'`, not Express's `false`, which is what keeps that gate correct
|
|
858
|
+
behind a same-host reverse proxy.
|
|
859
|
+
|
|
860
|
+
## Cycles and the build report
|
|
861
|
+
|
|
862
|
+
`nextCycleId()` reserves the id of the cycle a pending change will be picked up
|
|
863
|
+
by; `whenCycleCompletes(id)` resolves once it finishes, with its report.
|
|
864
|
+
Together they turn "write and guess" into one call that says what the edit
|
|
865
|
+
invalidated. `currentCycle()` and `buildReport()` read the cycle in progress and
|
|
866
|
+
the last completed report.
|
|
867
|
+
|
|
868
|
+
## Logging
|
|
869
|
+
|
|
870
|
+
`addLogTransport({ target, options, level })` adds a pino transport from a
|
|
871
|
+
plugin factory or any later hook. Called before the logger is built, it queues;
|
|
872
|
+
after, it live-rebuilds the multistream. This is what lets Better Stack,
|
|
873
|
+
Datadog, Loki, Axiom or Sentry ship as ordinary sibling plugins with no engine
|
|
874
|
+
change per vendor.
|
|
875
|
+
|
|
876
|
+
Prefer this over `runtime.config.logging.transports` from plugin code — the
|
|
877
|
+
declarative form is for user config.
|
|
878
|
+
|
|
879
|
+
## Junk
|
|
880
|
+
|
|
881
|
+
`registerJunk({ ignore, match })` teaches the engine to skip editor and OS
|
|
882
|
+
debris; `isJunkPath(filePath)` asks.
|
|
883
|
+
|
|
884
|
+
## What is not here
|
|
885
|
+
|
|
886
|
+
Plugin factories — `yaml()`, `json()`, `frontMatter()`, `assets()`,
|
|
887
|
+
`resources()`, `shares()`, `observer()`, `mapper()`, `commands()`,
|
|
888
|
+
`renderHbs()` — are configured rather than called, and live in
|
|
889
|
+
[configuration.md](configuration.md).
|
|
890
|
+
|
|
891
|
+
`mikser-io` exports more than this page covers, deliberately. Five kinds of
|
|
892
|
+
export are engine plumbing:
|
|
893
|
+
|
|
894
|
+
- **Factories the engine calls once** — `createManifest`, `createRefs`,
|
|
895
|
+
`createProvenance`, `createSqliteDatabase`, `createTrack`, `createIndex`,
|
|
896
|
+
`createSubscribers`.
|
|
897
|
+
- **Schema constants** the test suites build against rather than copying —
|
|
898
|
+
`SNAPSHOTS_SCHEMA`, `REFS_SCHEMA`, `FAILURES_SCHEMA`, `PROVENANCE_SCHEMA`.
|
|
899
|
+
- **Report and cycle internals** — `reportRendered`, `reportSkipped`,
|
|
900
|
+
`reportError`, `emitReport`, `resetReport`, `finishCycle`, `inputHashOf`.
|
|
901
|
+
- **Render-time tracking** — `recordReads`, `untrack`, `trackedInfo`,
|
|
902
|
+
`serializeTrack`, `mergeTrack`, `observeConsumed`. These implement
|
|
903
|
+
dependency recording; a plugin observes its RESULTS through
|
|
904
|
+
`runtime.manifest` and `runtime.refs` instead.
|
|
905
|
+
- **Template helper plumbing** — `assetUrlHelper`, `resourceUrlHelper`,
|
|
906
|
+
`hrefUrlHelpers`, `fileHelpers`, `renderPreset`, wired into renderers rather
|
|
907
|
+
than called.
|
|
908
|
+
|
|
909
|
+
They are exported because the engine's own modules and its test suite need them
|
|
910
|
+
across file boundaries, not as an invitation. If you find yourself reaching for
|
|
911
|
+
one from a plugin, that is worth raising — it usually means a capability is
|
|
912
|
+
missing from the surface above.
|
|
913
|
+
|
|
548
914
|
## Database
|
|
549
915
|
|
|
550
916
|
```js
|
package/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export * from './src/database/index.js'
|
|
|
12
12
|
export * from './src/journal.js'
|
|
13
13
|
export * from './src/catalog.js'
|
|
14
14
|
export * from './src/search.js'
|
|
15
|
+
export * from './src/write.js'
|
|
15
16
|
export * from './src/refs.js'
|
|
16
17
|
export * from './src/manifest.js'
|
|
17
18
|
export * from './src/provenance.js'
|
package/package.json
CHANGED
package/src/utils.js
CHANGED
|
@@ -1077,20 +1077,38 @@ export function useCollection(runtime, name) {
|
|
|
1077
1077
|
return folder
|
|
1078
1078
|
}
|
|
1079
1079
|
|
|
1080
|
+
// A path that cannot leave the collection folder.
|
|
1081
|
+
//
|
|
1082
|
+
// `path.join(folder, '../../x')` resolves outside the folder and writes
|
|
1083
|
+
// there, which turns a collection handle into an arbitrary-write
|
|
1084
|
+
// primitive the moment a relative path comes from a request body or a CMS
|
|
1085
|
+
// form. Resolved and then contained rather than rejected on a literal
|
|
1086
|
+
// `..`, so `a/../b.md` — which lands inside — still works.
|
|
1087
|
+
function resolveWithin(relativePath) {
|
|
1088
|
+
const folder = resolveFolder()
|
|
1089
|
+
const uri = path.resolve(folder, relativePath ?? '')
|
|
1090
|
+
const root = path.resolve(folder)
|
|
1091
|
+
if (uri !== root && !uri.startsWith(root + path.sep)) {
|
|
1092
|
+
throw new Error(
|
|
1093
|
+
`Path escapes the ${name} collection: ${JSON.stringify(relativePath)} resolves outside ${root}`)
|
|
1094
|
+
}
|
|
1095
|
+
return uri
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1080
1098
|
return {
|
|
1081
1099
|
name,
|
|
1082
1100
|
get folder() { return resolveFolder() },
|
|
1101
|
+
resolveWithin,
|
|
1083
1102
|
|
|
1084
1103
|
async write(relativePath, content = '') {
|
|
1085
|
-
const uri =
|
|
1104
|
+
const uri = resolveWithin(relativePath)
|
|
1086
1105
|
await mkdir(path.dirname(uri), { recursive: true })
|
|
1087
1106
|
await writeFile(uri, content, 'utf8')
|
|
1088
1107
|
return uri
|
|
1089
1108
|
},
|
|
1090
1109
|
|
|
1091
1110
|
async remove(relativePath) {
|
|
1092
|
-
|
|
1093
|
-
await unlink(uri)
|
|
1111
|
+
await unlink(resolveWithin(relativePath))
|
|
1094
1112
|
},
|
|
1095
1113
|
}
|
|
1096
1114
|
}
|
package/src/write.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Write a source file back, with the checks that make a whole-file rewrite
|
|
2
|
+
// safe to perform without having watched the file the whole time.
|
|
3
|
+
//
|
|
4
|
+
// The only write mode is whole-file: there is no patch. That makes the write
|
|
5
|
+
// itself the easy part and everything around it the point —
|
|
6
|
+
//
|
|
7
|
+
// - a checksum precondition, so a rewrite built from a stale copy is
|
|
8
|
+
// refused instead of silently discarding whoever edited in between
|
|
9
|
+
// - a dry run reporting which destinations the write would re-render
|
|
10
|
+
// - advisories naming a file that is GENERATED or answers to an external
|
|
11
|
+
// spec, which a caller must not edit blind
|
|
12
|
+
// - siblings that could render to the same destination
|
|
13
|
+
// - containment, so a relative path from a form or a request body cannot
|
|
14
|
+
// write outside the collection folder
|
|
15
|
+
//
|
|
16
|
+
// An editing agent gets these through mikser-io-mcp. An application driving
|
|
17
|
+
// mikser as a CMS writes its own files and gets none of them, which is the
|
|
18
|
+
// gap this closes: the safety belongs to the write, not to one transport.
|
|
19
|
+
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import { readdir } from 'node:fs/promises'
|
|
22
|
+
|
|
23
|
+
import runtime from './runtime.js'
|
|
24
|
+
import { readEntity, findEntities } from './catalog.js'
|
|
25
|
+
import { useCollection, checksum, readEntityContent } from './utils.js'
|
|
26
|
+
import { nextCycleId, whenCycleCompletes } from './report.js'
|
|
27
|
+
|
|
28
|
+
// How far into a file to look for a marker. A header nobody reads is not a
|
|
29
|
+
// header; one buried 200 lines down is not either.
|
|
30
|
+
const HEADER_SCAN_LINES = 40
|
|
31
|
+
|
|
32
|
+
// Files a caller must not edit blind, read from the bytes themselves.
|
|
33
|
+
//
|
|
34
|
+
// Two kinds, kept apart because the instruction differs. `spec-locked` means
|
|
35
|
+
// the bytes answer to a document outside the repo — change it and the site
|
|
36
|
+
// stops matching something a human signed off. `generated` means editing the
|
|
37
|
+
// file is pointless, because the next build overwrites it.
|
|
38
|
+
const HEADER_PATTERNS = [
|
|
39
|
+
{ kind: 'spec-locked', re: /^\W*spec source:\s*(.+?)\s*$/i },
|
|
40
|
+
{ kind: 'generated', re: /^\W*(?:generated by|do not edit)\b:?\s*(.*?)\s*$/i },
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
export function contentAdvisories(entity, content) {
|
|
44
|
+
const found = []
|
|
45
|
+
const push = (kind, detail, via, line) => {
|
|
46
|
+
if (found.some(a => a.kind === kind)) return
|
|
47
|
+
found.push({ kind, detail: detail || null, via, ...(line ? { line } : {}) })
|
|
48
|
+
}
|
|
49
|
+
// Explicit meta wins: someone wrote it down as data, on purpose.
|
|
50
|
+
if (entity?.meta?.specLocked) {
|
|
51
|
+
push('spec-locked', typeof entity.meta.specLocked === 'string' ? entity.meta.specLocked : null, 'meta.specLocked')
|
|
52
|
+
}
|
|
53
|
+
if (entity?.meta?.generated) {
|
|
54
|
+
push('generated', typeof entity.meta.generated === 'string' ? entity.meta.generated : null, 'meta.generated')
|
|
55
|
+
}
|
|
56
|
+
if (typeof content === 'string') {
|
|
57
|
+
const lines = content.split('\n', HEADER_SCAN_LINES)
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
for (const { kind, re } of HEADER_PATTERNS) {
|
|
60
|
+
const m = re.exec(lines[i])
|
|
61
|
+
if (m) push(kind, m[1], 'header', i + 1)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return found
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// One line of prose for a response that has to be read, not parsed.
|
|
69
|
+
export function advisoryWarning(advisories) {
|
|
70
|
+
if (!advisories?.length) return null
|
|
71
|
+
return advisories.map(a => a.kind === 'spec-locked'
|
|
72
|
+
? `SPEC-LOCKED: ${a.detail ?? 'this file answers to an external specification'}`
|
|
73
|
+
+ ' — changing it may break a signed-off design. Confirm against the spec before writing.'
|
|
74
|
+
: `GENERATED: ${a.detail ?? 'this file is produced by the build'}`
|
|
75
|
+
+ ' — edit its source instead; the next build overwrites this.').join(' ')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Files beside this one that differ only by extension.
|
|
79
|
+
//
|
|
80
|
+
// An empty `index.md` sitting next to a real `index.yml` renders to the same
|
|
81
|
+
// destination; whichever renders last wins and the other output is discarded.
|
|
82
|
+
// The destination is not known until the cycle runs, but the COLLIDING SHAPE
|
|
83
|
+
// is visible at write time, and the write is the cheapest moment to say so.
|
|
84
|
+
export async function siblingDestinations(folder, relativePath) {
|
|
85
|
+
const dir = path.dirname(path.join(folder, relativePath))
|
|
86
|
+
const base = path.basename(relativePath, path.extname(relativePath))
|
|
87
|
+
try {
|
|
88
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
89
|
+
return entries
|
|
90
|
+
.filter(e => e.isFile()
|
|
91
|
+
&& path.basename(e.name, path.extname(e.name)) === base
|
|
92
|
+
&& e.name !== path.basename(relativePath))
|
|
93
|
+
.map(e => ({
|
|
94
|
+
path: path.join(path.dirname(relativePath), e.name),
|
|
95
|
+
note: 'same name, different extension — may render to the same destination',
|
|
96
|
+
}))
|
|
97
|
+
} catch {
|
|
98
|
+
return []
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The checksum of a file, or null when there is nothing there. Not an error
|
|
103
|
+
// path: "does not exist yet" is the normal case for a create.
|
|
104
|
+
async function fileChecksum(uri) {
|
|
105
|
+
try {
|
|
106
|
+
return await checksum(uri)
|
|
107
|
+
} catch {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The catalog entity written from this file, when there is one.
|
|
113
|
+
async function findEntityAtUri(uri) {
|
|
114
|
+
if (!uri) return null
|
|
115
|
+
const matches = await findEntities({ uri })
|
|
116
|
+
return matches?.[0] ?? null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Resolve a catalog id to the (collection, relativePath) pair a write needs.
|
|
120
|
+
//
|
|
121
|
+
// Taken from the entity rather than by splitting the id: the id prefix is
|
|
122
|
+
// `idPrefix ?? '/' + collection` and the extension may have been stripped, so
|
|
123
|
+
// splitting on the first segment is a guess that is usually right and
|
|
124
|
+
// silently wrong for any source configured either way.
|
|
125
|
+
export async function locateEntityFile(id) {
|
|
126
|
+
const entity = await readEntity({ id })
|
|
127
|
+
if (!entity) return { error: `No entity with id ${id}.` }
|
|
128
|
+
if (!entity.collection) {
|
|
129
|
+
return { error: `Entity ${id} has no collection, so its file location cannot be derived.` }
|
|
130
|
+
}
|
|
131
|
+
let folder
|
|
132
|
+
try {
|
|
133
|
+
folder = useCollection(runtime, entity.collection).folder
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return { error: `Entity ${id} is in collection ${entity.collection}, which has no folder: ${err.message}` }
|
|
136
|
+
}
|
|
137
|
+
if (!entity.uri) {
|
|
138
|
+
return { error: `Entity ${id} has no uri — it is synthetic (emitted by a plugin, not read from a file) `
|
|
139
|
+
+ 'and has no file to rewrite.' }
|
|
140
|
+
}
|
|
141
|
+
const relativePath = path.relative(folder, entity.uri)
|
|
142
|
+
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
143
|
+
return { error: `Entity ${id} lives at ${entity.uri}, outside its collection folder ${folder}.` }
|
|
144
|
+
}
|
|
145
|
+
return { collection: entity.collection, relativePath }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Create or overwrite a source file inside a collection.
|
|
149
|
+
//
|
|
150
|
+
// Takes either `id` (an existing entity) or `collection` + `relativePath`.
|
|
151
|
+
// `content` is the COMPLETE file: anything omitted is deleted.
|
|
152
|
+
//
|
|
153
|
+
// Never throws for an expected outcome. A refusal — a bad id, a path that
|
|
154
|
+
// escapes the collection, a checksum that no longer matches — comes back as
|
|
155
|
+
// `{ ok: false, refused }` with the facts needed to retry, because those are
|
|
156
|
+
// answers rather than faults.
|
|
157
|
+
//
|
|
158
|
+
// dryRun write nothing; report what the write would touch
|
|
159
|
+
// ifChecksum only write if the file's current DISK checksum equals this
|
|
160
|
+
// awaitCycle resolve once the cycle that picks the write up has finished,
|
|
161
|
+
// with its build report attached
|
|
162
|
+
export async function writeEntitySource({
|
|
163
|
+
id,
|
|
164
|
+
collection,
|
|
165
|
+
relativePath,
|
|
166
|
+
content = '',
|
|
167
|
+
ifChecksum,
|
|
168
|
+
dryRun = false,
|
|
169
|
+
awaitCycle = false,
|
|
170
|
+
} = {}) {
|
|
171
|
+
if (id) {
|
|
172
|
+
const located = await locateEntityFile(id)
|
|
173
|
+
if (located.error) return { ok: false, refused: 'unresolvable-id', error: located.error }
|
|
174
|
+
// An explicit pair still wins if a caller passes both, but disagreeing
|
|
175
|
+
// with the id is a mistake worth refusing rather than silently
|
|
176
|
+
// resolving one way.
|
|
177
|
+
if (collection && collection !== located.collection) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
refused: 'collection-mismatch',
|
|
181
|
+
error: `id ${id} is in collection ${located.collection}, not ${collection}. Pass one or the other.`,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
collection ??= located.collection
|
|
185
|
+
relativePath ??= located.relativePath
|
|
186
|
+
}
|
|
187
|
+
if (!collection || !relativePath) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
refused: 'incomplete-target',
|
|
191
|
+
error: 'Pass either `id` (for an existing entity) or both `collection` and `relativePath`.',
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let handle
|
|
196
|
+
let uri
|
|
197
|
+
try {
|
|
198
|
+
handle = useCollection(runtime, collection)
|
|
199
|
+
// Containment before anything reads or writes. A relative path that
|
|
200
|
+
// escapes the collection must not even be STATTED — reporting a
|
|
201
|
+
// checksum for /etc/passwd is a disclosure on its own.
|
|
202
|
+
uri = handle.resolveWithin(relativePath)
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return { ok: false, refused: 'invalid-target', collection, relativePath, error: err.message }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Everything a caller should know BEFORE the bytes move. Computed for the
|
|
208
|
+
// dry run and the real write alike, so the preview and the thing it
|
|
209
|
+
// previews cannot disagree.
|
|
210
|
+
const existing = id ? await readEntity({ id }) : await findEntityAtUri(uri)
|
|
211
|
+
const onDisk = await readEntityContent({ uri }, { reload: true })
|
|
212
|
+
const advisories = contentAdvisories(existing, typeof onDisk.content === 'string' ? onDisk.content : null)
|
|
213
|
+
|
|
214
|
+
if (dryRun) {
|
|
215
|
+
const wouldAffect = existing?.id ? (runtime.manifest?.affectedBy?.(existing) ?? []) : []
|
|
216
|
+
const touched = new Set(wouldAffect.map(a => a.destination))
|
|
217
|
+
return {
|
|
218
|
+
ok: true, dryRun: true, collection, relativePath,
|
|
219
|
+
id: existing?.id ?? null,
|
|
220
|
+
exists: existing != null,
|
|
221
|
+
currentChecksum: await fileChecksum(uri),
|
|
222
|
+
advisories,
|
|
223
|
+
warning: advisoryWarning(advisories),
|
|
224
|
+
wouldAffect,
|
|
225
|
+
wouldAffectCount: wouldAffect.length,
|
|
226
|
+
siblingDestinations: await siblingDestinations(handle.folder, relativePath),
|
|
227
|
+
// Collisions ALREADY standing at the outputs this write would
|
|
228
|
+
// touch. A write cannot be blamed for them, but re-rendering into
|
|
229
|
+
// one is how the wrong half of a contested destination wins.
|
|
230
|
+
collisionsAtAffected: (runtime.manifest?.collisions?.() ?? [])
|
|
231
|
+
.filter(c => touched.has(c.destination)),
|
|
232
|
+
note: existing?.id
|
|
233
|
+
? 'Destinations are computed with the engine\'s own skip rule, so they match what a real cycle '
|
|
234
|
+
+ 'would do — EXCEPT for changes to the entity\'s own frontmatter, which is parsed during '
|
|
235
|
+
+ 'import and can move its destination.'
|
|
236
|
+
: 'This file is not in the catalog yet, so nothing depends on it and there is no blast radius '
|
|
237
|
+
+ 'to report.',
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Checked immediately before the write. Not a lock — a writer that lands
|
|
242
|
+
// between the check and the write still wins — but it closes the window
|
|
243
|
+
// that matters in practice: read, think, write back a whole file built
|
|
244
|
+
// from a copy that is now stale.
|
|
245
|
+
const before = await fileChecksum(uri)
|
|
246
|
+
if (ifChecksum !== undefined && ifChecksum !== before) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
refused: 'checksum-mismatch',
|
|
250
|
+
collection, relativePath,
|
|
251
|
+
expectedChecksum: ifChecksum,
|
|
252
|
+
currentChecksum: before,
|
|
253
|
+
hint: before === null
|
|
254
|
+
? 'The file does not exist. Omit ifChecksum to create it.'
|
|
255
|
+
: 'The file on disk changed since you read it. Re-read it for the CONTENT, re-apply your change, '
|
|
256
|
+
+ 'and retry with `currentChecksum` from THIS response.',
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const cycleId = nextCycleId()
|
|
261
|
+
await handle.write(relativePath, content)
|
|
262
|
+
|
|
263
|
+
const result = {
|
|
264
|
+
ok: true, collection, relativePath,
|
|
265
|
+
checksum: await fileChecksum(uri),
|
|
266
|
+
bytes: Buffer.byteLength(content),
|
|
267
|
+
cycleId,
|
|
268
|
+
siblingDestinations: await siblingDestinations(handle.folder, relativePath),
|
|
269
|
+
}
|
|
270
|
+
// Echoed on the way out, not only on read. A caller that never read the
|
|
271
|
+
// file — or read past the header — is exactly the one that needs telling,
|
|
272
|
+
// and telling it after the write still names what to check before deploy.
|
|
273
|
+
if (advisories.length) {
|
|
274
|
+
result.advisories = advisories
|
|
275
|
+
result.warning = advisoryWarning(advisories)
|
|
276
|
+
}
|
|
277
|
+
if (awaitCycle) result.report = await whenCycleCompletes(cycleId)
|
|
278
|
+
return result
|
|
279
|
+
}
|