@staix/vault-mem 0.0.0-bootstrap.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +251 -2
- package/bin/vmem.js +12 -0
- package/package.json +33 -2
- 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 +214 -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
package/src/setup.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
/** Hosts whose MCP configuration `vmem setup` can register; `all` covers every user-level host (not agy). */
|
|
9
|
+
export const SETUP_TARGETS = ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy"] as const;
|
|
10
|
+
export type SetupTarget = typeof SETUP_TARGETS[number];
|
|
11
|
+
export type SetupMode = "dry-run" | "apply" | "undo";
|
|
12
|
+
export const SERVER_NAME = "vmem";
|
|
13
|
+
const MAX_HOST_CONFIG = 4 * 1024 * 1024;
|
|
14
|
+
export interface McpEntry { command: string; args: string[] }
|
|
15
|
+
interface HostFile { target: SetupTarget; file: string; format: "json" | "toml"; key: string }
|
|
16
|
+
export interface SetupAction { target: SetupTarget; file: string; change: "add" | "replace" | "unchanged" | "restore" | "remove" | "refused" | "none"; reason?: string }
|
|
17
|
+
export interface SetupReport { mode: SetupMode; applied: boolean; entry: McpEntry; actions: SetupAction[]; notes: string[] }
|
|
18
|
+
interface Receipt { version: 1; target: SetupTarget; file: string; existed: boolean; keyExisted?: boolean; prior: unknown; priorToml?: string[]; installed: McpEntry }
|
|
19
|
+
|
|
20
|
+
function hosts(env: NodeJS.ProcessEnv, cwd: string): Record<SetupTarget, HostFile> {
|
|
21
|
+
const home = env.HOME?.trim() || homedir();
|
|
22
|
+
const dir = (key: string, fallback: string) => {
|
|
23
|
+
const value = env[key]?.trim();
|
|
24
|
+
if (!value) return join(home, fallback);
|
|
25
|
+
if (!isAbsolute(value)) throw new Error(`setup: ${key} must be absolute`);
|
|
26
|
+
return resolve(value);
|
|
27
|
+
};
|
|
28
|
+
const claude = env.CLAUDE_CONFIG_DIR?.trim() ? join(dir("CLAUDE_CONFIG_DIR", ".claude"), ".claude.json") : join(home, ".claude.json");
|
|
29
|
+
return {
|
|
30
|
+
claude: { target: "claude", file: claude, format: "json", key: "mcpServers" },
|
|
31
|
+
codex: { target: "codex", file: join(dir("CODEX_HOME", ".codex"), "config.toml"), format: "toml", key: "mcp_servers" },
|
|
32
|
+
kimi: { target: "kimi", file: join(dir("KIMI_CODE_HOME", ".kimi-code"), "mcp.json"), format: "json", key: "mcpServers" },
|
|
33
|
+
pi: { target: "pi", file: join(dir("PI_CODING_AGENT_DIR", ".pi/agent"), "mcp.json"), format: "json", key: "mcpServers" },
|
|
34
|
+
qwen: { target: "qwen", file: join(dir("QWEN_HOME", ".qwen"), "settings.json"), format: "json", key: "mcpServers" },
|
|
35
|
+
jcode: { target: "jcode", file: join(dir("JCODE_HOME", ".jcode"), "mcp.json"), format: "json", key: "servers" },
|
|
36
|
+
agy: { target: "agy", file: join(resolve(cwd), ".agents", "mcp_config.json"), format: "json", key: "mcpServers" },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function readText(file: string): string | null {
|
|
40
|
+
let stat;
|
|
41
|
+
try { stat = lstatSync(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
|
|
42
|
+
if (!stat.isFile()) throw new Error(`${file}: expected a plain file (links are refused)`);
|
|
43
|
+
if (stat.size > MAX_HOST_CONFIG) throw new Error(`${file}: exceeds 4 MiB`);
|
|
44
|
+
return readFileSync(file, "utf8");
|
|
45
|
+
}
|
|
46
|
+
function atomicWrite(file: string, text: string | null): void {
|
|
47
|
+
if (text === null) { unlinkSync(file); return; }
|
|
48
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
49
|
+
let mode = 0o600;
|
|
50
|
+
try { mode = statSync(file).mode & 0o777; } catch { /* new file */ }
|
|
51
|
+
const temp = join(dirname(file), `.${SERVER_NAME}-setup-${randomUUID()}.tmp`);
|
|
52
|
+
const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode);
|
|
53
|
+
try { writeFileSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
|
|
54
|
+
try { renameSync(temp, file); } catch (error) { try { unlinkSync(temp); } catch { /* best effort */ } throw error; }
|
|
55
|
+
}
|
|
56
|
+
const record = (value: unknown, label: string): Record<string, unknown> => {
|
|
57
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label}: expected an object`);
|
|
58
|
+
return value as Record<string, unknown>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// TOML: edit only the `[mcp_servers.vmem]` table (and its subtables) textually so unrelated bytes stay identical.
|
|
62
|
+
const tomlHeader = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/;
|
|
63
|
+
/** Every table owned by the entry (`[mcp_servers.vmem]` and any `[mcp_servers.vmem.*]`), wherever it appears. */
|
|
64
|
+
function tomlSpans(lines: string[]): [number, number][] {
|
|
65
|
+
const own = (name: string) => {
|
|
66
|
+
const key = name.replace(/\s*\.\s*/g, ".").replace(/"vmem"/g, SERVER_NAME);
|
|
67
|
+
return key === `mcp_servers.${SERVER_NAME}` || key.startsWith(`mcp_servers.${SERVER_NAME}.`);
|
|
68
|
+
};
|
|
69
|
+
const spans: [number, number][] = [];
|
|
70
|
+
let start = -1;
|
|
71
|
+
for (let i = 0; i <= lines.length; i++) {
|
|
72
|
+
const header = i < lines.length ? tomlHeader.exec(lines[i]!) : null;
|
|
73
|
+
if (i < lines.length && !header) continue;
|
|
74
|
+
if (start >= 0) {
|
|
75
|
+
let stop = i;
|
|
76
|
+
while (stop > start && lines[stop - 1]!.trim() === "") stop--;
|
|
77
|
+
spans.push([start, stop]); start = -1;
|
|
78
|
+
}
|
|
79
|
+
if (header && own(header[1]!)) start = i;
|
|
80
|
+
}
|
|
81
|
+
return spans;
|
|
82
|
+
}
|
|
83
|
+
function tomlString(value: string): string { return JSON.stringify(value); }
|
|
84
|
+
function readEntry(host: HostFile, text: string | null): unknown {
|
|
85
|
+
if (text === null) return undefined;
|
|
86
|
+
if (host.format === "toml") {
|
|
87
|
+
const doc = record(Bun.TOML.parse(text), host.file);
|
|
88
|
+
return doc.mcp_servers === undefined ? undefined : record(doc.mcp_servers, `${host.file} mcp_servers`)[SERVER_NAME];
|
|
89
|
+
}
|
|
90
|
+
let doc: unknown;
|
|
91
|
+
try { doc = JSON.parse(text); } catch { throw new Error(`${host.file}: not strict JSON; edit it by hand or fix it first`); }
|
|
92
|
+
const servers = record(doc, host.file)[host.key];
|
|
93
|
+
return servers === undefined ? undefined : record(servers, `${host.file} ${host.key}`)[SERVER_NAME];
|
|
94
|
+
}
|
|
95
|
+
function tomlBlock(text: string | null): string[] | undefined {
|
|
96
|
+
const lines = text ? text.split(/\r?\n/) : [], spans = tomlSpans(lines);
|
|
97
|
+
return spans.length ? spans.flatMap(([start, stop]) => lines.slice(start, stop)) : undefined;
|
|
98
|
+
}
|
|
99
|
+
/** Replace the vmem entry. TOML takes either an entry value or the exact prior block lines to restore. */
|
|
100
|
+
function putEntry(host: HostFile, text: string | null, value: unknown, rawToml?: string[], keepKey = false): string | null {
|
|
101
|
+
if (host.format === "toml") {
|
|
102
|
+
const source = text ?? "", newline = source.includes("\r\n") ? "\r\n" : "\n";
|
|
103
|
+
const lines = source.length ? source.split(/\r?\n/) : [];
|
|
104
|
+
const spans = tomlSpans(lines);
|
|
105
|
+
let block: string[] = rawToml ?? [];
|
|
106
|
+
if (!rawToml && value !== undefined) {
|
|
107
|
+
const entry = record(value, "entry");
|
|
108
|
+
block = [`[mcp_servers.${SERVER_NAME}]`, ...Object.entries(entry).map(([key, v]) => `${key} = ${Array.isArray(v) ? `[${v.map(item => tomlString(String(item))).join(", ")}]` : typeof v === "string" ? tomlString(v) : JSON.stringify(v)}`)];
|
|
109
|
+
}
|
|
110
|
+
if (spans.length) {
|
|
111
|
+
// Remove every owned table (last first so indexes stay valid), then insert at the first one.
|
|
112
|
+
// A later removed table also takes the blank separator just above it; blank runs elsewhere are untouched.
|
|
113
|
+
for (const [start, stop] of [...spans].reverse()) {
|
|
114
|
+
let from = start;
|
|
115
|
+
if (start !== spans[0]![0]) while (from > 0 && lines[from - 1]!.trim() === "") from--;
|
|
116
|
+
lines.splice(from, stop - from);
|
|
117
|
+
}
|
|
118
|
+
lines.splice(spans[0]![0], 0, ...block);
|
|
119
|
+
} else if (block.length) {
|
|
120
|
+
while (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
121
|
+
if (lines.length) lines.push("");
|
|
122
|
+
lines.push(...block);
|
|
123
|
+
}
|
|
124
|
+
while (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
125
|
+
const out = lines.length ? lines.join(newline) + newline : "";
|
|
126
|
+
Bun.TOML.parse(out); // never publish a file the host cannot parse
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
const doc = text === null ? {} : record(JSON.parse(text), host.file);
|
|
130
|
+
const servers = { ...record(doc[host.key] ?? {}, `${host.file} ${host.key}`) };
|
|
131
|
+
if (value === undefined) delete servers[SERVER_NAME]; else servers[SERVER_NAME] = value;
|
|
132
|
+
if (Object.keys(servers).length || keepKey) doc[host.key] = servers; else delete doc[host.key];
|
|
133
|
+
const indent = text && /\n(\t| +)"/.exec(text)?.[1] || " ";
|
|
134
|
+
return JSON.stringify(doc, null, indent) + "\n";
|
|
135
|
+
}
|
|
136
|
+
function hasKey(host: HostFile, text: string | null): boolean {
|
|
137
|
+
if (text === null) return false;
|
|
138
|
+
const doc = host.format === "toml" ? Bun.TOML.parse(text) as Record<string, unknown> : JSON.parse(text) as Record<string, unknown>;
|
|
139
|
+
return Object.hasOwn(doc, host.key);
|
|
140
|
+
}
|
|
141
|
+
/** One receipt per host configuration file, so host profiles sharing VMEM_HOME never share a receipt. */
|
|
142
|
+
function receiptFile(home: string, target: SetupTarget, file: string): string {
|
|
143
|
+
return join(home, "setup", `${target}-${createHash("sha256").update(resolve(file)).digest("hex").slice(0, 16)}.json`);
|
|
144
|
+
}
|
|
145
|
+
function readReceipt(file: string): Receipt | undefined {
|
|
146
|
+
const text = readText(file);
|
|
147
|
+
if (text === null) return undefined;
|
|
148
|
+
const value = JSON.parse(text) as Receipt;
|
|
149
|
+
if (value?.version !== 1 || typeof value.file !== "string") throw new Error(`${file}: unreadable setup receipt`);
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
export function defaultEntry(): McpEntry {
|
|
153
|
+
return { command: process.execPath, args: ["--smol", fileURLToPath(new URL("../bin/vmem.js", import.meta.url)), "mcp"] };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Plan, apply or undo vmem MCP registration. Dry-run writes nothing. Apply is idempotent, edits only
|
|
157
|
+
* the `vmem` server entry and records the prior value. Undo restores that prior value only while the
|
|
158
|
+
* entry is still the one setup installed; a later user edit is left alone and reported as refused.
|
|
159
|
+
*/
|
|
160
|
+
export function runSetup(target: string, mode: SetupMode, options: { home: string; env?: NodeJS.ProcessEnv; cwd?: string; entry?: McpEntry }): SetupReport {
|
|
161
|
+
const env = options.env ?? process.env, entry = options.entry ?? defaultEntry();
|
|
162
|
+
if (target !== "all" && !(SETUP_TARGETS as readonly string[]).includes(target)) throw new Error(`setup: unknown target ${target}; use ${SETUP_TARGETS.join("|")}|all`);
|
|
163
|
+
if (!isAbsolute(entry.command) || entry.args.some(arg => arg.includes("\0"))) throw new Error("setup: MCP command must be absolute");
|
|
164
|
+
const table = hosts(env, options.cwd ?? process.cwd());
|
|
165
|
+
const targets = target === "all" ? SETUP_TARGETS.filter(t => t !== "agy") : [target as SetupTarget];
|
|
166
|
+
const report: SetupReport = { mode, applied: false, entry, actions: [], notes: [
|
|
167
|
+
"Registers the stdio server `vmem mcp`; restart the host session to load it.",
|
|
168
|
+
"No console plugin manifest is written: vmem ships no console plugin yet.",
|
|
169
|
+
] };
|
|
170
|
+
if (target === "all") report.notes.push("`all` covers user-level hosts; run `vmem setup agy` inside a workspace for Antigravity.");
|
|
171
|
+
for (const name of targets) {
|
|
172
|
+
const host = table[name], receiptPath = receiptFile(options.home, name, host.file);
|
|
173
|
+
const action: SetupAction = { target: name, file: host.file, change: "none" };
|
|
174
|
+
report.actions.push(action);
|
|
175
|
+
try {
|
|
176
|
+
const text = readText(host.file), current = readEntry(host, text), receipt = readReceipt(receiptPath);
|
|
177
|
+
if (receipt && resolve(receipt.file) !== resolve(host.file)) throw new Error(`${receiptPath}: receipt belongs to ${receipt.file}, not ${host.file}`);
|
|
178
|
+
if (mode === "undo") {
|
|
179
|
+
if (!receipt) { action.change = "none"; action.reason = "no setup receipt"; continue; }
|
|
180
|
+
if (current !== undefined && !isDeepStrictEqual(current, receipt.installed)) { action.change = "refused"; action.reason = "vmem entry changed after setup; left as is"; continue; }
|
|
181
|
+
if (current === undefined) { action.change = "none"; action.reason = "vmem entry already absent; nothing restored"; unlinkSync(receiptPath); continue; }
|
|
182
|
+
action.change = receipt.prior === undefined || receipt.prior === null ? "remove" : "restore";
|
|
183
|
+
{
|
|
184
|
+
let next = current === undefined ? text : putEntry(host, text, receipt.prior ?? undefined, receipt.priorToml, receipt.keyExisted === true);
|
|
185
|
+
// A file setup created is removed again only when nothing else was added to it.
|
|
186
|
+
if (!receipt.existed && next !== null && readEntry(host, next) === undefined && isDeepStrictEqual(host.format === "toml" ? Bun.TOML.parse(next) : JSON.parse(next), {})) next = null;
|
|
187
|
+
if (next !== text && (next !== null || text !== null)) atomicWrite(host.file, next);
|
|
188
|
+
unlinkSync(receiptPath);
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (isDeepStrictEqual(current, entry)) { action.change = "unchanged"; continue; }
|
|
193
|
+
action.change = current === undefined ? "add" : "replace";
|
|
194
|
+
if (mode === "apply") {
|
|
195
|
+
const next = putEntry(host, text, entry);
|
|
196
|
+
// Keep the original prior value across repeated applies.
|
|
197
|
+
const prior: Receipt = receipt ?? { version: 1, target: name, file: host.file, existed: text !== null, keyExisted: hasKey(host, text), prior: current ?? null, ...(host.format === "toml" && current !== undefined ? { priorToml: tomlBlock(text) } : {}), installed: entry };
|
|
198
|
+
// The receipt is durable before the host file changes; a failed host write rolls the receipt back.
|
|
199
|
+
const previousReceipt = readText(receiptPath);
|
|
200
|
+
atomicWrite(receiptPath, JSON.stringify({ ...prior, installed: entry }, null, 2) + "\n");
|
|
201
|
+
try { atomicWrite(host.file, next); }
|
|
202
|
+
catch (error) {
|
|
203
|
+
try { atomicWrite(receiptPath, previousReceipt); }
|
|
204
|
+
catch { throw new Error(`${error instanceof Error ? error.message : "host write failed"}; host unchanged, stale receipt kept at ${receiptPath}`); }
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} catch (error) {
|
|
209
|
+
action.change = "refused"; action.reason = error instanceof Error ? error.message : "setup failed";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
report.applied = mode !== "dry-run" && report.actions.some(a => !["none", "unchanged", "refused"].includes(a.change));
|
|
213
|
+
return report;
|
|
214
|
+
}
|
package/src/source.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import type { VaultSpec } from "./config.ts";
|
|
4
|
+
export type SourceValue = string | string[];
|
|
5
|
+
export type SourceReference = {kind:"path"|"memory"|"url";value:string};
|
|
6
|
+
/** One string is one reference. Joined legacy strings are never silently split. */
|
|
7
|
+
export function sourceReferences(value:unknown):SourceReference[]{
|
|
8
|
+
const values=typeof value==="string"?[value]:Array.isArray(value)&&value.length>0&&value.length<=32?value:null;
|
|
9
|
+
if(!values)throw new Error("source must be a string or a nonempty list of at most 32 strings");
|
|
10
|
+
return values.map(item=>{
|
|
11
|
+
if(typeof item!=="string"||!item.trim()||Buffer.byteLength(item)>512||/\p{Cc}/u.test(item))throw new Error("source entries must be nonempty bounded strings without controls");
|
|
12
|
+
const text=item.trim();
|
|
13
|
+
if(text.startsWith("mem:")){
|
|
14
|
+
if(!/^mem:[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(text))throw new Error("source memory id is invalid");
|
|
15
|
+
return {kind:"memory",value:text};
|
|
16
|
+
}
|
|
17
|
+
if(/^[a-zA-Z]:[\\/]/.test(text))throw new Error("source paths must be workspace-relative");
|
|
18
|
+
if(/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(text)){
|
|
19
|
+
if(/\s/.test(text))throw new Error("source URL must be one absolute URL");
|
|
20
|
+
try{const url=new URL(text);if(["http:","https:","ftp:"].includes(url.protocol)&&!url.hostname)throw new Error();}
|
|
21
|
+
catch{throw new Error("source URL is invalid");}
|
|
22
|
+
return {kind:"url",value:text};
|
|
23
|
+
}
|
|
24
|
+
const path=text.startsWith("work/")?text.slice(5):text;
|
|
25
|
+
if(!path||isAbsolute(path)||path.includes("\\")||path.split("/").some(part=>!part||part==="."||part===".."))throw new Error("source paths must be workspace-relative without traversal");
|
|
26
|
+
return {kind:"path",value:path};
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function normalizeSource(value:unknown):SourceValue{
|
|
30
|
+
const entries=sourceReferences(value).map(ref=>ref.value);
|
|
31
|
+
return typeof value==="string"?entries[0]!:entries;
|
|
32
|
+
}
|
|
33
|
+
/** A configured project registry locates its workspace; standalone registries use their directory. */
|
|
34
|
+
export function sourceWorkspaceRoot(vault:VaultSpec):string|undefined{
|
|
35
|
+
if(!vault.registry)return undefined;
|
|
36
|
+
const fallback=resolve(dirname(vault.registry));let dir=fallback;
|
|
37
|
+
for(let depth=0;depth<32;depth++){
|
|
38
|
+
if(existsSync(join(dir,"workspace.config.yaml")))return dir;
|
|
39
|
+
const parent=dirname(dir);if(parent===dir)break;dir=parent;
|
|
40
|
+
}
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
export function missingSourceWarnings(value:unknown,root:string|undefined):string[]{
|
|
44
|
+
if(!root||value===undefined)return [];
|
|
45
|
+
const missing=new Set(sourceReferences(value).filter(ref=>ref.kind==="path"&&!existsSync(resolve(root,ref.value))).map(ref=>ref.value));
|
|
46
|
+
return [...missing].map(path=>`source path does not exist: ${path}`);
|
|
47
|
+
}
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { SpawnSyncReturns } from "node:child_process";
|
|
5
|
+
import { loadRegistry } from "./registry.ts";
|
|
6
|
+
import { isDenied, memoryGitArgv, memoryGitEnv } from "./platform.ts";
|
|
7
|
+
import type { WriteIssue } from "./write-types.ts";
|
|
8
|
+
|
|
9
|
+
/** Paths sync may stage; everything else in the clone is left alone (and reported). */
|
|
10
|
+
const STAGE_PREFIXES = ["notes/", "reports/"];
|
|
11
|
+
const STAGE_FILES = ["log"];
|
|
12
|
+
const GIT_TIMEOUT_MS = 60_000;
|
|
13
|
+
export interface SyncResult {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
applied: boolean;
|
|
16
|
+
preview?: boolean;
|
|
17
|
+
vault: string;
|
|
18
|
+
remote: { configured: string | null; origin: string | null; push?: string[]; matches: boolean };
|
|
19
|
+
branch: string | null;
|
|
20
|
+
/** Planned changed paths that will be staged and committed. */
|
|
21
|
+
paths: string[];
|
|
22
|
+
/** Changed paths outside notes/, reports/ and log: never staged. */
|
|
23
|
+
ignored: string[];
|
|
24
|
+
ahead: number;
|
|
25
|
+
behind: number;
|
|
26
|
+
fingerprint?: string;
|
|
27
|
+
commit?: string;
|
|
28
|
+
pushed?: boolean;
|
|
29
|
+
issues: WriteIssue[];
|
|
30
|
+
}
|
|
31
|
+
type Git = (args: string[], options?: { allowFail?: boolean }) => SpawnSyncReturns<string>;
|
|
32
|
+
const issue = (code: string, message: string): WriteIssue => ({ code, severity: "error", message });
|
|
33
|
+
/** Compare remotes after removing transport noise: scheme, credentials, trailing .git and slash. Host remotes compare case-insensitively without a trailing .git; local and file: paths keep their case and their .git suffix. */
|
|
34
|
+
export function sameRemote(a: string, b: string): boolean {
|
|
35
|
+
const norm = (url: string) => {
|
|
36
|
+
let value = url.trim();
|
|
37
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
|
|
38
|
+
try { const parsed = new URL(value); if (parsed.protocol === "file:") return decodeURIComponent(parsed.pathname).replace(/\/+$/, ""); value = `${parsed.hostname}${parsed.port && !["22", "443"].includes(parsed.port) ? `:${parsed.port}` : ""}${parsed.pathname}`; }
|
|
39
|
+
catch { return value; }
|
|
40
|
+
} else if (value.startsWith("/")) return value.replace(/\/+$/, ""); // local: `x` and `x.git` are different repositories
|
|
41
|
+
else if (/^[^/@:]+@[^:]+:/.test(value) || /^[^/:]+:[^/]/.test(value)) value = value.replace(/^[^@]+@/, "").replace(":", "/");
|
|
42
|
+
return value.replace(/\/+$/, "").replace(/\.git$/, "").toLowerCase();
|
|
43
|
+
};
|
|
44
|
+
return norm(a) === norm(b);
|
|
45
|
+
}
|
|
46
|
+
function gitIn(cwd: string, env: NodeJS.ProcessEnv): Git {
|
|
47
|
+
return (args, options = {}) => {
|
|
48
|
+
const argv = memoryGitArgv(["-C", cwd, ...args]);
|
|
49
|
+
const result = Bun.spawnSync(argv, { env: memoryGitEnv({ ...env, GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }, { repositoryIsolation: true }), stdin: "ignore", stdout: "pipe", stderr: "pipe", timeout: GIT_TIMEOUT_MS });
|
|
50
|
+
const out = { status: result.exitCode, stdout: result.stdout.toString(), stderr: result.stderr.toString(), pid: result.pid, output: [], signal: null } as unknown as SpawnSyncReturns<string>;
|
|
51
|
+
if (!options.allowFail && result.exitCode !== 0) throw Object.assign(new Error(`git ${args[0]} failed: ${out.stderr.trim().split("\n").at(-1)?.slice(0, 300) ?? "error"}`), { git: true });
|
|
52
|
+
return out;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function planned(path: string): boolean { return STAGE_FILES.includes(path) || STAGE_PREFIXES.some(prefix => path.startsWith(prefix)); }
|
|
56
|
+
/** Parse `status --porcelain=v1 -z` into changed paths (renames contribute both sides). */
|
|
57
|
+
function changed(git: Git): string[] {
|
|
58
|
+
const parts = git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]).stdout.split("\0").filter(Boolean), paths: string[] = [];
|
|
59
|
+
for (let i = 0; i < parts.length; i++) {
|
|
60
|
+
const entry = parts[i]!, code = entry.slice(0, 2);
|
|
61
|
+
paths.push(entry.slice(3));
|
|
62
|
+
if (code.includes("R") || code.includes("C")) paths.push(parts[++i]!);
|
|
63
|
+
}
|
|
64
|
+
return [...new Set(paths)].sort();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Explicit, never scheduled publication of one vault clone. Without `confirm` it fetches, checks the
|
|
69
|
+
* configured remote against `origin`, and previews what would be staged; nothing is committed or pushed.
|
|
70
|
+
* With `confirm` and the current fingerprint it stages only notes/, reports/ and log, commits, rebases on
|
|
71
|
+
* the upstream and pushes. Any failure (remote mismatch, divergence that cannot rebase, push refused,
|
|
72
|
+
* missing credentials) is reported with `ok: false`; it never reports success it did not observe.
|
|
73
|
+
*/
|
|
74
|
+
export function syncVault(home: string, request: { vault: string; confirm?: boolean; fingerprint?: string; message?: string }, options: { env?: NodeJS.ProcessEnv } = {}): SyncResult {
|
|
75
|
+
const config = loadRegistry(home), spec = config.vaults.find(v => v.id === request.vault);
|
|
76
|
+
const result: SyncResult = { ok: false, applied: false, vault: request.vault, remote: { configured: spec?.remote ?? null, origin: null, matches: false }, branch: null, paths: [], ignored: [], ahead: 0, behind: 0, issues: [] };
|
|
77
|
+
if (!spec) return { ...result, issues: [issue("V04", "unknown vault id")] };
|
|
78
|
+
if (!spec.remote) return { ...result, issues: [issue("SYNC", "vault has no configured remote; register it with --remote")] };
|
|
79
|
+
if (spec.write === "none") return { ...result, issues: [issue("POLICY", "vault write policy refuses sync")] };
|
|
80
|
+
const root = realpathSync(spec.path);
|
|
81
|
+
if (root !== spec.path) return { ...result, issues: [issue("V04", "registered vault path changed")] };
|
|
82
|
+
// Same rule as applyWrite: a denied vault root is never synced.
|
|
83
|
+
if (isDenied(root, config.denyPaths)) return { ...result, issues: [issue("V06", "vault path is denied; nothing was synced")] };
|
|
84
|
+
const git = gitIn(root, options.env ?? process.env);
|
|
85
|
+
try {
|
|
86
|
+
if (realpathSync(git(["rev-parse", "--show-toplevel"]).stdout.trim()) !== root) return { ...result, issues: [issue("SYNC", "vault path is not the root of its git repository")] };
|
|
87
|
+
const origin = git(["remote", "get-url", "origin"], { allowFail: true });
|
|
88
|
+
result.remote.origin = origin.status === 0 ? origin.stdout.trim() : null;
|
|
89
|
+
// Every effective fetch and push URL must be the configured remote; URL rewrites are refused outright
|
|
90
|
+
// because git does not report pushInsteadOf rewrites of an explicit pushurl.
|
|
91
|
+
const lines = (args: string[]) => { const out = git(args, { allowFail: true }); return out.status === 0 ? out.stdout.split("\n").map(line => line.trim()).filter(Boolean) : []; };
|
|
92
|
+
const fetchUrls = lines(["remote", "get-url", "--all", "origin"]), pushUrls = lines(["remote", "get-url", "--push", "--all", "origin"]);
|
|
93
|
+
const rewrites = lines(["config", "--get-regexp", "^url\\..*\\.(insteadof|pushinsteadof)$"]);
|
|
94
|
+
result.remote.push = pushUrls;
|
|
95
|
+
result.remote.matches = result.remote.origin !== null && rewrites.length === 0 && fetchUrls.length > 0 && pushUrls.length > 0 && [...fetchUrls, ...pushUrls].every(url => sameRemote(url, spec.remote!));
|
|
96
|
+
if (!result.remote.matches) return { ...result, issues: [issue("REMOTE", result.remote.origin === null ? "repository has no origin remote" : rewrites.length ? "git url insteadOf/pushInsteadOf rewrites are configured; refusing because the push destination cannot be verified" : "an origin fetch or push URL does not match the configured remote; nothing was fetched or pushed")] };
|
|
97
|
+
const branch = git(["symbolic-ref", "--quiet", "--short", "HEAD"], { allowFail: true });
|
|
98
|
+
if (branch.status !== 0) return { ...result, issues: [issue("SYNC", "detached HEAD; check out a branch first")] };
|
|
99
|
+
result.branch = branch.stdout.trim();
|
|
100
|
+
if (git(["rev-parse", "--verify", "--quiet", "REBASE_HEAD"], { allowFail: true }).status === 0 || git(["rev-parse", "--verify", "--quiet", "MERGE_HEAD"], { allowFail: true }).status === 0) return { ...result, issues: [issue("SYNC", "a rebase or merge is in progress; resolve it first")] };
|
|
101
|
+
const fetch = git(["fetch", "--quiet", "--no-tags", "origin", result.branch], { allowFail: true });
|
|
102
|
+
if (fetch.status !== 0) return { ...result, issues: [issue("FETCH", `fetch failed: ${fetch.stderr.trim().split("\n").at(-1)?.slice(0, 300) ?? "error"}`)] };
|
|
103
|
+
const upstream = `origin/${result.branch}`;
|
|
104
|
+
const hasUpstream = git(["rev-parse", "--verify", "--quiet", `refs/remotes/${upstream}`], { allowFail: true }).status === 0;
|
|
105
|
+
if (hasUpstream) {
|
|
106
|
+
const [behind, ahead] = git(["rev-list", "--left-right", "--count", `${upstream}...HEAD`]).stdout.trim().split(/\s+/).map(Number);
|
|
107
|
+
result.behind = behind ?? 0; result.ahead = ahead ?? 0;
|
|
108
|
+
}
|
|
109
|
+
const all = changed(git);
|
|
110
|
+
result.paths = all.filter(planned); result.ignored = all.filter(path => !planned(path));
|
|
111
|
+
// Denied files are never hashed, staged, committed or pushed: the whole sync refuses before any of that.
|
|
112
|
+
const denied = result.paths.filter(path => isDenied(join(root, path), config.denyPaths));
|
|
113
|
+
if (denied.length) return { ...result, paths: result.paths.filter(path => !denied.includes(path)), issues: [issue("V06", `${denied.length} changed path${denied.length === 1 ? " is" : "s are"} denied by policy; remove or revert ${denied.length === 1 ? "it" : "them"} before syncing`)] };
|
|
114
|
+
const head = git(["rev-parse", "HEAD"], { allowFail: true }).stdout.trim();
|
|
115
|
+
const remoteHead = hasUpstream ? git(["rev-parse", upstream]).stdout.trim() : null;
|
|
116
|
+
const contents = result.paths.map(path => [path, git(["hash-object", "--", path], { allowFail: true }).stdout.trim()]);
|
|
117
|
+
result.fingerprint = createHash("sha256").update(JSON.stringify({ vault: spec.id, remote: spec.remote, fetchUrls, pushUrls, branch: result.branch, head, remoteHead, contents })).digest("hex");
|
|
118
|
+
if (request.confirm !== true) return { ...result, ok: true, preview: true };
|
|
119
|
+
if (request.fingerprint !== result.fingerprint) return { ...result, issues: [issue("V09", "stale or missing preview fingerprint")] };
|
|
120
|
+
if (result.paths.length) {
|
|
121
|
+
git(["add", "--", ...result.paths]);
|
|
122
|
+
const message = request.message ?? `vmem sync: ${result.paths.length} path${result.paths.length === 1 ? "" : "s"}`;
|
|
123
|
+
git(["-c", "user.useConfigOnly=false", "commit", "--quiet", "--no-verify", "-m", message, "--", ...result.paths]);
|
|
124
|
+
result.commit = git(["rev-parse", "HEAD"]).stdout.trim();
|
|
125
|
+
}
|
|
126
|
+
if (hasUpstream && result.behind > 0) {
|
|
127
|
+
const rebase = git(["rebase", "--quiet", upstream], { allowFail: true });
|
|
128
|
+
if (rebase.status !== 0) {
|
|
129
|
+
git(["rebase", "--abort"], { allowFail: true });
|
|
130
|
+
return { ...result, applied: Boolean(result.commit), issues: [issue("DIVERGED", "local and remote histories conflict; the local commit was kept and nothing was pushed. Resolve with git, then sync again")] };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!result.commit && (!hasUpstream ? false : git(["rev-list", "--count", `${upstream}..HEAD`]).stdout.trim() === "0")) return { ...result, ok: true, applied: false, pushed: false };
|
|
134
|
+
if (lines(["remote", "get-url", "--push", "--all", "origin"]).some(url => !sameRemote(url, spec.remote!)) || lines(["config", "--get-regexp", "^url\\..*\\.(insteadof|pushinsteadof)$"]).length) return { ...result, applied: Boolean(result.commit), pushed: false, issues: [issue("REMOTE", "push destination changed before push; nothing was pushed")] };
|
|
135
|
+
const push = git(["push", "--quiet", "--porcelain", "origin", `HEAD:refs/heads/${result.branch}`], { allowFail: true });
|
|
136
|
+
if (push.status !== 0) return { ...result, applied: Boolean(result.commit), pushed: false, issues: [issue("PUSH", `push failed: ${push.stderr.trim().split("\n").at(-1)?.slice(0, 300) ?? "error"}; the local commit was kept`)] };
|
|
137
|
+
// Verify the remote really has our head before reporting success.
|
|
138
|
+
git(["fetch", "--quiet", "--no-tags", "origin", result.branch]);
|
|
139
|
+
const pushedHead = git(["rev-parse", upstream]).stdout.trim(), localHead = git(["rev-parse", "HEAD"]).stdout.trim();
|
|
140
|
+
if (pushedHead !== localHead) return { ...result, applied: true, pushed: false, issues: [issue("PUSH", "remote head does not match the pushed commit")] };
|
|
141
|
+
return { ...result, ok: true, applied: true, pushed: true, commit: localHead };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return { ...result, issues: [issue("SYNC", error instanceof Error ? error.message.slice(0, 400) : "git failed")] };
|
|
144
|
+
}
|
|
145
|
+
}
|