pocketspec 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +75 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/package.json +37 -0
- package/public/app.js +488 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/index.html +26 -0
- package/public/manifest.json +12 -0
- package/public/marked.min.js +6 -0
- package/public/style.css +344 -0
- package/server.js +395 -0
package/server.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
const ROOT_DIR = __dirname;
|
|
11
|
+
const PUBLIC_DIR = path.join(ROOT_DIR, 'public');
|
|
12
|
+
|
|
13
|
+
// Persist registered roots in the user's config dir, not inside the package —
|
|
14
|
+
// when run via `npx`, the package dir is ephemeral and often read-only.
|
|
15
|
+
const CONFIG_DIR = process.env.XDG_CONFIG_HOME
|
|
16
|
+
? path.join(process.env.XDG_CONFIG_HOME, 'pocketspec')
|
|
17
|
+
: path.join(os.homedir(), '.config', 'pocketspec');
|
|
18
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
19
|
+
// Older versions wrote config.json next to server.js; read it as a fallback.
|
|
20
|
+
const LEGACY_CONFIG_PATH = path.join(ROOT_DIR, 'config.json');
|
|
21
|
+
|
|
22
|
+
// ---------- config (persisted roots, used by the `add`/`list` subcommands) ----------
|
|
23
|
+
|
|
24
|
+
function loadConfig() {
|
|
25
|
+
const src = fs.existsSync(CONFIG_PATH) ? CONFIG_PATH
|
|
26
|
+
: fs.existsSync(LEGACY_CONFIG_PATH) ? LEGACY_CONFIG_PATH
|
|
27
|
+
: null;
|
|
28
|
+
if (!src) return { roots: [] };
|
|
29
|
+
try {
|
|
30
|
+
const data = JSON.parse(fs.readFileSync(src, 'utf8'));
|
|
31
|
+
return { roots: Array.isArray(data.roots) ? data.roots : [] };
|
|
32
|
+
} catch {
|
|
33
|
+
return { roots: [] };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function saveConfig(config) {
|
|
38
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
39
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------- CLI parsing ----------
|
|
43
|
+
// Usage:
|
|
44
|
+
// pocketspec [folder ...] [--port N] [--read-only] serve folders (ephemeral)
|
|
45
|
+
// pocketspec add <path> [name] register a persistent folder
|
|
46
|
+
// pocketspec list list persisted folders
|
|
47
|
+
//
|
|
48
|
+
// Folder arguments are served as ephemeral roots (not written to config.json).
|
|
49
|
+
// With no folder arguments, falls back to roots saved via `add`.
|
|
50
|
+
|
|
51
|
+
function printHelp() {
|
|
52
|
+
console.log(`pocketspec — read your markdown docs on your phone, comment, let your agent read back
|
|
53
|
+
|
|
54
|
+
Usage:
|
|
55
|
+
pocketspec [folder ...] [--port N] [--read-only]
|
|
56
|
+
pocketspec add <folder> [name] register a persistent folder
|
|
57
|
+
pocketspec list list registered folders
|
|
58
|
+
|
|
59
|
+
With no folder arguments, serves the folders saved via 'add'.
|
|
60
|
+
--port N starting port (default 4321; tries the next free one if taken)
|
|
61
|
+
--read-only disable editing and comments (read-only)
|
|
62
|
+
--password P require a password (HTTP Basic Auth)
|
|
63
|
+
safer: set POCKETSPEC_PASSWORD instead of passing it on the CLI`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const argv = process.argv.slice(2);
|
|
67
|
+
const options = { port: undefined, readOnly: false, password: undefined };
|
|
68
|
+
const positional = [];
|
|
69
|
+
for (let i = 0; i < argv.length; i++) {
|
|
70
|
+
const arg = argv[i];
|
|
71
|
+
if (arg === '--read-only') options.readOnly = true;
|
|
72
|
+
else if (arg === '--password') options.password = argv[++i];
|
|
73
|
+
else if (arg.startsWith('--password=')) options.password = arg.slice('--password='.length);
|
|
74
|
+
else if (arg === '--port') options.port = Number(argv[++i]);
|
|
75
|
+
else if (arg.startsWith('--port=')) options.port = Number(arg.slice('--port='.length));
|
|
76
|
+
else if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); }
|
|
77
|
+
else if (arg === 'serve') { /* legacy no-op subcommand */ }
|
|
78
|
+
else positional.push(arg);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const PORT_PREFERRED = options.port || (process.env.PORT ? Number(process.env.PORT) : 4321);
|
|
82
|
+
const READ_ONLY = options.readOnly;
|
|
83
|
+
const PASSWORD = options.password != null ? String(options.password) : (process.env.POCKETSPEC_PASSWORD || null);
|
|
84
|
+
const command = positional[0];
|
|
85
|
+
|
|
86
|
+
if (command === 'add') {
|
|
87
|
+
const target = positional[1];
|
|
88
|
+
if (!target) {
|
|
89
|
+
console.error('Usage: pocketspec add <folder> [name]');
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
const resolved = path.resolve(target);
|
|
93
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
|
|
94
|
+
console.error(`Folder does not exist: ${resolved}`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
const config = loadConfig();
|
|
98
|
+
if (config.roots.some((r) => r.path === resolved)) {
|
|
99
|
+
console.error(`Folder already registered: ${resolved}`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
config.roots.push({ name: positional[2] || path.basename(resolved), path: resolved });
|
|
103
|
+
saveConfig(config);
|
|
104
|
+
console.log(`Registered: ${resolved}`);
|
|
105
|
+
process.exit(0);
|
|
106
|
+
} else if (command === 'list') {
|
|
107
|
+
for (const [i, root] of loadConfig().roots.entries()) {
|
|
108
|
+
console.log(`${i} ${root.name} ${root.path}`);
|
|
109
|
+
}
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Ephemeral roots from folder arguments. If none given, fall back to config.
|
|
114
|
+
let RUNTIME_ROOTS = null;
|
|
115
|
+
if (positional.length) {
|
|
116
|
+
RUNTIME_ROOTS = [];
|
|
117
|
+
for (const p of positional) {
|
|
118
|
+
const resolved = path.resolve(p);
|
|
119
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
|
|
120
|
+
console.error(`Folder does not exist: ${resolved}`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
RUNTIME_ROOTS.push({ name: path.basename(resolved), path: resolved });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Active roots: ephemeral CLI folders if given, else the persisted config.
|
|
128
|
+
function currentRoots() {
|
|
129
|
+
return RUNTIME_ROOTS || loadConfig().roots;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------- helpers ----------
|
|
133
|
+
|
|
134
|
+
const MIME = {
|
|
135
|
+
'.html': 'text/html; charset=utf-8',
|
|
136
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
137
|
+
'.css': 'text/css; charset=utf-8',
|
|
138
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
139
|
+
'.png': 'image/png',
|
|
140
|
+
'.jpg': 'image/jpeg',
|
|
141
|
+
'.jpeg': 'image/jpeg',
|
|
142
|
+
'.gif': 'image/gif',
|
|
143
|
+
'.svg': 'image/svg+xml',
|
|
144
|
+
'.webp': 'image/webp',
|
|
145
|
+
'.pdf': 'application/pdf',
|
|
146
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
147
|
+
'.json': 'application/json; charset=utf-8',
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function send(res, status, body, type) {
|
|
151
|
+
res.writeHead(status, { 'Content-Type': type || 'text/plain; charset=utf-8' });
|
|
152
|
+
res.end(body);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sendJson(res, data) {
|
|
156
|
+
send(res, 200, JSON.stringify(data), 'application/json; charset=utf-8');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// HTTP Basic Auth gate. Returns true when no password is set, or when the
|
|
160
|
+
// request carries the right one (constant-time compare). Username is ignored.
|
|
161
|
+
function checkAuth(req) {
|
|
162
|
+
if (!PASSWORD) return true;
|
|
163
|
+
const header = req.headers.authorization || '';
|
|
164
|
+
const m = /^Basic (.+)$/.exec(header);
|
|
165
|
+
if (!m) return false;
|
|
166
|
+
let decoded;
|
|
167
|
+
try { decoded = Buffer.from(m[1], 'base64').toString('utf8'); } catch { return false; }
|
|
168
|
+
const given = Buffer.from(decoded.slice(decoded.indexOf(':') + 1));
|
|
169
|
+
const expected = Buffer.from(PASSWORD);
|
|
170
|
+
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Resolves a relative path inside a registered root, rejecting traversal.
|
|
174
|
+
function resolveInRoot(rootIndex, relPath) {
|
|
175
|
+
const root = currentRoots()[rootIndex];
|
|
176
|
+
if (!root) return null;
|
|
177
|
+
const rootReal = fs.realpathSync(root.path);
|
|
178
|
+
const resolved = path.resolve(rootReal, relPath || '.');
|
|
179
|
+
if (resolved !== rootReal && !resolved.startsWith(rootReal + path.sep)) return null;
|
|
180
|
+
if (!fs.existsSync(resolved)) return null;
|
|
181
|
+
const real = fs.realpathSync(resolved);
|
|
182
|
+
if (real !== rootReal && !real.startsWith(rootReal + path.sep)) return null;
|
|
183
|
+
return real;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function readBody(req, limit = 5 * 1024 * 1024) {
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
let size = 0;
|
|
189
|
+
const chunks = [];
|
|
190
|
+
req.on('data', (chunk) => {
|
|
191
|
+
size += chunk.length;
|
|
192
|
+
if (size > limit) {
|
|
193
|
+
reject(new Error('payload too large'));
|
|
194
|
+
req.destroy();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
chunks.push(chunk);
|
|
198
|
+
});
|
|
199
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
200
|
+
req.on('error', reject);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function commentsPathFor(absMd) {
|
|
205
|
+
return absMd + '.comments';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function loadComments(absMd) {
|
|
209
|
+
const file = commentsPathFor(absMd);
|
|
210
|
+
if (!fs.existsSync(file)) return { comments: [] };
|
|
211
|
+
try {
|
|
212
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
213
|
+
return { comments: Array.isArray(data.comments) ? data.comments : [] };
|
|
214
|
+
} catch {
|
|
215
|
+
return { comments: [] };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function saveComments(absMd, data) {
|
|
220
|
+
fs.writeFileSync(commentsPathFor(absMd), JSON.stringify(data, null, 2) + '\n');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function listDir(absDir) {
|
|
224
|
+
const entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
225
|
+
const dirs = [];
|
|
226
|
+
const files = [];
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (entry.name.startsWith('.')) continue;
|
|
229
|
+
if (entry.isDirectory()) {
|
|
230
|
+
dirs.push(entry.name);
|
|
231
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
|
232
|
+
const stat = fs.statSync(path.join(absDir, entry.name));
|
|
233
|
+
files.push({ name: entry.name, size: stat.size, mtime: stat.mtimeMs });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
dirs.sort((a, b) => a.localeCompare(b));
|
|
237
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
238
|
+
return { dirs, files };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------- server ----------
|
|
242
|
+
|
|
243
|
+
const server = http.createServer(async (req, res) => {
|
|
244
|
+
const url = new URL(req.url, 'http://localhost');
|
|
245
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
if (!checkAuth(req)) {
|
|
249
|
+
res.writeHead(401, {
|
|
250
|
+
'WWW-Authenticate': 'Basic realm="pocketspec", charset="UTF-8"',
|
|
251
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
252
|
+
});
|
|
253
|
+
res.end('authentication required');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (pathname === '/api/roots') {
|
|
258
|
+
return sendJson(res, currentRoots().map((r, i) => ({ id: i, name: r.name, path: r.path })));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (pathname === '/api/meta') {
|
|
262
|
+
return sendJson(res, { readOnly: READ_ONLY });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (pathname === '/api/comments' || pathname === '/api/save') {
|
|
266
|
+
const rootIndex = Number(url.searchParams.get('root'));
|
|
267
|
+
const relPath = url.searchParams.get('path') || '';
|
|
268
|
+
if (!Number.isInteger(rootIndex)) return send(res, 400, 'invalid root');
|
|
269
|
+
const abs = resolveInRoot(rootIndex, relPath);
|
|
270
|
+
if (!abs || !abs.toLowerCase().endsWith('.md') || !fs.statSync(abs).isFile()) {
|
|
271
|
+
return send(res, 404, 'document not found');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (READ_ONLY && req.method !== 'GET') {
|
|
275
|
+
return send(res, 403, 'read-only mode (--read-only)');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (pathname === '/api/save') {
|
|
279
|
+
if (req.method !== 'POST') return send(res, 405, 'use POST');
|
|
280
|
+
const body = JSON.parse(await readBody(req));
|
|
281
|
+
if (typeof body.content !== 'string') return send(res, 400, 'missing content');
|
|
282
|
+
fs.writeFileSync(abs, body.content);
|
|
283
|
+
return sendJson(res, { ok: true });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (req.method === 'GET') return sendJson(res, loadComments(abs));
|
|
287
|
+
if (req.method === 'POST') {
|
|
288
|
+
const body = JSON.parse(await readBody(req));
|
|
289
|
+
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
|
290
|
+
if (!text) return send(res, 400, 'empty comment');
|
|
291
|
+
const data = loadComments(abs);
|
|
292
|
+
const comment = {
|
|
293
|
+
id: crypto.randomUUID(),
|
|
294
|
+
text,
|
|
295
|
+
anchor: body.anchor && typeof body.anchor === 'object'
|
|
296
|
+
? { index: Number(body.anchor.index), snippet: String(body.anchor.snippet || '') }
|
|
297
|
+
: null,
|
|
298
|
+
ts: Date.now(),
|
|
299
|
+
};
|
|
300
|
+
data.comments.push(comment);
|
|
301
|
+
saveComments(abs, data);
|
|
302
|
+
return sendJson(res, comment);
|
|
303
|
+
}
|
|
304
|
+
if (req.method === 'DELETE') {
|
|
305
|
+
const id = url.searchParams.get('id');
|
|
306
|
+
const data = loadComments(abs);
|
|
307
|
+
const before = data.comments.length;
|
|
308
|
+
data.comments = data.comments.filter((c) => c.id !== id);
|
|
309
|
+
if (data.comments.length === before) return send(res, 404, 'comment not found');
|
|
310
|
+
saveComments(abs, data);
|
|
311
|
+
return sendJson(res, { ok: true });
|
|
312
|
+
}
|
|
313
|
+
return send(res, 405, 'method not supported');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (pathname === '/api/list' || pathname === '/api/doc' || pathname === '/api/raw') {
|
|
317
|
+
const rootIndex = Number(url.searchParams.get('root'));
|
|
318
|
+
const relPath = url.searchParams.get('path') || '';
|
|
319
|
+
if (!Number.isInteger(rootIndex)) return send(res, 400, 'invalid root');
|
|
320
|
+
const abs = resolveInRoot(rootIndex, relPath);
|
|
321
|
+
if (!abs) return send(res, 404, 'not found');
|
|
322
|
+
|
|
323
|
+
if (pathname === '/api/list') {
|
|
324
|
+
if (!fs.statSync(abs).isDirectory()) return send(res, 400, 'not a folder');
|
|
325
|
+
return sendJson(res, listDir(abs));
|
|
326
|
+
}
|
|
327
|
+
if (pathname === '/api/doc') {
|
|
328
|
+
if (!abs.toLowerCase().endsWith('.md')) return send(res, 400, '.md files only');
|
|
329
|
+
return send(res, 200, fs.readFileSync(abs, 'utf8'), MIME['.md']);
|
|
330
|
+
}
|
|
331
|
+
// /api/raw — images and other assets referenced by docs
|
|
332
|
+
if (!fs.statSync(abs).isFile()) return send(res, 400, 'not a file');
|
|
333
|
+
const type = MIME[path.extname(abs).toLowerCase()] || 'application/octet-stream';
|
|
334
|
+
return send(res, 200, fs.readFileSync(abs), type);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// static UI
|
|
338
|
+
const staticPath = pathname === '/' ? '/index.html' : pathname;
|
|
339
|
+
const abs = path.resolve(PUBLIC_DIR, '.' + staticPath);
|
|
340
|
+
if (abs.startsWith(PUBLIC_DIR + path.sep) && fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
|
341
|
+
const type = MIME[path.extname(abs).toLowerCase()] || 'application/octet-stream';
|
|
342
|
+
return send(res, 200, fs.readFileSync(abs), type);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// SPA fallback: any other route renders the app
|
|
346
|
+
return send(res, 200, fs.readFileSync(path.join(PUBLIC_DIR, 'index.html')), MIME['.html']);
|
|
347
|
+
} catch (err) {
|
|
348
|
+
console.error(err);
|
|
349
|
+
return send(res, 500, 'internal error');
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
function lanAddresses() {
|
|
354
|
+
const addrs = [];
|
|
355
|
+
for (const ifaces of Object.values(os.networkInterfaces())) {
|
|
356
|
+
for (const iface of ifaces || []) {
|
|
357
|
+
if (iface.family === 'IPv4' && !iface.internal) addrs.push(iface.address);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return addrs;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Fires once, when the server actually binds. Reads the real bound port from
|
|
364
|
+
// server.address() so a port-fallback retry can't print a stale port.
|
|
365
|
+
server.on('listening', () => {
|
|
366
|
+
server.removeAllListeners('error');
|
|
367
|
+
const port = server.address().port;
|
|
368
|
+
const roots = currentRoots();
|
|
369
|
+
console.log(`pocketspec running!${READ_ONLY ? ' (read-only)' : ''}${PASSWORD ? ' (password protected)' : ''}\n`);
|
|
370
|
+
console.log(` Local: http://localhost:${port}`);
|
|
371
|
+
for (const addr of lanAddresses()) {
|
|
372
|
+
console.log(` Network: http://${addr}:${port} ← open this on your phone`);
|
|
373
|
+
}
|
|
374
|
+
if (!roots.length) {
|
|
375
|
+
console.log('\nNo folders. Pass a folder: pocketspec <folder> (or register one: pocketspec add <folder>)');
|
|
376
|
+
} else {
|
|
377
|
+
console.log('\nFolders:');
|
|
378
|
+
for (const root of roots) console.log(` - ${root.name}: ${root.path}`);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
function startListening(port, attemptsLeft) {
|
|
383
|
+
server.once('error', (err) => {
|
|
384
|
+
if (err.code === 'EADDRINUSE' && attemptsLeft > 0) {
|
|
385
|
+
console.log(` port ${port} is busy, trying ${port + 1}…`);
|
|
386
|
+
startListening(port + 1, attemptsLeft - 1);
|
|
387
|
+
} else {
|
|
388
|
+
console.error(err.message);
|
|
389
|
+
process.exit(1);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
server.listen(port, '0.0.0.0');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
startListening(PORT_PREFERRED, 10);
|