@fyresmith/hive-server 1.0.1-3
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 +143 -0
- package/bin/hive.js +4 -0
- package/cli/checks.js +28 -0
- package/cli/config.js +33 -0
- package/cli/constants.js +45 -0
- package/cli/env-file.js +141 -0
- package/cli/errors.js +11 -0
- package/cli/exec.js +12 -0
- package/cli/main.js +730 -0
- package/cli/output.js +21 -0
- package/cli/service.js +360 -0
- package/cli/tunnel.js +238 -0
- package/index.js +129 -0
- package/lib/auth.js +50 -0
- package/lib/socketHandler.js +226 -0
- package/lib/vaultManager.js +258 -0
- package/lib/yjsServer.js +277 -0
- package/package.json +52 -0
- package/routes/auth.js +99 -0
package/index.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import express from 'express';
|
|
5
|
+
import { Server } from 'socket.io';
|
|
6
|
+
import authRoutes from './routes/auth.js';
|
|
7
|
+
import * as vault from './lib/vaultManager.js';
|
|
8
|
+
import { attachHandlers } from './lib/socketHandler.js';
|
|
9
|
+
import { startYjsServer, getActiveRooms, forceCloseRoom, getRoomStatus } from './lib/yjsServer.js';
|
|
10
|
+
|
|
11
|
+
const REQUIRED = [
|
|
12
|
+
'VAULT_PATH',
|
|
13
|
+
'JWT_SECRET',
|
|
14
|
+
'DISCORD_CLIENT_ID',
|
|
15
|
+
'DISCORD_CLIENT_SECRET',
|
|
16
|
+
'DISCORD_REDIRECT_URI',
|
|
17
|
+
'DISCORD_GUILD_ID',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function validateEnv() {
|
|
21
|
+
for (const key of REQUIRED) {
|
|
22
|
+
if (!process.env[key]) {
|
|
23
|
+
throw new Error(`[startup] Missing required env var: ${key}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const port = parseInt(process.env.PORT ?? '3000', 10);
|
|
28
|
+
const yjsPort = parseInt(process.env.YJS_PORT ?? '3001', 10);
|
|
29
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
30
|
+
throw new Error('[startup] PORT must be a positive integer');
|
|
31
|
+
}
|
|
32
|
+
if (!Number.isInteger(yjsPort) || yjsPort <= 0) {
|
|
33
|
+
throw new Error('[startup] YJS_PORT must be a positive integer');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { port };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Start Hive server runtime.
|
|
41
|
+
*
|
|
42
|
+
* @param {{ envFile?: string, quiet?: boolean }} [options]
|
|
43
|
+
*/
|
|
44
|
+
export async function startHiveServer(options = {}) {
|
|
45
|
+
const { envFile, quiet = false } = options;
|
|
46
|
+
|
|
47
|
+
dotenv.config(
|
|
48
|
+
envFile
|
|
49
|
+
? { path: envFile, override: true }
|
|
50
|
+
: undefined
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const { port } = validateEnv();
|
|
54
|
+
|
|
55
|
+
const app = express();
|
|
56
|
+
const httpServer = createServer(app);
|
|
57
|
+
|
|
58
|
+
const io = new Server(httpServer, {
|
|
59
|
+
cors: {
|
|
60
|
+
origin: '*',
|
|
61
|
+
methods: ['GET', 'POST'],
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
app.use(express.json());
|
|
66
|
+
|
|
67
|
+
// Auth routes
|
|
68
|
+
app.use('/auth', authRoutes);
|
|
69
|
+
|
|
70
|
+
// Health check
|
|
71
|
+
app.get('/health', (req, res) => res.json({ ok: true, ts: Date.now() }));
|
|
72
|
+
app.get('/rooms', (req, res) => res.json({ rooms: getRoomStatus() }));
|
|
73
|
+
|
|
74
|
+
function broadcastFileUpdated(relPath, hash, excludeSocketId) {
|
|
75
|
+
io.sockets.sockets.forEach((sock) => {
|
|
76
|
+
if (sock.id !== excludeSocketId) {
|
|
77
|
+
sock.emit('file-updated', { relPath, hash });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCloseRoom);
|
|
83
|
+
startYjsServer(broadcastFileUpdated);
|
|
84
|
+
|
|
85
|
+
// Chokidar watch for external (non-plugin) changes
|
|
86
|
+
vault.initWatcher((relPath, event) => {
|
|
87
|
+
const docName = encodeURIComponent(relPath);
|
|
88
|
+
if (getActiveRooms().has(docName)) {
|
|
89
|
+
if (!quiet) {
|
|
90
|
+
console.log(`[chokidar] Ignoring external change to active room: ${relPath}`);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!quiet) {
|
|
95
|
+
console.log(`[chokidar] External ${event}: ${relPath}`);
|
|
96
|
+
}
|
|
97
|
+
io.emit('external-update', { relPath, event });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await new Promise((resolve, reject) => {
|
|
101
|
+
httpServer.once('error', reject);
|
|
102
|
+
httpServer.listen(port, () => resolve());
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!quiet) {
|
|
106
|
+
console.log(`[server] Hive server listening on port ${port}`);
|
|
107
|
+
console.log(`[server] Vault: ${process.env.VAULT_PATH}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
app,
|
|
112
|
+
io,
|
|
113
|
+
httpServer,
|
|
114
|
+
port,
|
|
115
|
+
close: () => new Promise((resolve, reject) => {
|
|
116
|
+
httpServer.close((err) => {
|
|
117
|
+
if (err) reject(err);
|
|
118
|
+
else resolve();
|
|
119
|
+
});
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
125
|
+
startHiveServer().catch((err) => {
|
|
126
|
+
console.error(err?.message ?? err);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
});
|
|
129
|
+
}
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verify a JWT and return the decoded payload.
|
|
5
|
+
* Throws if the token is invalid or expired.
|
|
6
|
+
* @param {string} token
|
|
7
|
+
* @returns {object} decoded payload
|
|
8
|
+
*/
|
|
9
|
+
export function verifyToken(token) {
|
|
10
|
+
return jwt.verify(token, process.env.JWT_SECRET);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Express middleware — reads Authorization: Bearer <token> header.
|
|
15
|
+
*/
|
|
16
|
+
export function expressMiddleware(req, res, next) {
|
|
17
|
+
const auth = req.headers.authorization;
|
|
18
|
+
if (!auth?.startsWith('Bearer ')) {
|
|
19
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
req.user = verifyToken(auth.slice(7));
|
|
23
|
+
next();
|
|
24
|
+
} catch (err) {
|
|
25
|
+
res.status(401).json({ error: 'Invalid token' });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Socket.IO middleware — reads token from socket.handshake.auth.token.
|
|
31
|
+
*/
|
|
32
|
+
export function socketMiddleware(socket, next) {
|
|
33
|
+
const token = socket.handshake.auth?.token;
|
|
34
|
+
if (!token) return next(new Error('No token'));
|
|
35
|
+
try {
|
|
36
|
+
socket.user = verifyToken(token);
|
|
37
|
+
next();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
next(new Error('Invalid token'));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Verify a JWT for the y-websocket WS handshake.
|
|
45
|
+
* @param {string} token
|
|
46
|
+
* @returns {object} decoded payload
|
|
47
|
+
*/
|
|
48
|
+
export function verifyWsToken(token) {
|
|
49
|
+
return verifyToken(token);
|
|
50
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { socketMiddleware } from './auth.js';
|
|
2
|
+
import * as vault from './vaultManager.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* presenceByFile: file path → Set of socket IDs
|
|
6
|
+
* @type {Map<string, Set<string>>}
|
|
7
|
+
*/
|
|
8
|
+
const presenceByFile = new Map();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* socketToFiles: socket ID → Set of file paths (for cleanup on disconnect)
|
|
12
|
+
* @type {Map<string, Set<string>>}
|
|
13
|
+
*/
|
|
14
|
+
const socketToFiles = new Map();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* userBySocket: socket ID → user object
|
|
18
|
+
* @type {Map<string, object>}
|
|
19
|
+
*/
|
|
20
|
+
const userBySocket = new Map();
|
|
21
|
+
|
|
22
|
+
function respond(cb, payload) {
|
|
23
|
+
if (typeof cb === 'function') cb(payload);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rejectPath(cb, relPath) {
|
|
27
|
+
console.warn(`[socket] Rejected disallowed path: ${String(relPath)}`);
|
|
28
|
+
respond(cb, { ok: false, error: 'Path not allowed' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isAllowedPath(relPath) {
|
|
32
|
+
return typeof relPath === 'string' && vault.isAllowed(relPath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeHexColor(color) {
|
|
36
|
+
if (typeof color !== 'string') return null;
|
|
37
|
+
const trimmed = color.trim();
|
|
38
|
+
return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed.toLowerCase() : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Attach all Socket.IO event handlers.
|
|
43
|
+
*
|
|
44
|
+
* @param {import('socket.io').Server} io
|
|
45
|
+
* @param {() => Set<string>} getActiveRooms - returns encoded docNames currently in Yjs
|
|
46
|
+
* @param {(relPath: string, hash: string, excludeSocketId: string|null) => void} broadcastFileUpdated
|
|
47
|
+
* @param {(relPath: string) => Promise<void>} forceCloseRoom
|
|
48
|
+
*/
|
|
49
|
+
export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCloseRoom) {
|
|
50
|
+
// Auth middleware for every socket connection
|
|
51
|
+
io.use(socketMiddleware);
|
|
52
|
+
|
|
53
|
+
io.on('connection', (socket) => {
|
|
54
|
+
const user = socket.user;
|
|
55
|
+
console.log(`[socket] Connected: ${user.username} (${socket.id})`);
|
|
56
|
+
|
|
57
|
+
userBySocket.set(socket.id, user);
|
|
58
|
+
socketToFiles.set(socket.id, new Set());
|
|
59
|
+
|
|
60
|
+
// Notify others this user joined
|
|
61
|
+
socket.broadcast.emit('user-joined', { user });
|
|
62
|
+
|
|
63
|
+
// -----------------------------------------------------------------------
|
|
64
|
+
// vault-sync-request
|
|
65
|
+
// -----------------------------------------------------------------------
|
|
66
|
+
socket.on('vault-sync-request', async (cb) => {
|
|
67
|
+
try {
|
|
68
|
+
const manifest = await vault.getManifest();
|
|
69
|
+
respond(cb, { ok: true, manifest });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error('[socket] vault-sync-request error:', err);
|
|
72
|
+
respond(cb, { ok: false, error: err.message });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// -----------------------------------------------------------------------
|
|
77
|
+
// file-read
|
|
78
|
+
// -----------------------------------------------------------------------
|
|
79
|
+
socket.on('file-read', async (relPath, cb) => {
|
|
80
|
+
if (!isAllowedPath(relPath)) {
|
|
81
|
+
rejectPath(cb, relPath);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const content = await vault.readFile(relPath);
|
|
86
|
+
const hash = vault.hashContent(content);
|
|
87
|
+
respond(cb, { ok: true, content, hash });
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(`[socket] file-read error (${relPath}):`, err);
|
|
90
|
+
respond(cb, { ok: false, error: err.message });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// -----------------------------------------------------------------------
|
|
95
|
+
// file-write
|
|
96
|
+
// -----------------------------------------------------------------------
|
|
97
|
+
socket.on('file-write', async (payload, cb) => {
|
|
98
|
+
const relPath = payload?.relPath;
|
|
99
|
+
const content = payload?.content;
|
|
100
|
+
if (!isAllowedPath(relPath) || typeof content !== 'string') {
|
|
101
|
+
rejectPath(cb, relPath);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
await vault.writeFile(relPath, content);
|
|
106
|
+
const hash = vault.hashContent(content);
|
|
107
|
+
socket.broadcast.emit('file-updated', { relPath, hash, user });
|
|
108
|
+
respond(cb, { ok: true, hash });
|
|
109
|
+
console.log(`[socket] file-write: ${relPath} by ${user.username}`);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error(`[socket] file-write error (${relPath}):`, err);
|
|
112
|
+
respond(cb, { ok: false, error: err.message });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// -----------------------------------------------------------------------
|
|
117
|
+
// file-create
|
|
118
|
+
// -----------------------------------------------------------------------
|
|
119
|
+
socket.on('file-create', async (payload, cb) => {
|
|
120
|
+
const relPath = payload?.relPath;
|
|
121
|
+
const content = payload?.content;
|
|
122
|
+
if (!isAllowedPath(relPath) || typeof content !== 'string') {
|
|
123
|
+
rejectPath(cb, relPath);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
await vault.writeFile(relPath, content);
|
|
128
|
+
io.emit('file-created', { relPath, user });
|
|
129
|
+
respond(cb, { ok: true });
|
|
130
|
+
console.log(`[socket] file-create: ${relPath} by ${user.username}`);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error(`[socket] file-create error (${relPath}):`, err);
|
|
133
|
+
respond(cb, { ok: false, error: err.message });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// -----------------------------------------------------------------------
|
|
138
|
+
// file-delete
|
|
139
|
+
// -----------------------------------------------------------------------
|
|
140
|
+
socket.on('file-delete', async (relPath, cb) => {
|
|
141
|
+
if (!isAllowedPath(relPath)) {
|
|
142
|
+
rejectPath(cb, relPath);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
// Force-close active Yjs room first
|
|
147
|
+
const docName = encodeURIComponent(relPath);
|
|
148
|
+
if (getActiveRooms().has(docName)) {
|
|
149
|
+
await forceCloseRoom(relPath);
|
|
150
|
+
}
|
|
151
|
+
await vault.deleteFile(relPath);
|
|
152
|
+
io.emit('file-deleted', { relPath, user });
|
|
153
|
+
respond(cb, { ok: true });
|
|
154
|
+
console.log(`[socket] file-delete: ${relPath} by ${user.username}`);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error(`[socket] file-delete error (${relPath}):`, err);
|
|
157
|
+
respond(cb, { ok: false, error: err.message });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// -----------------------------------------------------------------------
|
|
162
|
+
// file-rename
|
|
163
|
+
// -----------------------------------------------------------------------
|
|
164
|
+
socket.on('file-rename', async (payload, cb) => {
|
|
165
|
+
const oldPath = payload?.oldPath;
|
|
166
|
+
const newPath = payload?.newPath;
|
|
167
|
+
if (!isAllowedPath(oldPath) || !isAllowedPath(newPath)) {
|
|
168
|
+
rejectPath(cb, `${String(oldPath)} -> ${String(newPath)}`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
// Force-close active Yjs room for old path
|
|
173
|
+
const docName = encodeURIComponent(oldPath);
|
|
174
|
+
if (getActiveRooms().has(docName)) {
|
|
175
|
+
await forceCloseRoom(oldPath);
|
|
176
|
+
}
|
|
177
|
+
await vault.renameFile(oldPath, newPath);
|
|
178
|
+
io.emit('file-renamed', { oldPath, newPath, user });
|
|
179
|
+
respond(cb, { ok: true });
|
|
180
|
+
console.log(`[socket] file-rename: ${oldPath} → ${newPath} by ${user.username}`);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.error(`[socket] file-rename error (${oldPath}):`, err);
|
|
183
|
+
respond(cb, { ok: false, error: err.message });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// -----------------------------------------------------------------------
|
|
188
|
+
// presence-file-opened
|
|
189
|
+
// -----------------------------------------------------------------------
|
|
190
|
+
socket.on('presence-file-opened', (payload) => {
|
|
191
|
+
const relPath = typeof payload === 'string' ? payload : payload?.relPath;
|
|
192
|
+
const color = normalizeHexColor(typeof payload === 'string' ? null : payload?.color);
|
|
193
|
+
if (!isAllowedPath(relPath)) return;
|
|
194
|
+
if (!presenceByFile.has(relPath)) presenceByFile.set(relPath, new Set());
|
|
195
|
+
presenceByFile.get(relPath).add(socket.id);
|
|
196
|
+
socketToFiles.get(socket.id)?.add(relPath);
|
|
197
|
+
const presenceUser = color ? { ...user, color } : user;
|
|
198
|
+
socket.broadcast.emit('presence-file-opened', { relPath, user: presenceUser });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// -----------------------------------------------------------------------
|
|
202
|
+
// presence-file-closed
|
|
203
|
+
// -----------------------------------------------------------------------
|
|
204
|
+
socket.on('presence-file-closed', (relPath) => {
|
|
205
|
+
if (!isAllowedPath(relPath)) return;
|
|
206
|
+
presenceByFile.get(relPath)?.delete(socket.id);
|
|
207
|
+
socketToFiles.get(socket.id)?.delete(relPath);
|
|
208
|
+
socket.broadcast.emit('presence-file-closed', { relPath, user });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// -----------------------------------------------------------------------
|
|
212
|
+
// disconnect
|
|
213
|
+
// -----------------------------------------------------------------------
|
|
214
|
+
socket.on('disconnect', () => {
|
|
215
|
+
console.log(`[socket] Disconnected: ${user.username} (${socket.id})`);
|
|
216
|
+
const openFiles = socketToFiles.get(socket.id) ?? new Set();
|
|
217
|
+
for (const relPath of openFiles) {
|
|
218
|
+
presenceByFile.get(relPath)?.delete(socket.id);
|
|
219
|
+
socket.broadcast.emit('presence-file-closed', { relPath, user });
|
|
220
|
+
}
|
|
221
|
+
socketToFiles.delete(socket.id);
|
|
222
|
+
userBySocket.delete(socket.id);
|
|
223
|
+
socket.broadcast.emit('user-left', { user });
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { resolve, join, sep, extname, relative, dirname } from 'path';
|
|
3
|
+
import { readFile, writeFile as fsWriteFile, rename, unlink, mkdir, stat, readdir } from 'fs/promises';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import chokidar from 'chokidar';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Deny / allow lists
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
const DENY_PREFIXES = ['.obsidian', 'Attachments', '.git'];
|
|
12
|
+
const DENY_FILES = ['.DS_Store', 'Thumbs.db'];
|
|
13
|
+
// Keep synced content text-based to ensure hash/read/write semantics stay safe.
|
|
14
|
+
const ALLOW_EXTS = new Set(['.md', '.canvas']);
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Internals
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** @type {string} */
|
|
21
|
+
let VAULT_ROOT;
|
|
22
|
+
|
|
23
|
+
function getVaultRoot() {
|
|
24
|
+
if (!VAULT_ROOT) {
|
|
25
|
+
if (!process.env.VAULT_PATH) throw new Error('VAULT_PATH env var is required');
|
|
26
|
+
VAULT_ROOT = resolve(process.env.VAULT_PATH);
|
|
27
|
+
}
|
|
28
|
+
return VAULT_ROOT;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* mtime+size → hash cache so we avoid rehashing unchanged files.
|
|
33
|
+
* @type {Map<string, {mtime: number, size: number, hash: string}>}
|
|
34
|
+
*/
|
|
35
|
+
const manifestCache = new Map();
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Path helpers
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a relative path against VAULT_ROOT and assert no traversal.
|
|
43
|
+
* @param {string} relPath
|
|
44
|
+
* @returns {string} absolute path
|
|
45
|
+
*/
|
|
46
|
+
export function safePath(relPath) {
|
|
47
|
+
const root = getVaultRoot();
|
|
48
|
+
const abs = resolve(root, relPath);
|
|
49
|
+
if (!abs.startsWith(root + sep) && abs !== root) {
|
|
50
|
+
throw new Error(`Path traversal: ${relPath}`);
|
|
51
|
+
}
|
|
52
|
+
return abs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Returns true if the relative path matches a deny prefix or deny filename.
|
|
57
|
+
* @param {string} relPath forward-slash relative path
|
|
58
|
+
*/
|
|
59
|
+
export function isDenied(relPath) {
|
|
60
|
+
const normalised = relPath.replace(/\\/g, '/');
|
|
61
|
+
const parts = normalised.split('/');
|
|
62
|
+
const base = parts[parts.length - 1];
|
|
63
|
+
if (DENY_FILES.includes(base)) return true;
|
|
64
|
+
for (const prefix of DENY_PREFIXES) {
|
|
65
|
+
if (normalised === prefix || normalised.startsWith(prefix + '/')) return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Returns true if the path is allowed to sync (extension + not denied).
|
|
72
|
+
* @param {string} relPath
|
|
73
|
+
*/
|
|
74
|
+
export function isAllowed(relPath) {
|
|
75
|
+
if (isDenied(relPath)) return false;
|
|
76
|
+
return ALLOW_EXTS.has(extname(relPath).toLowerCase());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Content hashing
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* SHA-256 hex digest of a UTF-8 string.
|
|
85
|
+
* @param {string} str
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function hashContent(str) {
|
|
89
|
+
return createHash('sha256').update(str, 'utf-8').digest('hex');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Recursive directory walk
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Walk VAULT_ROOT recursively, returning all allowed relative paths.
|
|
98
|
+
* @returns {Promise<string[]>}
|
|
99
|
+
*/
|
|
100
|
+
async function walkVault() {
|
|
101
|
+
const root = getVaultRoot();
|
|
102
|
+
const results = [];
|
|
103
|
+
|
|
104
|
+
async function walk(dir) {
|
|
105
|
+
let entries;
|
|
106
|
+
try {
|
|
107
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
108
|
+
} catch {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
const absPath = join(dir, entry.name);
|
|
113
|
+
const relPath = relative(root, absPath).replace(/\\/g, '/');
|
|
114
|
+
if (entry.isDirectory()) {
|
|
115
|
+
if (!isDenied(relPath + '/')) {
|
|
116
|
+
await walk(absPath);
|
|
117
|
+
}
|
|
118
|
+
} else if (entry.isFile()) {
|
|
119
|
+
if (isAllowed(relPath)) {
|
|
120
|
+
results.push(relPath);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await walk(root);
|
|
127
|
+
return results;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Manifest
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Returns the full vault manifest. Uses mtime+size first; only recomputes
|
|
136
|
+
* SHA-256 when mtime or size changes.
|
|
137
|
+
* @returns {Promise<Array<{path: string, hash: string, mtime: number, size: number}>>}
|
|
138
|
+
*/
|
|
139
|
+
export async function getManifest() {
|
|
140
|
+
const paths = await walkVault();
|
|
141
|
+
const manifest = [];
|
|
142
|
+
|
|
143
|
+
for (const relPath of paths) {
|
|
144
|
+
const abs = safePath(relPath);
|
|
145
|
+
let fileStat;
|
|
146
|
+
try {
|
|
147
|
+
fileStat = await stat(abs);
|
|
148
|
+
} catch {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const mtime = fileStat.mtimeMs;
|
|
152
|
+
const size = fileStat.size;
|
|
153
|
+
|
|
154
|
+
const cached = manifestCache.get(relPath);
|
|
155
|
+
let hash;
|
|
156
|
+
if (cached && cached.mtime === mtime && cached.size === size) {
|
|
157
|
+
hash = cached.hash;
|
|
158
|
+
} else {
|
|
159
|
+
try {
|
|
160
|
+
const content = await readFile(abs, 'utf-8');
|
|
161
|
+
hash = hashContent(content);
|
|
162
|
+
} catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
manifestCache.set(relPath, { mtime, size, hash });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
manifest.push({ path: relPath, hash, mtime, size });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return manifest;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// File operations — all paths go through safePath
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Read a file as UTF-8.
|
|
180
|
+
* @param {string} relPath
|
|
181
|
+
* @returns {Promise<string>}
|
|
182
|
+
*/
|
|
183
|
+
export async function readFile_(relPath) {
|
|
184
|
+
const abs = safePath(relPath);
|
|
185
|
+
return readFile(abs, 'utf-8');
|
|
186
|
+
}
|
|
187
|
+
// Export under the spec name
|
|
188
|
+
export { readFile_ as readFile };
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Atomically write content to a file (write .tmp → rename).
|
|
192
|
+
* @param {string} relPath
|
|
193
|
+
* @param {string} content
|
|
194
|
+
*/
|
|
195
|
+
export async function writeFile(relPath, content) {
|
|
196
|
+
const abs = safePath(relPath);
|
|
197
|
+
const tmp = abs + '.tmp';
|
|
198
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
199
|
+
await fsWriteFile(tmp, content, 'utf-8');
|
|
200
|
+
await rename(tmp, abs);
|
|
201
|
+
// Invalidate cache entry so next manifest reflects new content
|
|
202
|
+
manifestCache.delete(relPath);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Delete a file.
|
|
207
|
+
* @param {string} relPath
|
|
208
|
+
*/
|
|
209
|
+
export async function deleteFile(relPath) {
|
|
210
|
+
const abs = safePath(relPath);
|
|
211
|
+
await unlink(abs);
|
|
212
|
+
manifestCache.delete(relPath);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Rename a file.
|
|
217
|
+
* @param {string} oldRelPath
|
|
218
|
+
* @param {string} newRelPath
|
|
219
|
+
*/
|
|
220
|
+
export async function renameFile(oldRelPath, newRelPath) {
|
|
221
|
+
const oldAbs = safePath(oldRelPath);
|
|
222
|
+
const newAbs = safePath(newRelPath);
|
|
223
|
+
await mkdir(dirname(newAbs), { recursive: true });
|
|
224
|
+
await rename(oldAbs, newAbs);
|
|
225
|
+
manifestCache.delete(oldRelPath);
|
|
226
|
+
manifestCache.delete(newRelPath);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Chokidar watcher
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Start watching VAULT_ROOT for external file changes.
|
|
235
|
+
* @param {(relPath: string, event: string) => void} onExternalChange
|
|
236
|
+
* @returns {chokidar.FSWatcher}
|
|
237
|
+
*/
|
|
238
|
+
export function initWatcher(onExternalChange) {
|
|
239
|
+
const root = getVaultRoot();
|
|
240
|
+
const watcher = chokidar.watch(root, {
|
|
241
|
+
ignoreInitial: true,
|
|
242
|
+
persistent: true,
|
|
243
|
+
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 },
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const handler = (event) => (absPath) => {
|
|
247
|
+
const relPath = relative(root, absPath).replace(/\\/g, '/');
|
|
248
|
+
if (!isAllowed(relPath)) return;
|
|
249
|
+
onExternalChange(relPath, event);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
watcher.on('add', handler('add'));
|
|
253
|
+
watcher.on('change', handler('change'));
|
|
254
|
+
watcher.on('unlink', handler('unlink'));
|
|
255
|
+
|
|
256
|
+
console.log(`[vault] Watching: ${root}`);
|
|
257
|
+
return watcher;
|
|
258
|
+
}
|