mrplex 0.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/LICENSE +21 -0
- package/README.md +284 -0
- package/dist/cli/config.d.ts +28 -0
- package/dist/cli/config.js +48 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/exit-codes.d.ts +13 -0
- package/dist/cli/exit-codes.js +39 -0
- package/dist/cli/exit-codes.js.map +1 -0
- package/dist/cli/format.d.ts +22 -0
- package/dist/cli/format.js +148 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/main.d.ts +16 -0
- package/dist/cli/main.js +1518 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/client/kernel-client.d.ts +97 -0
- package/dist/client/kernel-client.js +14 -0
- package/dist/client/kernel-client.js.map +1 -0
- package/dist/client/local.d.ts +26 -0
- package/dist/client/local.js +103 -0
- package/dist/client/local.js.map +1 -0
- package/dist/client/remote-mcp.d.ts +35 -0
- package/dist/client/remote-mcp.js +174 -0
- package/dist/client/remote-mcp.js.map +1 -0
- package/dist/embed/backfill.d.ts +24 -0
- package/dist/embed/backfill.js +33 -0
- package/dist/embed/backfill.js.map +1 -0
- package/dist/embed/chunker.d.ts +31 -0
- package/dist/embed/chunker.js +121 -0
- package/dist/embed/chunker.js.map +1 -0
- package/dist/embed/cmd-hook.d.ts +21 -0
- package/dist/embed/cmd-hook.js +100 -0
- package/dist/embed/cmd-hook.js.map +1 -0
- package/dist/embed/config.d.ts +61 -0
- package/dist/embed/config.js +109 -0
- package/dist/embed/config.js.map +1 -0
- package/dist/embed/hook.d.ts +38 -0
- package/dist/embed/hook.js +56 -0
- package/dist/embed/hook.js.map +1 -0
- package/dist/embed/http-hook.d.ts +13 -0
- package/dist/embed/http-hook.js +39 -0
- package/dist/embed/http-hook.js.map +1 -0
- package/dist/embed/worker.d.ts +62 -0
- package/dist/embed/worker.js +228 -0
- package/dist/embed/worker.js.map +1 -0
- package/dist/fixtures/hwe/characters/albion-ever.md +12 -0
- package/dist/fixtures/hwe/characters/cent.md +11 -0
- package/dist/fixtures/hwe/concepts/cent-demiurge-resonance.md +10 -0
- package/dist/fixtures/hwe/concepts/phase-looper.md +10 -0
- package/dist/fixtures/hwe/entities/demiurge.md +11 -0
- package/dist/fixtures/hwe/entities/monad.md +11 -0
- package/dist/fixtures/hwe/moc/globocorp-network.md +19 -0
- package/dist/fixtures/hwe/organizations/globocorp.md +11 -0
- package/dist/fixtures/hwe/readme.md +24 -0
- package/dist/fixtures/starship/crew/aria-okonkwo.md +14 -0
- package/dist/fixtures/starship/crew/bexley-orr.md +14 -0
- package/dist/fixtures/starship/crew/dax-thorne.md +14 -0
- package/dist/fixtures/starship/crew/isolde-marsh.md +16 -0
- package/dist/fixtures/starship/crew/kestrel-vance.md +15 -0
- package/dist/fixtures/starship/crew/quill-vasquez.md +14 -0
- package/dist/fixtures/starship/crew/soren-halloway.md +16 -0
- package/dist/fixtures/starship/encounters/the-drift-choir.md +14 -0
- package/dist/fixtures/starship/encounters/the-hollow-signal.md +16 -0
- package/dist/fixtures/starship/encounters/the-wandering-buoy.md +14 -0
- package/dist/fixtures/starship/equipment/coolant-loop-b.md +13 -0
- package/dist/fixtures/starship/equipment/emergency-ration-bars.md +12 -0
- package/dist/fixtures/starship/equipment/long-range-sensors.md +13 -0
- package/dist/fixtures/starship/equipment/plasma-manifold-3.md +15 -0
- package/dist/fixtures/starship/equipment/shuttle-corvid.md +13 -0
- package/dist/fixtures/starship/logs/marsh-4419-1.md +13 -0
- package/dist/fixtures/starship/logs/okonkwo-4415-1.md +13 -0
- package/dist/fixtures/starship/logs/orr-4413-1.md +13 -0
- package/dist/fixtures/starship/logs/orr-4420-1.md +12 -0
- package/dist/fixtures/starship/logs/thorne-4413-2.md +13 -0
- package/dist/fixtures/starship/logs/vance-4419-2.md +13 -0
- package/dist/fixtures/starship/misc/the-galley-ficus.md +11 -0
- package/dist/fixtures/starship/missions/the-cinder-run.md +17 -0
- package/dist/fixtures/starship/missions/the-drift-choir.md +17 -0
- package/dist/fixtures/starship/missions/the-hollow-signal.md +17 -0
- package/dist/fixtures/starship/missions/the-silent-beacon.md +17 -0
- package/dist/fixtures/starship/moc/crew.md +19 -0
- package/dist/fixtures/starship/moc/encounters.md +14 -0
- package/dist/fixtures/starship/moc/missions.md +15 -0
- package/dist/fixtures/starship/readme.md +60 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/auth/glob.d.ts +46 -0
- package/dist/kernel/auth/glob.js +112 -0
- package/dist/kernel/auth/glob.js.map +1 -0
- package/dist/kernel/auth/scope.d.ts +53 -0
- package/dist/kernel/auth/scope.js +83 -0
- package/dist/kernel/auth/scope.js.map +1 -0
- package/dist/kernel/casefold.d.ts +37 -0
- package/dist/kernel/casefold.js +44 -0
- package/dist/kernel/casefold.js.map +1 -0
- package/dist/kernel/constants.d.ts +21 -0
- package/dist/kernel/constants.js +22 -0
- package/dist/kernel/constants.js.map +1 -0
- package/dist/kernel/context.d.ts +70 -0
- package/dist/kernel/context.js +114 -0
- package/dist/kernel/context.js.map +1 -0
- package/dist/kernel/deletion.d.ts +50 -0
- package/dist/kernel/deletion.js +83 -0
- package/dist/kernel/deletion.js.map +1 -0
- package/dist/kernel/diff.d.ts +36 -0
- package/dist/kernel/diff.js +61 -0
- package/dist/kernel/diff.js.map +1 -0
- package/dist/kernel/errors.d.ts +37 -0
- package/dist/kernel/errors.js +55 -0
- package/dist/kernel/errors.js.map +1 -0
- package/dist/kernel/frontmatter-input.d.ts +32 -0
- package/dist/kernel/frontmatter-input.js +85 -0
- package/dist/kernel/frontmatter-input.js.map +1 -0
- package/dist/kernel/graph.d.ts +34 -0
- package/dist/kernel/graph.js +559 -0
- package/dist/kernel/graph.js.map +1 -0
- package/dist/kernel/history.d.ts +74 -0
- package/dist/kernel/history.js +143 -0
- package/dist/kernel/history.js.map +1 -0
- package/dist/kernel/kernel.d.ts +162 -0
- package/dist/kernel/kernel.js +675 -0
- package/dist/kernel/kernel.js.map +1 -0
- package/dist/kernel/path-config.d.ts +101 -0
- package/dist/kernel/path-config.js +171 -0
- package/dist/kernel/path-config.js.map +1 -0
- package/dist/kernel/query/ast.d.ts +24 -0
- package/dist/kernel/query/ast.js +52 -0
- package/dist/kernel/query/ast.js.map +1 -0
- package/dist/kernel/query/cel-parse.d.ts +37 -0
- package/dist/kernel/query/cel-parse.js +88 -0
- package/dist/kernel/query/cel-parse.js.map +1 -0
- package/dist/kernel/query/degrees.d.ts +31 -0
- package/dist/kernel/query/degrees.js +135 -0
- package/dist/kernel/query/degrees.js.map +1 -0
- package/dist/kernel/query/graph-ast.d.ts +51 -0
- package/dist/kernel/query/graph-ast.js +139 -0
- package/dist/kernel/query/graph-ast.js.map +1 -0
- package/dist/kernel/query/query.d.ts +56 -0
- package/dist/kernel/query/query.js +276 -0
- package/dist/kernel/query/query.js.map +1 -0
- package/dist/kernel/validation.d.ts +64 -0
- package/dist/kernel/validation.js +183 -0
- package/dist/kernel/validation.js.map +1 -0
- package/dist/kernel/version-id.d.ts +10 -0
- package/dist/kernel/version-id.js +21 -0
- package/dist/kernel/version-id.js.map +1 -0
- package/dist/kernel/wire.d.ts +148 -0
- package/dist/kernel/wire.js +6 -0
- package/dist/kernel/wire.js.map +1 -0
- package/dist/links/backfill.d.ts +28 -0
- package/dist/links/backfill.js +33 -0
- package/dist/links/backfill.js.map +1 -0
- package/dist/links/extract.d.ts +29 -0
- package/dist/links/extract.js +233 -0
- package/dist/links/extract.js.map +1 -0
- package/dist/links/link-config.d.ts +70 -0
- package/dist/links/link-config.js +107 -0
- package/dist/links/link-config.js.map +1 -0
- package/dist/links/maintain.d.ts +37 -0
- package/dist/links/maintain.js +91 -0
- package/dist/links/maintain.js.map +1 -0
- package/dist/links/repair.d.ts +46 -0
- package/dist/links/repair.js +96 -0
- package/dist/links/repair.js.map +1 -0
- package/dist/links/resolve.d.ts +41 -0
- package/dist/links/resolve.js +121 -0
- package/dist/links/resolve.js.map +1 -0
- package/dist/links/stale.d.ts +32 -0
- package/dist/links/stale.js +73 -0
- package/dist/links/stale.js.map +1 -0
- package/dist/markdown/content-hash.d.ts +34 -0
- package/dist/markdown/content-hash.js +46 -0
- package/dist/markdown/content-hash.js.map +1 -0
- package/dist/markdown/frontmatter.d.ts +69 -0
- package/dist/markdown/frontmatter.js +147 -0
- package/dist/markdown/frontmatter.js.map +1 -0
- package/dist/markdown/hash-backfill.d.ts +22 -0
- package/dist/markdown/hash-backfill.js +43 -0
- package/dist/markdown/hash-backfill.js.map +1 -0
- package/dist/mcp/query-syntax.d.ts +20 -0
- package/dist/mcp/query-syntax.js +194 -0
- package/dist/mcp/query-syntax.js.map +1 -0
- package/dist/mcp/render.d.ts +31 -0
- package/dist/mcp/render.js +110 -0
- package/dist/mcp/render.js.map +1 -0
- package/dist/mcp/server.d.ts +60 -0
- package/dist/mcp/server.js +170 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +93 -0
- package/dist/mcp/tools.js +1218 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/rest/conditional.d.ts +37 -0
- package/dist/rest/conditional.js +58 -0
- package/dist/rest/conditional.js.map +1 -0
- package/dist/rest/negotiate.d.ts +33 -0
- package/dist/rest/negotiate.js +54 -0
- package/dist/rest/negotiate.js.map +1 -0
- package/dist/rest/routes.d.ts +37 -0
- package/dist/rest/routes.js +744 -0
- package/dist/rest/routes.js.map +1 -0
- package/dist/seed/fixture-seed.d.ts +35 -0
- package/dist/seed/fixture-seed.js +75 -0
- package/dist/seed/fixture-seed.js.map +1 -0
- package/dist/seed/starship-config.d.ts +3 -0
- package/dist/seed/starship-config.js +5 -0
- package/dist/seed/starship-config.js.map +1 -0
- package/dist/server/headers.d.ts +36 -0
- package/dist/server/headers.js +30 -0
- package/dist/server/headers.js.map +1 -0
- package/dist/server/http-error.d.ts +53 -0
- package/dist/server/http-error.js +104 -0
- package/dist/server/http-error.js.map +1 -0
- package/dist/server/serve.d.ts +56 -0
- package/dist/server/serve.js +135 -0
- package/dist/server/serve.js.map +1 -0
- package/dist/shell/audit.d.ts +32 -0
- package/dist/shell/audit.js +28 -0
- package/dist/shell/audit.js.map +1 -0
- package/dist/shell/guard.d.ts +50 -0
- package/dist/shell/guard.js +176 -0
- package/dist/shell/guard.js.map +1 -0
- package/dist/shell/keys.d.ts +36 -0
- package/dist/shell/keys.js +64 -0
- package/dist/shell/keys.js.map +1 -0
- package/dist/shell/login.d.ts +56 -0
- package/dist/shell/login.js +130 -0
- package/dist/shell/login.js.map +1 -0
- package/dist/shell/oidc.d.ts +62 -0
- package/dist/shell/oidc.js +102 -0
- package/dist/shell/oidc.js.map +1 -0
- package/dist/shell/policy.d.ts +100 -0
- package/dist/shell/policy.js +232 -0
- package/dist/shell/policy.js.map +1 -0
- package/dist/shell/proxy-policy.d.ts +34 -0
- package/dist/shell/proxy-policy.js +120 -0
- package/dist/shell/proxy-policy.js.map +1 -0
- package/dist/shell/proxy.d.ts +47 -0
- package/dist/shell/proxy.js +253 -0
- package/dist/shell/proxy.js.map +1 -0
- package/dist/shell/serve.d.ts +86 -0
- package/dist/shell/serve.js +0 -0
- package/dist/shell/serve.js.map +1 -0
- package/dist/shell/stdio.d.ts +55 -0
- package/dist/shell/stdio.js +63 -0
- package/dist/shell/stdio.js.map +1 -0
- package/dist/storage/registry.d.ts +20 -0
- package/dist/storage/registry.js +38 -0
- package/dist/storage/registry.js.map +1 -0
- package/dist/storage/search-plan.d.ts +93 -0
- package/dist/storage/search-plan.js +15 -0
- package/dist/storage/search-plan.js.map +1 -0
- package/dist/storage/types.d.ts +448 -0
- package/dist/storage/types.js +14 -0
- package/dist/storage/types.js.map +1 -0
- package/dist/storage/versions-since.d.ts +72 -0
- package/dist/storage/versions-since.js +92 -0
- package/dist/storage/versions-since.js.map +1 -0
- package/dist/storage-postgres/adapter.d.ts +17 -0
- package/dist/storage-postgres/adapter.js +782 -0
- package/dist/storage-postgres/adapter.js.map +1 -0
- package/dist/storage-postgres/compile-postgres.d.ts +22 -0
- package/dist/storage-postgres/compile-postgres.js +830 -0
- package/dist/storage-postgres/compile-postgres.js.map +1 -0
- package/dist/storage-postgres/errors.d.ts +34 -0
- package/dist/storage-postgres/errors.js +50 -0
- package/dist/storage-postgres/errors.js.map +1 -0
- package/dist/storage-postgres/migrations/0001_init.sql +113 -0
- package/dist/storage-postgres/migrations/0002_content_hash.sql +7 -0
- package/dist/storage-postgres/migrations/index.d.ts +22 -0
- package/dist/storage-postgres/migrations/index.js +70 -0
- package/dist/storage-postgres/migrations/index.js.map +1 -0
- package/dist/storage-sqlite/adapter.d.ts +2 -0
- package/dist/storage-sqlite/adapter.js +688 -0
- package/dist/storage-sqlite/adapter.js.map +1 -0
- package/dist/storage-sqlite/compile-filter.d.ts +41 -0
- package/dist/storage-sqlite/compile-filter.js +881 -0
- package/dist/storage-sqlite/compile-filter.js.map +1 -0
- package/dist/storage-sqlite/compile-sqlite.d.ts +19 -0
- package/dist/storage-sqlite/compile-sqlite.js +176 -0
- package/dist/storage-sqlite/compile-sqlite.js.map +1 -0
- package/dist/storage-sqlite/migrations/0001_init.sql +123 -0
- package/dist/storage-sqlite/migrations/0002_content_hash.sql +7 -0
- package/dist/storage-sqlite/migrations/index.d.ts +6 -0
- package/dist/storage-sqlite/migrations/index.js +41 -0
- package/dist/storage-sqlite/migrations/index.js.map +1 -0
- package/dist/storage-sqlite/vec.d.ts +40 -0
- package/dist/storage-sqlite/vec.js +62 -0
- package/dist/storage-sqlite/vec.js.map +1 -0
- package/dist/sync/cursor.d.ts +36 -0
- package/dist/sync/cursor.js +66 -0
- package/dist/sync/cursor.js.map +1 -0
- package/dist/sync/daemon.d.ts +40 -0
- package/dist/sync/daemon.js +202 -0
- package/dist/sync/daemon.js.map +1 -0
- package/dist/sync/feed.d.ts +38 -0
- package/dist/sync/feed.js +125 -0
- package/dist/sync/feed.js.map +1 -0
- package/dist/sync/fs-store.d.ts +8 -0
- package/dist/sync/fs-store.js +76 -0
- package/dist/sync/fs-store.js.map +1 -0
- package/dist/sync/intrinsics.d.ts +52 -0
- package/dist/sync/intrinsics.js +76 -0
- package/dist/sync/intrinsics.js.map +1 -0
- package/dist/sync/paths.d.ts +38 -0
- package/dist/sync/paths.js +56 -0
- package/dist/sync/paths.js.map +1 -0
- package/dist/sync/push.d.ts +48 -0
- package/dist/sync/push.js +328 -0
- package/dist/sync/push.js.map +1 -0
- package/dist/sync/reconcile.d.ts +54 -0
- package/dist/sync/reconcile.js +252 -0
- package/dist/sync/reconcile.js.map +1 -0
- package/dist/sync/run.d.ts +27 -0
- package/dist/sync/run.js +46 -0
- package/dist/sync/run.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +5 -0
- package/dist/version.js.map +1 -0
- package/package.json +80 -0
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,1518 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mrplex CLI — reads + writes + config + serve + remote mode.
|
|
4
|
+
*
|
|
5
|
+
* The CLI is a thin client over the MCP surface (§7.3). When `--server` is
|
|
6
|
+
* set, commands drive `tools/call` against `<server>/mcp`; otherwise the CLI
|
|
7
|
+
* opens the local SQLite file and calls the kernel in-process. The
|
|
8
|
+
* transport seam is `KernelClient` (`src/client/*`).
|
|
9
|
+
*
|
|
10
|
+
* No-auth (noauth plan): there is no bootstrap / tokens / users. Identity is
|
|
11
|
+
* one opaque `author` string (--author → MRPLEX_AUTHOR → config → "mrplex");
|
|
12
|
+
* `--scope <json>` narrows read visibility. In remote mode an optional --token
|
|
13
|
+
* is forwarded verbatim as a bearer for a shell fronting the server; mrplex
|
|
14
|
+
* itself ignores it. serve deliberately bypasses the seam — it IS the server.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { Command, InvalidArgumentError, Option } from "commander";
|
|
18
|
+
import { parseDocument as parseYamlDocument } from "yaml";
|
|
19
|
+
import { openLocalClient } from "../client/local.js";
|
|
20
|
+
import { openRemoteClient } from "../client/remote-mcp.js";
|
|
21
|
+
import { VERSION } from "../version.js";
|
|
22
|
+
import { backfillRepo } from "../embed/backfill.js";
|
|
23
|
+
import { addEmbedCliOptions, createHookFromConfig, embedFlagInputsFromCli, resolveEmbedConfig, } from "../embed/config.js";
|
|
24
|
+
import { createWorker } from "../embed/worker.js";
|
|
25
|
+
import { globToRegexSource } from "../kernel/auth/glob.js";
|
|
26
|
+
import { parseScopeClaims } from "../kernel/context.js";
|
|
27
|
+
import { KernelError } from "../kernel/errors.js";
|
|
28
|
+
import { createKernel } from "../kernel/kernel.js";
|
|
29
|
+
import { extractSystemProperties, split as splitFrontmatter } from "../markdown/frontmatter.js";
|
|
30
|
+
import { backfillContentHashes } from "../markdown/hash-backfill.js";
|
|
31
|
+
import { renderDocGetManyText, renderGraphSummary } from "../mcp/render.js";
|
|
32
|
+
import { startMcpStdio } from "../mcp/server.js";
|
|
33
|
+
import { startServer } from "../server/serve.js";
|
|
34
|
+
import { fileAuditSink } from "../shell/audit.js";
|
|
35
|
+
import { mintKey } from "../shell/keys.js";
|
|
36
|
+
import { deviceFlowLogin, loadTokenSet, saveTokenSet, } from "../shell/login.js";
|
|
37
|
+
import { createOidcVerifier } from "../shell/oidc.js";
|
|
38
|
+
import { PolicyError, compile, loadPolicyFile } from "../shell/policy.js";
|
|
39
|
+
import { startProxyServer } from "../shell/proxy.js";
|
|
40
|
+
import { startShellServer } from "../shell/serve.js";
|
|
41
|
+
import { startShellStdio } from "../shell/stdio.js";
|
|
42
|
+
import { normalizeDatabaseUrl, openStorage } from "../storage/registry.js";
|
|
43
|
+
import { startDaemon } from "../sync/daemon.js";
|
|
44
|
+
import { syncOnce } from "../sync/run.js";
|
|
45
|
+
import { loadConfig, saveConfig } from "./config.js";
|
|
46
|
+
import { exitCodeForKernelError } from "./exit-codes.js";
|
|
47
|
+
import { renderGraphMermaid, renderGraphYaml, renderHistoryTable, renderQueryTable, renderReposTable, renderVersionAsMarkdown, } from "./format.js";
|
|
48
|
+
// -----------------------------------------------------------------------------
|
|
49
|
+
// Utility parsers + helpers
|
|
50
|
+
// -----------------------------------------------------------------------------
|
|
51
|
+
// Emit `s` as a double-quoted CEL string literal. globToRegexSource output is
|
|
52
|
+
// a regex source, so the characters we can encounter that need escaping in a
|
|
53
|
+
// CEL "..." literal are `\` (regex escape marker) and `"` (literal terminator);
|
|
54
|
+
// glob syntax has no way to produce a raw newline. If globToRegexSource ever
|
|
55
|
+
// starts emitting new escapables, extend this set.
|
|
56
|
+
function celStringLiteral(s) {
|
|
57
|
+
const escaped = s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
58
|
+
return `"${escaped}"`;
|
|
59
|
+
}
|
|
60
|
+
// Compile a --path glob arg into a CEL $path.matches() expression and AND
|
|
61
|
+
// it with --filter (if any). Glob semantics match design §8.2.
|
|
62
|
+
function combinePathAndFilter(pathGlob, filter) {
|
|
63
|
+
if (!pathGlob)
|
|
64
|
+
return filter;
|
|
65
|
+
const rx = `^${globToRegexSource(pathGlob)}$`;
|
|
66
|
+
const pathExpr = `$path.matches(${celStringLiteral(rx)})`;
|
|
67
|
+
return filter ? `(${pathExpr}) && (${filter})` : pathExpr;
|
|
68
|
+
}
|
|
69
|
+
function parsePositiveInt(value, _prev) {
|
|
70
|
+
if (!/^\d+$/.test(value)) {
|
|
71
|
+
throw new InvalidArgumentError(`expected a positive integer, got "${value}"`);
|
|
72
|
+
}
|
|
73
|
+
const n = Number.parseInt(value, 10);
|
|
74
|
+
if (!Number.isSafeInteger(n) || n <= 0) {
|
|
75
|
+
throw new InvalidArgumentError(`expected a positive integer, got "${value}"`);
|
|
76
|
+
}
|
|
77
|
+
return n;
|
|
78
|
+
}
|
|
79
|
+
/** Like parsePositiveInt but allows 0 (e.g. `graph --degrees 0` = roots only). */
|
|
80
|
+
function parsePositiveIntOrZero(value, _prev) {
|
|
81
|
+
if (!/^\d+$/.test(value)) {
|
|
82
|
+
throw new InvalidArgumentError(`expected a non-negative integer, got "${value}"`);
|
|
83
|
+
}
|
|
84
|
+
const n = Number.parseInt(value, 10);
|
|
85
|
+
if (!Number.isSafeInteger(n) || n < 0) {
|
|
86
|
+
throw new InvalidArgumentError(`expected a non-negative integer, got "${value}"`);
|
|
87
|
+
}
|
|
88
|
+
return n;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Parse a --scope value (JSON ScopeClaim[]) into claims. The whole flag is one
|
|
92
|
+
* JSON array — the read-scope grammar is just the wire ScopeClaim shape.
|
|
93
|
+
*/
|
|
94
|
+
function parseScopeArg(value) {
|
|
95
|
+
try {
|
|
96
|
+
return parseScopeClaims(value);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
// parseScopeClaims throws KernelError("filter_invalid") with the human
|
|
100
|
+
// reason in .data.reason (its .message is just the code).
|
|
101
|
+
const reason = err instanceof KernelError
|
|
102
|
+
? String(err.data.reason)
|
|
103
|
+
: err instanceof Error
|
|
104
|
+
? err.message
|
|
105
|
+
: String(err);
|
|
106
|
+
throw new InvalidArgumentError(`--scope must be a JSON ScopeClaim array: ${reason}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the write author, git-style precedence: --author → MRPLEX_AUTHOR →
|
|
111
|
+
* config `author` → engine default (undefined = kernel stamps "mrplex").
|
|
112
|
+
*/
|
|
113
|
+
function resolveAuthor(opts) {
|
|
114
|
+
const cfg = loadConfig();
|
|
115
|
+
return opts.author ?? process.env.MRPLEX_AUTHOR ?? cfg.author;
|
|
116
|
+
}
|
|
117
|
+
/** Build the CallContext (author + scope) the client forwards on every call. */
|
|
118
|
+
function resolveContext(opts) {
|
|
119
|
+
const ctx = {};
|
|
120
|
+
const author = resolveAuthor(opts);
|
|
121
|
+
if (author !== undefined)
|
|
122
|
+
ctx.author = author;
|
|
123
|
+
if (opts.scope !== undefined)
|
|
124
|
+
ctx.scope = opts.scope;
|
|
125
|
+
return ctx;
|
|
126
|
+
}
|
|
127
|
+
function resolveDatabase(opts) {
|
|
128
|
+
const cfg = loadConfig();
|
|
129
|
+
const value = opts.database ?? process.env.MRPLEX_DATABASE ?? cfg.database ?? "sqlite:./mrplex.db";
|
|
130
|
+
return normalizeDatabaseUrl(value);
|
|
131
|
+
}
|
|
132
|
+
function resolveServer(opts) {
|
|
133
|
+
const cfg = loadConfig();
|
|
134
|
+
const value = opts.server ?? process.env.MRPLEX_SERVER ?? cfg.server;
|
|
135
|
+
// An empty value (e.g. `--server ""` to override an env/config default) means
|
|
136
|
+
// "no server" — the same falsy treatment openClient gives it. Normalize to
|
|
137
|
+
// undefined so downstream (source resolution, the mutex gate) sees it as unset.
|
|
138
|
+
return value ? value : undefined;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The sync cursor's source: exactly one of `server` (remote) or `database`
|
|
142
|
+
* (local), mirroring the --server/--database gate. A remote sync records the
|
|
143
|
+
* server URL; a local sync records the resolved database URL so the marker
|
|
144
|
+
* says where it came from.
|
|
145
|
+
*/
|
|
146
|
+
function resolveSyncSource(opts) {
|
|
147
|
+
const server = resolveServer(opts);
|
|
148
|
+
if (server !== undefined)
|
|
149
|
+
return { server };
|
|
150
|
+
return { database: resolveDatabase(opts) };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The policy|unsafe gate (auth-shell plan decision 11). A server-starting
|
|
154
|
+
* command demands EXACTLY ONE of --policy or --unsafe. Throws a cli_usage
|
|
155
|
+
* error otherwise so the CLI exits non-zero with a clear message — the raw
|
|
156
|
+
* kernel is a choice you spell out, never a default you fall into.
|
|
157
|
+
*/
|
|
158
|
+
function assertServeGate(policy, unsafe) {
|
|
159
|
+
const hasPolicy = policy !== undefined;
|
|
160
|
+
if (hasPolicy === unsafe) {
|
|
161
|
+
const err = new Error(hasPolicy
|
|
162
|
+
? "--policy and --unsafe are mutually exclusive; pick one"
|
|
163
|
+
: "refusing to start: pass --policy <file> (authenticated) or --unsafe (full-trust, no auth)");
|
|
164
|
+
err.code = "cli_usage";
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Build an OIDC verifier from --oidc-issuer/--oidc-audience (both required
|
|
170
|
+
* together), or undefined when neither is given. Throws cli_usage if only one
|
|
171
|
+
* is present — a half-configured verifier would silently reject every token.
|
|
172
|
+
*/
|
|
173
|
+
function buildOidcVerifier(opts) {
|
|
174
|
+
const { oidcIssuer, oidcAudience, oidcJwksUri } = opts;
|
|
175
|
+
if (oidcIssuer === undefined && oidcAudience === undefined)
|
|
176
|
+
return undefined;
|
|
177
|
+
if (oidcIssuer === undefined || oidcAudience === undefined) {
|
|
178
|
+
const err = new Error("--oidc-issuer and --oidc-audience must be given together");
|
|
179
|
+
err.code = "cli_usage";
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
return createOidcVerifier({
|
|
183
|
+
issuer: oidcIssuer,
|
|
184
|
+
audience: oidcAudience,
|
|
185
|
+
jwksUri: oidcJwksUri,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Resolve a stdio credential from the launcher flags: --principal (trust-by-
|
|
190
|
+
* spawn) or --key / MRPLEX_SHELL_KEY (an API key). Exactly one must be present.
|
|
191
|
+
*/
|
|
192
|
+
function resolveStdioCredential(principal, key, token) {
|
|
193
|
+
const given = [principal, key, token].filter((v) => v !== undefined).length;
|
|
194
|
+
if (given > 1) {
|
|
195
|
+
const err = new Error("--principal, --key, and --token are mutually exclusive");
|
|
196
|
+
err.code = "cli_conflict";
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
if (principal !== undefined)
|
|
200
|
+
return { kind: "principal", id: principal };
|
|
201
|
+
if (key !== undefined)
|
|
202
|
+
return { kind: "key", key };
|
|
203
|
+
if (token !== undefined)
|
|
204
|
+
return { kind: "token", token };
|
|
205
|
+
const err = new Error("mcp-stdio --policy needs a credential: --principal <id>, --key <key>, or --token <jwt>");
|
|
206
|
+
err.code = "cli_usage";
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
/** Wire SIGINT/SIGTERM to close a stdio mount + storage, then exit. */
|
|
210
|
+
function wireStdioShutdown(closeMount, closeStorage) {
|
|
211
|
+
const shutdown = async () => {
|
|
212
|
+
await closeMount().catch(() => { });
|
|
213
|
+
await closeStorage().catch(() => { });
|
|
214
|
+
process.exit(0);
|
|
215
|
+
};
|
|
216
|
+
process.on("SIGINT", () => void shutdown());
|
|
217
|
+
process.on("SIGTERM", () => void shutdown());
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Resolve the target repo slug for `docs *` commands — flag → env → config.
|
|
221
|
+
* Throws a friendly cli-usage error if none is set; the CLI turns that into
|
|
222
|
+
* a non-zero exit.
|
|
223
|
+
*/
|
|
224
|
+
function resolveRepoSlug(opts) {
|
|
225
|
+
const cfg = loadConfig();
|
|
226
|
+
const value = opts.repo ?? process.env.MRPLEX_REPO ?? cfg.repo;
|
|
227
|
+
if (!value) {
|
|
228
|
+
const err = new Error("no repo — set MRPLEX_REPO, use -r/--repo, or `mrplex config set-repo <slug>`");
|
|
229
|
+
err.code = "cli_usage";
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Open the right KernelClient — remote if `--server` (or MRPLEX_SERVER / config
|
|
236
|
+
* `server`) is set, otherwise the local in-process client. Enforces the
|
|
237
|
+
* m3-plan decision that --database and --server are mutually exclusive.
|
|
238
|
+
*
|
|
239
|
+
* In local mode we also resolve an embed hook (from --embedder or legacy
|
|
240
|
+
* --embed-url/--embed-cmd, or env/config) — needed for CLI-local semantic
|
|
241
|
+
* enqueue on writes done through the CLI.
|
|
242
|
+
*/
|
|
243
|
+
async function openClient(opts, embedFlags = {}) {
|
|
244
|
+
const server = resolveServer(opts);
|
|
245
|
+
const hasExplicitDatabase = opts.database !== undefined || process.env.MRPLEX_DATABASE !== undefined;
|
|
246
|
+
if (server && hasExplicitDatabase) {
|
|
247
|
+
const err = new Error("--database and --server are mutually exclusive; pick one");
|
|
248
|
+
err.code = "cli_conflict";
|
|
249
|
+
throw err;
|
|
250
|
+
}
|
|
251
|
+
const context = resolveContext(opts);
|
|
252
|
+
if (server) {
|
|
253
|
+
// --token is an optional bearer forwarded verbatim for a shell fronting the
|
|
254
|
+
// remote server (noauth plan §1); mrplex itself ignores it.
|
|
255
|
+
return openRemoteClient({
|
|
256
|
+
server,
|
|
257
|
+
context,
|
|
258
|
+
token: resolveTokenString(opts.token) ?? undefined,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const embedCfg = resolveEmbedConfig(embedFlags);
|
|
262
|
+
const embed = createHookFromConfig(embedCfg);
|
|
263
|
+
return openLocalClient({ database: resolveDatabase(opts), context, embed });
|
|
264
|
+
}
|
|
265
|
+
/** Optional bearer for remote pass-through: --token → MRPLEX_TOKEN → config. */
|
|
266
|
+
function resolveTokenString(cliFlag) {
|
|
267
|
+
if (cliFlag)
|
|
268
|
+
return cliFlag;
|
|
269
|
+
if (process.env.MRPLEX_TOKEN)
|
|
270
|
+
return process.env.MRPLEX_TOKEN;
|
|
271
|
+
return loadConfig().token ?? null;
|
|
272
|
+
}
|
|
273
|
+
async function withClient(cmd, fn, embedFlags = {}) {
|
|
274
|
+
const opts = cmd.optsWithGlobals();
|
|
275
|
+
const client = await openClient(opts, embedFlags);
|
|
276
|
+
try {
|
|
277
|
+
return await fn(client, opts);
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
await client.close();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function sleep(ms) {
|
|
284
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
285
|
+
}
|
|
286
|
+
function emit(result, opts, prettyText) {
|
|
287
|
+
if (opts.json) {
|
|
288
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const text = prettyText.endsWith("\n") ? prettyText : `${prettyText}\n`;
|
|
292
|
+
process.stdout.write(text);
|
|
293
|
+
}
|
|
294
|
+
function reportError(err) {
|
|
295
|
+
if (err instanceof KernelError) {
|
|
296
|
+
const payload = { code: err.code, data: err.data };
|
|
297
|
+
process.stderr.write(`${JSON.stringify(payload)}\n`);
|
|
298
|
+
process.exit(exitCodeForKernelError(err.code));
|
|
299
|
+
}
|
|
300
|
+
const code = err.code;
|
|
301
|
+
if (code === "unauthorized") {
|
|
302
|
+
process.stderr.write(`${JSON.stringify({ code, data: {} })}\n`);
|
|
303
|
+
process.exit(3);
|
|
304
|
+
}
|
|
305
|
+
if (code === "network") {
|
|
306
|
+
process.stderr.write(`${err.message}\n`);
|
|
307
|
+
process.exit(10);
|
|
308
|
+
}
|
|
309
|
+
if (code === "cli_conflict" || code === "cli_usage") {
|
|
310
|
+
process.stderr.write(`${err.message}\n`);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
process.stderr.write(`${err.message}\n`);
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Append a minted key's hash under `principals.<id>.keys` in a policy file,
|
|
318
|
+
* preserving comments and formatting via the yaml Document API (issuance is a
|
|
319
|
+
* diff, auth-shell decision 3). Throws if the principal isn't present — mint
|
|
320
|
+
* doesn't invent principals.
|
|
321
|
+
*/
|
|
322
|
+
function appendKeyToPolicy(policyPath, principalId, hash) {
|
|
323
|
+
const text = readFileSync(policyPath, "utf8");
|
|
324
|
+
const doc = parseYamlDocument(text);
|
|
325
|
+
const principals = doc.getIn(["principals"]);
|
|
326
|
+
if (!principals || !doc.hasIn(["principals", principalId])) {
|
|
327
|
+
const err = new Error(`policy: principal "${principalId}" not found in ${policyPath}`);
|
|
328
|
+
err.code = "cli_usage";
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
if (!doc.hasIn(["principals", principalId, "keys"])) {
|
|
332
|
+
doc.setIn(["principals", principalId, "keys"], doc.createNode([hash]));
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
const keys = doc.getIn(["principals", principalId, "keys"]);
|
|
336
|
+
if (typeof keys.add !== "function") {
|
|
337
|
+
const err = new Error(`policy: principals.${principalId}.keys is not a list`);
|
|
338
|
+
err.code = "cli_usage";
|
|
339
|
+
throw err;
|
|
340
|
+
}
|
|
341
|
+
keys.add(hash);
|
|
342
|
+
}
|
|
343
|
+
// Atomic write: a tmp file on the same volume + rename, so an interleaved
|
|
344
|
+
// reader (a SIGHUP reload) or a racing `key mint` sees either the whole old
|
|
345
|
+
// file or the whole new one, never a torn intermediate. Doesn't prevent two
|
|
346
|
+
// concurrent mints from losing one key (last rename wins), but eliminates
|
|
347
|
+
// partial reads/writes.
|
|
348
|
+
const tmp = `${policyPath}.tmp.${process.pid}`;
|
|
349
|
+
writeFileSync(tmp, String(doc));
|
|
350
|
+
renameSync(tmp, policyPath);
|
|
351
|
+
}
|
|
352
|
+
/** Human-readable dump of an effective entitlement — the operator's "why can't
|
|
353
|
+
* X read Y" answer. */
|
|
354
|
+
function renderEntitlement(principalId, e) {
|
|
355
|
+
const claims = (list) => list.length === 0
|
|
356
|
+
? " (none)\n"
|
|
357
|
+
: list
|
|
358
|
+
.map((c) => ` repo=${JSON.stringify(c.repo)} paths=${JSON.stringify(c.paths)}\n`)
|
|
359
|
+
.join("");
|
|
360
|
+
return (`principal: ${principalId}\n` +
|
|
361
|
+
`author: ${e.author}\n` +
|
|
362
|
+
`destructive: ${e.destructive} impersonate: ${e.impersonate}\n` +
|
|
363
|
+
`read:\n${claims(e.read)}` +
|
|
364
|
+
`write:\n${claims(e.write)}`);
|
|
365
|
+
}
|
|
366
|
+
function readFromFile(pathOrDash) {
|
|
367
|
+
if (pathOrDash === "-") {
|
|
368
|
+
return readFileSync(0, "utf8"); // fd 0 = stdin
|
|
369
|
+
}
|
|
370
|
+
return readFileSync(pathOrDash, "utf8");
|
|
371
|
+
}
|
|
372
|
+
function readDocumentInput(fromFile) {
|
|
373
|
+
if (!fromFile) {
|
|
374
|
+
return { frontmatter_raw: "", body: "" };
|
|
375
|
+
}
|
|
376
|
+
return splitFrontmatter(readFromFile(fromFile));
|
|
377
|
+
}
|
|
378
|
+
function emitVersionWrite(result, opts) {
|
|
379
|
+
if (opts.json) {
|
|
380
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
process.stdout.write(`${result.version_id}\n`);
|
|
384
|
+
process.stderr.write(`wrote ${result.repo}/${result.path} @ ${result.version_id} (author: ${result.author})\n`);
|
|
385
|
+
}
|
|
386
|
+
// -----------------------------------------------------------------------------
|
|
387
|
+
// Program construction
|
|
388
|
+
// -----------------------------------------------------------------------------
|
|
389
|
+
function buildProgram() {
|
|
390
|
+
const program = new Command()
|
|
391
|
+
.name("mrplex")
|
|
392
|
+
.description("Markdown Repos, plexed — CLI (M3)")
|
|
393
|
+
.version(VERSION)
|
|
394
|
+
.addOption(new Option("--database <url>", "sqlite:./path.db or postgres://…").env("MRPLEX_DATABASE"))
|
|
395
|
+
.addOption(new Option("--token <token>", "optional bearer forwarded verbatim in remote mode (mrplex ignores it; for a fronting shell)").env("MRPLEX_TOKEN"))
|
|
396
|
+
.addOption(new Option("--server <url>", "talk to a remote mrplex server (mutex with --database)").env("MRPLEX_SERVER"))
|
|
397
|
+
.addOption(new Option("-r, --repo <slug>", "target repo for `docs *` commands").env("MRPLEX_REPO"))
|
|
398
|
+
.addOption(new Option("--author <s>", "opaque author string stamped on writes").env("MRPLEX_AUTHOR"))
|
|
399
|
+
.option("--scope <json>", "read-visibility claims as a JSON ScopeClaim array (narrows what reads see)", parseScopeArg)
|
|
400
|
+
.option("--json", "emit raw JSON instead of pretty output", false)
|
|
401
|
+
.exitOverride((err) => {
|
|
402
|
+
if (err.exitCode === 0)
|
|
403
|
+
process.exit(0);
|
|
404
|
+
process.exit(err.exitCode || 1);
|
|
405
|
+
});
|
|
406
|
+
// -------- serve --------
|
|
407
|
+
// The policy|unsafe gate (auth-shell plan decision 11): a command that starts
|
|
408
|
+
// a server over a local database must spell out exactly one of --policy (run
|
|
409
|
+
// the authenticating shell) or --unsafe (raw full-trust kernel). Neither →
|
|
410
|
+
// refuse; both → refuse. Full trust is never the result of a forgotten flag.
|
|
411
|
+
const serveCmd = program
|
|
412
|
+
.command("serve")
|
|
413
|
+
.description("start HTTP surfaces (REST + MCP Streamable HTTP) — §7.3")
|
|
414
|
+
.option("--policy <file>", "YAML policy file — run the authenticating shell (auth-shell plan)")
|
|
415
|
+
.option("--unsafe", "serve the raw full-trust kernel with NO auth (say what it is)", false)
|
|
416
|
+
.option("--audit <file>", "append a JSONL audit line per authenticated call (--policy only)")
|
|
417
|
+
.option("--oidc-issuer <url>", "OIDC issuer to accept JWT bearers from (--policy only)")
|
|
418
|
+
.option("--oidc-audience <aud>", "OIDC audience the JWT must carry (--policy only)")
|
|
419
|
+
.option("--oidc-jwks-uri <url>", "JWKS endpoint (default: <issuer>/.well-known/jwks.json)")
|
|
420
|
+
.option("--port <n>", "TCP port (default 8321)", parsePositiveInt)
|
|
421
|
+
.option("--host <h>", "bind host (default 127.0.0.1)")
|
|
422
|
+
.option("--mcp-stdio", "also expose MCP over STDIO for the launch token (--unsafe only)", false);
|
|
423
|
+
addEmbedCliOptions(serveCmd);
|
|
424
|
+
serveCmd.action(function () {
|
|
425
|
+
const gopts = this.optsWithGlobals();
|
|
426
|
+
const localOpts = this.opts();
|
|
427
|
+
(async () => {
|
|
428
|
+
try {
|
|
429
|
+
assertServeGate(localOpts.policy, localOpts.unsafe);
|
|
430
|
+
const database = resolveDatabase(gopts);
|
|
431
|
+
const embedCfg = resolveEmbedConfig(embedFlagInputsFromCli(localOpts));
|
|
432
|
+
// Authenticated shell mode.
|
|
433
|
+
if (localOpts.policy !== undefined) {
|
|
434
|
+
if (localOpts.mcpStdio) {
|
|
435
|
+
const err = new Error("--mcp-stdio is unsafe-mode only; use `mrplex mcp-stdio` instead");
|
|
436
|
+
err.code = "cli_conflict";
|
|
437
|
+
throw err;
|
|
438
|
+
}
|
|
439
|
+
const shellHandle = await startShellServer({
|
|
440
|
+
database,
|
|
441
|
+
policyPath: localOpts.policy,
|
|
442
|
+
host: localOpts.host,
|
|
443
|
+
port: localOpts.port,
|
|
444
|
+
embed: embedCfg,
|
|
445
|
+
auditPath: localOpts.audit,
|
|
446
|
+
auditSinkFor: localOpts.audit
|
|
447
|
+
? (principal) => fileAuditSink(localOpts.audit, principal)
|
|
448
|
+
: undefined,
|
|
449
|
+
oidc: buildOidcVerifier(localOpts),
|
|
450
|
+
});
|
|
451
|
+
process.on("SIGHUP", () => shellHandle.reloadPolicy());
|
|
452
|
+
const shutdown = async () => {
|
|
453
|
+
await shellHandle.close();
|
|
454
|
+
process.exit(0);
|
|
455
|
+
};
|
|
456
|
+
process.on("SIGINT", () => void shutdown());
|
|
457
|
+
process.on("SIGTERM", () => void shutdown());
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
// Unsafe raw-kernel mode.
|
|
461
|
+
if (localOpts.audit !== undefined) {
|
|
462
|
+
const err = new Error("--audit requires --policy (nothing to attribute without auth)");
|
|
463
|
+
err.code = "cli_conflict";
|
|
464
|
+
throw err;
|
|
465
|
+
}
|
|
466
|
+
const handle = await startServer({
|
|
467
|
+
database,
|
|
468
|
+
host: localOpts.host,
|
|
469
|
+
port: localOpts.port,
|
|
470
|
+
embed: embedCfg,
|
|
471
|
+
});
|
|
472
|
+
// Optional STDIO — the launch process IS the shell (noauth plan §2),
|
|
473
|
+
// so --author / --scope pin the session's CallContext.
|
|
474
|
+
if (localOpts.mcpStdio) {
|
|
475
|
+
try {
|
|
476
|
+
await startMcpStdio({
|
|
477
|
+
kernel: handle.kernel,
|
|
478
|
+
context: resolveContext(gopts),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
process.stderr.write(`mrplex: --mcp-stdio failed: ${err.message}\n`);
|
|
483
|
+
await handle.close();
|
|
484
|
+
process.exit(3);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const shutdown = async () => {
|
|
488
|
+
await handle.close();
|
|
489
|
+
process.exit(0);
|
|
490
|
+
};
|
|
491
|
+
process.on("SIGINT", () => void shutdown());
|
|
492
|
+
process.on("SIGTERM", () => void shutdown());
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
reportError(err);
|
|
496
|
+
}
|
|
497
|
+
})();
|
|
498
|
+
});
|
|
499
|
+
// -------- mcp-stdio --------
|
|
500
|
+
// Guarded stdio MCP over a local database (auth-shell plan §1 launcher mode).
|
|
501
|
+
// Under the same policy|unsafe gate as serve: a server over a local database
|
|
502
|
+
// spells out its trust posture. The credential arrives via --principal,
|
|
503
|
+
// MRPLEX_SHELL_KEY / --key, or MRPLEX_SHELL_TOKEN / --token (an OAuth JWT).
|
|
504
|
+
const mcpStdioCmd = program
|
|
505
|
+
.command("mcp-stdio")
|
|
506
|
+
.description("run an MCP session over STDIO against a local database")
|
|
507
|
+
.option("--policy <file>", "YAML policy file — resolve a guarded principal")
|
|
508
|
+
.option("--unsafe", "raw full-trust kernel over stdio, NO auth", false)
|
|
509
|
+
.option("--principal <id>", "trust-by-spawn: run as this policy principal (no credential)")
|
|
510
|
+
.addOption(new Option("--key <key>", "API key to resolve a principal").env("MRPLEX_SHELL_KEY"))
|
|
511
|
+
.addOption(new Option("--token <jwt>", "OAuth access token to resolve a principal").env("MRPLEX_SHELL_TOKEN"))
|
|
512
|
+
.option("--oidc-issuer <url>", "OIDC issuer for --token verification")
|
|
513
|
+
.option("--oidc-audience <aud>", "OIDC audience for --token verification")
|
|
514
|
+
.option("--oidc-jwks-uri <url>", "JWKS endpoint (default: <issuer>/.well-known/jwks.json)")
|
|
515
|
+
.option("--audit <file>", "append a JSONL audit line per call (--policy only)");
|
|
516
|
+
addEmbedCliOptions(mcpStdioCmd);
|
|
517
|
+
mcpStdioCmd.action(function () {
|
|
518
|
+
const gopts = this.optsWithGlobals();
|
|
519
|
+
const localOpts = this.opts();
|
|
520
|
+
(async () => {
|
|
521
|
+
try {
|
|
522
|
+
assertServeGate(localOpts.policy, localOpts.unsafe);
|
|
523
|
+
const database = resolveDatabase(gopts);
|
|
524
|
+
const embedCfg = resolveEmbedConfig(embedFlagInputsFromCli(localOpts));
|
|
525
|
+
const hook = createHookFromConfig(embedCfg);
|
|
526
|
+
if (localOpts.policy === undefined) {
|
|
527
|
+
// Unsafe: raw kernel, launch-time --author/--scope pin the context.
|
|
528
|
+
const storage = await openStorage(database);
|
|
529
|
+
const kernel = createKernel({
|
|
530
|
+
storage,
|
|
531
|
+
onVersionCommitted: async (versionId) => {
|
|
532
|
+
await storage.backlog_enqueue(versionId);
|
|
533
|
+
},
|
|
534
|
+
queryEmbed: hook
|
|
535
|
+
? async (semantic) => {
|
|
536
|
+
const resp = await hook.embed([semantic]);
|
|
537
|
+
const vector = resp.vectors[0];
|
|
538
|
+
if (!vector)
|
|
539
|
+
throw new Error("embed hook returned no vector for query string");
|
|
540
|
+
return { vector, model: resp.model, dim: resp.dim };
|
|
541
|
+
}
|
|
542
|
+
: undefined,
|
|
543
|
+
});
|
|
544
|
+
const worker = hook ? createWorker({ storage, hook }) : null;
|
|
545
|
+
worker?.start();
|
|
546
|
+
const mount = await startMcpStdio({ kernel, context: resolveContext(gopts) });
|
|
547
|
+
wireStdioShutdown(async () => {
|
|
548
|
+
await mount.close();
|
|
549
|
+
if (worker)
|
|
550
|
+
await worker.stop();
|
|
551
|
+
}, storage.close.bind(storage));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
// Resolve everything fallible (policy parse, credential shape, OIDC
|
|
555
|
+
// config) BEFORE opening storage, so a malformed policy — a real
|
|
556
|
+
// operator failure mode — fails without leaking a db connection.
|
|
557
|
+
const policy = loadPolicyFile(localOpts.policy);
|
|
558
|
+
const credential = resolveStdioCredential(localOpts.principal, localOpts.key, localOpts.token);
|
|
559
|
+
const oidc = buildOidcVerifier(localOpts);
|
|
560
|
+
const storage = await openStorage(database);
|
|
561
|
+
const kernel = createKernel({
|
|
562
|
+
storage,
|
|
563
|
+
onVersionCommitted: async (versionId) => {
|
|
564
|
+
await storage.backlog_enqueue(versionId);
|
|
565
|
+
},
|
|
566
|
+
queryEmbed: hook
|
|
567
|
+
? async (semantic) => {
|
|
568
|
+
const resp = await hook.embed([semantic]);
|
|
569
|
+
const vector = resp.vectors[0];
|
|
570
|
+
if (!vector)
|
|
571
|
+
throw new Error("embed hook returned no vector for query string");
|
|
572
|
+
return { vector, model: resp.model, dim: resp.dim };
|
|
573
|
+
}
|
|
574
|
+
: undefined,
|
|
575
|
+
});
|
|
576
|
+
const worker = hook ? createWorker({ storage, hook }) : null;
|
|
577
|
+
worker?.start();
|
|
578
|
+
const mount = await startShellStdio({
|
|
579
|
+
kernel,
|
|
580
|
+
policy,
|
|
581
|
+
credential,
|
|
582
|
+
oidc,
|
|
583
|
+
auditSinkFor: localOpts.audit
|
|
584
|
+
? (principal) => fileAuditSink(localOpts.audit, principal)
|
|
585
|
+
: undefined,
|
|
586
|
+
});
|
|
587
|
+
wireStdioShutdown(async () => {
|
|
588
|
+
await mount.close();
|
|
589
|
+
if (worker)
|
|
590
|
+
await worker.stop();
|
|
591
|
+
}, storage.close.bind(storage));
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
reportError(err);
|
|
595
|
+
}
|
|
596
|
+
})();
|
|
597
|
+
});
|
|
598
|
+
// -------- proxy --------
|
|
599
|
+
// Fronting proxy: authenticate + enforce REST route policy, strip inbound
|
|
600
|
+
// X-Mrplex-* and inject the entitlement's, forward to a raw engine upstream.
|
|
601
|
+
// --policy is always required (an unsafe proxy is meaningless).
|
|
602
|
+
program
|
|
603
|
+
.command("proxy")
|
|
604
|
+
.description("authenticating reverse proxy in front of a raw engine upstream")
|
|
605
|
+
.requiredOption("--policy <file>", "YAML policy file")
|
|
606
|
+
.requiredOption("--upstream <target>", "unix:<socket-path> or http://<loopback:port>")
|
|
607
|
+
.option("--audit <file>", "append a JSONL audit line per authenticated request")
|
|
608
|
+
.option("--port <n>", "TCP port (default 8321)", parsePositiveInt)
|
|
609
|
+
.option("--host <h>", "bind host (default 127.0.0.1)")
|
|
610
|
+
.action(function () {
|
|
611
|
+
const localOpts = this.opts();
|
|
612
|
+
(async () => {
|
|
613
|
+
try {
|
|
614
|
+
const handle = await startProxyServer({
|
|
615
|
+
policyPath: localOpts.policy,
|
|
616
|
+
upstream: localOpts.upstream,
|
|
617
|
+
host: localOpts.host,
|
|
618
|
+
port: localOpts.port,
|
|
619
|
+
auditSinkFor: localOpts.audit
|
|
620
|
+
? (principal) => fileAuditSink(localOpts.audit, principal)
|
|
621
|
+
: undefined,
|
|
622
|
+
});
|
|
623
|
+
process.on("SIGHUP", () => handle.reloadPolicy());
|
|
624
|
+
const shutdown = async () => {
|
|
625
|
+
await handle.close();
|
|
626
|
+
process.exit(0);
|
|
627
|
+
};
|
|
628
|
+
process.on("SIGINT", () => void shutdown());
|
|
629
|
+
process.on("SIGTERM", () => void shutdown());
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
reportError(err);
|
|
633
|
+
}
|
|
634
|
+
})();
|
|
635
|
+
});
|
|
636
|
+
// -------- login --------
|
|
637
|
+
// OAuth device-authorization flow (auth-shell plan §1 login). Needs only the
|
|
638
|
+
// IdP's client config, not policy — mrplex is the resource server, the IdP is
|
|
639
|
+
// the authorization server. Caches access + refresh tokens (mode 600) for
|
|
640
|
+
// `mcp-stdio --token` to pick up.
|
|
641
|
+
program
|
|
642
|
+
.command("login")
|
|
643
|
+
.description("sign in via the OAuth device flow; cache the token for mcp-stdio")
|
|
644
|
+
.requiredOption("--device-authorization-endpoint <url>", "OAuth device authorization endpoint")
|
|
645
|
+
.requiredOption("--token-endpoint <url>", "OAuth token endpoint")
|
|
646
|
+
.requiredOption("--client-id <id>", "OAuth client id")
|
|
647
|
+
.option("--scope <scopes>", "space-delimited scopes", "openid email profile offline_access")
|
|
648
|
+
.option("--audience <aud>", "OAuth audience — required by Auth0 (and some IdPs) to issue a JWT access token rather than an opaque one; use the same value as the server's --oidc-audience")
|
|
649
|
+
.action(function () {
|
|
650
|
+
const localOpts = this.opts();
|
|
651
|
+
(async () => {
|
|
652
|
+
try {
|
|
653
|
+
const cfg = {
|
|
654
|
+
deviceAuthorizationEndpoint: localOpts.deviceAuthorizationEndpoint,
|
|
655
|
+
tokenEndpoint: localOpts.tokenEndpoint,
|
|
656
|
+
clientId: localOpts.clientId,
|
|
657
|
+
scope: localOpts.scope,
|
|
658
|
+
audience: localOpts.audience,
|
|
659
|
+
};
|
|
660
|
+
const tokens = await deviceFlowLogin(cfg);
|
|
661
|
+
saveTokenSet(tokens);
|
|
662
|
+
process.stderr.write("login: token cached (chmod 600)\n");
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
process.stderr.write(`login failed: ${err.message}\n`);
|
|
666
|
+
process.exit(1);
|
|
667
|
+
}
|
|
668
|
+
})();
|
|
669
|
+
});
|
|
670
|
+
program
|
|
671
|
+
.command("logout")
|
|
672
|
+
.description("clear the cached OAuth token")
|
|
673
|
+
.action(() => {
|
|
674
|
+
// Overwrite with an empty token set rather than deleting — keeps the
|
|
675
|
+
// mode-600 file in place and makes the intent explicit.
|
|
676
|
+
if (loadTokenSet() !== null) {
|
|
677
|
+
saveTokenSet({ access_token: "" });
|
|
678
|
+
}
|
|
679
|
+
process.stderr.write("logout: cached token cleared\n");
|
|
680
|
+
});
|
|
681
|
+
// -------- config --------
|
|
682
|
+
const cfg = program.command("config").description("local CLI config");
|
|
683
|
+
cfg
|
|
684
|
+
.command("set-database <url>")
|
|
685
|
+
.description("write the default --database URL to the CLI config")
|
|
686
|
+
.action((url) => {
|
|
687
|
+
const c = { ...loadConfig(), database: url };
|
|
688
|
+
saveConfig(c);
|
|
689
|
+
process.stderr.write("config: database set\n");
|
|
690
|
+
});
|
|
691
|
+
cfg
|
|
692
|
+
.command("set-token <token>")
|
|
693
|
+
.description("write the default --token to the CLI config (mode 600)")
|
|
694
|
+
.action((token) => {
|
|
695
|
+
const c = { ...loadConfig(), token };
|
|
696
|
+
saveConfig(c);
|
|
697
|
+
process.stderr.write("config: token set (chmod 600)\n");
|
|
698
|
+
});
|
|
699
|
+
cfg
|
|
700
|
+
.command("set-server <url>")
|
|
701
|
+
.description("write the default --server URL to the CLI config")
|
|
702
|
+
.action((url) => {
|
|
703
|
+
const c = { ...loadConfig(), server: url };
|
|
704
|
+
saveConfig(c);
|
|
705
|
+
process.stderr.write("config: server set\n");
|
|
706
|
+
});
|
|
707
|
+
cfg
|
|
708
|
+
.command("set-repo <slug>")
|
|
709
|
+
.description("write the default -r/--repo slug to the CLI config")
|
|
710
|
+
.action((slug) => {
|
|
711
|
+
const c = { ...loadConfig(), repo: slug };
|
|
712
|
+
saveConfig(c);
|
|
713
|
+
process.stderr.write("config: repo set\n");
|
|
714
|
+
});
|
|
715
|
+
cfg
|
|
716
|
+
.command("set-author <author>")
|
|
717
|
+
.description('write the default --author to the CLI config (e.g. "Full Name <email@addr>")')
|
|
718
|
+
.action((author) => {
|
|
719
|
+
const c = { ...loadConfig(), author };
|
|
720
|
+
saveConfig(c);
|
|
721
|
+
process.stderr.write("config: author set\n");
|
|
722
|
+
});
|
|
723
|
+
cfg
|
|
724
|
+
.command("set-embedder <spec>")
|
|
725
|
+
.description("write the default --embedder to the CLI config (command or http(s):// URL)")
|
|
726
|
+
.action((spec) => {
|
|
727
|
+
const prev = loadConfig();
|
|
728
|
+
const c = { ...prev, embedder: spec };
|
|
729
|
+
delete c.embed_url;
|
|
730
|
+
delete c.embed_cmd;
|
|
731
|
+
saveConfig(c);
|
|
732
|
+
process.stderr.write("config: embedder set\n");
|
|
733
|
+
});
|
|
734
|
+
cfg
|
|
735
|
+
.command("show")
|
|
736
|
+
.description("print the current CLI config")
|
|
737
|
+
.action(function () {
|
|
738
|
+
const opts = this.optsWithGlobals();
|
|
739
|
+
const c = loadConfig();
|
|
740
|
+
if (opts.json) {
|
|
741
|
+
process.stdout.write(`${JSON.stringify(c, null, 2)}\n`);
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
process.stdout.write(`database: ${c.database ?? "(unset)"}\nserver: ${c.server ?? "(unset)"}\nrepo: ${c.repo ?? "(unset)"}\nauthor: ${c.author ?? "(unset)"}\nembedder: ${c.embedder ?? "(unset)"}\ntoken: ${c.token ? "(set)" : "(unset)"}\n`);
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
// -------- key (policy tooling) --------
|
|
748
|
+
// `key mint` and `policy check` read/edit the policy file by definition, so
|
|
749
|
+
// they take --policy directly and never touch the serve gate (auth-shell §1
|
|
750
|
+
// "Policy tooling").
|
|
751
|
+
const key = program.command("key").description("API-key tooling for the auth shell");
|
|
752
|
+
key
|
|
753
|
+
.command("mint <principal>")
|
|
754
|
+
.description("generate a new API key; print the plaintext ONCE and the sha256 hash to store")
|
|
755
|
+
.option("--policy <file>", "append the hash under the principal's `keys:` in this policy file")
|
|
756
|
+
.action(function (principal) {
|
|
757
|
+
const localOpts = this.opts();
|
|
758
|
+
try {
|
|
759
|
+
const { plaintext, hash } = mintKey();
|
|
760
|
+
if (localOpts.policy !== undefined) {
|
|
761
|
+
appendKeyToPolicy(localOpts.policy, principal, hash);
|
|
762
|
+
process.stderr.write(`key: appended hash under principals.${principal}.keys\n`);
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
process.stderr.write(`key: add this line under principals.${principal}.keys in your policy file:\n - ${hash}\n`);
|
|
766
|
+
}
|
|
767
|
+
// The plaintext is shown ONCE, on stdout, so it can be piped/captured;
|
|
768
|
+
// it is never stored — only the hash lives in the policy file.
|
|
769
|
+
process.stdout.write(`${plaintext}\n`);
|
|
770
|
+
}
|
|
771
|
+
catch (err) {
|
|
772
|
+
reportError(err);
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
// -------- policy --------
|
|
776
|
+
const policy = program.command("policy").description("policy-file tooling for the auth shell");
|
|
777
|
+
policy
|
|
778
|
+
.command("check [principal]")
|
|
779
|
+
.description("validate a policy file; with a principal, print its effective entitlement")
|
|
780
|
+
.requiredOption("--policy <file>", "policy file to load")
|
|
781
|
+
.action(function (principal) {
|
|
782
|
+
const localOpts = this.opts();
|
|
783
|
+
const gopts = this.optsWithGlobals();
|
|
784
|
+
try {
|
|
785
|
+
const loaded = loadPolicyFile(localOpts.policy);
|
|
786
|
+
if (principal === undefined) {
|
|
787
|
+
const nP = Object.keys(loaded.principals).length;
|
|
788
|
+
const nR = Object.keys(loaded.roles).length;
|
|
789
|
+
process.stderr.write(`policy OK: ${nR} role(s), ${nP} principal(s)\n`);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const entitlement = compile(loaded, principal);
|
|
793
|
+
if (gopts.json) {
|
|
794
|
+
process.stdout.write(`${JSON.stringify(entitlement, null, 2)}\n`);
|
|
795
|
+
}
|
|
796
|
+
else {
|
|
797
|
+
process.stdout.write(renderEntitlement(principal, entitlement));
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
catch (err) {
|
|
801
|
+
if (err instanceof PolicyError) {
|
|
802
|
+
process.stderr.write(`policy: ${err.message}\n`);
|
|
803
|
+
process.exit(1);
|
|
804
|
+
}
|
|
805
|
+
reportError(err);
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
// -------- repos --------
|
|
809
|
+
const repos = program.command("repos").description("repo management");
|
|
810
|
+
repos
|
|
811
|
+
.command("list")
|
|
812
|
+
.description("list repos")
|
|
813
|
+
.option("--include-system", "include system-namespaced repos (§3.4)", false)
|
|
814
|
+
.action(function () {
|
|
815
|
+
const localOpts = this.opts();
|
|
816
|
+
withClient(this, async (client, opts) => {
|
|
817
|
+
const result = await client.repos.list({ include_system: localOpts.includeSystem });
|
|
818
|
+
emit(result, opts, renderReposTable(result));
|
|
819
|
+
}).catch(reportError);
|
|
820
|
+
});
|
|
821
|
+
repos
|
|
822
|
+
.command("get <slug>")
|
|
823
|
+
.description("show a repo")
|
|
824
|
+
.action(function (slug) {
|
|
825
|
+
withClient(this, async (client, opts) => {
|
|
826
|
+
const result = await client.repos.get(slug);
|
|
827
|
+
emit(result, opts, `${result.repo} ${result.path_config ? "(custom path_config)" : "(default path_config)"}`);
|
|
828
|
+
}).catch(reportError);
|
|
829
|
+
});
|
|
830
|
+
repos
|
|
831
|
+
.command("create <slug>")
|
|
832
|
+
.description("create a new repo (admin)")
|
|
833
|
+
.action(function (slug) {
|
|
834
|
+
withClient(this, async (client, opts) => {
|
|
835
|
+
const result = await client.repos.create(slug);
|
|
836
|
+
emit(result, opts, `created repo ${result.repo}`);
|
|
837
|
+
}).catch(reportError);
|
|
838
|
+
});
|
|
839
|
+
repos
|
|
840
|
+
.command("rename <slug> <new-slug>")
|
|
841
|
+
.description("rename a repo (admin)")
|
|
842
|
+
.action(function (slug, newSlug) {
|
|
843
|
+
withClient(this, async (client, opts) => {
|
|
844
|
+
const result = await client.repos.rename(slug, newSlug);
|
|
845
|
+
emit(result, opts, `renamed ${slug} → ${result.repo}`);
|
|
846
|
+
}).catch(reportError);
|
|
847
|
+
});
|
|
848
|
+
repos
|
|
849
|
+
.command("delete <slug>")
|
|
850
|
+
.description("delete a repo — renames slug into the system namespace (admin)")
|
|
851
|
+
.action(function (slug) {
|
|
852
|
+
withClient(this, async (client, opts) => {
|
|
853
|
+
const result = await client.repos.delete(slug);
|
|
854
|
+
emit(result, opts, `deleted (now ${result.repo})`);
|
|
855
|
+
}).catch(reportError);
|
|
856
|
+
});
|
|
857
|
+
repos
|
|
858
|
+
.command("set-path-config <slug>")
|
|
859
|
+
.description("set the per-repo path config override (§3.5)")
|
|
860
|
+
.option("--from-file <file>", "read JSON override from file (- for stdin)")
|
|
861
|
+
.option("--clear", "clear the override — inherit from server config", false)
|
|
862
|
+
.action(function (slug) {
|
|
863
|
+
const localOpts = this.opts();
|
|
864
|
+
withClient(this, async (client, opts) => {
|
|
865
|
+
const config = localOpts.clear
|
|
866
|
+
? null
|
|
867
|
+
: JSON.parse(readFromFile(localOpts.fromFile ?? "-"));
|
|
868
|
+
const result = await client.repos.set_path_config(slug, config);
|
|
869
|
+
emit(result, opts, `updated ${slug}\nwarnings: ${result.warnings.length}`);
|
|
870
|
+
}).catch(reportError);
|
|
871
|
+
});
|
|
872
|
+
repos
|
|
873
|
+
.command("set-link-config <slug>")
|
|
874
|
+
.description("set the per-repo link-extraction config override (§11.2); re-extracts the repo")
|
|
875
|
+
.option("--from-file <file>", "read JSON override from file (- for stdin)")
|
|
876
|
+
.option("--clear", "clear the override — inherit from server config", false)
|
|
877
|
+
.action(function (slug) {
|
|
878
|
+
const localOpts = this.opts();
|
|
879
|
+
withClient(this, async (client, opts) => {
|
|
880
|
+
const config = localOpts.clear
|
|
881
|
+
? null
|
|
882
|
+
: JSON.parse(readFromFile(localOpts.fromFile ?? "-"));
|
|
883
|
+
const result = await client.repos.set_link_config(slug, config);
|
|
884
|
+
emit(result, opts, `updated ${slug}\nreindexed ${result.reindexed.documents} doc(s), ${result.reindexed.edges} edge(s)`);
|
|
885
|
+
}).catch(reportError);
|
|
886
|
+
});
|
|
887
|
+
// -------- docs --------
|
|
888
|
+
// The target repo is a global (-r/--repo, MRPLEX_REPO, or config `repo`);
|
|
889
|
+
// `docs *` commands take only <path> because most sessions live inside one repo.
|
|
890
|
+
// Each action resolves the repo BEFORE `withClient` opens a connection — in
|
|
891
|
+
// remote mode that avoids a wasted network round-trip when the slug is unset.
|
|
892
|
+
const docs = program.command("docs").description("document ops");
|
|
893
|
+
docs
|
|
894
|
+
.command("get <path>")
|
|
895
|
+
.description("read the current version at <path>")
|
|
896
|
+
.option("--raw", "suppress server-injected $version (and other $* system properties) in the output", false)
|
|
897
|
+
.action(function (path) {
|
|
898
|
+
const localOpts = this.opts();
|
|
899
|
+
const globals = this.optsWithGlobals();
|
|
900
|
+
let repo;
|
|
901
|
+
try {
|
|
902
|
+
repo = resolveRepoSlug(globals);
|
|
903
|
+
}
|
|
904
|
+
catch (err) {
|
|
905
|
+
reportError(err);
|
|
906
|
+
}
|
|
907
|
+
withClient(this, async (client, opts) => {
|
|
908
|
+
const result = await client.docs.get(repo, path, { raw: localOpts.raw === true });
|
|
909
|
+
emit(result, opts, renderVersionAsMarkdown(result));
|
|
910
|
+
}).catch(reportError);
|
|
911
|
+
});
|
|
912
|
+
docs
|
|
913
|
+
.command("get-many <path...>")
|
|
914
|
+
.description("read the current versions of several paths at once")
|
|
915
|
+
.option("--raw", "suppress server-injected $version (and other $* system properties) in the output", false)
|
|
916
|
+
.action(function (paths) {
|
|
917
|
+
const localOpts = this.opts();
|
|
918
|
+
const globals = this.optsWithGlobals();
|
|
919
|
+
let repo;
|
|
920
|
+
try {
|
|
921
|
+
repo = resolveRepoSlug(globals);
|
|
922
|
+
}
|
|
923
|
+
catch (err) {
|
|
924
|
+
reportError(err);
|
|
925
|
+
}
|
|
926
|
+
if (paths.length === 0) {
|
|
927
|
+
reportError(new KernelError("filter_invalid", { reason: "at least one path is required" }));
|
|
928
|
+
}
|
|
929
|
+
withClient(this, async (client, opts) => {
|
|
930
|
+
const result = await client.docs.get_many(repo, paths, { raw: localOpts.raw === true });
|
|
931
|
+
emit(result, opts, renderDocGetManyText(result.items, result.errors));
|
|
932
|
+
}).catch(reportError);
|
|
933
|
+
});
|
|
934
|
+
docs
|
|
935
|
+
.command("get-version <version-id>")
|
|
936
|
+
.description("read a specific version by id")
|
|
937
|
+
.option("--raw", "suppress server-injected $version (and other $* system properties) in the output", false)
|
|
938
|
+
.action(function (versionId) {
|
|
939
|
+
const localOpts = this.opts();
|
|
940
|
+
const globals = this.optsWithGlobals();
|
|
941
|
+
let repo;
|
|
942
|
+
try {
|
|
943
|
+
repo = resolveRepoSlug(globals);
|
|
944
|
+
}
|
|
945
|
+
catch (err) {
|
|
946
|
+
reportError(err);
|
|
947
|
+
}
|
|
948
|
+
withClient(this, async (client, opts) => {
|
|
949
|
+
const result = await client.docs.get_version(repo, versionId, {
|
|
950
|
+
raw: localOpts.raw === true,
|
|
951
|
+
});
|
|
952
|
+
emit(result, opts, renderVersionAsMarkdown(result));
|
|
953
|
+
}).catch(reportError);
|
|
954
|
+
});
|
|
955
|
+
docs
|
|
956
|
+
.command("history <path>")
|
|
957
|
+
.description("list versions of a document newest-first (subsumed by `mrplex history`)")
|
|
958
|
+
.option("--limit <n>", "limit to N most-recent (positive integer)", parsePositiveInt)
|
|
959
|
+
.action(function (path) {
|
|
960
|
+
const localOpts = this.opts();
|
|
961
|
+
const globals = this.optsWithGlobals();
|
|
962
|
+
let repo;
|
|
963
|
+
try {
|
|
964
|
+
repo = resolveRepoSlug(globals);
|
|
965
|
+
}
|
|
966
|
+
catch (err) {
|
|
967
|
+
reportError(err);
|
|
968
|
+
}
|
|
969
|
+
withClient(this, async (client, opts) => {
|
|
970
|
+
// A single literal path is the old docs.history; route through the
|
|
971
|
+
// unified scoped walk (§3.5).
|
|
972
|
+
const result = await client.history.list({ repo, path, limit: localOpts.limit });
|
|
973
|
+
emit(result, opts, renderHistoryTable(result));
|
|
974
|
+
}).catch(reportError);
|
|
975
|
+
});
|
|
976
|
+
docs
|
|
977
|
+
.command("diff <path>")
|
|
978
|
+
.description("unified diff between two versions of a document (§4.3)")
|
|
979
|
+
.requiredOption("--from <version-id>", "source version id")
|
|
980
|
+
.requiredOption("--to <version-id>", "target version id")
|
|
981
|
+
.action(function (path) {
|
|
982
|
+
const localOpts = this.opts();
|
|
983
|
+
const globals = this.optsWithGlobals();
|
|
984
|
+
let repo;
|
|
985
|
+
try {
|
|
986
|
+
repo = resolveRepoSlug(globals);
|
|
987
|
+
}
|
|
988
|
+
catch (err) {
|
|
989
|
+
reportError(err);
|
|
990
|
+
}
|
|
991
|
+
withClient(this, async (client, opts) => {
|
|
992
|
+
const result = await client.docs.diff(repo, path, localOpts.from, localOpts.to);
|
|
993
|
+
if (opts.json) {
|
|
994
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
// Raw patch on stdout for `patch -p0`-friendly piping.
|
|
998
|
+
process.stdout.write(result.patch.endsWith("\n") ? result.patch : `${result.patch}\n`);
|
|
999
|
+
}
|
|
1000
|
+
}).catch(reportError);
|
|
1001
|
+
});
|
|
1002
|
+
docs
|
|
1003
|
+
.command("create <path>")
|
|
1004
|
+
.description("create a new document (fails if the path is taken)")
|
|
1005
|
+
.option("--from-file <file>", "read the markdown from a file or '-' for stdin")
|
|
1006
|
+
.action(function (path) {
|
|
1007
|
+
const localOpts = this.opts();
|
|
1008
|
+
const globals = this.optsWithGlobals();
|
|
1009
|
+
let repo;
|
|
1010
|
+
try {
|
|
1011
|
+
repo = resolveRepoSlug(globals);
|
|
1012
|
+
}
|
|
1013
|
+
catch (err) {
|
|
1014
|
+
reportError(err);
|
|
1015
|
+
}
|
|
1016
|
+
withClient(this, async (client, opts) => {
|
|
1017
|
+
const { frontmatter_raw, body } = readDocumentInput(localOpts.fromFile);
|
|
1018
|
+
const result = await client.docs.create(repo, path, { frontmatter_raw, body });
|
|
1019
|
+
emitVersionWrite(result, opts);
|
|
1020
|
+
}).catch(reportError);
|
|
1021
|
+
});
|
|
1022
|
+
docs
|
|
1023
|
+
.command("put <path>")
|
|
1024
|
+
.description("update or move a document — path may differ from prev's path")
|
|
1025
|
+
.option("--prev <version-id>", "current version id (from get / history) — optional if the input's frontmatter carries `$version: <id>`")
|
|
1026
|
+
.option("--from-file <file>", "read the markdown from a file or '-' for stdin")
|
|
1027
|
+
.action(function (path) {
|
|
1028
|
+
const localOpts = this.opts();
|
|
1029
|
+
const globals = this.optsWithGlobals();
|
|
1030
|
+
let repo;
|
|
1031
|
+
try {
|
|
1032
|
+
repo = resolveRepoSlug(globals);
|
|
1033
|
+
}
|
|
1034
|
+
catch (err) {
|
|
1035
|
+
reportError(err);
|
|
1036
|
+
}
|
|
1037
|
+
withClient(this, async (client, opts) => {
|
|
1038
|
+
const input = {};
|
|
1039
|
+
let embeddedVersion;
|
|
1040
|
+
if (localOpts.fromFile) {
|
|
1041
|
+
const parsed = readDocumentInput(localOpts.fromFile);
|
|
1042
|
+
const { raw: cleaned, props } = extractSystemProperties(parsed.frontmatter_raw);
|
|
1043
|
+
input.frontmatter_raw = cleaned;
|
|
1044
|
+
input.body = parsed.body;
|
|
1045
|
+
if (typeof props.version === "string" && props.version.length > 0) {
|
|
1046
|
+
embeddedVersion = props.version;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
const prev = localOpts.prev ?? embeddedVersion;
|
|
1050
|
+
if (prev === undefined) {
|
|
1051
|
+
const err = new Error("no prev version — pass --prev, or provide `$version: <id>` in the input frontmatter");
|
|
1052
|
+
err.code = "cli_usage";
|
|
1053
|
+
throw err;
|
|
1054
|
+
}
|
|
1055
|
+
const result = await client.docs.put(repo, prev, path, input);
|
|
1056
|
+
emitVersionWrite(result, opts);
|
|
1057
|
+
}).catch(reportError);
|
|
1058
|
+
});
|
|
1059
|
+
docs
|
|
1060
|
+
.command("delete")
|
|
1061
|
+
.description("delete a document — moves to :deleted/… (idempotent)")
|
|
1062
|
+
.requiredOption("--prev <version-id>", "current version id (from get / history)")
|
|
1063
|
+
.action(function () {
|
|
1064
|
+
const localOpts = this.opts();
|
|
1065
|
+
const globals = this.optsWithGlobals();
|
|
1066
|
+
let repo;
|
|
1067
|
+
try {
|
|
1068
|
+
repo = resolveRepoSlug(globals);
|
|
1069
|
+
}
|
|
1070
|
+
catch (err) {
|
|
1071
|
+
reportError(err);
|
|
1072
|
+
}
|
|
1073
|
+
withClient(this, async (client, opts) => {
|
|
1074
|
+
const result = await client.docs.delete(repo, localOpts.prev);
|
|
1075
|
+
emitVersionWrite(result, opts);
|
|
1076
|
+
}).catch(reportError);
|
|
1077
|
+
});
|
|
1078
|
+
docs
|
|
1079
|
+
.command("mv <to-path>")
|
|
1080
|
+
.description("move a document — sugar for put at <to-path> with body unchanged")
|
|
1081
|
+
.requiredOption("--prev <version-id>", "current version id (from get / history)")
|
|
1082
|
+
.action(function (toPath) {
|
|
1083
|
+
const localOpts = this.opts();
|
|
1084
|
+
const globals = this.optsWithGlobals();
|
|
1085
|
+
let repo;
|
|
1086
|
+
try {
|
|
1087
|
+
repo = resolveRepoSlug(globals);
|
|
1088
|
+
}
|
|
1089
|
+
catch (err) {
|
|
1090
|
+
reportError(err);
|
|
1091
|
+
}
|
|
1092
|
+
withClient(this, async (client, opts) => {
|
|
1093
|
+
const result = await client.docs.put(repo, localOpts.prev, toPath, {});
|
|
1094
|
+
emitVersionWrite(result, opts);
|
|
1095
|
+
}).catch(reportError);
|
|
1096
|
+
});
|
|
1097
|
+
// -------- query --------
|
|
1098
|
+
program
|
|
1099
|
+
.command("query")
|
|
1100
|
+
.description("search documents — CEL filter + FTS text + semantic (design §5)")
|
|
1101
|
+
.option("-r, --repo <slug-or-glob>", "repo slug or glob; repeat the flag to query multiple (default: all in scope)", (value, prev) => [...(prev ?? []), value])
|
|
1102
|
+
.option("--filter <expr>", "CEL filter expression")
|
|
1103
|
+
.option("--path <glob>", "gitignore-style path glob (bare name → any depth, leading `/` → root)")
|
|
1104
|
+
.option("--text <query>", "FTS5 query over body")
|
|
1105
|
+
.option("--semantic <query>", "semantic search via embeddings (§5.1); requires an embed hook")
|
|
1106
|
+
.option("--limit <n>", "max results (positive integer; default 50)", parsePositiveInt)
|
|
1107
|
+
.option("-s, --select <field>", "field to project onto each hit ($path/$repo/$body/… or a bare frontmatter key; " +
|
|
1108
|
+
"default $path); repeat to add more", (value, prev) => [...(prev ?? []), value])
|
|
1109
|
+
.option("--include-hidden", "surface .-prefixed paths", false)
|
|
1110
|
+
.option("--include-system", "surface :-prefixed (deleted, etc.) paths", false)
|
|
1111
|
+
.action(function () {
|
|
1112
|
+
const localOpts = this.opts();
|
|
1113
|
+
withClient(this, async (client, opts) => {
|
|
1114
|
+
const result = await client.query({
|
|
1115
|
+
repo: localOpts.repo,
|
|
1116
|
+
filter: combinePathAndFilter(localOpts.path, localOpts.filter),
|
|
1117
|
+
text: localOpts.text,
|
|
1118
|
+
semantic: localOpts.semantic,
|
|
1119
|
+
limit: localOpts.limit,
|
|
1120
|
+
select: localOpts.select,
|
|
1121
|
+
include_hidden: localOpts.includeHidden,
|
|
1122
|
+
include_system: localOpts.includeSystem,
|
|
1123
|
+
});
|
|
1124
|
+
emit(result, opts, renderQueryTable(result));
|
|
1125
|
+
}).catch(reportError);
|
|
1126
|
+
});
|
|
1127
|
+
// -------- graph --------
|
|
1128
|
+
program
|
|
1129
|
+
.command("graph")
|
|
1130
|
+
.description("explore how documents connect — BFS over the link graph (docs/graph-plan.md)")
|
|
1131
|
+
.option("-r, --repo <slug>", "repo slug (exactly one; links are repo-local; default: global -r/MRPLEX_REPO)")
|
|
1132
|
+
.requiredOption("--roots <path-or-glob>", "root path/glob; repeat to add more", (value, prev) => [...(prev ?? []), value])
|
|
1133
|
+
.option("--direction <lens>", "out | in | both (default both)")
|
|
1134
|
+
.option("--degrees <n>", "max hops from the nearest root (default 1; 0 = roots only)", parsePositiveIntOrZero)
|
|
1135
|
+
.option("--fields <field>", "restrict traversal + output links to this field ($body valid); repeat to add more", (value, prev) => [...(prev ?? []), value])
|
|
1136
|
+
.option("--filter <expr>", "CEL visibility filter; supports the graph-only $degrees intrinsic")
|
|
1137
|
+
.option("--select <key>", "frontmatter key to project onto documents (default title); repeat to add more", (value, prev) => [...(prev ?? []), value])
|
|
1138
|
+
.option("--max-documents <n>", "soft budget on documents (default 100)", parsePositiveInt)
|
|
1139
|
+
.option("--render <fmt>", "summary | yaml | mermaid | json (default summary)", "summary")
|
|
1140
|
+
.action(function () {
|
|
1141
|
+
const localOpts = this.opts();
|
|
1142
|
+
const globals = this.optsWithGlobals();
|
|
1143
|
+
const repo = localOpts.repo ?? resolveRepoSlug(globals);
|
|
1144
|
+
withClient(this, async (client, opts) => {
|
|
1145
|
+
const result = await client.graph({
|
|
1146
|
+
repo,
|
|
1147
|
+
roots: localOpts.roots,
|
|
1148
|
+
direction: localOpts.direction,
|
|
1149
|
+
degrees: localOpts.degrees,
|
|
1150
|
+
fields: localOpts.fields,
|
|
1151
|
+
filter: localOpts.filter,
|
|
1152
|
+
select: localOpts.select,
|
|
1153
|
+
max_documents: localOpts.maxDocuments,
|
|
1154
|
+
});
|
|
1155
|
+
// `--render` selects the presentation (its native idiom); `--json` (or
|
|
1156
|
+
// `--render json`) still yields the raw structured payload via emit.
|
|
1157
|
+
if (opts.json || localOpts.render === "json") {
|
|
1158
|
+
emit(result, { ...opts, json: true }, "");
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
const text = localOpts.render === "yaml"
|
|
1162
|
+
? renderGraphYaml(result)
|
|
1163
|
+
: localOpts.render === "mermaid"
|
|
1164
|
+
? renderGraphMermaid(result)
|
|
1165
|
+
: renderGraphSummary(result);
|
|
1166
|
+
emit(result, opts, text);
|
|
1167
|
+
}).catch(reportError);
|
|
1168
|
+
});
|
|
1169
|
+
// -------- tail (sync/history plan §3.6) --------
|
|
1170
|
+
// The reference change-feed consumer: one VersionRef per line as NDJSON.
|
|
1171
|
+
// Crash-resume is `tail --since <last-line-version_id>`. `--follow N` sits at
|
|
1172
|
+
// the live tip, re-polling every N seconds on a short/empty page — the proving
|
|
1173
|
+
// ground for the leading-gap heal path.
|
|
1174
|
+
program
|
|
1175
|
+
.command("tail")
|
|
1176
|
+
.description("stream the global change feed as NDJSON (sync/history plan §3.6)")
|
|
1177
|
+
.option("--since <version-id>", 'resume cursor; omitted or "" starts from the beginning')
|
|
1178
|
+
.option("--follow <seconds>", "poll interval in seconds; stay at the live tip re-polling on empty pages", parsePositiveInt)
|
|
1179
|
+
.option("--limit <n>", "max refs per page (positive integer)", parsePositiveInt)
|
|
1180
|
+
.action(function () {
|
|
1181
|
+
const localOpts = this.opts();
|
|
1182
|
+
// The optional repo filter rides the global -r/--repo (or MRPLEX_REPO /
|
|
1183
|
+
// config); an unset repo tails every repo in scope.
|
|
1184
|
+
const globals = this.optsWithGlobals();
|
|
1185
|
+
withClient(this, async (client) => {
|
|
1186
|
+
let cursor = localOpts.since ?? "";
|
|
1187
|
+
const pollOnce = async () => {
|
|
1188
|
+
const page = await client.history.since({
|
|
1189
|
+
after_version: cursor,
|
|
1190
|
+
repo: globals.repo,
|
|
1191
|
+
limit: localOpts.limit,
|
|
1192
|
+
});
|
|
1193
|
+
for (const ref of page.refs) {
|
|
1194
|
+
process.stdout.write(`${JSON.stringify(ref)}\n`);
|
|
1195
|
+
}
|
|
1196
|
+
cursor = page.next_since;
|
|
1197
|
+
return page.refs.length;
|
|
1198
|
+
};
|
|
1199
|
+
if (localOpts.follow === undefined) {
|
|
1200
|
+
// One-shot: drain the currently-safe feed in pages, then stop.
|
|
1201
|
+
let n = await pollOnce();
|
|
1202
|
+
while (n > 0)
|
|
1203
|
+
n = await pollOnce();
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
// Follow: never terminate; sleep between polls (including empty ones).
|
|
1207
|
+
const intervalMs = localOpts.follow * 1000;
|
|
1208
|
+
for (;;) {
|
|
1209
|
+
const n = await pollOnce();
|
|
1210
|
+
if (n === 0)
|
|
1211
|
+
await sleep(intervalMs);
|
|
1212
|
+
}
|
|
1213
|
+
}).catch(reportError);
|
|
1214
|
+
});
|
|
1215
|
+
// -------- history (sync/history plan §3.5) --------
|
|
1216
|
+
program
|
|
1217
|
+
.command("history [path-glob]")
|
|
1218
|
+
.description("scoped, document-spanning version history — glob + --ever (§3.5)")
|
|
1219
|
+
.option("--ever", "include documents that moved away or were deleted", false)
|
|
1220
|
+
.option("--since <version-id>", "exclusive lower version-id bound")
|
|
1221
|
+
.option("--until <version-id>", "inclusive upper version-id bound")
|
|
1222
|
+
.option("--order <dir>", "asc (oldest-first) | desc (newest-first, default)")
|
|
1223
|
+
.option("--limit <n>", "max versions (positive integer)", parsePositiveInt)
|
|
1224
|
+
.action(function (pathGlob) {
|
|
1225
|
+
const localOpts = this.opts();
|
|
1226
|
+
const globals = this.optsWithGlobals();
|
|
1227
|
+
let repo;
|
|
1228
|
+
try {
|
|
1229
|
+
repo = resolveRepoSlug(globals);
|
|
1230
|
+
}
|
|
1231
|
+
catch (err) {
|
|
1232
|
+
reportError(err);
|
|
1233
|
+
}
|
|
1234
|
+
const order = localOpts.order === "asc" ? "asc" : localOpts.order === "desc" ? "desc" : undefined;
|
|
1235
|
+
withClient(this, async (client, opts) => {
|
|
1236
|
+
const result = await client.history.list({
|
|
1237
|
+
repo,
|
|
1238
|
+
path: pathGlob,
|
|
1239
|
+
ever: localOpts.ever,
|
|
1240
|
+
since: localOpts.since,
|
|
1241
|
+
until: localOpts.until,
|
|
1242
|
+
order,
|
|
1243
|
+
limit: localOpts.limit,
|
|
1244
|
+
});
|
|
1245
|
+
emit(result, opts, renderHistoryTable(result));
|
|
1246
|
+
}).catch(reportError);
|
|
1247
|
+
});
|
|
1248
|
+
// -------- links (§11.2) --------
|
|
1249
|
+
const links = program.command("links").description("link index — backfill / stale / repair");
|
|
1250
|
+
// Repo comes from the global -r/--repo (or MRPLEX_REPO / config), the same
|
|
1251
|
+
// source `docs *` commands use — so `mrplex -r notes links stale`.
|
|
1252
|
+
links
|
|
1253
|
+
.command("backfill")
|
|
1254
|
+
.description("rebuild the link index for a repo (backfill / after a link-config change)")
|
|
1255
|
+
.action(function () {
|
|
1256
|
+
const globals = this.optsWithGlobals();
|
|
1257
|
+
const repo = resolveRepoSlug(globals);
|
|
1258
|
+
withClient(this, async (client, opts) => {
|
|
1259
|
+
const result = await client.links.backfill(repo);
|
|
1260
|
+
emit(result, opts, `backfill ${repo}: documents=${result.documents} edges=${result.edges}`);
|
|
1261
|
+
}).catch(reportError);
|
|
1262
|
+
});
|
|
1263
|
+
links
|
|
1264
|
+
.command("stale")
|
|
1265
|
+
.description("list live docs whose written link text no longer matches the target's path")
|
|
1266
|
+
.action(function () {
|
|
1267
|
+
const globals = this.optsWithGlobals();
|
|
1268
|
+
const repo = resolveRepoSlug(globals);
|
|
1269
|
+
withClient(this, async (client, opts) => {
|
|
1270
|
+
const result = await client.links.stale(repo);
|
|
1271
|
+
const text = result.length
|
|
1272
|
+
? result.map((r) => `${r.source_path}: "${r.written}" → "${r.current}"`).join("\n")
|
|
1273
|
+
: "no stale links";
|
|
1274
|
+
emit(result, opts, text);
|
|
1275
|
+
}).catch(reportError);
|
|
1276
|
+
});
|
|
1277
|
+
links
|
|
1278
|
+
.command("repair")
|
|
1279
|
+
.description("rewrite stale link text as optimistic docs.put (conflicts skipped)")
|
|
1280
|
+
.option("--dry-run", "report what would change without writing", false)
|
|
1281
|
+
.action(function () {
|
|
1282
|
+
const localOpts = this.opts();
|
|
1283
|
+
const globals = this.optsWithGlobals();
|
|
1284
|
+
const repo = resolveRepoSlug(globals);
|
|
1285
|
+
withClient(this, async (client, opts) => {
|
|
1286
|
+
const result = await client.links.repair(repo, { dry_run: localOpts.dryRun });
|
|
1287
|
+
const prefix = result.dry_run ? "[dry-run] " : "";
|
|
1288
|
+
const lines = [
|
|
1289
|
+
`${prefix}repaired ${result.repaired.length} doc(s), skipped ${result.skipped.length}`,
|
|
1290
|
+
...result.repaired.map((r) => ` ~ ${r.path} (${r.edges} link(s))`),
|
|
1291
|
+
...result.skipped.map((s) => ` ! ${s.path}: ${s.reason}`),
|
|
1292
|
+
];
|
|
1293
|
+
emit(result, opts, lines.join("\n"));
|
|
1294
|
+
}).catch(reportError);
|
|
1295
|
+
});
|
|
1296
|
+
// -------- embed --------
|
|
1297
|
+
// Embed commands are LOCAL-mode only (bypass the client seam like
|
|
1298
|
+
// `bootstrap` and `serve`): backfill drives the worker directly
|
|
1299
|
+
// against storage, and status reads backlog+chunks tables. Running
|
|
1300
|
+
// against a remote server means running these commands on that host
|
|
1301
|
+
// — same as bootstrap.
|
|
1302
|
+
const embed = program.command("embed").description("embedding worker + backlog");
|
|
1303
|
+
const embedBackfillCmd = embed
|
|
1304
|
+
.command("backfill")
|
|
1305
|
+
.description("re-chunk + re-embed current versions missing chunks (§5.3)");
|
|
1306
|
+
addEmbedCliOptions(embedBackfillCmd);
|
|
1307
|
+
embedBackfillCmd.action(function () {
|
|
1308
|
+
const localOpts = this.opts();
|
|
1309
|
+
const gopts = this.optsWithGlobals();
|
|
1310
|
+
(async () => {
|
|
1311
|
+
try {
|
|
1312
|
+
const repo = resolveRepoSlug(gopts);
|
|
1313
|
+
const embedCfg = resolveEmbedConfig(embedFlagInputsFromCli(localOpts));
|
|
1314
|
+
if (embedCfg.kind === "none") {
|
|
1315
|
+
process.stderr.write("embed backfill: no hook configured — set --embedder (or MRPLEX_EMBEDDER)\n");
|
|
1316
|
+
process.exit(1);
|
|
1317
|
+
}
|
|
1318
|
+
const hook = createHookFromConfig(embedCfg);
|
|
1319
|
+
if (!hook) {
|
|
1320
|
+
process.stderr.write("embed backfill: unreachable — missing hook\n");
|
|
1321
|
+
process.exit(1);
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
const storage = await openStorage(resolveDatabase(gopts));
|
|
1325
|
+
const worker = createWorker({ storage, hook });
|
|
1326
|
+
try {
|
|
1327
|
+
const report = await backfillRepo(storage, repo, worker, (m) => process.stderr.write(`${m}\n`));
|
|
1328
|
+
if (gopts.json) {
|
|
1329
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
process.stdout.write(`backfill ${repo}: enqueued=${report.enqueued} processed=${report.processed} failed=${report.failed} skipped=${report.skipped}\n`);
|
|
1333
|
+
}
|
|
1334
|
+
if (report.failed > 0)
|
|
1335
|
+
process.exit(1);
|
|
1336
|
+
}
|
|
1337
|
+
finally {
|
|
1338
|
+
await worker.stop();
|
|
1339
|
+
await storage.close();
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
catch (err) {
|
|
1343
|
+
reportError(err);
|
|
1344
|
+
}
|
|
1345
|
+
})();
|
|
1346
|
+
});
|
|
1347
|
+
embed
|
|
1348
|
+
.command("status")
|
|
1349
|
+
.description("inspect the embedding backlog (m4-plan §5 decision 6)")
|
|
1350
|
+
.action(function () {
|
|
1351
|
+
const gopts = this.optsWithGlobals();
|
|
1352
|
+
(async () => {
|
|
1353
|
+
try {
|
|
1354
|
+
const storage = await openStorage(resolveDatabase(gopts));
|
|
1355
|
+
try {
|
|
1356
|
+
const now = new Date().toISOString();
|
|
1357
|
+
const status = await storage.backlog_status(now);
|
|
1358
|
+
if (gopts.json) {
|
|
1359
|
+
process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
|
|
1360
|
+
}
|
|
1361
|
+
else {
|
|
1362
|
+
process.stdout.write(`pending: ${status.pending}\ndue: ${status.due}\nfailing: ${status.failing}\n`);
|
|
1363
|
+
if (status.oldest_next_retry_at) {
|
|
1364
|
+
process.stdout.write(`oldest retry: ${status.oldest_next_retry_at}\n`);
|
|
1365
|
+
}
|
|
1366
|
+
if (status.models.length > 0) {
|
|
1367
|
+
process.stdout.write("models:\n");
|
|
1368
|
+
for (const m of status.models) {
|
|
1369
|
+
process.stdout.write(` ${m.model} chunks=${m.chunk_count}\n`);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
if (status.recent_errors.length > 0) {
|
|
1373
|
+
process.stdout.write("recent errors:\n");
|
|
1374
|
+
for (const e of status.recent_errors) {
|
|
1375
|
+
process.stdout.write(` v${e.version_id}: ${e.last_error}\n`);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
finally {
|
|
1381
|
+
await storage.close();
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
catch (err) {
|
|
1385
|
+
reportError(err);
|
|
1386
|
+
}
|
|
1387
|
+
})();
|
|
1388
|
+
});
|
|
1389
|
+
// -------- sync (sync/history plan §4) --------
|
|
1390
|
+
const syncCmd = program
|
|
1391
|
+
.command("sync <root>")
|
|
1392
|
+
.description("two-way sync between a local vault and a mrplex repo (§4)")
|
|
1393
|
+
.addHelpText("after", "\nNote: on a repo created before migration 0002, run `mrplex hash backfill` first.\n" +
|
|
1394
|
+
"Until every version has a stored $content_hash, a clean local copy that lacks\n" +
|
|
1395
|
+
"sync intrinsics can be parked as a conflict instead of adopted (§2.6).\n\n" +
|
|
1396
|
+
"Note: --embedder only applies in local (--database) mode. When\n" +
|
|
1397
|
+
"syncing against a --server, embeddings are the server's responsibility and\n" +
|
|
1398
|
+
"this flag is ignored.")
|
|
1399
|
+
.option("--once", "run startup reconciliation once, then exit (no watcher)", false)
|
|
1400
|
+
.option("--interval <ms>", "feed poll interval in ms (daemon; default 5000)", parsePositiveInt)
|
|
1401
|
+
.option("--debounce <ms>", "burst debounce in ms (daemon; default 5000)", parsePositiveInt)
|
|
1402
|
+
.option("--settle <ms>", "skip files younger than this many ms (partial saves)", parsePositiveInt)
|
|
1403
|
+
.option("--include <glob>", "include glob (default **/*.md); repeat to add more", (value, prev) => [...(prev ?? []), value])
|
|
1404
|
+
.option("--exclude <glob>", "exclude glob (wins over include); repeat to add more", (value, prev) => [...(prev ?? []), value])
|
|
1405
|
+
.option("--dry-run", "report the actions a reconciliation would take, changing nothing", false)
|
|
1406
|
+
.option("-v, --verbose", "log each action to stderr", false);
|
|
1407
|
+
addEmbedCliOptions(syncCmd);
|
|
1408
|
+
syncCmd.action(function (root) {
|
|
1409
|
+
const localOpts = this.opts();
|
|
1410
|
+
const embedFlags = embedFlagInputsFromCli(localOpts);
|
|
1411
|
+
const globals = this.optsWithGlobals();
|
|
1412
|
+
let repo;
|
|
1413
|
+
try {
|
|
1414
|
+
repo = resolveRepoSlug(globals);
|
|
1415
|
+
}
|
|
1416
|
+
catch (err) {
|
|
1417
|
+
reportError(err);
|
|
1418
|
+
}
|
|
1419
|
+
const verboseLog = localOpts.verbose
|
|
1420
|
+
? (m) => process.stderr.write(`${m}\n`)
|
|
1421
|
+
: undefined;
|
|
1422
|
+
if (localOpts.once) {
|
|
1423
|
+
withClient(this, async (client, opts) => {
|
|
1424
|
+
const source = resolveSyncSource(globals);
|
|
1425
|
+
const report = await syncOnce(client, {
|
|
1426
|
+
root,
|
|
1427
|
+
repo,
|
|
1428
|
+
...source,
|
|
1429
|
+
include: localOpts.include,
|
|
1430
|
+
exclude: localOpts.exclude,
|
|
1431
|
+
dryRun: localOpts.dryRun,
|
|
1432
|
+
log: verboseLog,
|
|
1433
|
+
});
|
|
1434
|
+
const changed = report.actions.filter((a) => a.verdict !== "clean" && a.verdict !== "skip").length;
|
|
1435
|
+
emit(report, opts, `sync ${repo} @ ${root}: through=${report.through_version} ` +
|
|
1436
|
+
`actions=${changed} feed=${report.feed_applied}${localOpts.dryRun ? " (dry-run)" : ""}`);
|
|
1437
|
+
}, embedFlags).catch(reportError);
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
// Daemon: run until interrupted (Ctrl-C). The client stays open; SIGINT/
|
|
1441
|
+
// SIGTERM stop the daemon and close the transport cleanly.
|
|
1442
|
+
(async () => {
|
|
1443
|
+
const client = await openClient(globals, embedFlags);
|
|
1444
|
+
const daemon = startDaemon(client, {
|
|
1445
|
+
root,
|
|
1446
|
+
repo,
|
|
1447
|
+
...resolveSyncSource(globals),
|
|
1448
|
+
include: localOpts.include,
|
|
1449
|
+
exclude: localOpts.exclude,
|
|
1450
|
+
intervalMs: localOpts.interval,
|
|
1451
|
+
debounceMs: localOpts.debounce,
|
|
1452
|
+
settleMs: localOpts.settle,
|
|
1453
|
+
log: verboseLog,
|
|
1454
|
+
});
|
|
1455
|
+
const shutdown = async () => {
|
|
1456
|
+
await daemon.stop();
|
|
1457
|
+
await client.close();
|
|
1458
|
+
process.exit(0);
|
|
1459
|
+
};
|
|
1460
|
+
process.on("SIGINT", shutdown);
|
|
1461
|
+
process.on("SIGTERM", shutdown);
|
|
1462
|
+
try {
|
|
1463
|
+
await daemon.ready;
|
|
1464
|
+
process.stderr.write(`sync ${repo} @ ${root}: watching (Ctrl-C to stop)\n`);
|
|
1465
|
+
}
|
|
1466
|
+
catch (err) {
|
|
1467
|
+
await daemon.stop();
|
|
1468
|
+
await client.close();
|
|
1469
|
+
reportError(err);
|
|
1470
|
+
}
|
|
1471
|
+
})();
|
|
1472
|
+
});
|
|
1473
|
+
// -------- hash (sync/history plan §2.6) --------
|
|
1474
|
+
const hash = program.command("hash").description("content-hash maintenance");
|
|
1475
|
+
hash
|
|
1476
|
+
.command("backfill")
|
|
1477
|
+
.description("compute $content_hash for versions written before migration 0002")
|
|
1478
|
+
.action(function () {
|
|
1479
|
+
// The optional repo filter rides the global -r/--repo (or MRPLEX_REPO /
|
|
1480
|
+
// config); an unset repo backfills every repo.
|
|
1481
|
+
const gopts = this.optsWithGlobals();
|
|
1482
|
+
(async () => {
|
|
1483
|
+
const storage = await openStorage(resolveDatabase(gopts));
|
|
1484
|
+
try {
|
|
1485
|
+
let repoId;
|
|
1486
|
+
if (gopts.repo !== undefined) {
|
|
1487
|
+
const repo = await storage.repos_by_slug(gopts.repo);
|
|
1488
|
+
if (!repo)
|
|
1489
|
+
throw new KernelError("repo_not_found", { repo: gopts.repo });
|
|
1490
|
+
repoId = repo.id;
|
|
1491
|
+
}
|
|
1492
|
+
const report = await backfillContentHashes(storage, { repo_id: repoId });
|
|
1493
|
+
if (gopts.json) {
|
|
1494
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
1495
|
+
}
|
|
1496
|
+
else {
|
|
1497
|
+
const scope = gopts.repo ?? "all repos";
|
|
1498
|
+
process.stdout.write(`hash backfill ${scope}: hashed=${report.hashed}\n`);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
catch (err) {
|
|
1502
|
+
reportError(err);
|
|
1503
|
+
}
|
|
1504
|
+
finally {
|
|
1505
|
+
await storage.close();
|
|
1506
|
+
}
|
|
1507
|
+
})();
|
|
1508
|
+
});
|
|
1509
|
+
return program;
|
|
1510
|
+
}
|
|
1511
|
+
const program = buildProgram();
|
|
1512
|
+
try {
|
|
1513
|
+
program.parse(process.argv);
|
|
1514
|
+
}
|
|
1515
|
+
catch (err) {
|
|
1516
|
+
reportError(err);
|
|
1517
|
+
}
|
|
1518
|
+
//# sourceMappingURL=main.js.map
|