granite-mem 0.1.9 → 0.1.11
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 +116 -55
- package/dist/index.js +1267 -295
- package/package.json +18 -6
package/dist/index.js
CHANGED
|
@@ -2715,9 +2715,9 @@ function statusCommand(options = {}) {
|
|
|
2715
2715
|
}
|
|
2716
2716
|
|
|
2717
2717
|
// src/commands/mcp.ts
|
|
2718
|
-
import
|
|
2719
|
-
import
|
|
2720
|
-
import { spawn
|
|
2718
|
+
import fs19 from "fs";
|
|
2719
|
+
import path15 from "path";
|
|
2720
|
+
import { spawn } from "child_process";
|
|
2721
2721
|
|
|
2722
2722
|
// src/mcp/server.ts
|
|
2723
2723
|
import { serve as serve2 } from "@hono/node-server";
|
|
@@ -3165,7 +3165,21 @@ var STANDARD_FRONTMATTER_KEYS = /* @__PURE__ */ new Set([
|
|
|
3165
3165
|
]);
|
|
3166
3166
|
|
|
3167
3167
|
// src/version.ts
|
|
3168
|
-
|
|
3168
|
+
import fs13 from "fs";
|
|
3169
|
+
import path9 from "path";
|
|
3170
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3171
|
+
var GRANITE_VERSION = readPackageVersion();
|
|
3172
|
+
function readPackageVersion() {
|
|
3173
|
+
const packageJsonPath = path9.resolve(path9.dirname(fileURLToPath2(import.meta.url)), "../package.json");
|
|
3174
|
+
try {
|
|
3175
|
+
const packageJson = JSON.parse(fs13.readFileSync(packageJsonPath, "utf-8"));
|
|
3176
|
+
if (typeof packageJson.version === "string" && packageJson.version.length > 0) {
|
|
3177
|
+
return packageJson.version;
|
|
3178
|
+
}
|
|
3179
|
+
} catch {
|
|
3180
|
+
}
|
|
3181
|
+
return "0.0.0";
|
|
3182
|
+
}
|
|
3169
3183
|
|
|
3170
3184
|
// src/mcp/server.ts
|
|
3171
3185
|
var readOnlyAnnotations = {
|
|
@@ -3186,14 +3200,15 @@ var gardenAdjudicationReasonCodeSchema = z.enum([
|
|
|
3186
3200
|
"blocked",
|
|
3187
3201
|
"low-value"
|
|
3188
3202
|
]);
|
|
3189
|
-
function buildServerInstructions(runtime) {
|
|
3203
|
+
function buildServerInstructions(runtime, role) {
|
|
3190
3204
|
const types = runtime.listNoteTypes();
|
|
3191
3205
|
const signature = runtime.getTypeRegistrySignature();
|
|
3192
3206
|
const defaultType = runtime.getDefaultNoteType();
|
|
3207
|
+
const canWrite = role === "write";
|
|
3193
3208
|
const lines = [
|
|
3194
3209
|
"# Granite \u2014 Knowledge Compilation System",
|
|
3195
3210
|
"",
|
|
3196
|
-
"You are operating a local-first markdown knowledge base. You are the primary writer and gardener of this vault \u2014 the human rarely edits notes directly.",
|
|
3211
|
+
canWrite ? "You are operating a local-first markdown knowledge base. You are the primary writer and gardener of this vault \u2014 the human rarely edits notes directly." : "You are operating a local-first markdown knowledge base in read-only mode. Inspect and compile context from the vault, but do not attempt to mutate it.",
|
|
3197
3212
|
"",
|
|
3198
3213
|
"## Public Interface",
|
|
3199
3214
|
"",
|
|
@@ -3205,16 +3220,18 @@ function buildServerInstructions(runtime) {
|
|
|
3205
3220
|
"- **granite_query** \u2014 run structured queries over typed notes and indexed fields",
|
|
3206
3221
|
"- **granite_compile_context** \u2014 compile a typed brief for a topic or slug using the graph",
|
|
3207
3222
|
"- **granite_plan_garden** \u2014 compute the highest-leverage notes or clusters to revisit next",
|
|
3208
|
-
"- **granite_adjudicate_garden_opportunity** \u2014 explicitly downrank or clear a garden opportunity the operator has adjudicated",
|
|
3209
3223
|
"- **granite_list_garden_adjudications** \u2014 inspect the active operator adjudications influencing garden planning",
|
|
3210
|
-
"- **granite_capture_knowledge** \u2014 capture new knowledge into the vault",
|
|
3211
3224
|
"- **granite_extract_document** \u2014 read a local document into raw extracted text without importing it",
|
|
3212
|
-
"- **granite_import_document** \u2014 attach a file and create a linked source note with caller-provided content",
|
|
3213
3225
|
"- **granite_understand_note** \u2014 inspect a note in context, not in isolation",
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3226
|
+
...canWrite ? [
|
|
3227
|
+
"- **granite_adjudicate_garden_opportunity** \u2014 explicitly downrank or clear a garden opportunity the operator has adjudicated",
|
|
3228
|
+
"- **granite_capture_knowledge** \u2014 capture new knowledge into the vault",
|
|
3229
|
+
"- **granite_import_document** \u2014 attach a file and create a linked source note with caller-provided content",
|
|
3230
|
+
"- **granite_revise_note** \u2014 make targeted edits when workflow prompts are insufficient",
|
|
3231
|
+
"- **granite_dispose_note** \u2014 archive by default, delete only when intentional",
|
|
3232
|
+
"",
|
|
3233
|
+
"Use prompts for the higher-level workflows: refine notes, compile topics, and process the inbox."
|
|
3234
|
+
] : [],
|
|
3218
3235
|
"",
|
|
3219
3236
|
`## Note Types (vault registry, sig=${signature})`,
|
|
3220
3237
|
"",
|
|
@@ -3238,15 +3255,18 @@ function buildServerInstructions(runtime) {
|
|
|
3238
3255
|
"## Working Principles",
|
|
3239
3256
|
"",
|
|
3240
3257
|
"- **Read in context.** Prefer granite_understand_note over piecing together note, backlinks, and suggestions manually.",
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3258
|
+
...canWrite ? [
|
|
3259
|
+
"- **Capture first, refine second.** Capture quickly, then use workflow prompts to turn captures into durable knowledge.",
|
|
3260
|
+
"- **Link aggressively.** Use [[wikilinks]] in note bodies and follow recommendations after each revision.",
|
|
3261
|
+
"- **Archive before delete.** Knowledge systems should prefer reversible lifecycle transitions."
|
|
3262
|
+
] : [],
|
|
3263
|
+
canWrite ? "- **Respect the type registry.** Every note's type must exist in the vault config; write tools reject unknown types and report validation issues in their response." : "- **Respect the type registry.** Every note's type is governed by the vault config exposed in granite://vault/types.",
|
|
3245
3264
|
"- **Prefer explicit extraction for documents.** Use granite://notes/{slug} for markdown, granite://vault/types for type contracts, and granite_extract_document before summarizing imported documents."
|
|
3246
3265
|
);
|
|
3247
3266
|
return lines.join("\n");
|
|
3248
3267
|
}
|
|
3249
|
-
function createGraniteMcpServer(runtime) {
|
|
3268
|
+
function createGraniteMcpServer(runtime, options = {}) {
|
|
3269
|
+
const role = options.role ?? "write";
|
|
3250
3270
|
const server = new McpServer(
|
|
3251
3271
|
{
|
|
3252
3272
|
name: "granite",
|
|
@@ -3255,16 +3275,18 @@ function createGraniteMcpServer(runtime) {
|
|
|
3255
3275
|
},
|
|
3256
3276
|
{
|
|
3257
3277
|
capabilities: { logging: {} },
|
|
3258
|
-
instructions: buildServerInstructions(runtime)
|
|
3278
|
+
instructions: buildServerInstructions(runtime, role)
|
|
3259
3279
|
}
|
|
3260
3280
|
);
|
|
3261
|
-
registerTools(server, runtime);
|
|
3281
|
+
registerTools(server, runtime, role);
|
|
3262
3282
|
registerResources(server, runtime);
|
|
3263
|
-
|
|
3283
|
+
if (role === "write") {
|
|
3284
|
+
registerPrompts(server, runtime);
|
|
3285
|
+
}
|
|
3264
3286
|
return server;
|
|
3265
3287
|
}
|
|
3266
|
-
async function startGraniteMcpStdioServer(runtime) {
|
|
3267
|
-
const server = createGraniteMcpServer(runtime);
|
|
3288
|
+
async function startGraniteMcpStdioServer(runtime, options = {}) {
|
|
3289
|
+
const server = createGraniteMcpServer(runtime, options);
|
|
3268
3290
|
const transport = new StdioServerTransport();
|
|
3269
3291
|
await server.connect(transport);
|
|
3270
3292
|
console.error(`Granite MCP server listening on stdio for ${runtime.vaultRoot}`);
|
|
@@ -3332,7 +3354,8 @@ function buildTypeSchema(runtime, describe) {
|
|
|
3332
3354
|
`${describe} Valid types in this vault: ${names.join(", ")}.`
|
|
3333
3355
|
);
|
|
3334
3356
|
}
|
|
3335
|
-
function registerTools(server, runtime) {
|
|
3357
|
+
function registerTools(server, runtime, role) {
|
|
3358
|
+
const canWrite = role === "write";
|
|
3336
3359
|
server.registerTool("granite_wakeup", {
|
|
3337
3360
|
title: "Granite Wakeup",
|
|
3338
3361
|
description: "Load a compressed AAAK snapshot of the entire vault into context. Call this at the start of every session to know what exists, how notes cluster, and what changed recently. Costs ~200-500 tokens instead of reading every note.",
|
|
@@ -3365,7 +3388,7 @@ function registerTools(server, runtime) {
|
|
|
3365
3388
|
const result = runtime.planGarden({ anchor_slug, limit });
|
|
3366
3389
|
return toolResult(renderGardenPlanMarkdown(result));
|
|
3367
3390
|
});
|
|
3368
|
-
server.registerTool("granite_adjudicate_garden_opportunity", {
|
|
3391
|
+
if (canWrite) server.registerTool("granite_adjudicate_garden_opportunity", {
|
|
3369
3392
|
title: "Adjudicate Granite Garden Opportunity",
|
|
3370
3393
|
description: "Record an explicit operator adjudication for a specific garden opportunity. Use this when a deterministic signal is real but should be downranked locally, or when an old adjudication should be cleared.",
|
|
3371
3394
|
inputSchema: {
|
|
@@ -3403,7 +3426,7 @@ function registerTools(server, runtime) {
|
|
|
3403
3426
|
const adjudications = runtime.listGardenAdjudications();
|
|
3404
3427
|
return toolResult(renderGardenAdjudicationsMarkdown(adjudications));
|
|
3405
3428
|
});
|
|
3406
|
-
server.registerTool("granite_capture_knowledge", {
|
|
3429
|
+
if (canWrite) server.registerTool("granite_capture_knowledge", {
|
|
3407
3430
|
title: "Capture Granite Knowledge",
|
|
3408
3431
|
description: "Capture new knowledge into the vault. Use this for raw captures, semi-structured notes, or deliberately titled note drafts. Returns recommendations for what to connect or write next.",
|
|
3409
3432
|
inputSchema: {
|
|
@@ -3455,7 +3478,7 @@ function registerTools(server, runtime) {
|
|
|
3455
3478
|
});
|
|
3456
3479
|
return toolResult(renderMutationResultMarkdown("Captured", result.note, result.recommendations, result.validation));
|
|
3457
3480
|
});
|
|
3458
|
-
server.registerTool("granite_import_document", {
|
|
3481
|
+
if (canWrite) server.registerTool("granite_import_document", {
|
|
3459
3482
|
title: "Import Granite Document",
|
|
3460
3483
|
description: "Import a local document into the vault by attaching the file, creating a linked source note, and storing caller-provided document content in that note. This tool does not read, clean, or summarize the document for you.",
|
|
3461
3484
|
inputSchema: {
|
|
@@ -3501,7 +3524,7 @@ function registerTools(server, runtime) {
|
|
|
3501
3524
|
content: buildUnderstandNoteContent(result)
|
|
3502
3525
|
};
|
|
3503
3526
|
});
|
|
3504
|
-
server.registerTool("granite_revise_note", {
|
|
3527
|
+
if (canWrite) server.registerTool("granite_revise_note", {
|
|
3505
3528
|
title: "Revise Granite Note",
|
|
3506
3529
|
description: "Make a targeted revision to an existing note. Use this when a workflow prompt tells you exactly what to change, or when you need a precise manual intervention.",
|
|
3507
3530
|
inputSchema: {
|
|
@@ -3587,7 +3610,7 @@ Suggested stub: ${result.suggested_stub.type} "${result.suggested_stub.title}" \
|
|
|
3587
3610
|
].join("\n");
|
|
3588
3611
|
return toolResult(summary);
|
|
3589
3612
|
});
|
|
3590
|
-
server.registerTool("granite_dispose_note", {
|
|
3613
|
+
if (canWrite) server.registerTool("granite_dispose_note", {
|
|
3591
3614
|
title: "Dispose Granite Note",
|
|
3592
3615
|
description: "Remove a note from the active knowledge loop. Archive by default; delete only when you are intentionally discarding the note.",
|
|
3593
3616
|
inputSchema: {
|
|
@@ -3841,6 +3864,7 @@ function createGraniteMcpHttpApp(runtime, options) {
|
|
|
3841
3864
|
const app = new Hono2();
|
|
3842
3865
|
const allowedHosts = buildAllowedHosts(options.host, options.port);
|
|
3843
3866
|
const allowedOrigins = buildAllowedOrigins(options.host, options.port, options.allowedOrigins ?? []);
|
|
3867
|
+
const role = options.role ?? "write";
|
|
3844
3868
|
app.use("/mcp", async (c, next) => {
|
|
3845
3869
|
const requestHost = (c.req.header("host") ?? "").toLowerCase();
|
|
3846
3870
|
if (allowedHosts.size > 0 && !allowedHosts.has(requestHost)) {
|
|
@@ -3869,7 +3893,7 @@ function createGraniteMcpHttpApp(runtime, options) {
|
|
|
3869
3893
|
app.get("/health", (c) => c.json({ status: "ok", name: "granite-mcp", version: GRANITE_VERSION }));
|
|
3870
3894
|
app.all("/mcp", async (c) => {
|
|
3871
3895
|
const origin = c.req.header("origin");
|
|
3872
|
-
const server = createGraniteMcpServer(runtime);
|
|
3896
|
+
const server = createGraniteMcpServer(runtime, { role });
|
|
3873
3897
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
3874
3898
|
sessionIdGenerator: void 0,
|
|
3875
3899
|
enableJsonResponse: options.jsonResponse ?? false
|
|
@@ -3941,13 +3965,13 @@ function corsHeaders(origin, allowedOrigins) {
|
|
|
3941
3965
|
}
|
|
3942
3966
|
|
|
3943
3967
|
// src/mcp/runtime.ts
|
|
3944
|
-
import
|
|
3945
|
-
import
|
|
3968
|
+
import fs18 from "fs";
|
|
3969
|
+
import path14 from "path";
|
|
3946
3970
|
|
|
3947
3971
|
// src/core/assets.ts
|
|
3948
3972
|
import { createHash } from "crypto";
|
|
3949
|
-
import
|
|
3950
|
-
import
|
|
3973
|
+
import fs14 from "fs";
|
|
3974
|
+
import path10 from "path";
|
|
3951
3975
|
var MIME_TYPES = {
|
|
3952
3976
|
".gif": "image/gif",
|
|
3953
3977
|
".jpeg": "image/jpeg",
|
|
@@ -3967,23 +3991,23 @@ var MIME_TYPES = {
|
|
|
3967
3991
|
};
|
|
3968
3992
|
var IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
|
|
3969
3993
|
function attachAsset(vaultRoot, filePath) {
|
|
3970
|
-
if (!
|
|
3994
|
+
if (!fs14.existsSync(filePath)) {
|
|
3971
3995
|
throw new Error(`File not found: ${filePath}`);
|
|
3972
3996
|
}
|
|
3973
|
-
const assetsDir =
|
|
3974
|
-
|
|
3975
|
-
const fileName =
|
|
3976
|
-
const destPath =
|
|
3997
|
+
const assetsDir = path10.join(vaultRoot, "assets");
|
|
3998
|
+
fs14.mkdirSync(assetsDir, { recursive: true });
|
|
3999
|
+
const fileName = path10.basename(filePath);
|
|
4000
|
+
const destPath = path10.join(assetsDir, fileName);
|
|
3977
4001
|
let finalName = fileName;
|
|
3978
|
-
if (
|
|
3979
|
-
const ext2 =
|
|
3980
|
-
const base =
|
|
4002
|
+
if (fs14.existsSync(destPath)) {
|
|
4003
|
+
const ext2 = path10.extname(fileName);
|
|
4004
|
+
const base = path10.basename(fileName, ext2);
|
|
3981
4005
|
finalName = `${base}-${Date.now()}${ext2}`;
|
|
3982
4006
|
}
|
|
3983
|
-
const finalPath =
|
|
3984
|
-
|
|
3985
|
-
const relativePath =
|
|
3986
|
-
const ext =
|
|
4007
|
+
const finalPath = path10.join(assetsDir, finalName);
|
|
4008
|
+
fs14.copyFileSync(filePath, finalPath);
|
|
4009
|
+
const relativePath = path10.posix.join("assets", finalName);
|
|
4010
|
+
const ext = path10.extname(finalName).toLowerCase();
|
|
3987
4011
|
return {
|
|
3988
4012
|
file: finalName,
|
|
3989
4013
|
path: finalPath,
|
|
@@ -3998,11 +4022,11 @@ function assetResourceUri(fileName) {
|
|
|
3998
4022
|
return `granite://assets/${encodeURIComponent(fileName)}`;
|
|
3999
4023
|
}
|
|
4000
4024
|
function listAssetFiles(vaultRoot) {
|
|
4001
|
-
const assetsDir =
|
|
4002
|
-
if (!
|
|
4025
|
+
const assetsDir = path10.join(vaultRoot, "assets");
|
|
4026
|
+
if (!fs14.existsSync(assetsDir)) {
|
|
4003
4027
|
return [];
|
|
4004
4028
|
}
|
|
4005
|
-
return
|
|
4029
|
+
return fs14.readdirSync(assetsDir).filter((file) => fs14.statSync(path10.join(assetsDir, file)).isFile()).sort((a, b) => a.localeCompare(b));
|
|
4006
4030
|
}
|
|
4007
4031
|
function completeAssetFiles(vaultRoot, prefix = "") {
|
|
4008
4032
|
const normalizedPrefix = prefix.toLowerCase();
|
|
@@ -4010,12 +4034,12 @@ function completeAssetFiles(vaultRoot, prefix = "") {
|
|
|
4010
4034
|
}
|
|
4011
4035
|
function readAssetResource(vaultRoot, fileName) {
|
|
4012
4036
|
const resolvedName = sanitizeAssetFileName(fileName);
|
|
4013
|
-
const fullPath =
|
|
4014
|
-
if (!
|
|
4037
|
+
const fullPath = path10.join(vaultRoot, "assets", resolvedName);
|
|
4038
|
+
if (!fs14.existsSync(fullPath)) {
|
|
4015
4039
|
throw new Error(`Asset not found: ${resolvedName}`);
|
|
4016
4040
|
}
|
|
4017
4041
|
const mimeType = getAssetMimeType(resolvedName);
|
|
4018
|
-
const buffer =
|
|
4042
|
+
const buffer = fs14.readFileSync(fullPath);
|
|
4019
4043
|
const uri = assetResourceUri(resolvedName);
|
|
4020
4044
|
if (isTextMimeType(mimeType)) {
|
|
4021
4045
|
return {
|
|
@@ -4031,16 +4055,16 @@ function readAssetResource(vaultRoot, fileName) {
|
|
|
4031
4055
|
};
|
|
4032
4056
|
}
|
|
4033
4057
|
function getAssetMimeType(fileName) {
|
|
4034
|
-
const ext =
|
|
4058
|
+
const ext = path10.extname(fileName).toLowerCase();
|
|
4035
4059
|
return MIME_TYPES[ext] ?? "application/octet-stream";
|
|
4036
4060
|
}
|
|
4037
4061
|
function sha256File(filePath) {
|
|
4038
4062
|
const hash = createHash("sha256");
|
|
4039
|
-
hash.update(
|
|
4063
|
+
hash.update(fs14.readFileSync(filePath));
|
|
4040
4064
|
return hash.digest("hex");
|
|
4041
4065
|
}
|
|
4042
4066
|
function sanitizeAssetFileName(fileName) {
|
|
4043
|
-
const normalized =
|
|
4067
|
+
const normalized = path10.basename(fileName);
|
|
4044
4068
|
if (!normalized || normalized === "." || normalized === ".." || normalized !== fileName) {
|
|
4045
4069
|
throw new Error(`Invalid asset name: ${fileName}`);
|
|
4046
4070
|
}
|
|
@@ -4051,13 +4075,13 @@ function isTextMimeType(mimeType) {
|
|
|
4051
4075
|
}
|
|
4052
4076
|
|
|
4053
4077
|
// src/core/garden-adjudications.ts
|
|
4054
|
-
import
|
|
4055
|
-
import
|
|
4078
|
+
import fs15 from "fs";
|
|
4079
|
+
import path11 from "path";
|
|
4056
4080
|
var GARDEN_ADJUDICATION_STORE_VERSION = 1;
|
|
4057
4081
|
var GARDEN_ADJUDICATION_DEFAULT_BLOCKED_RECHECK_DAYS = 14;
|
|
4058
4082
|
var GARDEN_ADJUDICATION_FILE = "garden-adjudications.json";
|
|
4059
4083
|
function getGardenAdjudicationsPath(vaultRoot) {
|
|
4060
|
-
return
|
|
4084
|
+
return path11.join(getGraniteDir(vaultRoot), GARDEN_ADJUDICATION_FILE);
|
|
4061
4085
|
}
|
|
4062
4086
|
function createEmptyGardenAdjudicationStore() {
|
|
4063
4087
|
return {
|
|
@@ -4067,19 +4091,19 @@ function createEmptyGardenAdjudicationStore() {
|
|
|
4067
4091
|
}
|
|
4068
4092
|
function readGardenAdjudicationStore(vaultRoot) {
|
|
4069
4093
|
const filePath = getGardenAdjudicationsPath(vaultRoot);
|
|
4070
|
-
if (!
|
|
4094
|
+
if (!fs15.existsSync(filePath)) {
|
|
4071
4095
|
return createEmptyGardenAdjudicationStore();
|
|
4072
4096
|
}
|
|
4073
|
-
const parsed = JSON.parse(
|
|
4097
|
+
const parsed = JSON.parse(fs15.readFileSync(filePath, "utf-8"));
|
|
4074
4098
|
return normalizeGardenAdjudicationStore(parsed);
|
|
4075
4099
|
}
|
|
4076
4100
|
function writeGardenAdjudicationStore(vaultRoot, store) {
|
|
4077
4101
|
const filePath = getGardenAdjudicationsPath(vaultRoot);
|
|
4078
|
-
|
|
4102
|
+
fs15.mkdirSync(path11.dirname(filePath), { recursive: true });
|
|
4079
4103
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
4080
|
-
|
|
4104
|
+
fs15.writeFileSync(tempPath, `${JSON.stringify(store, null, 2)}
|
|
4081
4105
|
`, "utf-8");
|
|
4082
|
-
|
|
4106
|
+
fs15.renameSync(tempPath, filePath);
|
|
4083
4107
|
}
|
|
4084
4108
|
function normalizeGardenAdjudicationStore(value) {
|
|
4085
4109
|
if (!isRecord(value)) {
|
|
@@ -4207,9 +4231,9 @@ function isRecord(value) {
|
|
|
4207
4231
|
}
|
|
4208
4232
|
|
|
4209
4233
|
// src/core/extract-document.ts
|
|
4210
|
-
import
|
|
4234
|
+
import fs17 from "fs";
|
|
4211
4235
|
import os2 from "os";
|
|
4212
|
-
import
|
|
4236
|
+
import path13 from "path";
|
|
4213
4237
|
import * as childProcess from "child_process";
|
|
4214
4238
|
import mammoth from "mammoth";
|
|
4215
4239
|
import XLSX from "xlsx";
|
|
@@ -4217,13 +4241,13 @@ import { XMLParser } from "fast-xml-parser";
|
|
|
4217
4241
|
import { unzipSync } from "fflate";
|
|
4218
4242
|
|
|
4219
4243
|
// src/core/import-document.ts
|
|
4220
|
-
import
|
|
4221
|
-
import
|
|
4244
|
+
import fs16 from "fs";
|
|
4245
|
+
import path12 from "path";
|
|
4222
4246
|
function importDocument(vaultRoot, config, filePath, options = {}) {
|
|
4223
4247
|
const asset = attachAsset(vaultRoot, filePath);
|
|
4224
4248
|
const title = normalizeTitle(options.title) ?? defaultImportedDocumentTitle(filePath);
|
|
4225
4249
|
const created = createNote(vaultRoot, config, "source", title, buildImportedDocumentBody(asset, options.content));
|
|
4226
|
-
const raw =
|
|
4250
|
+
const raw = fs16.readFileSync(created.filepath, "utf-8");
|
|
4227
4251
|
const { frontmatter, body } = parseFrontmatter(raw);
|
|
4228
4252
|
frontmatter.status = "inbox";
|
|
4229
4253
|
frontmatter.source = "extraction";
|
|
@@ -4236,14 +4260,14 @@ function importDocument(vaultRoot, config, filePath, options = {}) {
|
|
|
4236
4260
|
frontmatter.document_mime = asset.mime_type;
|
|
4237
4261
|
frontmatter.document_sha256 = asset.sha256;
|
|
4238
4262
|
frontmatter.document_resource_uri = asset.resource_uri;
|
|
4239
|
-
|
|
4263
|
+
fs16.writeFileSync(created.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
4240
4264
|
return {
|
|
4241
4265
|
note: readNote(created.filepath),
|
|
4242
4266
|
asset
|
|
4243
4267
|
};
|
|
4244
4268
|
}
|
|
4245
4269
|
function defaultImportedDocumentTitle(filePath) {
|
|
4246
|
-
const base =
|
|
4270
|
+
const base = path12.basename(filePath, path12.extname(filePath)).trim();
|
|
4247
4271
|
if (!base) {
|
|
4248
4272
|
return "Imported Document";
|
|
4249
4273
|
}
|
|
@@ -4316,8 +4340,8 @@ var PPTX_XML_PARSER = new XMLParser({
|
|
|
4316
4340
|
trimValues: true
|
|
4317
4341
|
});
|
|
4318
4342
|
async function extractDocument(filePath) {
|
|
4319
|
-
const resolvedPath =
|
|
4320
|
-
const extension =
|
|
4343
|
+
const resolvedPath = path13.resolve(filePath);
|
|
4344
|
+
const extension = path13.extname(resolvedPath).toLowerCase();
|
|
4321
4345
|
switch (extension) {
|
|
4322
4346
|
case ".pdf":
|
|
4323
4347
|
return extractPdfWithFerrules(resolvedPath);
|
|
@@ -4328,7 +4352,7 @@ async function extractDocument(filePath) {
|
|
|
4328
4352
|
case ".pptx":
|
|
4329
4353
|
return extractPptx(resolvedPath);
|
|
4330
4354
|
default:
|
|
4331
|
-
throw new Error(`Unsupported document type: "${extension ||
|
|
4355
|
+
throw new Error(`Unsupported document type: "${extension || path13.basename(resolvedPath)}". Expected one of: .pdf, .docx, .xlsx, .pptx`);
|
|
4332
4356
|
}
|
|
4333
4357
|
}
|
|
4334
4358
|
async function extractDocx(filePath) {
|
|
@@ -4388,7 +4412,7 @@ async function extractXlsx(filePath) {
|
|
|
4388
4412
|
};
|
|
4389
4413
|
}
|
|
4390
4414
|
async function extractPptx(filePath) {
|
|
4391
|
-
const archive = unzipSync(new Uint8Array(
|
|
4415
|
+
const archive = unzipSync(new Uint8Array(fs17.readFileSync(filePath)));
|
|
4392
4416
|
const slideNames = Object.keys(archive).filter((name) => /^ppt\/slides\/slide\d+\.xml$/i.test(name)).sort(comparePptxSlides);
|
|
4393
4417
|
const sections = [];
|
|
4394
4418
|
for (const slideName of slideNames) {
|
|
@@ -4398,7 +4422,7 @@ async function extractPptx(filePath) {
|
|
|
4398
4422
|
if (textRuns.length === 0) {
|
|
4399
4423
|
continue;
|
|
4400
4424
|
}
|
|
4401
|
-
sections.push(`Slide: ${
|
|
4425
|
+
sections.push(`Slide: ${path13.basename(slideName, ".xml")}`);
|
|
4402
4426
|
sections.push(...textRuns);
|
|
4403
4427
|
}
|
|
4404
4428
|
const rawText = normalizeRawText(sections.join("\n"));
|
|
@@ -4420,7 +4444,7 @@ async function extractPptx(filePath) {
|
|
|
4420
4444
|
}
|
|
4421
4445
|
async function extractPdfWithFerrules(filePath) {
|
|
4422
4446
|
const titleHint = defaultImportedDocumentTitle(filePath);
|
|
4423
|
-
const tmpDir =
|
|
4447
|
+
const tmpDir = fs17.mkdtempSync(path13.join(os2.tmpdir(), "granite-ferrules-"));
|
|
4424
4448
|
try {
|
|
4425
4449
|
try {
|
|
4426
4450
|
childProcess.execFileSync("ferrules", [filePath, "--output-dir", tmpDir], {
|
|
@@ -4453,7 +4477,7 @@ async function extractPdfWithFerrules(filePath) {
|
|
|
4453
4477
|
title_hint: titleHint
|
|
4454
4478
|
};
|
|
4455
4479
|
} finally {
|
|
4456
|
-
|
|
4480
|
+
fs17.rmSync(tmpDir, { recursive: true, force: true });
|
|
4457
4481
|
}
|
|
4458
4482
|
}
|
|
4459
4483
|
function comparePptxSlides(left, right) {
|
|
@@ -4496,7 +4520,7 @@ function visitNode(value, onStringField) {
|
|
|
4496
4520
|
function extractFerrulesRawText(outputFiles) {
|
|
4497
4521
|
const markdownFiles = outputFiles.filter((file) => /\.(md|markdown|txt)$/i.test(file));
|
|
4498
4522
|
for (const markdownFile of markdownFiles) {
|
|
4499
|
-
const content = normalizeRawText(
|
|
4523
|
+
const content = normalizeRawText(fs17.readFileSync(markdownFile, "utf-8"));
|
|
4500
4524
|
if (content) {
|
|
4501
4525
|
return content;
|
|
4502
4526
|
}
|
|
@@ -4521,7 +4545,7 @@ function extractFerrulesRawText(outputFiles) {
|
|
|
4521
4545
|
function detectFerrulesReadingBasis(outputFiles) {
|
|
4522
4546
|
const jsonFiles = outputFiles.filter((file) => /\.json$/i.test(file));
|
|
4523
4547
|
for (const jsonFile of jsonFiles) {
|
|
4524
|
-
const content =
|
|
4548
|
+
const content = fs17.readFileSync(jsonFile, "utf-8").toLowerCase();
|
|
4525
4549
|
if (content.includes('"ocr"') || content.includes("ocr_") || content.includes("ocr ")) {
|
|
4526
4550
|
return "ocr";
|
|
4527
4551
|
}
|
|
@@ -4535,7 +4559,7 @@ function preferFerrulesResultsJson(left, right) {
|
|
|
4535
4559
|
}
|
|
4536
4560
|
function safeReadJson(filePath) {
|
|
4537
4561
|
try {
|
|
4538
|
-
return JSON.parse(
|
|
4562
|
+
return JSON.parse(fs17.readFileSync(filePath, "utf-8"));
|
|
4539
4563
|
} catch {
|
|
4540
4564
|
return null;
|
|
4541
4565
|
}
|
|
@@ -4580,10 +4604,10 @@ function collectLeafStrings(value) {
|
|
|
4580
4604
|
return dedupeStrings(out);
|
|
4581
4605
|
}
|
|
4582
4606
|
function walkFiles(root) {
|
|
4583
|
-
const entries =
|
|
4607
|
+
const entries = fs17.readdirSync(root, { withFileTypes: true });
|
|
4584
4608
|
const files = [];
|
|
4585
4609
|
for (const entry of entries) {
|
|
4586
|
-
const fullPath =
|
|
4610
|
+
const fullPath = path13.join(root, entry.name);
|
|
4587
4611
|
if (entry.isDirectory()) {
|
|
4588
4612
|
files.push(...walkFiles(fullPath));
|
|
4589
4613
|
continue;
|
|
@@ -5010,7 +5034,7 @@ var GraniteMcpRuntime = class {
|
|
|
5010
5034
|
lastSignature;
|
|
5011
5035
|
lastIndexCheckAt = 0;
|
|
5012
5036
|
constructor(vaultRoot, options = {}) {
|
|
5013
|
-
this.vaultRoot =
|
|
5037
|
+
this.vaultRoot = path14.resolve(vaultRoot);
|
|
5014
5038
|
this.config = loadConfig(this.vaultRoot);
|
|
5015
5039
|
this.db = openDatabase(this.vaultRoot);
|
|
5016
5040
|
this.indexCheckIntervalMs = options.indexCheckIntervalMs ?? 1500;
|
|
@@ -5456,7 +5480,7 @@ var GraniteMcpRuntime = class {
|
|
|
5456
5480
|
};
|
|
5457
5481
|
}
|
|
5458
5482
|
async extractDocument(input) {
|
|
5459
|
-
const resolvedPath =
|
|
5483
|
+
const resolvedPath = path14.isAbsolute(input.file_path) ? input.file_path : path14.join(this.vaultRoot, input.file_path);
|
|
5460
5484
|
return extractDocument(resolvedPath);
|
|
5461
5485
|
}
|
|
5462
5486
|
updateNote(slug, input) {
|
|
@@ -5480,7 +5504,7 @@ var GraniteMcpRuntime = class {
|
|
|
5480
5504
|
}
|
|
5481
5505
|
reviseNote(slug, input) {
|
|
5482
5506
|
const note = this.requireNote(slug);
|
|
5483
|
-
const raw =
|
|
5507
|
+
const raw = fs18.readFileSync(note.filepath, "utf-8");
|
|
5484
5508
|
const { frontmatter, body: existingBody } = parseFrontmatter(raw);
|
|
5485
5509
|
let body = existingBody;
|
|
5486
5510
|
let nextFilepath = note.filepath;
|
|
@@ -5492,10 +5516,10 @@ var GraniteMcpRuntime = class {
|
|
|
5492
5516
|
throw new Error(`Unknown note type "${input.type}". Valid types in this vault: ${valid}.`);
|
|
5493
5517
|
}
|
|
5494
5518
|
if (frontmatter.type !== input.type) {
|
|
5495
|
-
const targetFolder =
|
|
5496
|
-
|
|
5497
|
-
const candidatePath =
|
|
5498
|
-
if (candidatePath !== note.filepath &&
|
|
5519
|
+
const targetFolder = path14.join(this.vaultRoot, nextType.folder);
|
|
5520
|
+
fs18.mkdirSync(targetFolder, { recursive: true });
|
|
5521
|
+
const candidatePath = path14.join(targetFolder, `${slug}.md`);
|
|
5522
|
+
if (candidatePath !== note.filepath && fs18.existsSync(candidatePath)) {
|
|
5499
5523
|
throw new Error(`Cannot move "${slug}" to type "${input.type}" because ${candidatePath} already exists.`);
|
|
5500
5524
|
}
|
|
5501
5525
|
frontmatter.type = input.type;
|
|
@@ -5554,9 +5578,9 @@ ${input.append}
|
|
|
5554
5578
|
}
|
|
5555
5579
|
}
|
|
5556
5580
|
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
5557
|
-
|
|
5558
|
-
if (movedType && nextFilepath !== note.filepath &&
|
|
5559
|
-
|
|
5581
|
+
fs18.writeFileSync(nextFilepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
5582
|
+
if (movedType && nextFilepath !== note.filepath && fs18.existsSync(note.filepath)) {
|
|
5583
|
+
fs18.unlinkSync(note.filepath);
|
|
5560
5584
|
}
|
|
5561
5585
|
return this.afterWrite(slug, movedType || input.title !== void 0 || (input.aliases?.length ?? 0) > 0);
|
|
5562
5586
|
}
|
|
@@ -5576,7 +5600,7 @@ ${input.append}
|
|
|
5576
5600
|
note: revised.note
|
|
5577
5601
|
};
|
|
5578
5602
|
}
|
|
5579
|
-
|
|
5603
|
+
fs18.unlinkSync(note.filepath);
|
|
5580
5604
|
this.refreshIndex(true);
|
|
5581
5605
|
return {
|
|
5582
5606
|
slug,
|
|
@@ -5587,7 +5611,7 @@ ${input.append}
|
|
|
5587
5611
|
};
|
|
5588
5612
|
}
|
|
5589
5613
|
readVaultConfigRaw() {
|
|
5590
|
-
return
|
|
5614
|
+
return fs18.readFileSync(path14.join(this.vaultRoot, CONFIG_FILENAME), "utf-8");
|
|
5591
5615
|
}
|
|
5592
5616
|
readVaultTypesJson() {
|
|
5593
5617
|
return JSON.stringify({
|
|
@@ -5600,7 +5624,7 @@ ${input.append}
|
|
|
5600
5624
|
}
|
|
5601
5625
|
readNoteMarkdown(slug) {
|
|
5602
5626
|
const note = this.requireNote(slug);
|
|
5603
|
-
return
|
|
5627
|
+
return fs18.readFileSync(note.filepath, "utf-8");
|
|
5604
5628
|
}
|
|
5605
5629
|
readAsset(fileName) {
|
|
5606
5630
|
return readAssetResource(this.vaultRoot, fileName);
|
|
@@ -6037,7 +6061,7 @@ ${input.append}
|
|
|
6037
6061
|
};
|
|
6038
6062
|
}
|
|
6039
6063
|
applyMutations(filepath, input) {
|
|
6040
|
-
const raw =
|
|
6064
|
+
const raw = fs18.readFileSync(filepath, "utf-8");
|
|
6041
6065
|
const { frontmatter, body: existingBody } = parseFrontmatter(raw);
|
|
6042
6066
|
let body = existingBody;
|
|
6043
6067
|
if (input.title !== void 0) {
|
|
@@ -6077,7 +6101,7 @@ ${input.append}
|
|
|
6077
6101
|
`;
|
|
6078
6102
|
}
|
|
6079
6103
|
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
6080
|
-
|
|
6104
|
+
fs18.writeFileSync(filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
6081
6105
|
}
|
|
6082
6106
|
afterWrite(slug, fullRebuild) {
|
|
6083
6107
|
if (fullRebuild) {
|
|
@@ -6109,16 +6133,16 @@ ${input.append}
|
|
|
6109
6133
|
let noteCount = 0;
|
|
6110
6134
|
let latestNoteMtimeMs = 0;
|
|
6111
6135
|
for (const typeConfig of Object.values(this.config.note_types)) {
|
|
6112
|
-
const folder =
|
|
6113
|
-
if (!
|
|
6136
|
+
const folder = path14.join(this.vaultRoot, typeConfig.folder);
|
|
6137
|
+
if (!fs18.existsSync(folder)) {
|
|
6114
6138
|
continue;
|
|
6115
6139
|
}
|
|
6116
|
-
for (const entry of
|
|
6140
|
+
for (const entry of fs18.readdirSync(folder)) {
|
|
6117
6141
|
if (!entry.endsWith(".md")) {
|
|
6118
6142
|
continue;
|
|
6119
6143
|
}
|
|
6120
6144
|
noteCount += 1;
|
|
6121
|
-
const stat =
|
|
6145
|
+
const stat = fs18.statSync(path14.join(folder, entry));
|
|
6122
6146
|
latestNoteMtimeMs = Math.max(latestNoteMtimeMs, stat.mtimeMs);
|
|
6123
6147
|
}
|
|
6124
6148
|
}
|
|
@@ -6130,8 +6154,8 @@ ${input.append}
|
|
|
6130
6154
|
};
|
|
6131
6155
|
}
|
|
6132
6156
|
getConfigMtimeMs() {
|
|
6133
|
-
const configPath =
|
|
6134
|
-
return
|
|
6157
|
+
const configPath = path14.join(this.vaultRoot, CONFIG_FILENAME);
|
|
6158
|
+
return fs18.existsSync(configPath) ? fs18.statSync(configPath).mtimeMs : 0;
|
|
6135
6159
|
}
|
|
6136
6160
|
getLastRebuildMs() {
|
|
6137
6161
|
const value = this.getMetaValue("last_rebuild");
|
|
@@ -6395,145 +6419,19 @@ function jaccard(left, right) {
|
|
|
6395
6419
|
return union === 0 ? 0 : intersection / union;
|
|
6396
6420
|
}
|
|
6397
6421
|
|
|
6398
|
-
// src/tunnel.ts
|
|
6399
|
-
import { spawn } from "child_process";
|
|
6400
|
-
async function startTunnel(options) {
|
|
6401
|
-
if (options.provider === "cloudflare") {
|
|
6402
|
-
return startCloudflareTunnel(options);
|
|
6403
|
-
}
|
|
6404
|
-
return startTailscaleFunnel(options);
|
|
6405
|
-
}
|
|
6406
|
-
function stopTunnel(tunnelProcess) {
|
|
6407
|
-
if (!tunnelProcess.killed) {
|
|
6408
|
-
tunnelProcess.kill("SIGTERM");
|
|
6409
|
-
}
|
|
6410
|
-
}
|
|
6411
|
-
function spawnError(binary, installUrl, err) {
|
|
6412
|
-
if (err.code === "ENOENT") {
|
|
6413
|
-
return new Error(`${binary} is not installed. Install it from ${installUrl}`);
|
|
6414
|
-
}
|
|
6415
|
-
return new Error(`Failed to start ${binary}: ${err.message}`);
|
|
6416
|
-
}
|
|
6417
|
-
function startCloudflareTunnel(options) {
|
|
6418
|
-
return new Promise((resolve, reject) => {
|
|
6419
|
-
const localUrl = `http://${options.host}:${options.port}`;
|
|
6420
|
-
const child = spawn("cloudflared", ["tunnel", "--url", localUrl], {
|
|
6421
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
6422
|
-
});
|
|
6423
|
-
let settled = false;
|
|
6424
|
-
const settle = (fn) => {
|
|
6425
|
-
if (settled) return;
|
|
6426
|
-
settled = true;
|
|
6427
|
-
fn();
|
|
6428
|
-
};
|
|
6429
|
-
const onData = (chunk) => {
|
|
6430
|
-
const match = chunk.toString().match(/https:\/\/[^\s]+\.trycloudflare\.com/);
|
|
6431
|
-
if (match) {
|
|
6432
|
-
clearTimeout(timer);
|
|
6433
|
-
settle(() => resolve({ url: match[0], process: child }));
|
|
6434
|
-
}
|
|
6435
|
-
};
|
|
6436
|
-
child.stderr?.on("data", onData);
|
|
6437
|
-
child.stdout?.on("data", onData);
|
|
6438
|
-
child.on("error", (err) => {
|
|
6439
|
-
clearTimeout(timer);
|
|
6440
|
-
settle(() => reject(spawnError(
|
|
6441
|
-
"cloudflared",
|
|
6442
|
-
"https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
|
|
6443
|
-
err
|
|
6444
|
-
)));
|
|
6445
|
-
});
|
|
6446
|
-
child.on("exit", (code) => {
|
|
6447
|
-
clearTimeout(timer);
|
|
6448
|
-
settle(() => reject(new Error(`cloudflared exited with code ${code} before establishing tunnel`)));
|
|
6449
|
-
});
|
|
6450
|
-
const timer = setTimeout(() => {
|
|
6451
|
-
child.kill("SIGTERM");
|
|
6452
|
-
settle(() => reject(new Error("Timed out waiting for cloudflared to establish tunnel")));
|
|
6453
|
-
}, 3e4);
|
|
6454
|
-
});
|
|
6455
|
-
}
|
|
6456
|
-
async function getTailscaleHostname() {
|
|
6457
|
-
return new Promise((resolve, reject) => {
|
|
6458
|
-
const status = spawn("tailscale", ["status", "--json"], {
|
|
6459
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
6460
|
-
});
|
|
6461
|
-
let settled = false;
|
|
6462
|
-
let output = "";
|
|
6463
|
-
status.stdout?.on("data", (chunk) => {
|
|
6464
|
-
output += chunk.toString();
|
|
6465
|
-
});
|
|
6466
|
-
status.on("error", (err) => {
|
|
6467
|
-
if (!settled) {
|
|
6468
|
-
settled = true;
|
|
6469
|
-
reject(spawnError("tailscale", "https://tailscale.com/download", err));
|
|
6470
|
-
}
|
|
6471
|
-
});
|
|
6472
|
-
status.on("exit", (code) => {
|
|
6473
|
-
if (settled) return;
|
|
6474
|
-
settled = true;
|
|
6475
|
-
if (code !== 0) {
|
|
6476
|
-
reject(new Error(`tailscale status exited with code ${code}. Is Tailscale running?`));
|
|
6477
|
-
return;
|
|
6478
|
-
}
|
|
6479
|
-
try {
|
|
6480
|
-
const parsed = JSON.parse(output);
|
|
6481
|
-
const dnsName = parsed.Self?.DNSName ?? "";
|
|
6482
|
-
const hostname = dnsName.replace(/\.$/, "");
|
|
6483
|
-
if (!hostname) {
|
|
6484
|
-
reject(new Error("Could not determine Tailscale hostname"));
|
|
6485
|
-
} else {
|
|
6486
|
-
resolve(hostname);
|
|
6487
|
-
}
|
|
6488
|
-
} catch {
|
|
6489
|
-
reject(new Error("Failed to parse tailscale status output"));
|
|
6490
|
-
}
|
|
6491
|
-
});
|
|
6492
|
-
});
|
|
6493
|
-
}
|
|
6494
|
-
async function startTailscaleFunnel(options) {
|
|
6495
|
-
const hostname = await getTailscaleHostname();
|
|
6496
|
-
const publicUrl = `https://${hostname}:${options.port}`;
|
|
6497
|
-
return new Promise((resolve, reject) => {
|
|
6498
|
-
const child = spawn("tailscale", ["funnel", String(options.port)], {
|
|
6499
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
6500
|
-
});
|
|
6501
|
-
let settled = false;
|
|
6502
|
-
child.on("error", (err) => {
|
|
6503
|
-
if (!settled) {
|
|
6504
|
-
settled = true;
|
|
6505
|
-
reject(new Error(`Failed to start tailscale funnel: ${err.message}`));
|
|
6506
|
-
}
|
|
6507
|
-
});
|
|
6508
|
-
const timer = setTimeout(() => {
|
|
6509
|
-
settled = true;
|
|
6510
|
-
resolve({ url: publicUrl, process: child });
|
|
6511
|
-
}, 2e3);
|
|
6512
|
-
child.on("exit", (exitCode) => {
|
|
6513
|
-
if (!settled) {
|
|
6514
|
-
settled = true;
|
|
6515
|
-
clearTimeout(timer);
|
|
6516
|
-
reject(new Error(
|
|
6517
|
-
`tailscale funnel exited with code ${exitCode}. Make sure Tailscale Funnel is enabled: https://tailscale.com/kb/1223/funnel`
|
|
6518
|
-
));
|
|
6519
|
-
}
|
|
6520
|
-
});
|
|
6521
|
-
});
|
|
6522
|
-
}
|
|
6523
|
-
|
|
6524
6422
|
// src/commands/mcp.ts
|
|
6525
6423
|
function pidPath(vaultRoot) {
|
|
6526
|
-
return
|
|
6424
|
+
return path15.join(getGraniteDir(vaultRoot), "mcp.pid");
|
|
6527
6425
|
}
|
|
6528
6426
|
function urlPath(vaultRoot) {
|
|
6529
|
-
return
|
|
6427
|
+
return path15.join(getGraniteDir(vaultRoot), "mcp.url");
|
|
6530
6428
|
}
|
|
6531
6429
|
function ensureGraniteDir(vaultRoot) {
|
|
6532
|
-
|
|
6430
|
+
fs19.mkdirSync(getGraniteDir(vaultRoot), { recursive: true });
|
|
6533
6431
|
}
|
|
6534
6432
|
function readPid(vaultRoot) {
|
|
6535
6433
|
try {
|
|
6536
|
-
const raw =
|
|
6434
|
+
const raw = fs19.readFileSync(pidPath(vaultRoot), "utf-8").trim();
|
|
6537
6435
|
const pid = Number.parseInt(raw, 10);
|
|
6538
6436
|
if (Number.isNaN(pid)) return null;
|
|
6539
6437
|
try {
|
|
@@ -6548,26 +6446,26 @@ function readPid(vaultRoot) {
|
|
|
6548
6446
|
}
|
|
6549
6447
|
function writeDaemonState(vaultRoot, pid, url) {
|
|
6550
6448
|
ensureGraniteDir(vaultRoot);
|
|
6551
|
-
|
|
6552
|
-
|
|
6449
|
+
fs19.writeFileSync(pidPath(vaultRoot), String(pid), "utf-8");
|
|
6450
|
+
fs19.writeFileSync(urlPath(vaultRoot), url, "utf-8");
|
|
6553
6451
|
}
|
|
6554
6452
|
function cleanupFiles(vaultRoot) {
|
|
6555
6453
|
try {
|
|
6556
|
-
|
|
6454
|
+
fs19.unlinkSync(pidPath(vaultRoot));
|
|
6557
6455
|
} catch {
|
|
6558
6456
|
}
|
|
6559
6457
|
try {
|
|
6560
|
-
|
|
6458
|
+
fs19.unlinkSync(urlPath(vaultRoot));
|
|
6561
6459
|
} catch {
|
|
6562
6460
|
}
|
|
6563
6461
|
}
|
|
6564
6462
|
async function mcpCommand(options) {
|
|
6565
|
-
const
|
|
6566
|
-
const
|
|
6463
|
+
const transport = parseTransport(options.transport);
|
|
6464
|
+
const role = parseMcpRole(options.role);
|
|
6567
6465
|
const vaultRoot = resolveVaultRoot(options.vault);
|
|
6568
6466
|
if (options.background) {
|
|
6569
6467
|
if (transport !== "http") {
|
|
6570
|
-
console.error("--background requires --transport http
|
|
6468
|
+
console.error("--background requires --transport http");
|
|
6571
6469
|
process.exit(1);
|
|
6572
6470
|
}
|
|
6573
6471
|
const running = readPid(vaultRoot);
|
|
@@ -6575,16 +6473,16 @@ async function mcpCommand(options) {
|
|
|
6575
6473
|
console.error(`MCP server already running (PID ${running}). Use "granite mcp stop" first.`);
|
|
6576
6474
|
process.exit(1);
|
|
6577
6475
|
}
|
|
6578
|
-
const logFile =
|
|
6476
|
+
const logFile = path15.join(getGraniteDir(vaultRoot), "mcp.log");
|
|
6579
6477
|
ensureGraniteDir(vaultRoot);
|
|
6580
|
-
const fd =
|
|
6581
|
-
const child =
|
|
6478
|
+
const fd = fs19.openSync(logFile, "a");
|
|
6479
|
+
const child = spawn(process.execPath, process.argv.slice(1), {
|
|
6582
6480
|
detached: true,
|
|
6583
6481
|
stdio: ["ignore", fd, fd],
|
|
6584
6482
|
env: { ...process.env, GRANITE_MCP_DAEMONIZED: "1" }
|
|
6585
6483
|
});
|
|
6586
6484
|
child.unref();
|
|
6587
|
-
|
|
6485
|
+
fs19.closeSync(fd);
|
|
6588
6486
|
if (!child.pid) {
|
|
6589
6487
|
console.error("Failed to start background process");
|
|
6590
6488
|
process.exit(1);
|
|
@@ -6597,9 +6495,7 @@ async function mcpCommand(options) {
|
|
|
6597
6495
|
}
|
|
6598
6496
|
const isDaemon = process.env.GRANITE_MCP_DAEMONIZED === "1";
|
|
6599
6497
|
const runtime = new GraniteMcpRuntime(vaultRoot);
|
|
6600
|
-
let tunnelProcess = null;
|
|
6601
6498
|
const shutdown = () => {
|
|
6602
|
-
if (tunnelProcess) stopTunnel(tunnelProcess);
|
|
6603
6499
|
if (isDaemon) cleanupFiles(vaultRoot);
|
|
6604
6500
|
runtime.close();
|
|
6605
6501
|
process.exit(0);
|
|
@@ -6614,34 +6510,13 @@ async function mcpCommand(options) {
|
|
|
6614
6510
|
host,
|
|
6615
6511
|
port,
|
|
6616
6512
|
allowedOrigins: options.allowOrigin ?? [],
|
|
6617
|
-
jsonResponse: options.jsonResponse ?? false
|
|
6513
|
+
jsonResponse: options.jsonResponse ?? false,
|
|
6514
|
+
role
|
|
6618
6515
|
});
|
|
6619
6516
|
if (isDaemon) writeDaemonState(vaultRoot, process.pid, localUrl);
|
|
6620
|
-
if (tunnel) {
|
|
6621
|
-
try {
|
|
6622
|
-
console.error(`
|
|
6623
|
-
Starting ${tunnel} tunnel...`);
|
|
6624
|
-
const result = await startTunnel({ provider: tunnel, port, host });
|
|
6625
|
-
tunnelProcess = result.process;
|
|
6626
|
-
const publicMcpUrl = `${result.url}/mcp`;
|
|
6627
|
-
console.error(`
|
|
6628
|
-
\u{1F687} Tunnel active!
|
|
6629
|
-
`);
|
|
6630
|
-
console.error(` Public MCP endpoint: ${publicMcpUrl}`);
|
|
6631
|
-
console.error(` Provider: ${tunnel}`);
|
|
6632
|
-
console.error(`
|
|
6633
|
-
Use this URL in your remote MCP client configuration.
|
|
6634
|
-
`);
|
|
6635
|
-
if (isDaemon) writeDaemonState(vaultRoot, process.pid, publicMcpUrl);
|
|
6636
|
-
} catch (err) {
|
|
6637
|
-
console.error(`
|
|
6638
|
-
Failed to start tunnel: ${err instanceof Error ? err.message : err}`);
|
|
6639
|
-
shutdown();
|
|
6640
|
-
}
|
|
6641
|
-
}
|
|
6642
6517
|
return;
|
|
6643
6518
|
}
|
|
6644
|
-
await startGraniteMcpStdioServer(runtime);
|
|
6519
|
+
await startGraniteMcpStdioServer(runtime, { role });
|
|
6645
6520
|
}
|
|
6646
6521
|
function mcpStopCommand(options) {
|
|
6647
6522
|
const vaultRoot = resolveVaultRoot(options.vault);
|
|
@@ -6663,7 +6538,7 @@ function mcpStatusCommand(options) {
|
|
|
6663
6538
|
}
|
|
6664
6539
|
let url = "";
|
|
6665
6540
|
try {
|
|
6666
|
-
url =
|
|
6541
|
+
url = fs19.readFileSync(urlPath(vaultRoot), "utf-8").trim();
|
|
6667
6542
|
} catch {
|
|
6668
6543
|
}
|
|
6669
6544
|
console.error(`MCP server is running (PID ${pid})`);
|
|
@@ -6672,10 +6547,10 @@ function mcpStatusCommand(options) {
|
|
|
6672
6547
|
function resolveVaultRoot(explicitVault) {
|
|
6673
6548
|
const fromEnv = process.env.GRANITE_VAULT;
|
|
6674
6549
|
if (explicitVault) {
|
|
6675
|
-
return
|
|
6550
|
+
return path15.resolve(explicitVault);
|
|
6676
6551
|
}
|
|
6677
6552
|
if (fromEnv) {
|
|
6678
|
-
return
|
|
6553
|
+
return path15.resolve(fromEnv);
|
|
6679
6554
|
}
|
|
6680
6555
|
return requireVaultRoot();
|
|
6681
6556
|
}
|
|
@@ -6697,26 +6572,31 @@ function parsePort(value) {
|
|
|
6697
6572
|
}
|
|
6698
6573
|
return parsed;
|
|
6699
6574
|
}
|
|
6575
|
+
function parseMcpRole(value) {
|
|
6576
|
+
const role = value ?? "write";
|
|
6577
|
+
if (role === "read" || role === "write") return role;
|
|
6578
|
+
throw new Error(`Invalid MCP role: ${role}. Expected "read" or "write".`);
|
|
6579
|
+
}
|
|
6700
6580
|
|
|
6701
6581
|
// src/commands/daemon.ts
|
|
6702
|
-
import
|
|
6703
|
-
import
|
|
6704
|
-
import { spawn as
|
|
6582
|
+
import fs20 from "fs";
|
|
6583
|
+
import path16 from "path";
|
|
6584
|
+
import { spawn as spawn2 } from "child_process";
|
|
6705
6585
|
function pidPath2(vaultRoot) {
|
|
6706
|
-
return
|
|
6586
|
+
return path16.join(getGraniteDir(vaultRoot), "daemon.pid");
|
|
6707
6587
|
}
|
|
6708
6588
|
function statePath(vaultRoot) {
|
|
6709
|
-
return
|
|
6589
|
+
return path16.join(getGraniteDir(vaultRoot), "daemon.state.json");
|
|
6710
6590
|
}
|
|
6711
6591
|
function logPath(vaultRoot) {
|
|
6712
|
-
return
|
|
6592
|
+
return path16.join(getGraniteDir(vaultRoot), "daemon.log");
|
|
6713
6593
|
}
|
|
6714
6594
|
function ensureGraniteDir2(vaultRoot) {
|
|
6715
|
-
|
|
6595
|
+
fs20.mkdirSync(getGraniteDir(vaultRoot), { recursive: true });
|
|
6716
6596
|
}
|
|
6717
6597
|
function readPid2(vaultRoot) {
|
|
6718
6598
|
try {
|
|
6719
|
-
const raw =
|
|
6599
|
+
const raw = fs20.readFileSync(pidPath2(vaultRoot), "utf-8").trim();
|
|
6720
6600
|
const pid = Number.parseInt(raw, 10);
|
|
6721
6601
|
if (Number.isNaN(pid)) return null;
|
|
6722
6602
|
try {
|
|
@@ -6731,7 +6611,7 @@ function readPid2(vaultRoot) {
|
|
|
6731
6611
|
}
|
|
6732
6612
|
function readState(vaultRoot) {
|
|
6733
6613
|
try {
|
|
6734
|
-
const raw =
|
|
6614
|
+
const raw = fs20.readFileSync(statePath(vaultRoot), "utf-8");
|
|
6735
6615
|
return JSON.parse(raw);
|
|
6736
6616
|
} catch {
|
|
6737
6617
|
return null;
|
|
@@ -6739,16 +6619,16 @@ function readState(vaultRoot) {
|
|
|
6739
6619
|
}
|
|
6740
6620
|
function writeState(vaultRoot, state) {
|
|
6741
6621
|
ensureGraniteDir2(vaultRoot);
|
|
6742
|
-
|
|
6743
|
-
|
|
6622
|
+
fs20.writeFileSync(pidPath2(vaultRoot), String(state.pid), "utf-8");
|
|
6623
|
+
fs20.writeFileSync(statePath(vaultRoot), JSON.stringify(state, null, 2), "utf-8");
|
|
6744
6624
|
}
|
|
6745
6625
|
function cleanupFiles2(vaultRoot) {
|
|
6746
6626
|
try {
|
|
6747
|
-
|
|
6627
|
+
fs20.unlinkSync(pidPath2(vaultRoot));
|
|
6748
6628
|
} catch {
|
|
6749
6629
|
}
|
|
6750
6630
|
try {
|
|
6751
|
-
|
|
6631
|
+
fs20.unlinkSync(statePath(vaultRoot));
|
|
6752
6632
|
} catch {
|
|
6753
6633
|
}
|
|
6754
6634
|
}
|
|
@@ -6762,10 +6642,10 @@ function parsePort2(value, fallback) {
|
|
|
6762
6642
|
}
|
|
6763
6643
|
function resolveVaultRoot2(explicitVault) {
|
|
6764
6644
|
const fromEnv = process.env.GRANITE_VAULT;
|
|
6765
|
-
if (explicitVault) return
|
|
6766
|
-
if (fromEnv) return
|
|
6645
|
+
if (explicitVault) return path16.resolve(explicitVault);
|
|
6646
|
+
if (fromEnv) return path16.resolve(fromEnv);
|
|
6767
6647
|
const defaultRoot = getDefaultVaultRoot();
|
|
6768
|
-
if (
|
|
6648
|
+
if (fs20.existsSync(path16.join(defaultRoot, "granite.yml"))) return defaultRoot;
|
|
6769
6649
|
return requireVaultRoot();
|
|
6770
6650
|
}
|
|
6771
6651
|
async function daemonCommand(options) {
|
|
@@ -6821,20 +6701,20 @@ function daemonStartCommand(options) {
|
|
|
6821
6701
|
}
|
|
6822
6702
|
ensureGraniteDir2(vaultRoot);
|
|
6823
6703
|
const logFile = logPath(vaultRoot);
|
|
6824
|
-
const fd =
|
|
6704
|
+
const fd = fs20.openSync(logFile, "a");
|
|
6825
6705
|
const origArgs = process.argv.slice(1);
|
|
6826
6706
|
const startIdx = origArgs.findIndex((a) => a === "start");
|
|
6827
6707
|
const childArgs = startIdx >= 0 ? [...origArgs.slice(0, startIdx), ...origArgs.slice(startIdx + 1)] : origArgs;
|
|
6828
6708
|
const runningTs = /\.ts$/.test(process.argv[1] ?? "");
|
|
6829
6709
|
const runner = runningTs ? "npx" : process.execPath;
|
|
6830
6710
|
const runnerArgs = runningTs ? ["tsx", ...childArgs] : childArgs;
|
|
6831
|
-
const child =
|
|
6711
|
+
const child = spawn2(runner, runnerArgs, {
|
|
6832
6712
|
detached: true,
|
|
6833
6713
|
stdio: ["ignore", fd, fd],
|
|
6834
6714
|
env: { ...process.env, GRANITE_DAEMONIZED: "1", GRANITE_VAULT: vaultRoot }
|
|
6835
6715
|
});
|
|
6836
6716
|
child.unref();
|
|
6837
|
-
|
|
6717
|
+
fs20.closeSync(fd);
|
|
6838
6718
|
if (!child.pid) {
|
|
6839
6719
|
console.error("Failed to start daemon.");
|
|
6840
6720
|
process.exit(1);
|
|
@@ -6876,12 +6756,12 @@ function daemonStatusCommand(options) {
|
|
|
6876
6756
|
function daemonLogsCommand(options) {
|
|
6877
6757
|
const vaultRoot = resolveVaultRoot2(options.vault);
|
|
6878
6758
|
const lf = logPath(vaultRoot);
|
|
6879
|
-
if (!
|
|
6759
|
+
if (!fs20.existsSync(lf)) {
|
|
6880
6760
|
console.error("No log file yet.");
|
|
6881
6761
|
process.exit(1);
|
|
6882
6762
|
}
|
|
6883
6763
|
const n = Math.max(1, parseInt(options.lines ?? "100", 10) || 100);
|
|
6884
|
-
const raw =
|
|
6764
|
+
const raw = fs20.readFileSync(lf, "utf-8");
|
|
6885
6765
|
const lines = raw.split("\n");
|
|
6886
6766
|
const tail = lines.slice(Math.max(0, lines.length - n)).join("\n");
|
|
6887
6767
|
process.stdout.write(tail);
|
|
@@ -7164,6 +7044,1056 @@ function splitCsv(value) {
|
|
|
7164
7044
|
return value ? value.split(",").map((part) => part.trim()).filter(Boolean) : [];
|
|
7165
7045
|
}
|
|
7166
7046
|
|
|
7047
|
+
// src/commands/sync.ts
|
|
7048
|
+
import fs22 from "fs";
|
|
7049
|
+
import path18 from "path";
|
|
7050
|
+
import { Hono as Hono3 } from "hono";
|
|
7051
|
+
import { serve as serve3 } from "@hono/node-server";
|
|
7052
|
+
|
|
7053
|
+
// src/core/sync.ts
|
|
7054
|
+
import crypto2 from "crypto";
|
|
7055
|
+
import fs21 from "fs";
|
|
7056
|
+
import os3 from "os";
|
|
7057
|
+
import path17 from "path";
|
|
7058
|
+
var SYNC_STATE_FILENAME = "sync.json";
|
|
7059
|
+
var SYNC_PROTOCOL_VERSION = 1;
|
|
7060
|
+
function getSyncStatePath(vaultRoot) {
|
|
7061
|
+
return path17.join(getGraniteDir(vaultRoot), SYNC_STATE_FILENAME);
|
|
7062
|
+
}
|
|
7063
|
+
function loadSyncState(vaultRoot) {
|
|
7064
|
+
const statePath2 = getSyncStatePath(vaultRoot);
|
|
7065
|
+
if (!fs21.existsSync(statePath2)) {
|
|
7066
|
+
const state2 = createDefaultSyncState();
|
|
7067
|
+
saveSyncState(vaultRoot, state2);
|
|
7068
|
+
return state2;
|
|
7069
|
+
}
|
|
7070
|
+
const raw = fs21.readFileSync(statePath2, "utf-8");
|
|
7071
|
+
let parsed;
|
|
7072
|
+
try {
|
|
7073
|
+
parsed = JSON.parse(raw);
|
|
7074
|
+
} catch {
|
|
7075
|
+
preserveCorruptSyncState(statePath2, raw);
|
|
7076
|
+
const state2 = createDefaultSyncState();
|
|
7077
|
+
saveSyncState(vaultRoot, state2);
|
|
7078
|
+
return state2;
|
|
7079
|
+
}
|
|
7080
|
+
const deviceId = parsed.device_id || createDeviceId();
|
|
7081
|
+
const localToken = parsed.local_token || createToken();
|
|
7082
|
+
const state = {
|
|
7083
|
+
version: SYNC_PROTOCOL_VERSION,
|
|
7084
|
+
device_id: deviceId,
|
|
7085
|
+
device_name: parsed.device_name || os3.hostname(),
|
|
7086
|
+
local_token: localToken,
|
|
7087
|
+
conflict_policy: normalizeConflictPolicy(parsed.conflict_policy),
|
|
7088
|
+
primary_device_id: parsed.primary_device_id || deviceId,
|
|
7089
|
+
remotes: parsed.remotes ?? {},
|
|
7090
|
+
access_tokens: normalizeAccessTokens(parsed.access_tokens, localToken),
|
|
7091
|
+
baselines: parsed.baselines ?? {},
|
|
7092
|
+
tombstones: parsed.tombstones ?? {}
|
|
7093
|
+
};
|
|
7094
|
+
if (state.primary_device_id === "" || state.primary_device_id === void 0) {
|
|
7095
|
+
state.primary_device_id = state.device_id;
|
|
7096
|
+
}
|
|
7097
|
+
return state;
|
|
7098
|
+
}
|
|
7099
|
+
function saveSyncState(vaultRoot, state) {
|
|
7100
|
+
const statePath2 = getSyncStatePath(vaultRoot);
|
|
7101
|
+
fs21.mkdirSync(path17.dirname(statePath2), { recursive: true });
|
|
7102
|
+
fs21.writeFileSync(statePath2, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
7103
|
+
}
|
|
7104
|
+
function createDefaultSyncState() {
|
|
7105
|
+
const deviceId = createDeviceId();
|
|
7106
|
+
const localToken = createToken();
|
|
7107
|
+
return {
|
|
7108
|
+
version: SYNC_PROTOCOL_VERSION,
|
|
7109
|
+
device_id: deviceId,
|
|
7110
|
+
device_name: os3.hostname(),
|
|
7111
|
+
local_token: localToken,
|
|
7112
|
+
conflict_policy: "manual",
|
|
7113
|
+
primary_device_id: deviceId,
|
|
7114
|
+
remotes: {},
|
|
7115
|
+
access_tokens: {
|
|
7116
|
+
default: {
|
|
7117
|
+
name: "default",
|
|
7118
|
+
token: localToken,
|
|
7119
|
+
role: "write",
|
|
7120
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
7121
|
+
}
|
|
7122
|
+
},
|
|
7123
|
+
baselines: {},
|
|
7124
|
+
tombstones: {}
|
|
7125
|
+
};
|
|
7126
|
+
}
|
|
7127
|
+
function grantSyncAccess(state, name, role, now = /* @__PURE__ */ new Date()) {
|
|
7128
|
+
const safeName = normalizeAccessName(name);
|
|
7129
|
+
const grant = {
|
|
7130
|
+
name: safeName,
|
|
7131
|
+
token: createToken(),
|
|
7132
|
+
role,
|
|
7133
|
+
created_at: now.toISOString()
|
|
7134
|
+
};
|
|
7135
|
+
state.access_tokens[safeName] = grant;
|
|
7136
|
+
return grant;
|
|
7137
|
+
}
|
|
7138
|
+
function revokeSyncAccess(state, name) {
|
|
7139
|
+
const safeName = normalizeAccessName(name);
|
|
7140
|
+
if (!state.access_tokens[safeName]) {
|
|
7141
|
+
return false;
|
|
7142
|
+
}
|
|
7143
|
+
delete state.access_tokens[safeName];
|
|
7144
|
+
return true;
|
|
7145
|
+
}
|
|
7146
|
+
function resolveSyncAccessRole(state, token) {
|
|
7147
|
+
if (!token) return null;
|
|
7148
|
+
for (const grant of Object.values(state.access_tokens)) {
|
|
7149
|
+
if (grant.token === token) return grant.role;
|
|
7150
|
+
}
|
|
7151
|
+
return null;
|
|
7152
|
+
}
|
|
7153
|
+
function setSyncConflictPolicy(state, policy, primaryDeviceId) {
|
|
7154
|
+
state.conflict_policy = policy;
|
|
7155
|
+
if (primaryDeviceId) {
|
|
7156
|
+
state.primary_device_id = primaryDeviceId;
|
|
7157
|
+
}
|
|
7158
|
+
}
|
|
7159
|
+
function buildSyncManifest(vaultRoot, state) {
|
|
7160
|
+
const files = listSyncFiles(vaultRoot);
|
|
7161
|
+
const filePaths = new Set(files.map((file) => file.path));
|
|
7162
|
+
const deletions = /* @__PURE__ */ new Map();
|
|
7163
|
+
for (const [relativePath, tombstone] of Object.entries(state.tombstones)) {
|
|
7164
|
+
if (filePaths.has(relativePath)) continue;
|
|
7165
|
+
deletions.set(relativePath, {
|
|
7166
|
+
path: relativePath,
|
|
7167
|
+
hash: tombstone.hash,
|
|
7168
|
+
deleted_at: tombstone.deleted_at,
|
|
7169
|
+
device_id: tombstone.device_id
|
|
7170
|
+
});
|
|
7171
|
+
}
|
|
7172
|
+
for (const [relativePath, baseline] of Object.entries(state.baselines)) {
|
|
7173
|
+
if (filePaths.has(relativePath) || deletions.has(relativePath)) continue;
|
|
7174
|
+
deletions.set(relativePath, {
|
|
7175
|
+
path: relativePath,
|
|
7176
|
+
hash: baseline.hash,
|
|
7177
|
+
deleted_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7178
|
+
device_id: state.device_id
|
|
7179
|
+
});
|
|
7180
|
+
}
|
|
7181
|
+
return {
|
|
7182
|
+
version: SYNC_PROTOCOL_VERSION,
|
|
7183
|
+
device_id: state.device_id,
|
|
7184
|
+
device_name: state.device_name,
|
|
7185
|
+
conflict_policy: state.conflict_policy,
|
|
7186
|
+
primary_device_id: state.primary_device_id,
|
|
7187
|
+
files,
|
|
7188
|
+
deletions: [...deletions.values()].sort((a, b) => a.path.localeCompare(b.path))
|
|
7189
|
+
};
|
|
7190
|
+
}
|
|
7191
|
+
function listSyncFiles(vaultRoot) {
|
|
7192
|
+
const root = path17.resolve(vaultRoot);
|
|
7193
|
+
const files = [];
|
|
7194
|
+
walkVault(root, "", files);
|
|
7195
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
7196
|
+
return files;
|
|
7197
|
+
}
|
|
7198
|
+
function readSyncFilePayload(vaultRoot, relativePath, state) {
|
|
7199
|
+
const safePath = normalizeSyncPath(relativePath);
|
|
7200
|
+
const absolutePath = resolveSyncPath(vaultRoot, safePath);
|
|
7201
|
+
if (!fs21.existsSync(absolutePath) || !fs21.statSync(absolutePath).isFile()) {
|
|
7202
|
+
throw new Error(`Sync file not found: ${safePath}`);
|
|
7203
|
+
}
|
|
7204
|
+
const stat = fs21.statSync(absolutePath);
|
|
7205
|
+
const content = fs21.readFileSync(absolutePath);
|
|
7206
|
+
return {
|
|
7207
|
+
path: safePath,
|
|
7208
|
+
hash: hashBuffer(content),
|
|
7209
|
+
size: stat.size,
|
|
7210
|
+
modified: stat.mtime.toISOString(),
|
|
7211
|
+
device_id: state.device_id,
|
|
7212
|
+
content_base64: content.toString("base64"),
|
|
7213
|
+
base_hash: state.baselines[safePath]?.hash ?? null
|
|
7214
|
+
};
|
|
7215
|
+
}
|
|
7216
|
+
function getSyncFileHash(vaultRoot, relativePath) {
|
|
7217
|
+
const safePath = normalizeSyncPath(relativePath);
|
|
7218
|
+
const absolutePath = resolveSyncPath(vaultRoot, safePath);
|
|
7219
|
+
if (!fs21.existsSync(absolutePath) || !fs21.statSync(absolutePath).isFile()) {
|
|
7220
|
+
return null;
|
|
7221
|
+
}
|
|
7222
|
+
return hashFile(absolutePath);
|
|
7223
|
+
}
|
|
7224
|
+
function detectLocalDeletions(vaultRoot, state, now = /* @__PURE__ */ new Date()) {
|
|
7225
|
+
let changed = false;
|
|
7226
|
+
const existing = new Set(listSyncFiles(vaultRoot).map((file) => file.path));
|
|
7227
|
+
for (const [relativePath, baseline] of Object.entries(state.baselines)) {
|
|
7228
|
+
if (existing.has(relativePath) || state.tombstones[relativePath]) continue;
|
|
7229
|
+
state.tombstones[relativePath] = {
|
|
7230
|
+
hash: baseline.hash,
|
|
7231
|
+
deleted_at: now.toISOString(),
|
|
7232
|
+
device_id: state.device_id
|
|
7233
|
+
};
|
|
7234
|
+
delete state.baselines[relativePath];
|
|
7235
|
+
changed = true;
|
|
7236
|
+
}
|
|
7237
|
+
return changed;
|
|
7238
|
+
}
|
|
7239
|
+
function applyIncomingFile(vaultRoot, state, incoming, now = /* @__PURE__ */ new Date()) {
|
|
7240
|
+
const safePath = normalizeSyncPath(incoming.path);
|
|
7241
|
+
const content = decodeBase64Strict(incoming.content_base64, safePath);
|
|
7242
|
+
const incomingHash = hashBuffer(content);
|
|
7243
|
+
if (incomingHash !== incoming.hash) {
|
|
7244
|
+
throw new Error(`Hash mismatch for ${safePath}`);
|
|
7245
|
+
}
|
|
7246
|
+
const absolutePath = resolveSyncPath(vaultRoot, safePath);
|
|
7247
|
+
const localHash = fs21.existsSync(absolutePath) && fs21.statSync(absolutePath).isFile() ? hashFile(absolutePath) : null;
|
|
7248
|
+
const baseHash = state.baselines[safePath]?.hash ?? incoming.base_hash ?? null;
|
|
7249
|
+
const tombstone = state.tombstones[safePath];
|
|
7250
|
+
if (localHash === incoming.hash) {
|
|
7251
|
+
markBaseline(state, safePath, incoming.hash, incoming.modified);
|
|
7252
|
+
delete state.tombstones[safePath];
|
|
7253
|
+
return { path: safePath, action: "unchanged" };
|
|
7254
|
+
}
|
|
7255
|
+
if (!localHash && tombstone) {
|
|
7256
|
+
if (tombstone.hash === incoming.hash) {
|
|
7257
|
+
return { path: safePath, action: "unchanged" };
|
|
7258
|
+
}
|
|
7259
|
+
if (state.conflict_policy === "primary_wins") {
|
|
7260
|
+
if (incoming.device_id === state.primary_device_id) {
|
|
7261
|
+
writeSyncedFile(absolutePath, content, incoming.modified);
|
|
7262
|
+
markBaseline(state, safePath, incoming.hash, incoming.modified);
|
|
7263
|
+
delete state.tombstones[safePath];
|
|
7264
|
+
return { path: safePath, action: "created" };
|
|
7265
|
+
}
|
|
7266
|
+
if (state.device_id === state.primary_device_id) {
|
|
7267
|
+
const conflictPath3 = writeConflictCopy(vaultRoot, safePath, incoming.device_id, content, incoming.modified, now);
|
|
7268
|
+
return { path: safePath, action: "kept_local_primary", conflict_path: conflictPath3 };
|
|
7269
|
+
}
|
|
7270
|
+
}
|
|
7271
|
+
const conflictPath2 = writeConflictCopy(vaultRoot, safePath, incoming.device_id, content, incoming.modified, now);
|
|
7272
|
+
return { path: safePath, action: "conflict_copy_created", conflict_path: conflictPath2 };
|
|
7273
|
+
}
|
|
7274
|
+
if (!localHash) {
|
|
7275
|
+
writeSyncedFile(absolutePath, content, incoming.modified);
|
|
7276
|
+
markBaseline(state, safePath, incoming.hash, incoming.modified);
|
|
7277
|
+
delete state.tombstones[safePath];
|
|
7278
|
+
return { path: safePath, action: "created" };
|
|
7279
|
+
}
|
|
7280
|
+
if (baseHash && incoming.hash === baseHash) {
|
|
7281
|
+
return { path: safePath, action: "unchanged" };
|
|
7282
|
+
}
|
|
7283
|
+
if (baseHash && localHash === baseHash) {
|
|
7284
|
+
writeSyncedFile(absolutePath, content, incoming.modified);
|
|
7285
|
+
markBaseline(state, safePath, incoming.hash, incoming.modified);
|
|
7286
|
+
delete state.tombstones[safePath];
|
|
7287
|
+
return { path: safePath, action: "updated" };
|
|
7288
|
+
}
|
|
7289
|
+
if (state.conflict_policy === "primary_wins") {
|
|
7290
|
+
if (incoming.device_id === state.primary_device_id) {
|
|
7291
|
+
writeSyncedFile(absolutePath, content, incoming.modified);
|
|
7292
|
+
markBaseline(state, safePath, incoming.hash, incoming.modified);
|
|
7293
|
+
delete state.tombstones[safePath];
|
|
7294
|
+
return { path: safePath, action: "overwrote_local_primary" };
|
|
7295
|
+
}
|
|
7296
|
+
if (state.device_id === state.primary_device_id) {
|
|
7297
|
+
const conflictPath2 = writeConflictCopy(vaultRoot, safePath, incoming.device_id, content, incoming.modified, now);
|
|
7298
|
+
return { path: safePath, action: "kept_local_primary", conflict_path: conflictPath2 };
|
|
7299
|
+
}
|
|
7300
|
+
}
|
|
7301
|
+
const conflictPath = writeConflictCopy(vaultRoot, safePath, incoming.device_id, content, incoming.modified, now);
|
|
7302
|
+
return { path: safePath, action: "conflict_copy_created", conflict_path: conflictPath };
|
|
7303
|
+
}
|
|
7304
|
+
function applyIncomingDeletion(vaultRoot, state, incoming, now = /* @__PURE__ */ new Date()) {
|
|
7305
|
+
const safePath = normalizeSyncPath(incoming.path);
|
|
7306
|
+
const absolutePath = resolveSyncPath(vaultRoot, safePath);
|
|
7307
|
+
const localHash = fs21.existsSync(absolutePath) && fs21.statSync(absolutePath).isFile() ? hashFile(absolutePath) : null;
|
|
7308
|
+
const baseHash = state.baselines[safePath]?.hash ?? null;
|
|
7309
|
+
const tombstone = state.tombstones[safePath];
|
|
7310
|
+
const incomingDeletesKnownBase = baseHash !== null && incoming.hash === baseHash;
|
|
7311
|
+
if (!localHash) {
|
|
7312
|
+
if (!incomingDeletesKnownBase && tombstone?.hash !== incoming.hash) {
|
|
7313
|
+
return { path: safePath, action: "delete_ignored_missing" };
|
|
7314
|
+
}
|
|
7315
|
+
state.tombstones[safePath] = {
|
|
7316
|
+
hash: incoming.hash,
|
|
7317
|
+
deleted_at: incoming.deleted_at,
|
|
7318
|
+
device_id: incoming.device_id
|
|
7319
|
+
};
|
|
7320
|
+
delete state.baselines[safePath];
|
|
7321
|
+
return { path: safePath, action: "delete_ignored_missing" };
|
|
7322
|
+
}
|
|
7323
|
+
if (tombstone?.hash === incoming.hash) {
|
|
7324
|
+
return { path: safePath, action: "unchanged" };
|
|
7325
|
+
}
|
|
7326
|
+
if (localHash !== incoming.hash && !incomingDeletesKnownBase) {
|
|
7327
|
+
return { path: safePath, action: "delete_rejected_unknown_hash" };
|
|
7328
|
+
}
|
|
7329
|
+
if (localHash === incoming.hash || baseHash !== null && localHash === baseHash) {
|
|
7330
|
+
fs21.unlinkSync(absolutePath);
|
|
7331
|
+
state.tombstones[safePath] = {
|
|
7332
|
+
hash: incoming.hash,
|
|
7333
|
+
deleted_at: incoming.deleted_at,
|
|
7334
|
+
device_id: incoming.device_id
|
|
7335
|
+
};
|
|
7336
|
+
delete state.baselines[safePath];
|
|
7337
|
+
return { path: safePath, action: "deleted" };
|
|
7338
|
+
}
|
|
7339
|
+
if (state.conflict_policy === "primary_wins") {
|
|
7340
|
+
if (incoming.device_id === state.primary_device_id && incomingDeletesKnownBase) {
|
|
7341
|
+
fs21.unlinkSync(absolutePath);
|
|
7342
|
+
state.tombstones[safePath] = {
|
|
7343
|
+
hash: incoming.hash,
|
|
7344
|
+
deleted_at: incoming.deleted_at,
|
|
7345
|
+
device_id: incoming.device_id
|
|
7346
|
+
};
|
|
7347
|
+
delete state.baselines[safePath];
|
|
7348
|
+
return { path: safePath, action: "deleted" };
|
|
7349
|
+
}
|
|
7350
|
+
if (state.device_id === state.primary_device_id) {
|
|
7351
|
+
state.tombstones[safePath] = {
|
|
7352
|
+
hash: incoming.hash,
|
|
7353
|
+
deleted_at: incoming.deleted_at,
|
|
7354
|
+
device_id: incoming.device_id
|
|
7355
|
+
};
|
|
7356
|
+
return { path: safePath, action: "kept_local_primary" };
|
|
7357
|
+
}
|
|
7358
|
+
}
|
|
7359
|
+
const content = fs21.readFileSync(absolutePath);
|
|
7360
|
+
const conflictPath = writeConflictCopy(vaultRoot, safePath, incoming.device_id, content, now.toISOString(), now);
|
|
7361
|
+
fs21.unlinkSync(absolutePath);
|
|
7362
|
+
state.tombstones[safePath] = {
|
|
7363
|
+
hash: incoming.hash,
|
|
7364
|
+
deleted_at: incoming.deleted_at,
|
|
7365
|
+
device_id: incoming.device_id
|
|
7366
|
+
};
|
|
7367
|
+
delete state.baselines[safePath];
|
|
7368
|
+
return { path: safePath, action: "delete_conflict_copy_created", conflict_path: conflictPath };
|
|
7369
|
+
}
|
|
7370
|
+
function markBaseline(state, relativePath, hash, modified) {
|
|
7371
|
+
state.baselines[relativePath] = { hash, modified };
|
|
7372
|
+
}
|
|
7373
|
+
function normalizeSyncPath(relativePath) {
|
|
7374
|
+
const withSlashes = relativePath.replace(/\\/g, "/");
|
|
7375
|
+
const normalized = path17.posix.normalize(withSlashes);
|
|
7376
|
+
if (!withSlashes || normalized === "." || normalized.startsWith("../") || normalized === ".." || path17.isAbsolute(withSlashes)) {
|
|
7377
|
+
throw new Error(`Unsafe sync path: ${relativePath}`);
|
|
7378
|
+
}
|
|
7379
|
+
if (isIgnoredSyncPath(normalized)) {
|
|
7380
|
+
throw new Error(`Path is not syncable: ${relativePath}`);
|
|
7381
|
+
}
|
|
7382
|
+
return normalized;
|
|
7383
|
+
}
|
|
7384
|
+
function isIgnoredSyncPath(relativePath) {
|
|
7385
|
+
const normalized = relativePath.replace(/\\/g, "/");
|
|
7386
|
+
const first = normalized.split("/")[0];
|
|
7387
|
+
if (first === ".git" || first === "node_modules") return true;
|
|
7388
|
+
if (first === ".granite") return true;
|
|
7389
|
+
if (normalized === ".DS_Store") return true;
|
|
7390
|
+
const rootDerivedFiles = /* @__PURE__ */ new Set([
|
|
7391
|
+
"index.db",
|
|
7392
|
+
"mcp.pid",
|
|
7393
|
+
"mcp.url",
|
|
7394
|
+
"mcp.log",
|
|
7395
|
+
"daemon.pid",
|
|
7396
|
+
"daemon.state.json",
|
|
7397
|
+
"daemon.log",
|
|
7398
|
+
SYNC_STATE_FILENAME
|
|
7399
|
+
]);
|
|
7400
|
+
return rootDerivedFiles.has(normalized) || normalized.startsWith("index.db-");
|
|
7401
|
+
}
|
|
7402
|
+
function createDefaultRemoteName(url) {
|
|
7403
|
+
return url.replace(/^https?:\/\//, "").replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "");
|
|
7404
|
+
}
|
|
7405
|
+
function normalizeRemoteUrl(address, port) {
|
|
7406
|
+
const withScheme = /^https?:\/\//.test(address) ? address : `http://${address}`;
|
|
7407
|
+
const url = new URL(withScheme);
|
|
7408
|
+
if (port && !url.port) {
|
|
7409
|
+
url.port = port;
|
|
7410
|
+
}
|
|
7411
|
+
if (url.pathname === "/sync" || url.pathname.endsWith("/sync/")) {
|
|
7412
|
+
url.pathname = url.pathname.replace(/\/sync\/?$/, "");
|
|
7413
|
+
}
|
|
7414
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
7415
|
+
url.search = "";
|
|
7416
|
+
url.hash = "";
|
|
7417
|
+
return url.toString().replace(/\/$/, "");
|
|
7418
|
+
}
|
|
7419
|
+
function suggestedRemoteName(address) {
|
|
7420
|
+
return createDefaultRemoteName(normalizeRemoteUrl(address)) || "remote";
|
|
7421
|
+
}
|
|
7422
|
+
function createDeviceId() {
|
|
7423
|
+
return `dev_${crypto2.randomUUID()}`;
|
|
7424
|
+
}
|
|
7425
|
+
function createToken() {
|
|
7426
|
+
return crypto2.randomBytes(24).toString("base64url");
|
|
7427
|
+
}
|
|
7428
|
+
function normalizeConflictPolicy(value) {
|
|
7429
|
+
return value === "primary_wins" ? "primary_wins" : "manual";
|
|
7430
|
+
}
|
|
7431
|
+
function normalizeAccessTokens(value, localToken) {
|
|
7432
|
+
const grants = {};
|
|
7433
|
+
let shouldMigrateLocalToken = value === void 0;
|
|
7434
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
7435
|
+
const entries = Object.entries(value);
|
|
7436
|
+
shouldMigrateLocalToken = true;
|
|
7437
|
+
for (const [rawName, rawGrant] of entries) {
|
|
7438
|
+
if (!rawGrant || typeof rawGrant !== "object" || Array.isArray(rawGrant)) continue;
|
|
7439
|
+
const grant = rawGrant;
|
|
7440
|
+
const token = typeof grant.token === "string" && grant.token ? grant.token : null;
|
|
7441
|
+
if (!token) continue;
|
|
7442
|
+
const name = normalizeOptionalAccessName(grant.name) ?? normalizeOptionalAccessName(rawName);
|
|
7443
|
+
if (!name) continue;
|
|
7444
|
+
grants[name] = {
|
|
7445
|
+
name,
|
|
7446
|
+
token,
|
|
7447
|
+
role: normalizeAccessRole(grant.role),
|
|
7448
|
+
created_at: typeof grant.created_at === "string" && grant.created_at ? grant.created_at : (/* @__PURE__ */ new Date(0)).toISOString()
|
|
7449
|
+
};
|
|
7450
|
+
shouldMigrateLocalToken = false;
|
|
7451
|
+
}
|
|
7452
|
+
} else if (value !== void 0) {
|
|
7453
|
+
shouldMigrateLocalToken = true;
|
|
7454
|
+
}
|
|
7455
|
+
if (shouldMigrateLocalToken && !Object.values(grants).some((grant) => grant.token === localToken)) {
|
|
7456
|
+
grants.default = {
|
|
7457
|
+
name: "default",
|
|
7458
|
+
token: localToken,
|
|
7459
|
+
role: "write",
|
|
7460
|
+
created_at: (/* @__PURE__ */ new Date(0)).toISOString()
|
|
7461
|
+
};
|
|
7462
|
+
}
|
|
7463
|
+
return grants;
|
|
7464
|
+
}
|
|
7465
|
+
function normalizeAccessRole(value) {
|
|
7466
|
+
return value === "read" ? "read" : "write";
|
|
7467
|
+
}
|
|
7468
|
+
function normalizeAccessName(name) {
|
|
7469
|
+
const normalized = normalizeOptionalAccessName(name);
|
|
7470
|
+
if (!normalized) {
|
|
7471
|
+
throw new Error(`Invalid sync access name: ${name}`);
|
|
7472
|
+
}
|
|
7473
|
+
return normalized;
|
|
7474
|
+
}
|
|
7475
|
+
function normalizeOptionalAccessName(name) {
|
|
7476
|
+
if (typeof name !== "string") return null;
|
|
7477
|
+
const normalized = name.trim();
|
|
7478
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(normalized)) return null;
|
|
7479
|
+
return normalized;
|
|
7480
|
+
}
|
|
7481
|
+
function preserveCorruptSyncState(statePath2, raw) {
|
|
7482
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
7483
|
+
const backupPath = `${statePath2}.corrupt.${stamp}`;
|
|
7484
|
+
try {
|
|
7485
|
+
fs21.writeFileSync(backupPath, raw, "utf-8");
|
|
7486
|
+
} catch {
|
|
7487
|
+
}
|
|
7488
|
+
}
|
|
7489
|
+
function walkVault(root, relativeDir, out) {
|
|
7490
|
+
const absoluteDir = relativeDir ? path17.join(root, ...relativeDir.split("/")) : root;
|
|
7491
|
+
if (!fs21.existsSync(absoluteDir)) return;
|
|
7492
|
+
for (const entry of fs21.readdirSync(absoluteDir, { withFileTypes: true })) {
|
|
7493
|
+
const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
|
|
7494
|
+
if (isIgnoredSyncPath(relativePath)) continue;
|
|
7495
|
+
const absolutePath = path17.join(absoluteDir, entry.name);
|
|
7496
|
+
if (entry.isDirectory()) {
|
|
7497
|
+
walkVault(root, relativePath, out);
|
|
7498
|
+
continue;
|
|
7499
|
+
}
|
|
7500
|
+
if (!entry.isFile()) continue;
|
|
7501
|
+
const stat = fs21.statSync(absolutePath);
|
|
7502
|
+
out.push({
|
|
7503
|
+
path: relativePath,
|
|
7504
|
+
hash: hashFile(absolutePath),
|
|
7505
|
+
size: stat.size,
|
|
7506
|
+
modified: stat.mtime.toISOString()
|
|
7507
|
+
});
|
|
7508
|
+
}
|
|
7509
|
+
}
|
|
7510
|
+
function resolveSyncPath(vaultRoot, relativePath) {
|
|
7511
|
+
const root = path17.resolve(vaultRoot);
|
|
7512
|
+
const absolutePath = path17.resolve(root, ...relativePath.split("/"));
|
|
7513
|
+
if (absolutePath !== root && !absolutePath.startsWith(`${root}${path17.sep}`)) {
|
|
7514
|
+
throw new Error(`Unsafe sync path: ${relativePath}`);
|
|
7515
|
+
}
|
|
7516
|
+
return absolutePath;
|
|
7517
|
+
}
|
|
7518
|
+
function hashFile(filePath) {
|
|
7519
|
+
return hashBuffer(fs21.readFileSync(filePath));
|
|
7520
|
+
}
|
|
7521
|
+
function hashBuffer(content) {
|
|
7522
|
+
return crypto2.createHash("sha256").update(content).digest("hex");
|
|
7523
|
+
}
|
|
7524
|
+
function decodeBase64Strict(value, relativePath) {
|
|
7525
|
+
const validBase64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
7526
|
+
if (!validBase64.test(value)) {
|
|
7527
|
+
throw new Error(`Invalid base64 content for ${relativePath}`);
|
|
7528
|
+
}
|
|
7529
|
+
const content = Buffer.from(value, "base64");
|
|
7530
|
+
if (content.toString("base64") !== value) {
|
|
7531
|
+
throw new Error(`Invalid base64 content for ${relativePath}`);
|
|
7532
|
+
}
|
|
7533
|
+
return content;
|
|
7534
|
+
}
|
|
7535
|
+
function writeSyncedFile(absolutePath, content, modified) {
|
|
7536
|
+
fs21.mkdirSync(path17.dirname(absolutePath), { recursive: true });
|
|
7537
|
+
fs21.writeFileSync(absolutePath, content);
|
|
7538
|
+
const mtime = new Date(modified);
|
|
7539
|
+
if (!Number.isNaN(mtime.getTime())) {
|
|
7540
|
+
fs21.utimesSync(absolutePath, mtime, mtime);
|
|
7541
|
+
}
|
|
7542
|
+
}
|
|
7543
|
+
function writeConflictCopy(vaultRoot, originalPath, deviceId, content, modified, now) {
|
|
7544
|
+
const conflictPath = buildConflictPath(originalPath, deviceId, now);
|
|
7545
|
+
const absolutePath = resolveSyncPath(vaultRoot, conflictPath);
|
|
7546
|
+
writeSyncedFile(absolutePath, content, modified);
|
|
7547
|
+
return conflictPath;
|
|
7548
|
+
}
|
|
7549
|
+
function buildConflictPath(originalPath, deviceId, now) {
|
|
7550
|
+
const dir = path17.posix.dirname(originalPath);
|
|
7551
|
+
const ext = path17.posix.extname(originalPath);
|
|
7552
|
+
const base = path17.posix.basename(originalPath, ext);
|
|
7553
|
+
const safeDevice = deviceId.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
7554
|
+
const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
7555
|
+
const filename = `${base}.conflict.${safeDevice}.${stamp}${ext}`;
|
|
7556
|
+
return dir === "." ? filename : `${dir}/${filename}`;
|
|
7557
|
+
}
|
|
7558
|
+
|
|
7559
|
+
// src/commands/sync.ts
|
|
7560
|
+
var DEFAULT_SYNC_PORT = 8765;
|
|
7561
|
+
function syncStatusCommand(options = {}) {
|
|
7562
|
+
const vaultRoot = resolveVaultRoot3(options.vault);
|
|
7563
|
+
const state = loadSyncState(vaultRoot);
|
|
7564
|
+
const manifest = withDetectedDeletions(vaultRoot, state);
|
|
7565
|
+
if (options.json) {
|
|
7566
|
+
console.log(jsonSuccess({
|
|
7567
|
+
vault: vaultRoot,
|
|
7568
|
+
state_file: getSyncStatePath(vaultRoot),
|
|
7569
|
+
device_id: state.device_id,
|
|
7570
|
+
device_name: state.device_name,
|
|
7571
|
+
local_token_hint: maskToken(state.local_token),
|
|
7572
|
+
access_tokens: listSafeAccessTokens(state),
|
|
7573
|
+
conflict_policy: state.conflict_policy,
|
|
7574
|
+
primary_device_id: state.primary_device_id,
|
|
7575
|
+
remotes: state.remotes,
|
|
7576
|
+
files: manifest.files.length,
|
|
7577
|
+
deletions: manifest.deletions.length
|
|
7578
|
+
}));
|
|
7579
|
+
return;
|
|
7580
|
+
}
|
|
7581
|
+
console.log("Granite sync");
|
|
7582
|
+
console.log(` Vault: ${vaultRoot}`);
|
|
7583
|
+
console.log(` Device: ${state.device_name} (${state.device_id})`);
|
|
7584
|
+
console.log(` Write token: ${maskToken(getDefaultWriteGrant(state)?.token ?? "")}`);
|
|
7585
|
+
console.log(` Policy: ${formatPolicy(state)}`);
|
|
7586
|
+
console.log(` Access grants: ${Object.keys(state.access_tokens).length}`);
|
|
7587
|
+
console.log(` Files: ${manifest.files.length}`);
|
|
7588
|
+
if (manifest.deletions.length > 0) {
|
|
7589
|
+
console.log(` Pending deletes:${manifest.deletions.length}`);
|
|
7590
|
+
}
|
|
7591
|
+
const remotes = Object.entries(state.remotes);
|
|
7592
|
+
console.log("");
|
|
7593
|
+
if (remotes.length === 0) {
|
|
7594
|
+
console.log("No remotes configured.");
|
|
7595
|
+
console.log("Add one: granite sync remote add macbook http://100.x.y.z:8765 --token <token>");
|
|
7596
|
+
return;
|
|
7597
|
+
}
|
|
7598
|
+
console.log("Remotes:");
|
|
7599
|
+
for (const [name, remote] of remotes) {
|
|
7600
|
+
console.log(` ${name} ${remote.url}${remote.token ? "" : " (no token)"}`);
|
|
7601
|
+
}
|
|
7602
|
+
}
|
|
7603
|
+
function syncConfigCommand(options = {}) {
|
|
7604
|
+
const vaultRoot = resolveVaultRoot3(options.vault);
|
|
7605
|
+
const state = loadSyncState(vaultRoot);
|
|
7606
|
+
if (options.policy !== void 0) {
|
|
7607
|
+
setSyncConflictPolicy(state, parsePolicy(options.policy), state.primary_device_id);
|
|
7608
|
+
}
|
|
7609
|
+
if (options.primaryThisDevice) {
|
|
7610
|
+
state.primary_device_id = state.device_id;
|
|
7611
|
+
}
|
|
7612
|
+
if (options.primaryDevice) {
|
|
7613
|
+
state.primary_device_id = options.primaryDevice;
|
|
7614
|
+
}
|
|
7615
|
+
saveSyncState(vaultRoot, state);
|
|
7616
|
+
if (options.json) {
|
|
7617
|
+
console.log(jsonSuccess({
|
|
7618
|
+
conflict_policy: state.conflict_policy,
|
|
7619
|
+
primary_device_id: state.primary_device_id,
|
|
7620
|
+
device_id: state.device_id
|
|
7621
|
+
}));
|
|
7622
|
+
return;
|
|
7623
|
+
}
|
|
7624
|
+
console.log(`Sync policy: ${formatPolicy(state)}`);
|
|
7625
|
+
}
|
|
7626
|
+
function syncRemoteListCommand(options = {}) {
|
|
7627
|
+
const vaultRoot = resolveVaultRoot3(options.vault);
|
|
7628
|
+
const state = loadSyncState(vaultRoot);
|
|
7629
|
+
if (options.json) {
|
|
7630
|
+
console.log(jsonSuccess(state.remotes));
|
|
7631
|
+
return;
|
|
7632
|
+
}
|
|
7633
|
+
const remotes = Object.entries(state.remotes);
|
|
7634
|
+
if (remotes.length === 0) {
|
|
7635
|
+
console.log("No remotes configured.");
|
|
7636
|
+
return;
|
|
7637
|
+
}
|
|
7638
|
+
for (const [name, remote] of remotes) {
|
|
7639
|
+
console.log(`${name} ${remote.url}${remote.token ? "" : " (no token)"}`);
|
|
7640
|
+
}
|
|
7641
|
+
}
|
|
7642
|
+
function syncAccessListCommand(options = {}) {
|
|
7643
|
+
const vaultRoot = resolveVaultRoot3(options.vault);
|
|
7644
|
+
const state = loadSyncState(vaultRoot);
|
|
7645
|
+
const grants = listSafeAccessTokens(state);
|
|
7646
|
+
if (options.json) {
|
|
7647
|
+
console.log(jsonSuccess(grants));
|
|
7648
|
+
return;
|
|
7649
|
+
}
|
|
7650
|
+
if (grants.length === 0) {
|
|
7651
|
+
console.log("No sync access grants configured.");
|
|
7652
|
+
return;
|
|
7653
|
+
}
|
|
7654
|
+
for (const grant of grants) {
|
|
7655
|
+
console.log(`${grant.name} ${grant.role} ${grant.token_hint} ${grant.created_at}`);
|
|
7656
|
+
}
|
|
7657
|
+
}
|
|
7658
|
+
function syncAccessGrantCommand(name, options = {}) {
|
|
7659
|
+
const vaultRoot = resolveVaultRoot3(options.vault);
|
|
7660
|
+
const state = loadSyncState(vaultRoot);
|
|
7661
|
+
const grant = grantSyncAccess(state, name, parseAccessRole(options.role));
|
|
7662
|
+
saveSyncState(vaultRoot, state);
|
|
7663
|
+
if (options.json) {
|
|
7664
|
+
console.log(jsonSuccess(grant));
|
|
7665
|
+
return;
|
|
7666
|
+
}
|
|
7667
|
+
console.log(`Granted ${grant.role} sync access "${grant.name}"`);
|
|
7668
|
+
console.log(`Token: ${grant.token}`);
|
|
7669
|
+
}
|
|
7670
|
+
function syncAccessRevokeCommand(name, options = {}) {
|
|
7671
|
+
const vaultRoot = resolveVaultRoot3(options.vault);
|
|
7672
|
+
const state = loadSyncState(vaultRoot);
|
|
7673
|
+
if (!revokeSyncAccess(state, name)) {
|
|
7674
|
+
console.error(`Unknown sync access grant: ${name}`);
|
|
7675
|
+
process.exit(1);
|
|
7676
|
+
}
|
|
7677
|
+
saveSyncState(vaultRoot, state);
|
|
7678
|
+
if (options.json) {
|
|
7679
|
+
console.log(jsonSuccess({ revoked: name }));
|
|
7680
|
+
return;
|
|
7681
|
+
}
|
|
7682
|
+
console.log(`Revoked sync access "${name}"`);
|
|
7683
|
+
}
|
|
7684
|
+
function syncRemoteAddCommand(nameOrAddress, addressMaybe, options = {}) {
|
|
7685
|
+
const vaultRoot = resolveVaultRoot3(options.vault);
|
|
7686
|
+
const state = loadSyncState(vaultRoot);
|
|
7687
|
+
const address = addressMaybe ?? nameOrAddress;
|
|
7688
|
+
const name = addressMaybe ? nameOrAddress : suggestedRemoteName(address);
|
|
7689
|
+
const url = normalizeRemoteUrl(address, options.port);
|
|
7690
|
+
state.remotes[name] = {
|
|
7691
|
+
url,
|
|
7692
|
+
...options.token ? { token: options.token } : {}
|
|
7693
|
+
};
|
|
7694
|
+
saveSyncState(vaultRoot, state);
|
|
7695
|
+
if (options.json) {
|
|
7696
|
+
console.log(jsonSuccess({ name, remote: state.remotes[name] }));
|
|
7697
|
+
return;
|
|
7698
|
+
}
|
|
7699
|
+
console.log(`Added remote "${name}" at ${url}`);
|
|
7700
|
+
if (!options.token) {
|
|
7701
|
+
console.log("No token stored. Add one with: granite sync remote add " + name + " " + url + " --token <token>");
|
|
7702
|
+
}
|
|
7703
|
+
}
|
|
7704
|
+
function syncRemoteRemoveCommand(name, options = {}) {
|
|
7705
|
+
const vaultRoot = resolveVaultRoot3(options.vault);
|
|
7706
|
+
const state = loadSyncState(vaultRoot);
|
|
7707
|
+
if (!state.remotes[name]) {
|
|
7708
|
+
console.error(`Unknown sync remote: ${name}`);
|
|
7709
|
+
process.exit(1);
|
|
7710
|
+
}
|
|
7711
|
+
delete state.remotes[name];
|
|
7712
|
+
saveSyncState(vaultRoot, state);
|
|
7713
|
+
if (options.json) {
|
|
7714
|
+
console.log(jsonSuccess({ removed: name }));
|
|
7715
|
+
return;
|
|
7716
|
+
}
|
|
7717
|
+
console.log(`Removed remote "${name}"`);
|
|
7718
|
+
}
|
|
7719
|
+
function syncServeCommand(options = {}) {
|
|
7720
|
+
const vaultRoot = resolveVaultRoot3(options.vault);
|
|
7721
|
+
const state = loadSyncState(vaultRoot);
|
|
7722
|
+
const host = options.host ?? "0.0.0.0";
|
|
7723
|
+
const port = parsePort3(options.port, DEFAULT_SYNC_PORT);
|
|
7724
|
+
const app = createSyncApp(vaultRoot);
|
|
7725
|
+
console.log("Granite sync server running");
|
|
7726
|
+
console.log(` Vault: ${vaultRoot}`);
|
|
7727
|
+
console.log(` Device: ${state.device_name} (${state.device_id})`);
|
|
7728
|
+
console.log(` Listen: http://${host}:${port}/sync`);
|
|
7729
|
+
console.log(` Grants: ${Object.keys(state.access_tokens).length}`);
|
|
7730
|
+
console.log("");
|
|
7731
|
+
console.log("Create a token to share:");
|
|
7732
|
+
console.log(" granite sync access grant <name> --role read # pull-only replicas");
|
|
7733
|
+
console.log(" granite sync access grant <name> --role write # bidirectional sync");
|
|
7734
|
+
console.log("");
|
|
7735
|
+
console.log("Then on the other machine:");
|
|
7736
|
+
console.log(` granite sync remote add ${state.device_name} http://<tailscale-ip-or-dns>:${port} --token <token>`);
|
|
7737
|
+
return serve3({ fetch: app.fetch, hostname: host, port });
|
|
7738
|
+
}
|
|
7739
|
+
async function syncPullCommand(remoteName, options = {}) {
|
|
7740
|
+
const vaultRoot = resolveVaultRoot3(options.vault);
|
|
7741
|
+
const state = loadSyncState(vaultRoot);
|
|
7742
|
+
const remote = resolveRemote(state, remoteName, options.token);
|
|
7743
|
+
withDetectedDeletions(vaultRoot, state);
|
|
7744
|
+
const manifest = await fetchRemoteJson(remote, "/sync/manifest");
|
|
7745
|
+
const summary = createSummary(remoteName, "pull");
|
|
7746
|
+
for (const remoteFile of manifest.files) {
|
|
7747
|
+
summary.checked++;
|
|
7748
|
+
const localHash = getSyncFileHash(vaultRoot, remoteFile.path);
|
|
7749
|
+
if (localHash === remoteFile.hash) {
|
|
7750
|
+
markBaseline(state, remoteFile.path, remoteFile.hash, remoteFile.modified);
|
|
7751
|
+
summary.skipped++;
|
|
7752
|
+
continue;
|
|
7753
|
+
}
|
|
7754
|
+
const incoming = await fetchRemoteJson(
|
|
7755
|
+
remote,
|
|
7756
|
+
`/sync/file?path=${encodeURIComponent(remoteFile.path)}`
|
|
7757
|
+
);
|
|
7758
|
+
const result = applyIncomingFile(vaultRoot, state, incoming);
|
|
7759
|
+
recordAction(summary, result);
|
|
7760
|
+
}
|
|
7761
|
+
for (const deletion of manifest.deletions) {
|
|
7762
|
+
summary.checked++;
|
|
7763
|
+
const validation = validateIncomingDeletion(vaultRoot, state, deletion);
|
|
7764
|
+
if (validation) {
|
|
7765
|
+
recordAction(summary, validation);
|
|
7766
|
+
continue;
|
|
7767
|
+
}
|
|
7768
|
+
const result = applyIncomingDeletion(vaultRoot, state, deletion);
|
|
7769
|
+
recordAction(summary, result);
|
|
7770
|
+
}
|
|
7771
|
+
saveSyncState(vaultRoot, state);
|
|
7772
|
+
if (summary.changed > 0 || summary.deleted > 0 || summary.conflicts > 0) {
|
|
7773
|
+
rebuildVaultIndex(vaultRoot);
|
|
7774
|
+
}
|
|
7775
|
+
if (!options.quiet) printSummary(summary, options.json);
|
|
7776
|
+
return summary;
|
|
7777
|
+
}
|
|
7778
|
+
async function syncPushCommand(remoteName, options = {}) {
|
|
7779
|
+
const vaultRoot = resolveVaultRoot3(options.vault);
|
|
7780
|
+
const state = loadSyncState(vaultRoot);
|
|
7781
|
+
const remote = resolveRemote(state, remoteName, options.token);
|
|
7782
|
+
const manifest = withDetectedDeletions(vaultRoot, state);
|
|
7783
|
+
const remoteManifest = await fetchRemoteJson(remote, "/sync/manifest");
|
|
7784
|
+
const remoteHashes = new Map(remoteManifest.files.map((file) => [file.path, file.hash]));
|
|
7785
|
+
const remoteDeletions = new Set(remoteManifest.deletions.map((deletion) => deletion.path));
|
|
7786
|
+
const summary = createSummary(remoteName, "push");
|
|
7787
|
+
for (const localFile of manifest.files) {
|
|
7788
|
+
summary.checked++;
|
|
7789
|
+
if (remoteHashes.get(localFile.path) === localFile.hash) {
|
|
7790
|
+
markBaseline(state, localFile.path, localFile.hash, localFile.modified);
|
|
7791
|
+
summary.skipped++;
|
|
7792
|
+
continue;
|
|
7793
|
+
}
|
|
7794
|
+
const payload = readSyncFilePayload(vaultRoot, localFile.path, state);
|
|
7795
|
+
const result = await fetchRemoteJson(remote, "/sync/file", {
|
|
7796
|
+
method: "POST",
|
|
7797
|
+
body: JSON.stringify(payload)
|
|
7798
|
+
});
|
|
7799
|
+
recordAction(summary, result);
|
|
7800
|
+
if (isAcceptedRemoteAction(result.action)) {
|
|
7801
|
+
markBaseline(state, localFile.path, localFile.hash, localFile.modified);
|
|
7802
|
+
}
|
|
7803
|
+
}
|
|
7804
|
+
for (const deletion of manifest.deletions) {
|
|
7805
|
+
if (remoteDeletions.has(deletion.path)) {
|
|
7806
|
+
summary.skipped++;
|
|
7807
|
+
continue;
|
|
7808
|
+
}
|
|
7809
|
+
summary.checked++;
|
|
7810
|
+
const payload = {
|
|
7811
|
+
path: deletion.path,
|
|
7812
|
+
hash: deletion.hash,
|
|
7813
|
+
deleted_at: deletion.deleted_at,
|
|
7814
|
+
device_id: state.device_id
|
|
7815
|
+
};
|
|
7816
|
+
const result = await fetchRemoteJson(remote, "/sync/delete", {
|
|
7817
|
+
method: "POST",
|
|
7818
|
+
body: JSON.stringify(payload)
|
|
7819
|
+
});
|
|
7820
|
+
recordAction(summary, result);
|
|
7821
|
+
}
|
|
7822
|
+
saveSyncState(vaultRoot, state);
|
|
7823
|
+
if (!options.quiet) printSummary(summary, options.json);
|
|
7824
|
+
return summary;
|
|
7825
|
+
}
|
|
7826
|
+
async function syncRunCommand(remoteName, options = {}) {
|
|
7827
|
+
const pull = await syncPullCommand(remoteName, { ...options, quiet: true });
|
|
7828
|
+
const push = await syncPushCommand(remoteName, { ...options, quiet: true });
|
|
7829
|
+
const summary = {
|
|
7830
|
+
remote: remoteName,
|
|
7831
|
+
direction: "run",
|
|
7832
|
+
checked: pull.checked + push.checked,
|
|
7833
|
+
changed: pull.changed + push.changed,
|
|
7834
|
+
skipped: pull.skipped + push.skipped,
|
|
7835
|
+
conflicts: pull.conflicts + push.conflicts,
|
|
7836
|
+
deleted: pull.deleted + push.deleted,
|
|
7837
|
+
actions: [...pull.actions, ...push.actions]
|
|
7838
|
+
};
|
|
7839
|
+
printSummary(summary, options.json);
|
|
7840
|
+
}
|
|
7841
|
+
async function syncWatchCommand(remoteName, options = {}) {
|
|
7842
|
+
const intervalSeconds = parsePort3(options.interval, 30);
|
|
7843
|
+
const direction = parseWatchDirection(options.direction);
|
|
7844
|
+
console.error(`Granite sync watch ${direction} running every ${intervalSeconds}s for remote "${remoteName}".`);
|
|
7845
|
+
while (true) {
|
|
7846
|
+
try {
|
|
7847
|
+
if (direction === "pull") {
|
|
7848
|
+
await syncPullCommand(remoteName, { ...options, json: false });
|
|
7849
|
+
} else {
|
|
7850
|
+
await syncRunCommand(remoteName, { ...options, json: false });
|
|
7851
|
+
}
|
|
7852
|
+
} catch (error) {
|
|
7853
|
+
console.error(`Sync failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7854
|
+
}
|
|
7855
|
+
await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1e3));
|
|
7856
|
+
}
|
|
7857
|
+
}
|
|
7858
|
+
function createSyncApp(vaultRoot) {
|
|
7859
|
+
const app = new Hono3();
|
|
7860
|
+
app.get("/sync/health", (c) => {
|
|
7861
|
+
const state = loadSyncState(vaultRoot);
|
|
7862
|
+
return c.json({
|
|
7863
|
+
ok: true,
|
|
7864
|
+
device_id: state.device_id,
|
|
7865
|
+
device_name: state.device_name,
|
|
7866
|
+
conflict_policy: state.conflict_policy,
|
|
7867
|
+
primary_device_id: state.primary_device_id
|
|
7868
|
+
});
|
|
7869
|
+
});
|
|
7870
|
+
app.use("/sync/*", async (c, next) => {
|
|
7871
|
+
const state = loadSyncState(vaultRoot);
|
|
7872
|
+
const token = readBearerToken(c.req.header("authorization"));
|
|
7873
|
+
const role = resolveSyncAccessRole(state, token);
|
|
7874
|
+
if (!role) {
|
|
7875
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
7876
|
+
}
|
|
7877
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD" && role !== "write") {
|
|
7878
|
+
return c.json({ error: "Forbidden: write sync access is required" }, 403);
|
|
7879
|
+
}
|
|
7880
|
+
await next();
|
|
7881
|
+
});
|
|
7882
|
+
app.get("/sync/manifest", (c) => {
|
|
7883
|
+
const state = loadSyncState(vaultRoot);
|
|
7884
|
+
const manifest = buildSyncManifest(vaultRoot, state);
|
|
7885
|
+
return c.json(manifest);
|
|
7886
|
+
});
|
|
7887
|
+
app.get("/sync/file", (c) => {
|
|
7888
|
+
try {
|
|
7889
|
+
const relativePath = c.req.query("path");
|
|
7890
|
+
if (!relativePath) return c.json({ error: "path is required" }, 400);
|
|
7891
|
+
const state = loadSyncState(vaultRoot);
|
|
7892
|
+
return c.json(readSyncFilePayload(vaultRoot, relativePath, state));
|
|
7893
|
+
} catch (error) {
|
|
7894
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
7895
|
+
}
|
|
7896
|
+
});
|
|
7897
|
+
app.post("/sync/file", async (c) => {
|
|
7898
|
+
try {
|
|
7899
|
+
const incoming = await c.req.json();
|
|
7900
|
+
const state = loadSyncState(vaultRoot);
|
|
7901
|
+
const result = applyIncomingFile(vaultRoot, state, incoming);
|
|
7902
|
+
saveSyncState(vaultRoot, state);
|
|
7903
|
+
if (result.action !== "unchanged") rebuildVaultIndex(vaultRoot);
|
|
7904
|
+
return c.json(result);
|
|
7905
|
+
} catch (error) {
|
|
7906
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
7907
|
+
}
|
|
7908
|
+
});
|
|
7909
|
+
app.post("/sync/delete", async (c) => {
|
|
7910
|
+
try {
|
|
7911
|
+
const incoming = await c.req.json();
|
|
7912
|
+
const state = loadSyncState(vaultRoot);
|
|
7913
|
+
const validation = validateIncomingDeletion(vaultRoot, state, incoming);
|
|
7914
|
+
if (validation) return c.json(validation);
|
|
7915
|
+
const result = applyIncomingDeletion(vaultRoot, state, incoming);
|
|
7916
|
+
saveSyncState(vaultRoot, state);
|
|
7917
|
+
if (result.action !== "delete_ignored_missing") rebuildVaultIndex(vaultRoot);
|
|
7918
|
+
return c.json(result);
|
|
7919
|
+
} catch (error) {
|
|
7920
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
7921
|
+
}
|
|
7922
|
+
});
|
|
7923
|
+
return app;
|
|
7924
|
+
}
|
|
7925
|
+
function resolveVaultRoot3(explicitVault) {
|
|
7926
|
+
if (explicitVault) return path18.resolve(explicitVault);
|
|
7927
|
+
if (process.env.GRANITE_VAULT) return path18.resolve(process.env.GRANITE_VAULT);
|
|
7928
|
+
return requireVaultRoot();
|
|
7929
|
+
}
|
|
7930
|
+
function withDetectedDeletions(vaultRoot, state) {
|
|
7931
|
+
if (detectLocalDeletions(vaultRoot, state)) {
|
|
7932
|
+
saveSyncState(vaultRoot, state);
|
|
7933
|
+
}
|
|
7934
|
+
return buildSyncManifest(vaultRoot, state);
|
|
7935
|
+
}
|
|
7936
|
+
function parsePort3(value, fallback) {
|
|
7937
|
+
const raw = value?.trim();
|
|
7938
|
+
if (!raw) return fallback;
|
|
7939
|
+
if (!/^\d+$/.test(raw)) throw new Error(`Invalid number: ${raw}`);
|
|
7940
|
+
const parsed = Number.parseInt(raw, 10);
|
|
7941
|
+
if (parsed <= 0 || parsed > 65535) throw new Error(`Invalid number: ${raw}`);
|
|
7942
|
+
return parsed;
|
|
7943
|
+
}
|
|
7944
|
+
function parsePolicy(value) {
|
|
7945
|
+
if (value === "manual") return "manual";
|
|
7946
|
+
if (value === "primary-wins" || value === "primary_wins") return "primary_wins";
|
|
7947
|
+
throw new Error("Invalid sync policy. Expected manual or primary-wins.");
|
|
7948
|
+
}
|
|
7949
|
+
function parseAccessRole(value) {
|
|
7950
|
+
const role = value ?? "read";
|
|
7951
|
+
if (role === "read" || role === "write") return role;
|
|
7952
|
+
throw new Error("Invalid sync access role. Expected read or write.");
|
|
7953
|
+
}
|
|
7954
|
+
function parseWatchDirection(value) {
|
|
7955
|
+
const direction = value ?? "run";
|
|
7956
|
+
if (direction === "pull" || direction === "run") return direction;
|
|
7957
|
+
throw new Error("Invalid sync watch direction. Expected pull or run.");
|
|
7958
|
+
}
|
|
7959
|
+
function formatPolicy(state) {
|
|
7960
|
+
if (state.conflict_policy === "primary_wins") {
|
|
7961
|
+
return `primary-wins (${state.primary_device_id})`;
|
|
7962
|
+
}
|
|
7963
|
+
return "manual";
|
|
7964
|
+
}
|
|
7965
|
+
function listSafeAccessTokens(state) {
|
|
7966
|
+
return Object.values(state.access_tokens).map((grant) => ({
|
|
7967
|
+
name: grant.name,
|
|
7968
|
+
role: grant.role,
|
|
7969
|
+
created_at: grant.created_at,
|
|
7970
|
+
token_hint: maskToken(grant.token)
|
|
7971
|
+
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
7972
|
+
}
|
|
7973
|
+
function getDefaultWriteGrant(state) {
|
|
7974
|
+
const defaultGrant = state.access_tokens.default;
|
|
7975
|
+
if (defaultGrant?.role === "write") return defaultGrant;
|
|
7976
|
+
return Object.values(state.access_tokens).find((grant) => grant.role === "write") ?? null;
|
|
7977
|
+
}
|
|
7978
|
+
function maskToken(token) {
|
|
7979
|
+
if (!token) return "(none)";
|
|
7980
|
+
if (token.length <= 8) return "********";
|
|
7981
|
+
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
|
7982
|
+
}
|
|
7983
|
+
function resolveRemote(state, remoteName, tokenOverride) {
|
|
7984
|
+
const remote = state.remotes[remoteName];
|
|
7985
|
+
if (!remote) {
|
|
7986
|
+
throw new Error(`Unknown sync remote: ${remoteName}`);
|
|
7987
|
+
}
|
|
7988
|
+
return {
|
|
7989
|
+
...remote,
|
|
7990
|
+
...tokenOverride ? { token: tokenOverride } : {}
|
|
7991
|
+
};
|
|
7992
|
+
}
|
|
7993
|
+
async function fetchRemoteJson(remote, route, init = {}) {
|
|
7994
|
+
const response = await fetch(remoteUrl(remote, route), {
|
|
7995
|
+
...init,
|
|
7996
|
+
headers: {
|
|
7997
|
+
...init.body ? { "Content-Type": "application/json" } : {},
|
|
7998
|
+
...remote.token ? { Authorization: `Bearer ${remote.token}` } : {},
|
|
7999
|
+
...init.headers ?? {}
|
|
8000
|
+
}
|
|
8001
|
+
});
|
|
8002
|
+
const text = await response.text();
|
|
8003
|
+
let data;
|
|
8004
|
+
try {
|
|
8005
|
+
data = text ? JSON.parse(text) : {};
|
|
8006
|
+
} catch {
|
|
8007
|
+
throw new Error(`Remote returned non-JSON response (${response.status})`);
|
|
8008
|
+
}
|
|
8009
|
+
if (!response.ok) {
|
|
8010
|
+
const message = typeof data === "object" && data && "error" in data ? String(data.error) : `Remote request failed (${response.status})`;
|
|
8011
|
+
throw new Error(message);
|
|
8012
|
+
}
|
|
8013
|
+
return data;
|
|
8014
|
+
}
|
|
8015
|
+
function remoteUrl(remote, route) {
|
|
8016
|
+
const base = remote.url.replace(/\/+$/, "");
|
|
8017
|
+
return `${base}${route}`;
|
|
8018
|
+
}
|
|
8019
|
+
function readBearerToken(header) {
|
|
8020
|
+
if (!header) return null;
|
|
8021
|
+
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
8022
|
+
return match ? match[1] : null;
|
|
8023
|
+
}
|
|
8024
|
+
function validateIncomingDeletion(vaultRoot, state, incoming) {
|
|
8025
|
+
const safePath = normalizeSyncPath(incoming.path);
|
|
8026
|
+
const localHash = getSyncFileHash(vaultRoot, safePath);
|
|
8027
|
+
const baselineHash = state.baselines[safePath]?.hash;
|
|
8028
|
+
const tombstoneHash = state.tombstones[safePath]?.hash;
|
|
8029
|
+
if (incoming.hash === localHash || incoming.hash === baselineHash || incoming.hash === tombstoneHash) {
|
|
8030
|
+
return null;
|
|
8031
|
+
}
|
|
8032
|
+
if (!localHash && !baselineHash && !tombstoneHash) {
|
|
8033
|
+
return { path: safePath, action: "delete_ignored_missing" };
|
|
8034
|
+
}
|
|
8035
|
+
return { path: safePath, action: "delete_rejected_unknown_hash" };
|
|
8036
|
+
}
|
|
8037
|
+
function createSummary(remote, direction) {
|
|
8038
|
+
return {
|
|
8039
|
+
remote,
|
|
8040
|
+
direction,
|
|
8041
|
+
checked: 0,
|
|
8042
|
+
changed: 0,
|
|
8043
|
+
skipped: 0,
|
|
8044
|
+
conflicts: 0,
|
|
8045
|
+
deleted: 0,
|
|
8046
|
+
actions: []
|
|
8047
|
+
};
|
|
8048
|
+
}
|
|
8049
|
+
function recordAction(summary, result) {
|
|
8050
|
+
summary.actions.push(result);
|
|
8051
|
+
if (isConflictAction(result.action)) {
|
|
8052
|
+
summary.conflicts++;
|
|
8053
|
+
return;
|
|
8054
|
+
}
|
|
8055
|
+
if (result.action === "unchanged" || result.action === "delete_ignored_missing") {
|
|
8056
|
+
summary.skipped++;
|
|
8057
|
+
return;
|
|
8058
|
+
}
|
|
8059
|
+
if (result.action === "delete_rejected_unknown_hash") {
|
|
8060
|
+
summary.conflicts++;
|
|
8061
|
+
return;
|
|
8062
|
+
}
|
|
8063
|
+
if (result.action === "deleted") {
|
|
8064
|
+
summary.deleted++;
|
|
8065
|
+
return;
|
|
8066
|
+
}
|
|
8067
|
+
summary.changed++;
|
|
8068
|
+
}
|
|
8069
|
+
function isConflictAction(action) {
|
|
8070
|
+
return action === "conflict_copy_created" || action === "delete_conflict_copy_created" || action === "kept_local_primary";
|
|
8071
|
+
}
|
|
8072
|
+
function isAcceptedRemoteAction(action) {
|
|
8073
|
+
return action === "unchanged" || action === "created" || action === "updated" || action === "overwrote_local_primary" || action === "deleted" || action === "delete_ignored_missing";
|
|
8074
|
+
}
|
|
8075
|
+
function printSummary(summary, asJson) {
|
|
8076
|
+
if (asJson) {
|
|
8077
|
+
console.log(jsonSuccess(summary));
|
|
8078
|
+
return;
|
|
8079
|
+
}
|
|
8080
|
+
console.log(
|
|
8081
|
+
`Sync ${summary.direction} ${summary.remote}: ${summary.changed} changed, ${summary.deleted} deleted, ${summary.conflicts} conflict(s), ${summary.skipped} skipped`
|
|
8082
|
+
);
|
|
8083
|
+
for (const action of summary.actions.filter((item) => item.conflict_path)) {
|
|
8084
|
+
console.log(` conflict: ${action.path} -> ${action.conflict_path}`);
|
|
8085
|
+
}
|
|
8086
|
+
}
|
|
8087
|
+
function rebuildVaultIndex(vaultRoot) {
|
|
8088
|
+
try {
|
|
8089
|
+
const config = loadConfig(vaultRoot);
|
|
8090
|
+
const db = openDatabase(vaultRoot);
|
|
8091
|
+
rebuildIndex(vaultRoot, config, db);
|
|
8092
|
+
db.close();
|
|
8093
|
+
} catch {
|
|
8094
|
+
}
|
|
8095
|
+
}
|
|
8096
|
+
|
|
7167
8097
|
// src/index.ts
|
|
7168
8098
|
var program = new Command();
|
|
7169
8099
|
program.name("granite").description("Granite \u2014 a local-first knowledge compiler. capture \u2192 compile \u2192 query \u2192 output \u2192 lint").version(GRANITE_VERSION);
|
|
@@ -7224,7 +8154,7 @@ program.command("wakeup").description("Generate a compressed AAAK snapshot of th
|
|
|
7224
8154
|
program.command("serve").description("Start the local web UI \u2014 browse, search, and visualize the knowledge graph").option("-p, --port <port>", "Port number", "4321").action((options) => {
|
|
7225
8155
|
serveCommand(options);
|
|
7226
8156
|
});
|
|
7227
|
-
var mcpCmd = program.command("mcp").description("Start Granite as an MCP server").option("--vault <path>", "Vault root. Defaults to the current Granite vault or $GRANITE_VAULT").option("--transport <transport>", "Transport to use: stdio or http", parseTransportOption, "stdio").option("--host <host>", "Host for HTTP transport", "127.0.0.1").option("--port <port>", "Port for HTTP transport", "3321").option("--allow-origin <origin>", "Allow an HTTP Origin for browser-based HTTP clients", collectValues, []).option("--json-response", "Prefer JSON HTTP responses instead of SSE streams").option("--
|
|
8157
|
+
var mcpCmd = program.command("mcp").description("Start Granite as an MCP server").option("--vault <path>", "Vault root. Defaults to the current Granite vault or $GRANITE_VAULT").option("--transport <transport>", "Transport to use: stdio or http", parseTransportOption, "stdio").option("--host <host>", "Host for HTTP transport", "127.0.0.1").option("--port <port>", "Port for HTTP transport", "3321").option("--allow-origin <origin>", "Allow an HTTP Origin for browser-based HTTP clients", collectValues, []).option("--json-response", "Prefer JSON HTTP responses instead of SSE streams").option("--background", "Run MCP server as a background daemon").option("--role <role>", "MCP access role: read or write", "write").action(async (options) => {
|
|
7228
8158
|
await mcpCommand(options);
|
|
7229
8159
|
});
|
|
7230
8160
|
mcpCmd.command("stop").description("Stop a background MCP server").option("--vault <path>", "Vault root").action((options) => {
|
|
@@ -7248,6 +8178,48 @@ daemonCmd.command("status").description("Show status of the background daemon").
|
|
|
7248
8178
|
daemonCmd.command("logs").description("Tail the daemon log file").option("--vault <path>", "Vault root").option("-n, --lines <count>", "Number of tail lines", "100").action((options) => {
|
|
7249
8179
|
daemonLogsCommand(options);
|
|
7250
8180
|
});
|
|
8181
|
+
var syncCmd = program.command("sync").description("Sync a vault directly with another machine over LAN, Tailscale, or private DNS");
|
|
8182
|
+
syncCmd.command("status").description("Show local sync identity, token, policy, and remotes").option("--vault <path>", "Vault root").option("--json", "Output as JSON").action((options) => {
|
|
8183
|
+
syncStatusCommand(options);
|
|
8184
|
+
});
|
|
8185
|
+
syncCmd.command("config").description("Configure conflict resolution for this vault").option("--vault <path>", "Vault root").option("--policy <policy>", "Conflict policy: manual or primary-wins").option("--primary-device <device_id>", "Device that wins conflicts when policy is primary-wins").option("--primary-this-device", "Make this machine the primary device").option("--json", "Output as JSON").action((options) => {
|
|
8186
|
+
syncConfigCommand(options);
|
|
8187
|
+
});
|
|
8188
|
+
var syncRemoteCmd = syncCmd.command("remote").description("Manage direct sync remotes");
|
|
8189
|
+
syncRemoteCmd.command("list").alias("ls").description("List configured sync remotes").option("--vault <path>", "Vault root").option("--json", "Output as JSON").action((options) => {
|
|
8190
|
+
syncRemoteListCommand(options);
|
|
8191
|
+
});
|
|
8192
|
+
syncRemoteCmd.command("add").description("Add a remote by IP, Tailscale DNS name, or private domain").argument("<name-or-address>", "Remote name, or address when address is omitted").argument("[address]", "Remote address, e.g. http://100.x.y.z:8765").option("--vault <path>", "Vault root").option("--port <port>", "Port to add when the address has no port", "8765").option("--token <token>", "Remote sync token").option("--json", "Output as JSON").action((nameOrAddress, address, options) => {
|
|
8193
|
+
syncRemoteAddCommand(nameOrAddress, address, options);
|
|
8194
|
+
});
|
|
8195
|
+
syncRemoteCmd.command("remove").alias("rm").description("Remove a sync remote").argument("<name>", "Remote name").option("--vault <path>", "Vault root").option("--json", "Output as JSON").action((name, options) => {
|
|
8196
|
+
syncRemoteRemoveCommand(name, options);
|
|
8197
|
+
});
|
|
8198
|
+
var syncAccessCmd = syncCmd.command("access").description("Manage read/write sync access grants");
|
|
8199
|
+
syncAccessCmd.command("list").alias("ls").description("List sync access grants without revealing full tokens").option("--vault <path>", "Vault root").option("--json", "Output as JSON").action((options) => {
|
|
8200
|
+
syncAccessListCommand(options);
|
|
8201
|
+
});
|
|
8202
|
+
syncAccessCmd.command("grant").description("Create or replace a named sync access grant").argument("<name>", "Grant name, e.g. ipad or teammate-alice").option("--vault <path>", "Vault root").option("--role <role>", "Access role: read or write", "read").option("--json", "Output as JSON").action((name, options) => {
|
|
8203
|
+
syncAccessGrantCommand(name, options);
|
|
8204
|
+
});
|
|
8205
|
+
syncAccessCmd.command("revoke").description("Revoke a named sync access grant").argument("<name>", "Grant name").option("--vault <path>", "Vault root").option("--json", "Output as JSON").action((name, options) => {
|
|
8206
|
+
syncAccessRevokeCommand(name, options);
|
|
8207
|
+
});
|
|
8208
|
+
syncCmd.command("serve").description("Serve this vault for direct sync from another machine").option("--vault <path>", "Vault root").option("--host <host>", "Host to bind to", "0.0.0.0").option("--port <port>", "Port to listen on", "8765").action((options) => {
|
|
8209
|
+
syncServeCommand(options);
|
|
8210
|
+
});
|
|
8211
|
+
syncCmd.command("pull").description("Pull changes from a direct sync remote").argument("<remote>", "Remote name").option("--vault <path>", "Vault root").option("--token <token>", "Override remote token").option("--json", "Output as JSON").action(async (remote, options) => {
|
|
8212
|
+
await syncPullCommand(remote, options);
|
|
8213
|
+
});
|
|
8214
|
+
syncCmd.command("push").description("Push changes to a direct sync remote").argument("<remote>", "Remote name").option("--vault <path>", "Vault root").option("--token <token>", "Override remote token").option("--json", "Output as JSON").action(async (remote, options) => {
|
|
8215
|
+
await syncPushCommand(remote, options);
|
|
8216
|
+
});
|
|
8217
|
+
syncCmd.command("run").description("Pull then push changes with a direct sync remote").argument("<remote>", "Remote name").option("--vault <path>", "Vault root").option("--token <token>", "Override remote token").option("--json", "Output as JSON").action(async (remote, options) => {
|
|
8218
|
+
await syncRunCommand(remote, options);
|
|
8219
|
+
});
|
|
8220
|
+
syncCmd.command("watch").description("Continuously run direct sync with a remote").argument("<remote>", "Remote name").option("--vault <path>", "Vault root").option("--interval <seconds>", "Seconds between sync runs", "30").option("--direction <direction>", "Watch direction: pull or run", "run").action(async (remote, options) => {
|
|
8221
|
+
await syncWatchCommand(remote, options);
|
|
8222
|
+
});
|
|
7251
8223
|
await program.parseAsync();
|
|
7252
8224
|
function collectValues(value, previous) {
|
|
7253
8225
|
return [...previous, value];
|