@staix/vault-mem 0.0.0-stage → 0.1.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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +251 -2
- package/bin/vmem.js +12 -0
- package/package.json +39 -4
- package/src/amem/seam.ts +56 -0
- package/src/analysis-commands.ts +36 -0
- package/src/analysis-input.ts +82 -0
- package/src/analysis-reports.ts +69 -0
- package/src/analysis-types.ts +19 -0
- package/src/analysis-yaml.ts +178 -0
- package/src/cli.ts +101 -0
- package/src/commands-table.ts +88 -0
- package/src/config.ts +99 -0
- package/src/frontmatter.ts +154 -0
- package/src/home.ts +16 -0
- package/src/index.ts +44 -0
- package/src/links.ts +149 -0
- package/src/log-commands.ts +31 -0
- package/src/log-filter.ts +65 -0
- package/src/log-format.ts +57 -0
- package/src/log-types.ts +10 -0
- package/src/mcp-vault.ts +167 -0
- package/src/mcp.ts +110 -0
- package/src/org-commands.ts +31 -0
- package/src/org-promote.ts +259 -0
- package/src/platform.ts +1 -0
- package/src/promote-command.ts +77 -0
- package/src/promote.ts +167 -0
- package/src/read-commands.ts +43 -0
- package/src/registry.ts +170 -0
- package/src/setup.ts +219 -0
- package/src/source.ts +47 -0
- package/src/sync.ts +145 -0
- package/src/tidy.ts +386 -0
- package/src/vault-graph.ts +233 -0
- package/src/vault-index.ts +369 -0
- package/src/vault-lint.ts +135 -0
- package/src/vault-log.ts +229 -0
- package/src/write-commands.ts +93 -0
- package/src/write-operations.ts +181 -0
- package/src/write-types.ts +50 -0
- package/src/write-validation.ts +285 -0
- package/src/writer.ts +259 -0
- package/src/yaml-quoted.ts +141 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { VaultSpec } from "./config.ts";
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { loadRegistry } from "./registry.ts";
|
|
5
|
+
import { analysisRead } from "./analysis-input.ts";
|
|
6
|
+
import { appendLog } from "./vault-log.ts";
|
|
7
|
+
import type { AnalysisSnapshot } from "./analysis-types.ts";
|
|
8
|
+
import type { WriteReceipt } from "./write-types.ts";
|
|
9
|
+
import { applyWrite, sha256 } from "./writer.ts";
|
|
10
|
+
import { analysisInput } from "./analysis-input.ts";
|
|
11
|
+
import { lintVault } from "./vault-lint.ts";
|
|
12
|
+
import { buildGraph, graphReport } from "./vault-graph.ts";
|
|
13
|
+
|
|
14
|
+
export interface AnalysisRequest { vault: string; kind: "lint" | "graph"; dryRun?: boolean; channel?: "cli" | "mcp"; today?: string; note?: string; expectedVault?: VaultSpec }
|
|
15
|
+
function trend(snapshot: AnalysisSnapshot, nodes: number, edges: number, communities: number, top: string): string {
|
|
16
|
+
const seed = "---\ntype: report\nkind: graph-trend\n---\n\n# Graph Trend (vault layer)\n\n| 빌드 | Nodes | Edges | Communities | Density | Top hub (degree 1위) |\n|---|---|---|---|---|---|\n";
|
|
17
|
+
const source = snapshot.reports["graph-trend.md"] ?? seed;
|
|
18
|
+
const lines = source.trimEnd().split("\n");
|
|
19
|
+
const density = nodes > 1 ? 2*edges/(nodes*(nodes-1)) : 0;
|
|
20
|
+
const row = `| ${snapshot.today} | ${nodes} | ${edges} | ${communities} | ${density.toFixed(4)} | ${top ? `\`${top}\`` : "-"} |`;
|
|
21
|
+
let insert = -1;
|
|
22
|
+
for (const [i,line] of lines.entries()) if (/^\| (?:\d{4}-\d{2}-\d{2}) \|/.test(line) || line.startsWith("|---")) insert = i;
|
|
23
|
+
if (insert < 0) { lines.push("", ...seed.trimEnd().split("\n").slice(-2)); insert = lines.length-1; }
|
|
24
|
+
lines.splice(insert+1,0,row);
|
|
25
|
+
return lines.join("\n")+"\n";
|
|
26
|
+
}
|
|
27
|
+
export function writeAnalysisReports(home: string, request: AnalysisRequest, env: NodeJS.ProcessEnv = process.env) {
|
|
28
|
+
const {snapshot,vault,config,identity} = analysisInput(home,request.vault,{today:request.today,expectedVault:request.expectedVault});
|
|
29
|
+
const binding = {vault,denyPaths:config.denyPaths ?? [],piiPatterns:config.piiPatterns ?? [],dev:Number(identity.dev),ino:Number(identity.ino)};
|
|
30
|
+
const unchanged = () => {
|
|
31
|
+
const current = loadRegistry(home), stat = lstatSync(vault.path);
|
|
32
|
+
if (JSON.stringify(current) !== JSON.stringify(config) || stat.dev !== identity.dev || stat.ino !== identity.ino || stat.isSymbolicLink()) throw new Error("analysis: vault or policy changed during build");
|
|
33
|
+
};
|
|
34
|
+
const outputs: {path:string;text:string}[] = [];
|
|
35
|
+
let summary: unknown;
|
|
36
|
+
if (request.kind === "lint") {
|
|
37
|
+
const lint = lintVault(snapshot,request.note); summary = {findings:lint.findings,errors:lint.errors,warnings:lint.warnings,summary:lint.summary};
|
|
38
|
+
outputs.push({path:`reports/lint-report-${snapshot.today.slice(2).replaceAll("-","")}.md`,text:lint.markdown+"\n"+lint.summary+"\n"});
|
|
39
|
+
} else {
|
|
40
|
+
const graph = buildGraph(snapshot), communities = new Set(graph.nodes.flatMap(n => n.community === null ? [] : [n.community])).size;
|
|
41
|
+
summary = {nodes:graph.nodes.length,edges:graph.edges.length,communities};
|
|
42
|
+
const top = [...graph.nodes].filter(n => n.type !== "moc").sort((a,b) => b.degree-a.degree || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0]?.id ?? "";
|
|
43
|
+
outputs.push({path:"reports/vault-graph.json",text:JSON.stringify(graph,null,2)+"\n"},
|
|
44
|
+
{path:`reports/graph-report-${snapshot.today.slice(2).replaceAll("-","")}.md`,text:graphReport(graph,snapshot.today)},
|
|
45
|
+
{path:"reports/graph-trend.md",text:trend(snapshot,graph.nodes.length,graph.edges.length,communities,top)});
|
|
46
|
+
}
|
|
47
|
+
const originals = new Map(outputs.map(out => {
|
|
48
|
+
const name = out.path.slice("reports/".length);
|
|
49
|
+
return [out.path, request.kind === "graph" ? snapshot.reports[name] ?? null : analysisRead(join(vault.path,out.path))] as const;
|
|
50
|
+
}));
|
|
51
|
+
unchanged();
|
|
52
|
+
for (const out of outputs) if (analysisRead(join(vault.path,out.path)) !== originals.get(out.path)) throw new Error("analysis: report changed during build");
|
|
53
|
+
const expectedShas = Object.fromEntries(snapshot.notes.map(n => ["notes/"+n.path.replace(/^notes\//,""),sha256(n.source)]));
|
|
54
|
+
// Preflight every output through the same validation pipeline, before any output is written.
|
|
55
|
+
const previews = outputs.map(out => applyWrite(home,{vault:request.vault,operation:{op:"report",...out},dryRun:true,channel:request.channel,expectedShas,log:false},{env}));
|
|
56
|
+
if (request.dryRun || previews.some(r => !r.ok)) return {ok:previews.every(r => r.ok),applied:false,summary,paths:outputs.map(o => o.path),receipts:previews};
|
|
57
|
+
const receipts: WriteReceipt[] = [];
|
|
58
|
+
for (const out of outputs) {
|
|
59
|
+
unchanged();
|
|
60
|
+
if (analysisRead(join(vault.path,out.path)) !== originals.get(out.path)) throw new Error("analysis: report changed during build");
|
|
61
|
+
const receipt = applyWrite(home,{vault:request.vault,operation:{op:"report",...out},channel:request.channel,expectedShas,log:false},{env});
|
|
62
|
+
receipts.push(receipt);
|
|
63
|
+
if (!receipt.ok) break;
|
|
64
|
+
}
|
|
65
|
+
const complete = receipts.length === outputs.length && receipts.every(r => r.ok);
|
|
66
|
+
unchanged();
|
|
67
|
+
const log = complete ? appendLog(home,{vault:request.vault,channel:request.channel,event:{type:request.kind === "lint" ? "LINT" : "GRAPH",project:"-",source:"vmem",dest:outputs[0]!.path,note:request.kind === "lint" ? "Vault lint report" : "Vault graph reports"}},{env,binding}) : undefined;
|
|
68
|
+
return {ok:complete && log?.ok === true,applied:receipts.some(r => r.applied),summary,paths:outputs.map(o => o.path),receipts,log};
|
|
69
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Private provider input. Never serialize a snapshot into CLI/MCP results or public evidence. */
|
|
2
|
+
export interface AnalysisSnapshot {
|
|
3
|
+
notes: { path: string; source: string }[];
|
|
4
|
+
today: string;
|
|
5
|
+
logPath: string;
|
|
6
|
+
log: string | null;
|
|
7
|
+
registryIds: string[] | null;
|
|
8
|
+
reports: Record<string, string>;
|
|
9
|
+
/** Bounded auxiliary source-path existence warnings captured outside the pure lint transform. */
|
|
10
|
+
sourceWarnings?: Record<string,string[]>;
|
|
11
|
+
}
|
|
12
|
+
export type LintCode = "L01" | "L02" | "L03" | "L09" | "L10" | "L11" | "L12" | "L13" | "L14";
|
|
13
|
+
export interface LintReport {
|
|
14
|
+
findings: Record<LintCode, string[]>;
|
|
15
|
+
errors: number;
|
|
16
|
+
warnings: number;
|
|
17
|
+
markdown: string;
|
|
18
|
+
summary: string;
|
|
19
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { parseYamlWithQuotedEscapes } from "./yaml-quoted.ts";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
/** Preserve Python scalar display and type tests without introducing a YAML runtime dependency. */
|
|
5
|
+
export class AnalysisYamlScalar {
|
|
6
|
+
constructor(readonly kind: "timestamp" | "float", readonly text: string, readonly truthy = true) {}
|
|
7
|
+
toString(): string { return this.text; }
|
|
8
|
+
}
|
|
9
|
+
export class AnalysisYamlUnsupported extends Error {
|
|
10
|
+
constructor(message: string) { super(`analysis YAML: unsupported ${message}`); }
|
|
11
|
+
}
|
|
12
|
+
export function scalarText(value: unknown): string | undefined { return value instanceof AnalysisYamlScalar ? value.text : undefined; }
|
|
13
|
+
export function yamlTruthy(value: unknown): boolean {
|
|
14
|
+
if (value instanceof AnalysisYamlScalar) return value.truthy;
|
|
15
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
16
|
+
if (value !== null && typeof value === "object") return Object.keys(value).length > 0;
|
|
17
|
+
return Boolean(value);
|
|
18
|
+
}
|
|
19
|
+
function float(value: number): AnalysisYamlScalar {
|
|
20
|
+
let text: string;
|
|
21
|
+
if (Number.isNaN(value)) text = "nan";
|
|
22
|
+
else if (!Number.isFinite(value)) text = value < 0 ? "-inf" : "inf";
|
|
23
|
+
else if (Object.is(value, -0)) text = "-0.0";
|
|
24
|
+
else {
|
|
25
|
+
const absolute = Math.abs(value);
|
|
26
|
+
text = absolute !== 0 && (absolute < 0.0001 || absolute >= 1e16) ? value.toExponential() : String(value);
|
|
27
|
+
if (text.includes("e")) text = text.replace(/e([+-]?)(\d+)$/, (_, sign: string, exponent: string) => `e${sign || "+"}${exponent.padStart(2,"0")}`);
|
|
28
|
+
else if (!text.includes(".")) text += ".0";
|
|
29
|
+
}
|
|
30
|
+
return new AnalysisYamlScalar("float",text,value !== 0);
|
|
31
|
+
}
|
|
32
|
+
function integer(value: string): number | bigint {
|
|
33
|
+
const raw = value.replaceAll("_", ""), sign = raw.startsWith("-") ? -1 : 1, unsigned = raw.replace(/^[+-]/, "");
|
|
34
|
+
let number: bigint;
|
|
35
|
+
if (unsigned.includes(":")) number = unsigned.split(":").reduce((n, part) => n * 60n + BigInt(part),0n);
|
|
36
|
+
else if (/^0[0-7]+$/.test(unsigned)) number = BigInt("0o"+unsigned.slice(1));
|
|
37
|
+
else number = BigInt(unsigned);
|
|
38
|
+
number *= BigInt(sign);
|
|
39
|
+
return number >= BigInt(Number.MIN_SAFE_INTEGER) && number <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(number) : number;
|
|
40
|
+
}
|
|
41
|
+
function timestamp(value: string): AnalysisYamlScalar | null {
|
|
42
|
+
const match = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[Tt \t]+(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d*))?(?:[ \t]*(Z|([+-])(\d{1,2})(?::(\d{2}))?))?)?$/.exec(value);
|
|
43
|
+
if (!match || (!match[4] && !/^\d{4}-\d{2}-\d{2}$/.test(value))) return null;
|
|
44
|
+
const year = Number(match[1]), month = Number(match[2]), day = Number(match[3]);
|
|
45
|
+
const date = `${match[1]}-${match[2]!.padStart(2,"0")}-${match[3]!.padStart(2,"0")}`;
|
|
46
|
+
const parsed = new Date(date + "T00:00:00Z");
|
|
47
|
+
if (year < 1 || month < 1 || day < 1 || !Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0,10) !== date) throw new Error("analysis YAML: invalid timestamp");
|
|
48
|
+
if (!match[4]) return new AnalysisYamlScalar("timestamp",date);
|
|
49
|
+
if (Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6]) > 59) throw new Error("analysis YAML: invalid timestamp");
|
|
50
|
+
const fraction = (match[7] ?? "").slice(0,6).padEnd(6,"0");
|
|
51
|
+
let zone = "";
|
|
52
|
+
if (match[8]) {
|
|
53
|
+
const minutes = match[8] === "Z" ? 0 : Number(match[10]) * 60 + Number(match[11] ?? 0);
|
|
54
|
+
if (minutes >= 24*60) throw new Error("analysis YAML: invalid timestamp timezone");
|
|
55
|
+
zone = `${minutes === 0 || match[9] !== "-" ? "+" : "-"}${String(Math.floor(minutes/60)).padStart(2,"0")}:${String(minutes%60).padStart(2,"0")}`;
|
|
56
|
+
}
|
|
57
|
+
return new AnalysisYamlScalar("timestamp",`${date} ${match[4]!.padStart(2,"0")}:${match[5]}:${match[6]}${Number(fraction) ? "."+fraction : ""}${zone}`);
|
|
58
|
+
}
|
|
59
|
+
function resolveScalar(value: string): unknown {
|
|
60
|
+
if (/^(?:yes|Yes|YES|true|True|TRUE|on|On|ON)$/.test(value)) return true;
|
|
61
|
+
if (/^(?:no|No|NO|false|False|FALSE|off|Off|OFF)$/.test(value)) return false;
|
|
62
|
+
if (/^(?:~|null|Null|NULL)$/.test(value)) return null;
|
|
63
|
+
if (/^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+][0-9]+)?|\.[0-9][0-9_]*(?:[eE][-+][0-9]+)?|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$/.test(value)) {
|
|
64
|
+
const raw = value.replaceAll("_", "");
|
|
65
|
+
const number = /nan/i.test(raw) ? NaN : /inf/i.test(raw) ? (raw.startsWith("-") ? -Infinity : Infinity)
|
|
66
|
+
: raw.includes(":") ? raw.replace(/^[+-]/,"").split(":").reduce((n,part)=>n*60+Number(part),0)*(raw.startsWith("-")?-1:1) : Number(raw);
|
|
67
|
+
return float(number);
|
|
68
|
+
}
|
|
69
|
+
if (/^(?:[-+]?0b[0-1_]+|[-+]?0[0-7_]+|[-+]?(?:0|[1-9][0-9_]*)|[-+]?0x[0-9a-fA-F_]+|[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+)$/.test(value)) return integer(value);
|
|
70
|
+
return timestamp(value) ?? value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Resolve YAML 1.1 plain scalars, leaving quoted and literal/folded strings byte-for-byte to Bun. */
|
|
74
|
+
export function parseAnalysisYaml(source: string): unknown {
|
|
75
|
+
const prefix = `vmem-yaml-${randomUUID()}-`, replacements = new Map<string,unknown>();
|
|
76
|
+
let out = "", offset = 0, flow = 0, blockIndent: number | null = null;
|
|
77
|
+
let lineIndent = 0, effectiveIndent = 0, atLineStart = true, mappingOnLine = false, previousPlain: number | null = null;
|
|
78
|
+
const placeholder = (token: string): string => { const key = prefix+replacements.size; replacements.set(key,resolveScalar(token)); return JSON.stringify(key); };
|
|
79
|
+
while (offset < source.length) {
|
|
80
|
+
if (atLineStart) {
|
|
81
|
+
const end = source.indexOf("\n",offset), line = source.slice(offset,end === -1 ? undefined : end);
|
|
82
|
+
lineIndent = line.match(/^[ \t]*/)![0].length; effectiveIndent = lineIndent;mappingOnLine=false;
|
|
83
|
+
if (blockIndent !== null && (!line.trim() || lineIndent > blockIndent)) { out += line; offset += line.length; if (source[offset] === "\n") { out += "\n";offset++; } continue; }
|
|
84
|
+
blockIndent = null;
|
|
85
|
+
if (previousPlain !== null && line.trim() && !line.trimStart().startsWith("#") && lineIndent > previousPlain && !flow) throw new AnalysisYamlUnsupported("multiline plain scalar (use a quoted or block scalar)");
|
|
86
|
+
if (line.trim() && !line.trimStart().startsWith("#")) previousPlain = null;
|
|
87
|
+
atLineStart = false;
|
|
88
|
+
}
|
|
89
|
+
const character = source[offset]!;
|
|
90
|
+
if (character === "\n") { out += character;offset++;atLineStart = true;continue; }
|
|
91
|
+
if (/[ \t\r]/.test(character)) { out += character;offset++;continue; }
|
|
92
|
+
if (character === "#") { const end = source.indexOf("\n",offset); const stop = end === -1 ? source.length : end; out += source.slice(offset,stop);offset=stop;continue; }
|
|
93
|
+
if (character === '"' || character === "'") {
|
|
94
|
+
const start = offset++, quote = character;
|
|
95
|
+
while (offset < source.length) {
|
|
96
|
+
const next = source[offset++]!;
|
|
97
|
+
if (quote === '"' && next === "\\") { offset++;continue; }
|
|
98
|
+
if (next === quote) { if (quote === "'" && source[offset] === "'") {offset++;continue;} break; }
|
|
99
|
+
}
|
|
100
|
+
out += source.slice(start,offset);continue;
|
|
101
|
+
}
|
|
102
|
+
if (!flow && (character === "|" || character === ">") && /^(?:[|>])(?:[+-][1-9]?|[1-9][+-]?)?[ \t]*(?:#[^\n]*)?\r?(?:\n|$)/.test(source.slice(offset))) {
|
|
103
|
+
const end = source.indexOf("\n",offset), stop = end === -1 ? source.length : end;
|
|
104
|
+
out += source.slice(offset,stop);offset=stop;blockIndent=mappingOnLine ? effectiveIndent : lineIndent;previousPlain=null;continue;
|
|
105
|
+
}
|
|
106
|
+
if (character === "!") throw new AnalysisYamlUnsupported("explicit tags");
|
|
107
|
+
if (character === "?" && /\s/.test(source[offset+1] ?? "\n")) throw new AnalysisYamlUnsupported("complex mapping keys");
|
|
108
|
+
if (character === "&" || character === "*") { const match = /^[&*][^\s,[\]{}]+/.exec(source.slice(offset)); if (!match) throw new Error("analysis YAML: invalid anchor");out+=match[0];offset+=match[0].length;continue; }
|
|
109
|
+
if ("[{".includes(character)) { flow++;out+=character;offset++;previousPlain=null;continue; }
|
|
110
|
+
if ("]}".includes(character)) { flow--;out+=character;offset++;continue; }
|
|
111
|
+
if ((flow && character === ",") || character === ":") { if(character === ":" && !flow)mappingOnLine=true;out+=character;offset++;previousPlain=null;continue; }
|
|
112
|
+
if (!flow && character === "-" && /\s/.test(source[offset+1] ?? "\n")) { out+=character;offset++;effectiveIndent=offset-(source.lastIndexOf("\n",offset-1)+1)+1;previousPlain=null;continue; }
|
|
113
|
+
const start = offset;
|
|
114
|
+
while (offset < source.length) {
|
|
115
|
+
const next = source[offset]!;
|
|
116
|
+
if (next === "\n" || (flow && ",[]{}".includes(next)) || (next === ":" && /[\s,\]}]/.test(source[offset+1] ?? "\n")) || (next === "#" && /\s/.test(source[offset-1] ?? " "))) break;
|
|
117
|
+
offset++;
|
|
118
|
+
}
|
|
119
|
+
if (offset === start) throw new Error("analysis YAML: invalid token");
|
|
120
|
+
const raw = source.slice(start,offset);
|
|
121
|
+
let token = raw.trimEnd(), consumed = offset;
|
|
122
|
+
if (source[offset] === "\n") {
|
|
123
|
+
let cursor=offset, blanks=0;
|
|
124
|
+
while(source[cursor] === "\n") {
|
|
125
|
+
const next=cursor+1, end=source.indexOf("\n",next), line=source.slice(next,end===-1 ? undefined : end), indent=line.match(/^[ \t]*/)![0].length;
|
|
126
|
+
if(!line.trim()) {blanks++;cursor=end===-1 ? source.length : end;continue;}
|
|
127
|
+
if ((!flow && indent <= effectiveIndent) || line.trimStart().startsWith("#") || (flow && /^[,\]}]/.test(line.trimStart()))) break;
|
|
128
|
+
const stop = flow ? line.search(/[,\]}]/) : -1;
|
|
129
|
+
const part=(stop===-1 ? line : line.slice(0,stop)).trim();
|
|
130
|
+
if (!part || /:\s|\s#/.test(part)) break;
|
|
131
|
+
token+=(blanks ? "\n".repeat(blanks) : " ")+part;blanks=0;
|
|
132
|
+
consumed=stop===-1 ? next+line.length : next+stop;
|
|
133
|
+
cursor=consumed;
|
|
134
|
+
if(stop!==-1)break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (token === "---" || token === "..." || token.startsWith("%")) throw new AnalysisYamlUnsupported("document directives or multiple documents");
|
|
138
|
+
out += placeholder(token)+(consumed===offset ? raw.slice(token.length) : "");
|
|
139
|
+
offset=consumed;
|
|
140
|
+
if (!flow && source[offset] !== ":") previousPlain=effectiveIndent;
|
|
141
|
+
}
|
|
142
|
+
let parsed: unknown;
|
|
143
|
+
try { parsed=parseYamlWithQuotedEscapes(out); }
|
|
144
|
+
catch(error) {
|
|
145
|
+
try { parseYamlWithQuotedEscapes(source); } catch { throw error; }
|
|
146
|
+
throw new AnalysisYamlUnsupported("plain scalar layout (use a quoted or block scalar)");
|
|
147
|
+
}
|
|
148
|
+
const active = new Set<object>();
|
|
149
|
+
function restore(value: unknown, depth: number): unknown {
|
|
150
|
+
if (depth > 64) throw new AnalysisYamlUnsupported("nesting deeper than 64 levels");
|
|
151
|
+
if (typeof value === "string") return replacements.has(value) ? replacements.get(value) : value;
|
|
152
|
+
if (value === null || typeof value !== "object") return value;
|
|
153
|
+
if (active.has(value)) throw new AnalysisYamlUnsupported("recursive aliases");
|
|
154
|
+
active.add(value);
|
|
155
|
+
let result: unknown;
|
|
156
|
+
if (Array.isArray(value)) result=value.map(item=>restore(item,depth+1));
|
|
157
|
+
else {
|
|
158
|
+
const mapping: Record<string,unknown> = Object.create(null) as Record<string,unknown>;
|
|
159
|
+
const explicit:[string,unknown][]=[];
|
|
160
|
+
for (const [key,item] of Object.entries(value)) {
|
|
161
|
+
const resolvedKey=restore(key,depth+1);
|
|
162
|
+
if (typeof resolvedKey !== "string") throw new AnalysisYamlUnsupported("non-string mapping keys");
|
|
163
|
+
const restored=restore(item,depth+1);
|
|
164
|
+
if (replacements.get(key)==="<<") {
|
|
165
|
+
const merged=Array.isArray(restored) ? [...restored].reverse() : [restored];
|
|
166
|
+
for(const entry of merged) {
|
|
167
|
+
if(entry===null || typeof entry!=="object" || Array.isArray(entry) || entry instanceof AnalysisYamlScalar)throw new Error("analysis YAML: merge source must be a mapping");
|
|
168
|
+
Object.assign(mapping,entry);
|
|
169
|
+
}
|
|
170
|
+
} else explicit.push([resolvedKey,restored]);
|
|
171
|
+
}
|
|
172
|
+
for(const [key,item] of explicit)mapping[key]=item;
|
|
173
|
+
result=mapping;
|
|
174
|
+
}
|
|
175
|
+
active.delete(value);return result;
|
|
176
|
+
}
|
|
177
|
+
return restore(parsed,0);
|
|
178
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { runAnalysisCommand } from "./analysis-commands.ts";
|
|
2
|
+
import { runLogCommand } from "./log-commands.ts";
|
|
3
|
+
import { runOrgCommand } from "./org-commands.ts";
|
|
4
|
+
import { isWriteCommand, runWriteCommand } from "./write-commands.ts";
|
|
5
|
+
import { isReadCommand, runReadCommand } from "./read-commands.ts";
|
|
6
|
+
import { version } from "../package.json";
|
|
7
|
+
import { completionScript, help } from "./commands-table.ts";
|
|
8
|
+
import { runSetup, type SetupMode } from "./setup.ts";
|
|
9
|
+
import { resolveHome } from "./home.ts";
|
|
10
|
+
import { addVault, effectivePii, loadRegistry, recoverRegistry, registryStatus, removeVault } from "./registry.ts";
|
|
11
|
+
function parse(args: string[], allowed: string[], values: string[]) {
|
|
12
|
+
const flags: Record<string, string | boolean> = {}, positional: string[] = [];
|
|
13
|
+
for (let i = 0; i < args.length; i++) {
|
|
14
|
+
const word = args[i]!;
|
|
15
|
+
if (!word.startsWith("-")) { positional.push(word); continue; }
|
|
16
|
+
if (!allowed.includes(word) || word in flags) throw new Error(`unknown or repeated option: ${word}`);
|
|
17
|
+
if (values.includes(word)) {
|
|
18
|
+
const value = args[++i];
|
|
19
|
+
if (!value || value.startsWith("--")) throw new Error(`${word}: value required`);
|
|
20
|
+
flags[word] = value;
|
|
21
|
+
} else flags[word] = true;
|
|
22
|
+
}
|
|
23
|
+
return { flags, positional };
|
|
24
|
+
}
|
|
25
|
+
export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { text: string; code: number } {
|
|
26
|
+
if (!args.length || (args.length === 1 && ["--help", "-h", "help"].includes(args[0]!))) return { text: help(), code: 0 };
|
|
27
|
+
if (args.length === 1 && args[0] === "--version") return { text: version, code: 0 };
|
|
28
|
+
if (args[0] === "setup") {
|
|
29
|
+
const rest = args.slice(1), flags = rest.filter(a => a.startsWith("--")), positional = rest.filter(a => !a.startsWith("--"));
|
|
30
|
+
if (positional.length !== 1 || flags.some(f => !["--apply", "--undo", "--json"].includes(f)) || new Set(flags).size !== flags.length || (flags.includes("--apply") && flags.includes("--undo"))) throw new Error("setup: requires one target and at most one of --apply/--undo");
|
|
31
|
+
const mode: SetupMode = flags.includes("--apply") ? "apply" : flags.includes("--undo") ? "undo" : "dry-run";
|
|
32
|
+
const report = runSetup(positional[0]!, mode, { home: resolveHome(env).home, env });
|
|
33
|
+
return { text: JSON.stringify(report, null, flags.includes("--json") ? undefined : 2), code: report.actions.some(a => a.change === "refused") ? 1 : 0 };
|
|
34
|
+
}
|
|
35
|
+
if (args[0] === "completion") {
|
|
36
|
+
if (args.length !== 2) throw new Error("completion: requires bash, zsh or fish");
|
|
37
|
+
return { text: completionScript(args[1]!).trimEnd(), code: 0 };
|
|
38
|
+
}
|
|
39
|
+
if (args[0] === "lint" || args[0] === "graph") return runAnalysisCommand(args[0],args.slice(1),resolveHome(env).home,env);
|
|
40
|
+
if (args[0] === "promote" || args[0] === "sync") return runOrgCommand(args[0], args.slice(1), resolveHome(env).home, env);
|
|
41
|
+
if (args[0] === "log") return runLogCommand(args.slice(1), resolveHome(env).home, env);
|
|
42
|
+
if (isWriteCommand(args[0]!)) return runWriteCommand(args[0]!, args.slice(1), resolveHome(env).home, env);
|
|
43
|
+
if (isReadCommand(args[0]!)) return runReadCommand(args[0]!, args.slice(1), resolveHome(env).home);
|
|
44
|
+
const command = args[0] === "vaults" ? `vaults ${args[1] ?? ""}` : args[0];
|
|
45
|
+
const tail = args.slice(args[0] === "vaults" ? 2 : 1);
|
|
46
|
+
const options = command === "vaults add" ? ["--kind", "--write", "--pii", "--remote", "--registry"] : [];
|
|
47
|
+
const { flags, positional } = parse(tail, ["--json", ...options, ...(command === "vaults remove" || command === "recover" ? ["--confirm"] : [])], options);
|
|
48
|
+
const home = resolveHome(env);
|
|
49
|
+
let result: unknown, code = 0;
|
|
50
|
+
switch (command) {
|
|
51
|
+
case "status": case "doctor": {
|
|
52
|
+
if (positional.length) throw new Error(`${command}: unexpected arguments`);
|
|
53
|
+
const vaults = registryStatus(home.home), healthy = vaults.every(v => v.healthy);
|
|
54
|
+
result = { ...home, version, stage: "foundation", healthy, vaults };
|
|
55
|
+
if (command === "doctor" && !healthy) code = 1;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case "recover":
|
|
59
|
+
if (positional.length) throw new Error("recover: unexpected arguments");
|
|
60
|
+
result = recoverRegistry(home.home, flags["--confirm"] === true);
|
|
61
|
+
if ((result as { blocked?: string }).blocked) code = 1;
|
|
62
|
+
break;
|
|
63
|
+
case "vaults list":
|
|
64
|
+
if (positional.length) throw new Error("vaults list: unexpected arguments");
|
|
65
|
+
result = loadRegistry(home.home).vaults.map(v => ({ ...v, pii: effectivePii(v) }));
|
|
66
|
+
break;
|
|
67
|
+
case "vaults add": {
|
|
68
|
+
if (positional.length !== 2) throw new Error("vaults add: requires id and absolute path");
|
|
69
|
+
const value: Record<string, unknown> = { id: positional[0], path: positional[1] };
|
|
70
|
+
for (const option of options) if (flags[option] !== undefined) value[option.slice(2)] = flags[option];
|
|
71
|
+
result = { applied: true, vault: addVault(home.home, value) };
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "vaults remove":
|
|
75
|
+
if (positional.length !== 1) throw new Error("vaults remove: requires id");
|
|
76
|
+
result = removeVault(home.home, positional[0]!, flags["--confirm"] === true);
|
|
77
|
+
break;
|
|
78
|
+
default: throw new Error(`unknown command: ${command}; use --help`);
|
|
79
|
+
}
|
|
80
|
+
return { text: JSON.stringify(result, null, flags["--json"] ? undefined : 2), code };
|
|
81
|
+
}
|
|
82
|
+
/** `vmem mcp`: the vault set is fixed now; stdout carries protocol frames only. */
|
|
83
|
+
export async function runMcp(env: NodeJS.ProcessEnv = process.env): Promise<void> {
|
|
84
|
+
const [{ serveMcp }, { createVaultMcp }] = await Promise.all([import("./mcp.ts"), import("./mcp-vault.ts")]);
|
|
85
|
+
const server = createVaultMcp(resolveHome(env).home, env);
|
|
86
|
+
await serveMcp({ tools: server.tools, handlers: server.handlers, name: "vmem", version });
|
|
87
|
+
}
|
|
88
|
+
/** Async commands. Only `promote-from-amem` loads the agent-mem seam, and only when it runs. */
|
|
89
|
+
export async function runAsync(args: string[], env: NodeJS.ProcessEnv = process.env, io: { stdinIsTTY?: boolean } = {}): Promise<{ text: string; code: number }> {
|
|
90
|
+
if (args[0] !== "promote-from-amem") return run(args, env);
|
|
91
|
+
const { runPromoteCommand } = await import("./promote-command.ts");
|
|
92
|
+
return runPromoteCommand(args.slice(1), resolveHome(env).home, env, io);
|
|
93
|
+
}
|
|
94
|
+
export async function mainAsync(args: string[]): Promise<number> {
|
|
95
|
+
try { const output = await runAsync(args, process.env, { stdinIsTTY: process.stdin.isTTY === true }); console.log(output.text); return output.code; }
|
|
96
|
+
catch (error) { console.error(`vmem: ${error instanceof Error ? error.message : "operation failed"}`); return 1; }
|
|
97
|
+
}
|
|
98
|
+
export function main(args: string[]): number {
|
|
99
|
+
try { const output = run(args); console.log(output.text); return output.code; }
|
|
100
|
+
catch (error) { console.error(`vmem: ${error instanceof Error ? error.message : "operation failed"}`); return 1; }
|
|
101
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export const COMMANDS = [
|
|
2
|
+
{ name: "lint", usage: "lint <vault> [--note PATH] [--write] [--dry-run] [--json]", mutates: "write" },
|
|
3
|
+
{ name: "graph build", usage: "graph build <vault> [--dry-run] [--json]", mutates: true },
|
|
4
|
+
{ name: "graph neighbors", usage: "graph neighbors <vault> <stem> [--hops 1..2] [--limit 1..500] [--json]", mutates: false },
|
|
5
|
+
{ name: "rebuild", usage: "rebuild <vault> [--json]", mutates: "cache" },
|
|
6
|
+
{ name: "search", usage: "search <vault> <query> [--type TYPE] [--domain DOMAIN] [--limit 1..500] [--json]", mutates: "cache" },
|
|
7
|
+
{ name: "show", usage: "show <vault> <target> [--section TEXT] [--offset 0..2097152] [--max-bytes 1..65536] [--json]", mutates: "cache" },
|
|
8
|
+
{ name: "links", usage: "links <vault> <target> [--limit 1..500] [--json]", mutates: "cache" },
|
|
9
|
+
{ name: "status", usage: "status [--json]", mutates: false },
|
|
10
|
+
{ name: "doctor", usage: "doctor [--json]", mutates: false },
|
|
11
|
+
{ name: "recover", usage: "recover [--confirm] [--json]", mutates: "confirm" },
|
|
12
|
+
{ name: "vaults list", usage: "vaults list [--json]", mutates: false },
|
|
13
|
+
{ name: "vaults add", usage: "vaults add <id> <absolute-path> --kind personal|org --write mcp|cli|none [--pii allow|deny] [--remote URL] [--registry PATH] [--json]", mutates: true },
|
|
14
|
+
{ name: "vaults remove", usage: "vaults remove <id> [--confirm] [--json]", mutates: "confirm" },
|
|
15
|
+
{ name: "new", usage: "new <vault> <stem> --type TYPE --domain DOMAIN --topics a,b --description TEXT [--title TEXT] [--source TEXT|JSON] [--confidence TEXT] [--status TEXT] [--body file|-] [--extra JSON] [--dry-run] [--json]", mutates: true },
|
|
16
|
+
{ name: "patch", usage: "patch <vault> <note> --old TEXT --new TEXT [--expected-sha SHA] [--dry-run] [--json]", mutates: true },
|
|
17
|
+
{ name: "append", usage: "append <vault> <note> --section SECTION --markdown TEXT [--expected-sha SHA] [--dry-run] [--json]", mutates: true },
|
|
18
|
+
{ name: "frontmatter", usage: "frontmatter <vault> <note> [--set JSON] [--unset key,key] [--expected-sha SHA] [--dry-run] [--json]", mutates: true },
|
|
19
|
+
{ name: "link", usage: "link <vault> <from> <to> --relation R [--context TEXT] [--one-way] [--dry-run] [--json]", mutates: true },
|
|
20
|
+
{ name: "supersede", usage: "supersede <vault> <old> <next> [--dry-run] [--json]", mutates: true },
|
|
21
|
+
{ name: "move", usage: "move <vault> <from> <to> [--confirm --fingerprint SHA] [--dry-run] [--json]", mutates: "confirm" },
|
|
22
|
+
{ name: "delete", usage: "delete <vault> <note> [--confirm --fingerprint SHA] [--dry-run] [--json]", mutates: "confirm" },
|
|
23
|
+
{ name: "tidy", usage: "tidy <vault> [--connections] [--escapes] [--enums] [--keys] [--sources] [--topics] [--secrets] [--log] [--all] [--map file] [--confirm --fingerprint SHA] [--dry-run] [--json]", mutates: "confirm" },
|
|
24
|
+
{ name: "report", usage: "report <vault> <reports/path|ops/path> --body file|- [--dry-run] [--json]", mutates: true },
|
|
25
|
+
{ name: "check", usage: "check <vault> <file|-> [--path stem] [--json]", mutates: false },
|
|
26
|
+
{ name: "log tail", usage: "log tail <vault> [--limit 1..200] [--type TYPE] [--json]", mutates: false },
|
|
27
|
+
{ name: "log grep", usage: "log grep <vault> <pattern> [--limit 1..200] [--type TYPE] [--json]", mutates: false },
|
|
28
|
+
{ name: "log append", usage: "log append <vault> <TYPE> <project> <source> <note> [--dest PATH] [--json]", mutates: true },
|
|
29
|
+
{ name: "promote-from-amem", usage: "promote-from-amem <vault> [<mem-id>] [--project KEY|--all-projects] [--limit 1..200] [--stem STEM] [--type TYPE] [--domain DOMAIN] [--topics a,b] [--description TEXT] [--title TEXT] [--body file|-] [--link-to NOTE] [--log-project ID] [--edit] [--confirm --fingerprint SHA] [--json]", mutates: "confirm" },
|
|
30
|
+
{ name: "promote", usage: "promote <personal-vault> <note> --to ORG [--topics a,b] [--again] [--log-project ID] [--confirm --fingerprint SHA] [--json]", mutates: "confirm" },
|
|
31
|
+
{ name: "sync", usage: "sync <vault> [--message TEXT] [--confirm --fingerprint SHA] [--json]", mutates: "confirm" },
|
|
32
|
+
{ name: "mcp", usage: "mcp", mutates: false },
|
|
33
|
+
{ name: "setup", usage: "setup <claude|codex|kimi|pi|qwen|jcode|agy|all> [--apply|--undo] [--json]", mutates: "apply" },
|
|
34
|
+
{ name: "completion", usage: "completion <bash|zsh|fish>", mutates: false },
|
|
35
|
+
] as const;
|
|
36
|
+
const RELATIONS = ["supports", "contradicts", "extends", "applies-to", "context-for"] as const;
|
|
37
|
+
const LOG_EVENT_TYPES = ["INGEST", "ROUTE", "EXTRACT", "CONNECT", "DIGEST", "LEARN", "LINT", "TASK", "GRAPH", "SYNC", "RETHINK", "SOURCE"] as const;
|
|
38
|
+
const VALUE = /^--[a-z-]+ (?!\.\.\.)([A-Z]|[a-z0-9.|,-]+|file\|-|JSON|SHA|URL|PATH|TEXT)/;
|
|
39
|
+
export interface CompletionDefinition { path: string[]; options: Record<string, { value?: string | string[] }>; args: string[] }
|
|
40
|
+
/** Completion metadata derived from the usage strings, so help and completion cannot drift. */
|
|
41
|
+
export function completionDefinitions(): CompletionDefinition[] {
|
|
42
|
+
return COMMANDS.map(command => {
|
|
43
|
+
const words = command.name.split(" "), usage = command.usage.slice(command.name.length).trim();
|
|
44
|
+
const options: CompletionDefinition["options"] = {}, args: string[] = [];
|
|
45
|
+
for (const match of usage.matchAll(/--[a-z-]+(?: (?:[A-Za-z0-9.|,/][A-Za-z0-9.|,/-]*|-(?![-\w])))?|<([^>]+)>/g)) {
|
|
46
|
+
if (match[1] !== undefined) { args.push(match[1]); continue; }
|
|
47
|
+
const [flag, value] = match[0].split(" ") as [string, string | undefined];
|
|
48
|
+
if (Object.hasOwn(options, flag)) continue;
|
|
49
|
+
const nextFlag = value?.startsWith("--");
|
|
50
|
+
options[flag] = value && !nextFlag && VALUE.test(match[0]) ? { value: /^[a-z]+(\|[a-z]+)+$/.test(value) ? value.split("|") : value } : {};
|
|
51
|
+
}
|
|
52
|
+
if (!["mcp", "completion"].includes(command.name)) options["--json"] ??= {};
|
|
53
|
+
if (options["--relation"]) options["--relation"] = { value: [...RELATIONS] };
|
|
54
|
+
if (command.name.startsWith("log ") && options["--type"]) options["--type"] = { value: [...LOG_EVENT_TYPES] };
|
|
55
|
+
return { path: words, options, args };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Static completion script. Lists subcommands, then the options of the typed command. */
|
|
59
|
+
export function completionScript(shell: string): string {
|
|
60
|
+
if (!["bash", "zsh", "fish"].includes(shell)) throw new Error("completion: use bash, zsh or fish");
|
|
61
|
+
const defs = completionDefinitions(), top = [...new Set(defs.map(d => d.path[0]!))].sort();
|
|
62
|
+
const quote = (text: string) => `'${text.replace(/'/g, `'"'"'`)}'`;
|
|
63
|
+
if (shell === "fish") {
|
|
64
|
+
const lines = [`complete -c vmem -f`, `complete -c vmem -n '__fish_use_subcommand' -a ${quote(top.join(" "))}`];
|
|
65
|
+
for (const d of defs) {
|
|
66
|
+
const cond = d.path.length === 1 ? `__fish_seen_subcommand_from ${d.path[0]}` : `__fish_seen_subcommand_from ${d.path[1]}`;
|
|
67
|
+
for (const flag of Object.keys(d.options)) lines.push(`complete -c vmem -n ${quote(cond)} -l ${flag.slice(2)}`);
|
|
68
|
+
}
|
|
69
|
+
for (const parent of ["vaults", "log", "graph"]) lines.push(`complete -c vmem -n '__fish_seen_subcommand_from ${parent}' -a ${quote(defs.filter(d => d.path[0] === parent).map(d => d.path[1]).join(" "))}`);
|
|
70
|
+
return lines.join("\n") + "\n";
|
|
71
|
+
}
|
|
72
|
+
const cases = defs.map(d => ` ${quote(d.path.join(" "))}) words=${quote(Object.keys(d.options).join(" "))} ;;`);
|
|
73
|
+
const fn = `_vmem() {
|
|
74
|
+
local cur key words
|
|
75
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
76
|
+
if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=($(compgen -W ${quote(top.join(" "))} -- "$cur")); return; fi
|
|
77
|
+
key="\${COMP_WORDS[1]}"
|
|
78
|
+
case "$key" in graph) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W 'build neighbors' -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; vaults|log) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W "$( [ "$key" = vaults ] && echo 'list add remove' || echo 'tail grep append')" -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; esac
|
|
79
|
+
case "$key" in
|
|
80
|
+
${cases.join("\n")}
|
|
81
|
+
*) words='' ;;
|
|
82
|
+
esac
|
|
83
|
+
COMPREPLY=($(compgen -W "$words" -- "$cur"))
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
return shell === "zsh" ? `autoload -U +X bashcompinit && bashcompinit\n${fn}complete -F _vmem vmem\n` : `${fn}complete -F _vmem vmem\n`;
|
|
87
|
+
}
|
|
88
|
+
export function help(): string { return "vmem - validated knowledge vaults for agents and people\n\n" + COMMANDS.map(c => ` vmem ${c.usage}`).join("\n") + "\n\n vmem --help | --version\n"; }
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const CONFIG_FILE = "vmem.config.json";
|
|
5
|
+
export const MAX_CONFIG_BYTES = 64 * 1024;
|
|
6
|
+
export interface VaultSpec {
|
|
7
|
+
id: string;
|
|
8
|
+
path: string;
|
|
9
|
+
kind: "personal" | "org";
|
|
10
|
+
write: "mcp" | "cli" | "none";
|
|
11
|
+
pii?: "allow" | "deny";
|
|
12
|
+
remote?: string;
|
|
13
|
+
registry?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface VmemConfig { home?: string; vaults: VaultSpec[]; denyPaths?: string[]; piiPatterns?: string[] }
|
|
16
|
+
const controls = /[\x00-\x1f\x7f]/;
|
|
17
|
+
function object(value: unknown, keys: string[], label: string): Record<string, unknown> {
|
|
18
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label}: expected object`);
|
|
19
|
+
const record = value as Record<string, unknown>;
|
|
20
|
+
for (const key of Object.keys(record)) if (!keys.includes(key)) throw new Error(`${label}: unknown key ${key}`);
|
|
21
|
+
return record;
|
|
22
|
+
}
|
|
23
|
+
function string(value: unknown, label: string): string {
|
|
24
|
+
if (typeof value !== "string" || !value.trim() || controls.test(value)) throw new Error(`${label}: expected nonempty string without control characters`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
export function absolute(value: unknown, label: string): string {
|
|
28
|
+
const text = string(value, label);
|
|
29
|
+
if (!isAbsolute(text)) throw new Error(`${label}: expected absolute path`);
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
function strings(value: unknown, label: string): string[] | undefined {
|
|
33
|
+
if (value === undefined) return undefined;
|
|
34
|
+
if (!Array.isArray(value) || value.length > 64) throw new Error(`${label}: expected at most 64 strings`);
|
|
35
|
+
return value.map((item) => {
|
|
36
|
+
const text = string(item, label);
|
|
37
|
+
if (Buffer.byteLength(text) > 256) throw new Error(`${label}: item exceeds 256 bytes`);
|
|
38
|
+
return text;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function parseVault(value: unknown): VaultSpec {
|
|
42
|
+
const v = object(value, ["id", "path", "kind", "write", "pii", "remote", "registry"], "vault");
|
|
43
|
+
const id = string(v.id, "vault.id");
|
|
44
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(id)) throw new Error("vault.id: invalid id");
|
|
45
|
+
if (v.kind !== "personal" && v.kind !== "org") throw new Error("vault.kind: expected personal or org");
|
|
46
|
+
if (v.write !== "mcp" && v.write !== "cli" && v.write !== "none") throw new Error("vault.write: expected mcp, cli or none");
|
|
47
|
+
const result: VaultSpec = { id, path: absolute(v.path, "vault.path"), kind: v.kind, write: v.write };
|
|
48
|
+
if (v.pii !== undefined) {
|
|
49
|
+
if (v.pii !== "allow" && v.pii !== "deny") throw new Error("vault.pii: expected allow or deny");
|
|
50
|
+
result.pii = v.pii;
|
|
51
|
+
}
|
|
52
|
+
if (v.remote !== undefined) result.remote = string(v.remote, "vault.remote");
|
|
53
|
+
if (v.registry !== undefined) result.registry = absolute(v.registry, "vault.registry");
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
export function parseConfig(value: unknown): VmemConfig {
|
|
57
|
+
const raw = object(value, ["home", "vaults", "denyPaths", "piiPatterns"], "config");
|
|
58
|
+
if (raw.home !== undefined) {
|
|
59
|
+
if (Object.keys(raw).length !== 1) throw new Error("config: home pointer must not contain vaults or policy");
|
|
60
|
+
return { home: absolute(raw.home, "config.home"), vaults: [] };
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(raw.vaults)) throw new Error("config.vaults: expected array");
|
|
63
|
+
const vaults = raw.vaults.map(parseVault);
|
|
64
|
+
if (new Set(vaults.map(v => v.id)).size !== vaults.length) throw new Error("config: duplicate vault id");
|
|
65
|
+
if (new Set(vaults.map(v => v.path)).size !== vaults.length) throw new Error("config: duplicate vault path");
|
|
66
|
+
const denyPaths = strings(raw.denyPaths, "denyPaths"), piiPatterns = strings(raw.piiPatterns, "piiPatterns");
|
|
67
|
+
for (const pattern of piiPatterns ?? []) {
|
|
68
|
+
try { new RegExp(pattern); } catch { throw new Error("piiPatterns: invalid regular expression"); }
|
|
69
|
+
}
|
|
70
|
+
return { vaults, ...(denyPaths ? { denyPaths } : {}), ...(piiPatterns ? { piiPatterns } : {}) };
|
|
71
|
+
}
|
|
72
|
+
/** Bounded descriptor read: reject links, devices and oversized/growing files. */
|
|
73
|
+
export function readConfig(home: string): VmemConfig {
|
|
74
|
+
const file = join(home, CONFIG_FILE);
|
|
75
|
+
let fd: number;
|
|
76
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
77
|
+
catch (error) {
|
|
78
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { vaults: [] };
|
|
79
|
+
throw new Error(`${file}: cannot read plain config file`, { cause: error });
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const stat = fstatSync(fd);
|
|
83
|
+
if (!stat.isFile() || stat.size > MAX_CONFIG_BYTES) throw new Error(`${file}: expected plain file <= ${MAX_CONFIG_BYTES} bytes`);
|
|
84
|
+
const buffer = Buffer.alloc(MAX_CONFIG_BYTES + 1);
|
|
85
|
+
let size = 0;
|
|
86
|
+
while (size < buffer.length) {
|
|
87
|
+
const n = readSync(fd, buffer, size, buffer.length - size, null);
|
|
88
|
+
if (!n) break;
|
|
89
|
+
size += n;
|
|
90
|
+
}
|
|
91
|
+
if (size > MAX_CONFIG_BYTES) throw new Error(`${file}: config too large`);
|
|
92
|
+
let parsed: unknown;
|
|
93
|
+
// Do not echo parser messages: they can quote config content.
|
|
94
|
+
try { parsed = JSON.parse(buffer.subarray(0, size).toString("utf8")); }
|
|
95
|
+
catch { throw new Error(`${file}: invalid JSON`); }
|
|
96
|
+
try { return parseConfig(parsed); }
|
|
97
|
+
catch (error) { throw new Error(`${file}: ${error instanceof Error ? error.message : "invalid config"}`, { cause: error }); }
|
|
98
|
+
} finally { closeSync(fd); }
|
|
99
|
+
}
|