@fyresmith/hive-server 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -89
- package/cli/commands/env.js +80 -0
- package/cli/commands/root.js +91 -0
- package/cli/commands/service.js +112 -0
- package/cli/commands/tunnel.js +165 -0
- package/cli/core/app.js +57 -0
- package/cli/core/context.js +110 -0
- package/cli/flows/doctor.js +101 -0
- package/cli/flows/setup.js +142 -0
- package/cli/flows/system.js +170 -0
- package/cli/main.js +5 -926
- package/cli/tunnel.js +17 -1
- package/lib/adapterRegistry.js +152 -0
- package/lib/collabProtocol.js +25 -0
- package/lib/collabStore.js +448 -0
- package/lib/discordWebhook.js +81 -0
- package/lib/mentionUtils.js +13 -0
- package/lib/socketHandler.js +891 -38
- package/lib/vaultManager.js +220 -4
- package/lib/yjsServer.js +6 -1
- package/package.json +3 -3
package/lib/vaultManager.js
CHANGED
|
@@ -3,15 +3,26 @@ import { resolve, join, sep, extname, relative, dirname } from 'path';
|
|
|
3
3
|
import { readFile, writeFile as fsWriteFile, rename, unlink, mkdir, stat, readdir } from 'fs/promises';
|
|
4
4
|
import { existsSync } from 'fs';
|
|
5
5
|
import chokidar from 'chokidar';
|
|
6
|
+
import { getMetadataAllowlist, isMetadataPathAllowed } from './adapterRegistry.js';
|
|
6
7
|
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
// Deny / allow lists
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
11
|
|
|
11
|
-
const DENY_PREFIXES = ['.obsidian', 'Attachments', '.git'];
|
|
12
|
+
const DENY_PREFIXES = ['.obsidian', '.hive-history', 'Attachments', '.git'];
|
|
12
13
|
const DENY_FILES = ['.DS_Store', 'Thumbs.db'];
|
|
13
14
|
// Keep synced content text-based to ensure hash/read/write semantics stay safe.
|
|
14
15
|
const ALLOW_EXTS = new Set(['.md', '.canvas']);
|
|
16
|
+
const METADATA_ALLOWLIST = new Set(getMetadataAllowlist());
|
|
17
|
+
const HISTORY_DIR_NAME = '.hive-history';
|
|
18
|
+
const HISTORY_FILES_DIR = 'files';
|
|
19
|
+
const DEFAULT_HISTORY_LIMIT = 50;
|
|
20
|
+
const MAX_HISTORY_LIMIT = 500;
|
|
21
|
+
const HISTORY_RETENTION_PER_FILE = Math.max(
|
|
22
|
+
1,
|
|
23
|
+
parseInt(process.env.HIVE_HISTORY_RETENTION_PER_FILE ?? '200', 10) || 200,
|
|
24
|
+
);
|
|
25
|
+
const VERSION_ID_RE = /^\d{13}-[a-z0-9]+$/;
|
|
15
26
|
|
|
16
27
|
// ---------------------------------------------------------------------------
|
|
17
28
|
// Internals
|
|
@@ -34,6 +45,137 @@ function getVaultRoot() {
|
|
|
34
45
|
*/
|
|
35
46
|
const manifestCache = new Map();
|
|
36
47
|
|
|
48
|
+
function getHistoryRoot() {
|
|
49
|
+
const custom = process.env.HIVE_HISTORY_PATH?.trim();
|
|
50
|
+
if (custom) return resolve(custom);
|
|
51
|
+
return join(getVaultRoot(), HISTORY_DIR_NAME);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function encodeHistoryKey(relPath) {
|
|
55
|
+
return Buffer.from(relPath, 'utf-8').toString('base64url');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function historyDirForPath(relPath) {
|
|
59
|
+
return join(getHistoryRoot(), HISTORY_FILES_DIR, encodeHistoryKey(relPath));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeHistoryActor(actor) {
|
|
63
|
+
if (!actor || typeof actor !== 'object') return null;
|
|
64
|
+
const id = typeof actor.id === 'string' ? actor.id : null;
|
|
65
|
+
const username = typeof actor.username === 'string' ? actor.username : null;
|
|
66
|
+
if (!id && !username) return null;
|
|
67
|
+
return { id, username };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeHistorySource(source) {
|
|
71
|
+
return typeof source === 'string' && source.length > 0 ? source : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function clampLimit(value, fallback) {
|
|
75
|
+
if (!Number.isFinite(value)) return fallback;
|
|
76
|
+
const n = Math.trunc(value);
|
|
77
|
+
if (n < 1) return 1;
|
|
78
|
+
if (n > MAX_HISTORY_LIMIT) return MAX_HISTORY_LIMIT;
|
|
79
|
+
return n;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function listHistoryFiles(relPath) {
|
|
83
|
+
const dir = historyDirForPath(relPath);
|
|
84
|
+
let names;
|
|
85
|
+
try {
|
|
86
|
+
names = await readdir(dir);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (err?.code === 'ENOENT') return [];
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return names
|
|
93
|
+
.filter((name) => name.endsWith('.json'))
|
|
94
|
+
.sort();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function readHistoryRecord(absPath) {
|
|
98
|
+
const raw = await readFile(absPath, 'utf-8');
|
|
99
|
+
return JSON.parse(raw);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toHistoryMeta(record) {
|
|
103
|
+
return {
|
|
104
|
+
versionId: record.versionId,
|
|
105
|
+
relPath: record.relPath,
|
|
106
|
+
ts: record.ts,
|
|
107
|
+
action: record.action,
|
|
108
|
+
hash: record.hash,
|
|
109
|
+
size: record.size,
|
|
110
|
+
actor: record.actor ?? null,
|
|
111
|
+
source: record.source ?? null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function pruneHistory(relPath) {
|
|
116
|
+
const files = await listHistoryFiles(relPath);
|
|
117
|
+
if (files.length <= HISTORY_RETENTION_PER_FILE) return;
|
|
118
|
+
|
|
119
|
+
const dir = historyDirForPath(relPath);
|
|
120
|
+
const trimCount = files.length - HISTORY_RETENTION_PER_FILE;
|
|
121
|
+
for (const name of files.slice(0, trimCount)) {
|
|
122
|
+
try {
|
|
123
|
+
await unlink(join(dir, name));
|
|
124
|
+
} catch {
|
|
125
|
+
// Best effort; skip failures and continue pruning.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function writeHistoryRecord(relPath, content, history = {}) {
|
|
131
|
+
if (!isAllowed(relPath)) return;
|
|
132
|
+
const action = typeof history.action === 'string' && history.action.length > 0
|
|
133
|
+
? history.action
|
|
134
|
+
: 'write';
|
|
135
|
+
|
|
136
|
+
const dir = historyDirForPath(relPath);
|
|
137
|
+
await mkdir(dir, { recursive: true });
|
|
138
|
+
|
|
139
|
+
const hash = hashContent(content);
|
|
140
|
+
const existing = await listHistoryFiles(relPath);
|
|
141
|
+
if (existing.length > 0) {
|
|
142
|
+
const lastFile = existing[existing.length - 1];
|
|
143
|
+
try {
|
|
144
|
+
const latest = await readHistoryRecord(join(dir, lastFile));
|
|
145
|
+
if (latest?.hash === hash && latest?.action === action) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// Ignore malformed historical entry and continue writing a new one.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ts = Date.now();
|
|
154
|
+
const versionId = `${ts}-${Math.random().toString(36).slice(2, 10)}`;
|
|
155
|
+
const record = {
|
|
156
|
+
versionId,
|
|
157
|
+
relPath,
|
|
158
|
+
ts,
|
|
159
|
+
action,
|
|
160
|
+
hash,
|
|
161
|
+
size: Buffer.byteLength(content, 'utf-8'),
|
|
162
|
+
actor: normalizeHistoryActor(history.actor),
|
|
163
|
+
source: normalizeHistorySource(history.source),
|
|
164
|
+
content,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
await fsWriteFile(join(dir, `${versionId}.json`), JSON.stringify(record), 'utf-8');
|
|
168
|
+
await pruneHistory(relPath);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function writeHistoryRecordBestEffort(relPath, content, history = {}) {
|
|
172
|
+
try {
|
|
173
|
+
await writeHistoryRecord(relPath, content, history);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
console.warn(`[history] Failed to record version for ${relPath}:`, err?.message ?? err);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
37
179
|
// ---------------------------------------------------------------------------
|
|
38
180
|
// Path helpers
|
|
39
181
|
// ---------------------------------------------------------------------------
|
|
@@ -58,6 +200,15 @@ export function safePath(relPath) {
|
|
|
58
200
|
*/
|
|
59
201
|
export function isDenied(relPath) {
|
|
60
202
|
const normalised = relPath.replace(/\\/g, '/');
|
|
203
|
+
if (isMetadataPathAllowed(normalised)) return false;
|
|
204
|
+
|
|
205
|
+
if (normalised === '.obsidian/' || normalised === '.obsidian') {
|
|
206
|
+
// Allow descending into .obsidian only when we explicitly whitelist files inside.
|
|
207
|
+
if ([...METADATA_ALLOWLIST].some((entry) => entry.startsWith('.obsidian/'))) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
61
212
|
const parts = normalised.split('/');
|
|
62
213
|
const base = parts[parts.length - 1];
|
|
63
214
|
if (DENY_FILES.includes(base)) return true;
|
|
@@ -72,6 +223,7 @@ export function isDenied(relPath) {
|
|
|
72
223
|
* @param {string} relPath
|
|
73
224
|
*/
|
|
74
225
|
export function isAllowed(relPath) {
|
|
226
|
+
if (isMetadataPathAllowed(relPath)) return true;
|
|
75
227
|
if (isDenied(relPath)) return false;
|
|
76
228
|
return ALLOW_EXTS.has(extname(relPath).toLowerCase());
|
|
77
229
|
}
|
|
@@ -192,7 +344,7 @@ export { readFile_ as readFile };
|
|
|
192
344
|
* @param {string} relPath
|
|
193
345
|
* @param {string} content
|
|
194
346
|
*/
|
|
195
|
-
export async function writeFile(relPath, content) {
|
|
347
|
+
export async function writeFile(relPath, content, options = {}) {
|
|
196
348
|
const abs = safePath(relPath);
|
|
197
349
|
const tmp = abs + '.tmp';
|
|
198
350
|
await mkdir(dirname(abs), { recursive: true });
|
|
@@ -200,16 +352,22 @@ export async function writeFile(relPath, content) {
|
|
|
200
352
|
await rename(tmp, abs);
|
|
201
353
|
// Invalidate cache entry so next manifest reflects new content
|
|
202
354
|
manifestCache.delete(relPath);
|
|
355
|
+
await writeHistoryRecordBestEffort(relPath, content, options.history);
|
|
203
356
|
}
|
|
204
357
|
|
|
205
358
|
/**
|
|
206
359
|
* Delete a file.
|
|
207
360
|
* @param {string} relPath
|
|
208
361
|
*/
|
|
209
|
-
export async function deleteFile(relPath) {
|
|
362
|
+
export async function deleteFile(relPath, options = {}) {
|
|
210
363
|
const abs = safePath(relPath);
|
|
364
|
+
const previous = await readFile(abs, 'utf-8');
|
|
211
365
|
await unlink(abs);
|
|
212
366
|
manifestCache.delete(relPath);
|
|
367
|
+
await writeHistoryRecordBestEffort(relPath, previous, {
|
|
368
|
+
...(options.history ?? {}),
|
|
369
|
+
action: options.history?.action ?? 'delete',
|
|
370
|
+
});
|
|
213
371
|
}
|
|
214
372
|
|
|
215
373
|
/**
|
|
@@ -217,13 +375,71 @@ export async function deleteFile(relPath) {
|
|
|
217
375
|
* @param {string} oldRelPath
|
|
218
376
|
* @param {string} newRelPath
|
|
219
377
|
*/
|
|
220
|
-
export async function renameFile(oldRelPath, newRelPath) {
|
|
378
|
+
export async function renameFile(oldRelPath, newRelPath, options = {}) {
|
|
221
379
|
const oldAbs = safePath(oldRelPath);
|
|
222
380
|
const newAbs = safePath(newRelPath);
|
|
381
|
+
const previous = await readFile(oldAbs, 'utf-8');
|
|
223
382
|
await mkdir(dirname(newAbs), { recursive: true });
|
|
224
383
|
await rename(oldAbs, newAbs);
|
|
225
384
|
manifestCache.delete(oldRelPath);
|
|
226
385
|
manifestCache.delete(newRelPath);
|
|
386
|
+
|
|
387
|
+
const history = options.history ?? {};
|
|
388
|
+
await writeHistoryRecordBestEffort(oldRelPath, previous, {
|
|
389
|
+
...history,
|
|
390
|
+
action: 'rename-out',
|
|
391
|
+
});
|
|
392
|
+
await writeHistoryRecordBestEffort(newRelPath, previous, {
|
|
393
|
+
...history,
|
|
394
|
+
action: 'rename-in',
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* List historical versions for a file (newest first).
|
|
400
|
+
* @param {string} relPath
|
|
401
|
+
* @param {number} [limit]
|
|
402
|
+
*/
|
|
403
|
+
export async function listFileHistory(relPath, limit = DEFAULT_HISTORY_LIMIT) {
|
|
404
|
+
if (!isAllowed(relPath)) return [];
|
|
405
|
+
const clampedLimit = clampLimit(Number(limit), DEFAULT_HISTORY_LIMIT);
|
|
406
|
+
const dir = historyDirForPath(relPath);
|
|
407
|
+
const names = await listHistoryFiles(relPath);
|
|
408
|
+
const out = [];
|
|
409
|
+
|
|
410
|
+
for (const name of names.reverse()) {
|
|
411
|
+
try {
|
|
412
|
+
const record = await readHistoryRecord(join(dir, name));
|
|
413
|
+
if (record?.relPath !== relPath) continue;
|
|
414
|
+
out.push(toHistoryMeta(record));
|
|
415
|
+
if (out.length >= clampedLimit) break;
|
|
416
|
+
} catch {
|
|
417
|
+
// Skip malformed entries.
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Read a historical file version.
|
|
426
|
+
* @param {string} relPath
|
|
427
|
+
* @param {string} versionId
|
|
428
|
+
*/
|
|
429
|
+
export async function readFileHistoryVersion(relPath, versionId) {
|
|
430
|
+
if (!isAllowed(relPath)) {
|
|
431
|
+
throw new Error('Path not allowed');
|
|
432
|
+
}
|
|
433
|
+
if (typeof versionId !== 'string' || !VERSION_ID_RE.test(versionId)) {
|
|
434
|
+
throw new Error('Invalid version ID');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const abs = join(historyDirForPath(relPath), `${versionId}.json`);
|
|
438
|
+
const record = await readHistoryRecord(abs);
|
|
439
|
+
if (record?.relPath !== relPath) {
|
|
440
|
+
throw new Error('Version/path mismatch');
|
|
441
|
+
}
|
|
442
|
+
return record;
|
|
227
443
|
}
|
|
228
444
|
|
|
229
445
|
// ---------------------------------------------------------------------------
|
package/lib/yjsServer.js
CHANGED
|
@@ -79,7 +79,12 @@ async function flushRoomState(state) {
|
|
|
79
79
|
|
|
80
80
|
const text = ydoc.getText('content').toString();
|
|
81
81
|
try {
|
|
82
|
-
await vault.writeFile(state.relPath, text
|
|
82
|
+
await vault.writeFile(state.relPath, text, {
|
|
83
|
+
history: {
|
|
84
|
+
action: 'yjs-write',
|
|
85
|
+
source: 'yjs',
|
|
86
|
+
},
|
|
87
|
+
});
|
|
83
88
|
const hash = vault.hashContent(text);
|
|
84
89
|
state.lastPersistAt = Date.now();
|
|
85
90
|
state.lastPersistHash = hash;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fyresmith/hive-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Collaborative Obsidian vault server",
|
|
6
6
|
"main": "index.js",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"build": "npm run verify && npm pack --dry-run",
|
|
28
28
|
"status": "node bin/hive.js status",
|
|
29
29
|
"verify": "node scripts/verify.mjs",
|
|
30
|
+
"test:unit": "node --test test/**/*.test.js",
|
|
30
31
|
"install-hive": "node scripts/install-hive.mjs",
|
|
31
|
-
"test": "npm run verify"
|
|
32
|
+
"test": "npm run test:unit && npm run verify"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@fyresmith/hive-server": "^2.2.0",
|
|
35
35
|
"chalk": "^5.6.2",
|
|
36
36
|
"chokidar": "^3.6.0",
|
|
37
37
|
"commander": "^13.1.0",
|