sdocs-dev 1.6.1 → 1.12.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/bin/sdocs-bridge.js +974 -0
- package/bin/sdocs-dev.js +145 -2102
- package/bin/sdocs-icon-names.js +1965 -0
- package/lib/agent-block.js +245 -0
- package/lib/agent-files.js +162 -0
- package/lib/bridge-commands.js +171 -0
- package/lib/cells-transclude.js +111 -0
- package/lib/commands.js +291 -0
- package/lib/constants.js +283 -0
- package/lib/help-text.js +2706 -0
- package/lib/io.js +173 -0
- package/lib/library-autostart.js +145 -0
- package/lib/library-commands.js +307 -0
- package/lib/library-ephemeral.js +111 -0
- package/lib/library-index.js +280 -0
- package/lib/library-paths.js +20 -0
- package/lib/library-scan.js +258 -0
- package/lib/library-server.js +400 -0
- package/lib/library-store.js +141 -0
- package/lib/router.js +52 -0
- package/lib/safe.js +200 -0
- package/lib/setup.js +332 -0
- package/lib/short-link.js +105 -0
- package/lib/styles.js +91 -0
- package/lib/update-check.js +163 -0
- package/lib/url.js +111 -0
- package/package.json +5 -18
- package/shared/sdocs-contrast.js +196 -0
- package/shared/sdocs-form-block.js +605 -0
- package/shared/sdocs-library-tags.js +41 -0
- package/{public → shared}/sdocs-styles.js +134 -5
- package/README.md +0 -149
- /package/{public → shared}/sdocs-slugify.js +0 -0
- /package/{public → shared}/sdocs-yaml.js +0 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// Local data agent for the library UI. Binds to 127.0.0.1 only. The UI
|
|
2
|
+
// page itself is served by the main SDocs site (`/library`); this agent
|
|
3
|
+
// exposes the JSON API the page calls into. CORS is open because the
|
|
4
|
+
// agent is loopback-only - only same-machine code can reach it, and the
|
|
5
|
+
// data is local.
|
|
6
|
+
//
|
|
7
|
+
// Lifetime: started by `sdoc library`, opens the browser, stays alive
|
|
8
|
+
// in the foreground. Ctrl-C ends it.
|
|
9
|
+
|
|
10
|
+
const http = require('http');
|
|
11
|
+
const { URL } = require('url');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const store = require('./library-store');
|
|
16
|
+
const libIndex = require('./library-index');
|
|
17
|
+
const libScan = require('./library-scan');
|
|
18
|
+
const autostart = require('./library-autostart');
|
|
19
|
+
const url = require('./url');
|
|
20
|
+
const { startBridge } = require('../bin/sdocs-bridge');
|
|
21
|
+
|
|
22
|
+
// The CLI version this agent ships with. The library page reads it
|
|
23
|
+
// from /api/library/health and /api/library/data so it can flag stale
|
|
24
|
+
// installs ("update your CLI") versus an absent agent ("install it").
|
|
25
|
+
let CLI_VERSION = '';
|
|
26
|
+
try {
|
|
27
|
+
CLI_VERSION = require('../package.json').version || '';
|
|
28
|
+
} catch (_) { /* tarball edge case; surface as empty string */ }
|
|
29
|
+
|
|
30
|
+
// The two endpoints that work with a caller-supplied path - /file and
|
|
31
|
+
// /bridge-for - go through this gate. Three checks, all independent:
|
|
32
|
+
//
|
|
33
|
+
// 1. realpath() the requested path so a symlink can't smuggle the
|
|
34
|
+
// target past the membership check.
|
|
35
|
+
// 2. The deny-pattern list (SSH keys, .env, credentials.{json,...}
|
|
36
|
+
// and anything under .ssh/.aws/.gnupg/...).
|
|
37
|
+
// 3. Library-membership: the real path must appear in the index.
|
|
38
|
+
// The index is what the user has explicitly opened with sdoc or
|
|
39
|
+
// placed under a scanned root; arbitrary paths outside that set
|
|
40
|
+
// are refused.
|
|
41
|
+
//
|
|
42
|
+
// Returns { ok: true, realPath } on pass, { ok: false, reason, status }
|
|
43
|
+
// on refusal. Caller picks the HTTP status from `status`.
|
|
44
|
+
function gatePath(filePath) {
|
|
45
|
+
const fsMod = require('fs');
|
|
46
|
+
const pathMod = require('path');
|
|
47
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
48
|
+
return { ok: false, status: 400, reason: 'path required' };
|
|
49
|
+
}
|
|
50
|
+
const resolved = pathMod.resolve(filePath);
|
|
51
|
+
if (!fsMod.existsSync(resolved)) {
|
|
52
|
+
return { ok: false, status: 404, reason: 'file not found' };
|
|
53
|
+
}
|
|
54
|
+
let real = resolved;
|
|
55
|
+
try { real = fsMod.realpathSync(resolved); } catch (_) {}
|
|
56
|
+
if (libScan.deniedByPattern(real) || libScan.deniedByPattern(resolved)) {
|
|
57
|
+
return { ok: false, status: 403, reason: 'path is on the deny list' };
|
|
58
|
+
}
|
|
59
|
+
if (!store.isIndexed(real)) {
|
|
60
|
+
return { ok: false, status: 403, reason: 'path is not in the library index' };
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, realPath: real };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Five-digit port in a relatively quiet range. The agent falls back to
|
|
66
|
+
// a random free port if this one is occupied, and the CLI prints the
|
|
67
|
+
// agent URL on launch either way - so a rare collision degrades
|
|
68
|
+
// gracefully rather than crashing.
|
|
69
|
+
const DEFAULT_PORT = 47843;
|
|
70
|
+
|
|
71
|
+
// Browser origins the agent will accept cross-origin requests from.
|
|
72
|
+
// The set is the SDocs site (production + future) plus the local dev
|
|
73
|
+
// server. SDOCS_URL extends the list for users hosting the page on a
|
|
74
|
+
// non-default origin (e.g. running the dev server on a different
|
|
75
|
+
// port). SDOCS_AGENT_ALLOWED_ORIGINS is the explicit override.
|
|
76
|
+
//
|
|
77
|
+
// Loopback-anchored requests with no Origin header (curl, the CLI's
|
|
78
|
+
// own callers, other local tools) are allowed - the agent's bind to
|
|
79
|
+
// 127.0.0.1 is the same-machine boundary for those.
|
|
80
|
+
function defaultAllowedOrigins() {
|
|
81
|
+
const set = new Set([
|
|
82
|
+
'https://sdocs.dev',
|
|
83
|
+
'https://smalldocs.org',
|
|
84
|
+
'http://localhost:3000',
|
|
85
|
+
'http://127.0.0.1:3000',
|
|
86
|
+
]);
|
|
87
|
+
if (process.env.SDOCS_URL) {
|
|
88
|
+
try { set.add(new URL(process.env.SDOCS_URL).origin); } catch (_) {}
|
|
89
|
+
}
|
|
90
|
+
if (process.env.SDOCS_AGENT_ALLOWED_ORIGINS) {
|
|
91
|
+
process.env.SDOCS_AGENT_ALLOWED_ORIGINS.split(',')
|
|
92
|
+
.map(s => s.trim()).filter(Boolean).forEach(o => set.add(o));
|
|
93
|
+
}
|
|
94
|
+
return set;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function originAllowed(origin) {
|
|
98
|
+
if (!origin) return true; // no-Origin → non-browser caller, loopback-only
|
|
99
|
+
return defaultAllowedOrigins().has(origin);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Host header must point at loopback. Same rule the Bridge applies, for
|
|
103
|
+
// the same reason (DNS rebinding: an attacker DNS that resolves to
|
|
104
|
+
// 127.0.0.1 from a script-running page can otherwise reach us via the
|
|
105
|
+
// browser even when CORS is locked down).
|
|
106
|
+
function hostOk(host) {
|
|
107
|
+
if (!host) return false;
|
|
108
|
+
const h = String(host).toLowerCase();
|
|
109
|
+
// Strip port for comparison.
|
|
110
|
+
const noPort = h.replace(/:\d+$/, '');
|
|
111
|
+
return noPort === '127.0.0.1' || noPort === 'localhost' || noPort === '[::1]';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function corsHeadersFor(origin) {
|
|
115
|
+
// No Origin → echo back nothing (the response is for a non-browser
|
|
116
|
+
// caller; CORS doesn't apply). Allowed Origin → echo it back. Disallowed
|
|
117
|
+
// origins never reach this function because we 403 before they do.
|
|
118
|
+
if (!origin) return {};
|
|
119
|
+
return {
|
|
120
|
+
'Access-Control-Allow-Origin': origin,
|
|
121
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
122
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
123
|
+
// Chrome's Private Network Access: a public origin reaching a
|
|
124
|
+
// loopback target normally prompts the user ("smalldocs.org wants
|
|
125
|
+
// to access other apps and services"). Responding to the preflight
|
|
126
|
+
// with this header lets us consent server-side, which Chrome
|
|
127
|
+
// accepts as enough - no prompt. Safe because the origin allowlist
|
|
128
|
+
// already gates who can talk to the agent.
|
|
129
|
+
'Access-Control-Allow-Private-Network': 'true',
|
|
130
|
+
'Access-Control-Max-Age': '86400',
|
|
131
|
+
'Vary': 'Origin',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function send(res, status, body, headers) {
|
|
136
|
+
// CORS for the request's origin is stashed on res by the request
|
|
137
|
+
// handler before any sendJson runs - keeps callsites unchanged.
|
|
138
|
+
res.writeHead(status, Object.assign({
|
|
139
|
+
'Cache-Control': 'no-store',
|
|
140
|
+
}, corsHeadersFor(res._origin || null), headers || {}));
|
|
141
|
+
res.end(body);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function sendJson(res, status, obj) {
|
|
145
|
+
send(res, status, JSON.stringify(obj),
|
|
146
|
+
{ 'Content-Type': 'application/json; charset=utf-8' });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readBody(req) {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
let chunks = [];
|
|
152
|
+
req.on('data', c => chunks.push(c));
|
|
153
|
+
req.on('end', () => {
|
|
154
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
155
|
+
if (!raw) return resolve({});
|
|
156
|
+
try { resolve(JSON.parse(raw)); } catch (e) { reject(e); }
|
|
157
|
+
});
|
|
158
|
+
req.on('error', reject);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function stripBody(e) {
|
|
163
|
+
const { body, ...rest } = e;
|
|
164
|
+
return rest;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Build a smalldocs.org hash URL from a file's contents. Returns null when
|
|
168
|
+
// the file has gone missing on disk (entry might be stale).
|
|
169
|
+
function buildOpenUrl(entry) {
|
|
170
|
+
// Always read from entry.path. For rescued entries that is the
|
|
171
|
+
// snapshot copy under the sdocs home, which is the authoritative
|
|
172
|
+
// version - the original (rescuedFrom) may be stale or gone.
|
|
173
|
+
const filePath = entry.path;
|
|
174
|
+
if (!fs.existsSync(filePath)) return null;
|
|
175
|
+
let content;
|
|
176
|
+
try { content = fs.readFileSync(filePath, 'utf8'); } catch (_) { return null; }
|
|
177
|
+
// Use the existing CLI url builder. We pass no `local` (no path leak
|
|
178
|
+
// through the URL) and read mode by default.
|
|
179
|
+
const u = url.buildUrl(content, {});
|
|
180
|
+
// The page substitutes its own origin when opening, so we return only
|
|
181
|
+
// the part after the origin. `buildUrl` returns the full URL; carve
|
|
182
|
+
// out the path + hash.
|
|
183
|
+
try {
|
|
184
|
+
const parsed = new URL(u);
|
|
185
|
+
return parsed.pathname + parsed.search + parsed.hash;
|
|
186
|
+
} catch (_) { return u; }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function createServer({ port } = {}) {
|
|
190
|
+
const server = http.createServer(async (req, res) => {
|
|
191
|
+
const origin = req.headers['origin'] || null;
|
|
192
|
+
// Stash so sendJson echoes the right Access-Control-Allow-Origin.
|
|
193
|
+
// Only set when the origin is allowed - disallowed/missing origins
|
|
194
|
+
// produce responses with no CORS headers, which is the desired
|
|
195
|
+
// browser-side rejection signal.
|
|
196
|
+
res._origin = (origin && originAllowed(origin)) ? origin : null;
|
|
197
|
+
try {
|
|
198
|
+
// Gate every request, including preflight. Order matters: Host
|
|
199
|
+
// first (a missing/wrong Host means the request didn't reach us
|
|
200
|
+
// legitimately, including via DNS rebinding), then Origin
|
|
201
|
+
// (browser cross-origin rejection), then route.
|
|
202
|
+
if (!hostOk(req.headers['host'])) {
|
|
203
|
+
send(res, 403, 'bad host');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (origin && !originAllowed(origin)) {
|
|
207
|
+
send(res, 403, 'origin not allowed');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (req.method === 'OPTIONS') {
|
|
212
|
+
send(res, 204, '');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const u = new URL(req.url, 'http://localhost');
|
|
217
|
+
const route = u.pathname;
|
|
218
|
+
|
|
219
|
+
if (req.method === 'GET' && (route === '/' || route === '/api/library/health')) {
|
|
220
|
+
sendJson(res, 200, { ok: true, agent: 'sdocs-library', version: CLI_VERSION });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (req.method === 'GET' && route === '/api/library/data') {
|
|
225
|
+
const idx = store.loadIndex();
|
|
226
|
+
const state = store.loadState();
|
|
227
|
+
const as = autostart.status();
|
|
228
|
+
sendJson(res, 200, {
|
|
229
|
+
version: CLI_VERSION,
|
|
230
|
+
entries: idx.entries.map(stripBody),
|
|
231
|
+
generatedAt: idx.generatedAt,
|
|
232
|
+
enabled: state.enabled !== false,
|
|
233
|
+
lastScanAt: state.lastScanAt || 0,
|
|
234
|
+
autostart: {
|
|
235
|
+
supported: as.supported,
|
|
236
|
+
enabled: as.enabled,
|
|
237
|
+
userDisabled: state.autostartUserDisabled === true,
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (req.method === 'GET' && route === '/api/library/entry') {
|
|
244
|
+
const id = u.searchParams.get('id');
|
|
245
|
+
const e = store.getEntry(id);
|
|
246
|
+
if (!e) { sendJson(res, 404, { error: 'not found' }); return; }
|
|
247
|
+
sendJson(res, 200, e);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (req.method === 'GET' && route === '/api/library/open') {
|
|
252
|
+
const id = u.searchParams.get('id');
|
|
253
|
+
const e = store.getEntry(id);
|
|
254
|
+
if (!e) { sendJson(res, 404, { error: 'not found' }); return; }
|
|
255
|
+
const openPath = buildOpenUrl(e);
|
|
256
|
+
if (!openPath) { sendJson(res, 410, { error: 'file missing on disk' }); return; }
|
|
257
|
+
sendJson(res, 200, { url: openPath, path: e.path });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (req.method === 'GET' && route === '/api/library/tags-under') {
|
|
262
|
+
const prefix = u.searchParams.get('prefix');
|
|
263
|
+
if (!prefix) { sendJson(res, 400, { error: 'prefix required' }); return; }
|
|
264
|
+
sendJson(res, 200, { tags: libIndex.tagsUnderPrefix(prefix) });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Tags used by other files in the same project as the given path.
|
|
269
|
+
// The agent walks up from `path` looking for `.git/` to find the
|
|
270
|
+
// project root; if none, falls back to the file's parent directory
|
|
271
|
+
// (NOT `/` - all-tags-ever is too noisy to suggest).
|
|
272
|
+
if (req.method === 'GET' && route === '/api/library/project-tags') {
|
|
273
|
+
const filePath = u.searchParams.get('path');
|
|
274
|
+
if (!filePath) { sendJson(res, 400, { error: 'path required' }); return; }
|
|
275
|
+
const pathMod = require('path');
|
|
276
|
+
const fsMod = require('fs');
|
|
277
|
+
const startDir = pathMod.dirname(pathMod.resolve(filePath));
|
|
278
|
+
let root = startDir;
|
|
279
|
+
let foundGit = false;
|
|
280
|
+
for (let i = 0; i < 30; i++) {
|
|
281
|
+
if (fsMod.existsSync(pathMod.join(root, '.git'))) { foundGit = true; break; }
|
|
282
|
+
const parent = pathMod.dirname(root);
|
|
283
|
+
if (parent === root) break;
|
|
284
|
+
root = parent;
|
|
285
|
+
}
|
|
286
|
+
if (!foundGit) root = startDir;
|
|
287
|
+
sendJson(res, 200, { root, tags: libIndex.tagsUnderPrefix(root) });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (req.method === 'POST' && route === '/api/library/star') {
|
|
292
|
+
const body = await readBody(req);
|
|
293
|
+
const ok = store.setStar(body.id, !!body.starred);
|
|
294
|
+
sendJson(res, ok ? 200 : 404, { ok });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (req.method === 'POST' && route === '/api/library/rescan') {
|
|
299
|
+
const result = libIndex.scanAndIndex();
|
|
300
|
+
sendJson(res, 200, result);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Serve the current contents of a local file. The editor page
|
|
305
|
+
// uses this to refresh content after the URL-hash snapshot goes
|
|
306
|
+
// stale (e.g. after the user edited tags then reloaded). Gated
|
|
307
|
+
// by gatePath() - only files already in the library index are
|
|
308
|
+
// readable, after realpath resolution and deny-pattern check.
|
|
309
|
+
if (req.method === 'GET' && route === '/api/library/file') {
|
|
310
|
+
const g = gatePath(u.searchParams.get('path'));
|
|
311
|
+
if (!g.ok) { sendJson(res, g.status, { error: g.reason }); return; }
|
|
312
|
+
const fsMod = require('fs');
|
|
313
|
+
let content;
|
|
314
|
+
try { content = fsMod.readFileSync(g.realPath, 'utf8'); }
|
|
315
|
+
catch (e) { sendJson(res, 500, { error: e.message }); return; }
|
|
316
|
+
const stat = fsMod.statSync(g.realPath);
|
|
317
|
+
sendJson(res, 200, { path: g.realPath, content, mtimeMs: stat.mtimeMs });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Re-index a single file. Called by the editor page after a Bridge
|
|
322
|
+
// save so the library catches up immediately (instead of waiting
|
|
323
|
+
// for the next manual scan). Pure read-then-index; never writes
|
|
324
|
+
// the file the path points at.
|
|
325
|
+
if (req.method === 'POST' && route === '/api/library/reindex') {
|
|
326
|
+
const body = await readBody(req);
|
|
327
|
+
const filePath = body && body.path;
|
|
328
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
329
|
+
sendJson(res, 400, { error: 'path required' });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const resolved = require('path').resolve(filePath);
|
|
333
|
+
if (!require('fs').existsSync(resolved)) {
|
|
334
|
+
sendJson(res, 404, { error: 'file not found' });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const entry = libIndex.indexFile(resolved);
|
|
338
|
+
sendJson(res, 200, { ok: true, tags: entry ? entry.tags : [] });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Start a Bridge for a path and hand the page back the address
|
|
343
|
+
// and one-time token. The agent process runs this Bridge in-tree
|
|
344
|
+
// (same node process), so there's no subprocess to babysit; the
|
|
345
|
+
// Bridge's own idle-timeout handles cleanup when the tab closes.
|
|
346
|
+
// The agent never writes user files itself - any write goes
|
|
347
|
+
// through the Bridge after the user's page connects to it. Gated
|
|
348
|
+
// by gatePath() - only library-indexed files can be bridged.
|
|
349
|
+
if (req.method === 'POST' && route === '/api/library/bridge-for') {
|
|
350
|
+
const body = await readBody(req);
|
|
351
|
+
const g = gatePath(body && body.path);
|
|
352
|
+
if (!g.ok) { sendJson(res, g.status, { error: g.reason }); return; }
|
|
353
|
+
const pathMod = require('path');
|
|
354
|
+
try {
|
|
355
|
+
const bridge = await startBridge({ files: [g.realPath], mode: 'open' });
|
|
356
|
+
sendJson(res, 200, {
|
|
357
|
+
port: bridge.port,
|
|
358
|
+
token: bridge.token,
|
|
359
|
+
file: pathMod.basename(g.realPath),
|
|
360
|
+
});
|
|
361
|
+
} catch (e) {
|
|
362
|
+
sendJson(res, 500, { error: 'could not start bridge: ' + e.message });
|
|
363
|
+
}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
sendJson(res, 404, { error: 'not found' });
|
|
368
|
+
} catch (e) {
|
|
369
|
+
sendJson(res, 500, { error: e.message });
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
return new Promise((resolve, reject) => {
|
|
374
|
+
const tryPort = (p) => {
|
|
375
|
+
const handleErr = (err) => {
|
|
376
|
+
server.removeListener('error', handleErr);
|
|
377
|
+
if (err.code === 'EADDRINUSE' && p === DEFAULT_PORT) {
|
|
378
|
+
// Fall back to a random port if the canonical one is occupied.
|
|
379
|
+
tryPort(0);
|
|
380
|
+
} else {
|
|
381
|
+
reject(err);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
server.once('error', handleErr);
|
|
385
|
+
server.listen(p, '127.0.0.1', () => {
|
|
386
|
+
server.removeListener('error', handleErr);
|
|
387
|
+
const addr = server.address();
|
|
388
|
+
const agentUrl = `http://127.0.0.1:${addr.port}`;
|
|
389
|
+
resolve({ server, agentUrl, port: addr.port });
|
|
390
|
+
});
|
|
391
|
+
};
|
|
392
|
+
tryPort(port == null ? DEFAULT_PORT : port);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
module.exports = {
|
|
397
|
+
createServer, DEFAULT_PORT,
|
|
398
|
+
// Exported for tests of the gate logic.
|
|
399
|
+
originAllowed, hostOk, defaultAllowedOrigins,
|
|
400
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// JSON-backed library index. Crash-safe via temp-file + rename.
|
|
2
|
+
//
|
|
3
|
+
// Two files on disk:
|
|
4
|
+
// ~/.sdocs/library-index.json - entries
|
|
5
|
+
// ~/.sdocs/library-state.json - { enabled, lastScanAt }
|
|
6
|
+
//
|
|
7
|
+
// The index is a rebuildable cache; the markdown files on disk are the
|
|
8
|
+
// source of truth. Hence we don't bother with WAL or row-level locking
|
|
9
|
+
// for v1 - a corrupted index gets dropped and rebuilt by `sdoc library
|
|
10
|
+
// rebuild`.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
|
|
16
|
+
const paths = require('./library-paths');
|
|
17
|
+
|
|
18
|
+
function ensureDir(dir) {
|
|
19
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function atomicWriteJson(file, obj) {
|
|
23
|
+
ensureDir(path.dirname(file));
|
|
24
|
+
const tmp = file + '.tmp-' + process.pid + '-' + Date.now();
|
|
25
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
|
|
26
|
+
fs.renameSync(tmp, file);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readJson(file, fallback) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
32
|
+
return JSON.parse(raw);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return fallback;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadState() {
|
|
39
|
+
const defaults = { enabled: true, lastScanAt: 0, autostartUserDisabled: false };
|
|
40
|
+
const s = readJson(paths.stateFile(), null);
|
|
41
|
+
if (s && typeof s === 'object') return Object.assign(defaults, s);
|
|
42
|
+
return defaults;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function saveState(state) {
|
|
46
|
+
atomicWriteJson(paths.stateFile(), state);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function loadIndex() {
|
|
50
|
+
const data = readJson(paths.indexFile(), null);
|
|
51
|
+
if (!data || !Array.isArray(data.entries)) return { entries: [], generatedAt: 0 };
|
|
52
|
+
return data;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function saveIndex(index) {
|
|
56
|
+
atomicWriteJson(paths.indexFile(), {
|
|
57
|
+
entries: index.entries,
|
|
58
|
+
generatedAt: Date.now(),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function idForPath(absPath) {
|
|
63
|
+
return crypto.createHash('sha1').update(absPath).digest('hex').slice(0, 10);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function upsertEntry(entry) {
|
|
67
|
+
const idx = loadIndex();
|
|
68
|
+
const i = idx.entries.findIndex(e => e.id === entry.id);
|
|
69
|
+
if (i >= 0) {
|
|
70
|
+
idx.entries[i] = Object.assign({}, idx.entries[i], entry);
|
|
71
|
+
} else {
|
|
72
|
+
idx.entries.push(Object.assign({ firstSeen: new Date().toISOString() }, entry));
|
|
73
|
+
}
|
|
74
|
+
saveIndex(idx);
|
|
75
|
+
return idx.entries.find(e => e.id === entry.id);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function removeEntry(id) {
|
|
79
|
+
const idx = loadIndex();
|
|
80
|
+
const before = idx.entries.length;
|
|
81
|
+
idx.entries = idx.entries.filter(e => e.id !== id);
|
|
82
|
+
if (idx.entries.length !== before) saveIndex(idx);
|
|
83
|
+
return before - idx.entries.length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getEntry(id) {
|
|
87
|
+
return loadIndex().entries.find(e => e.id === id) || null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function setStar(id, starred) {
|
|
91
|
+
const idx = loadIndex();
|
|
92
|
+
const e = idx.entries.find(e => e.id === id);
|
|
93
|
+
if (!e) return false;
|
|
94
|
+
e.starred = !!starred;
|
|
95
|
+
saveIndex(idx);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function clearIndex() {
|
|
100
|
+
saveIndex({ entries: [] });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// "Is this absolute path currently in the index?" Compares the
|
|
104
|
+
// requested path (and, where it exists, its realpath) against each
|
|
105
|
+
// entry's stored path AND - for rescued entries - the path the rescue
|
|
106
|
+
// copy came from. The realpath check is what guards against symlink
|
|
107
|
+
// shenanigans (Item C): a symlink that points outside the library can
|
|
108
|
+
// be requested by its source path, but realpath resolves outside any
|
|
109
|
+
// indexed location and the lookup fails.
|
|
110
|
+
function isIndexed(absPath) {
|
|
111
|
+
if (!absPath) return false;
|
|
112
|
+
const fs = require('fs');
|
|
113
|
+
const path = require('path');
|
|
114
|
+
let real = absPath;
|
|
115
|
+
try { real = fs.realpathSync(absPath); } catch (_) { /* not a real path - take as-is */ }
|
|
116
|
+
const idx = loadIndex();
|
|
117
|
+
for (const e of idx.entries) {
|
|
118
|
+
if (e.path === absPath || e.path === real) return true;
|
|
119
|
+
if (e.rescued && e.rescuedFrom && (e.rescuedFrom === absPath || e.rescuedFrom === real)) return true;
|
|
120
|
+
// Also realpath each entry's path for the symmetric case where the
|
|
121
|
+
// index stored a symlinked path. Cheap: at most a few hundred
|
|
122
|
+
// entries on a typical library.
|
|
123
|
+
try {
|
|
124
|
+
const er = fs.realpathSync(e.path);
|
|
125
|
+
if (er === absPath || er === real) return true;
|
|
126
|
+
} catch (_) { /* entry path may have vanished - skip */ }
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
ensureDir,
|
|
133
|
+
atomicWriteJson,
|
|
134
|
+
readJson,
|
|
135
|
+
loadState, saveState,
|
|
136
|
+
loadIndex, saveIndex,
|
|
137
|
+
upsertEntry, removeEntry, getEntry, setStar,
|
|
138
|
+
clearIndex,
|
|
139
|
+
idForPath,
|
|
140
|
+
isIndexed,
|
|
141
|
+
};
|
package/lib/router.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Central command dispatch for the `sdoc` CLI.
|
|
2
|
+
//
|
|
3
|
+
// register(verb, { handler, help }) stores a verb. The default handler
|
|
4
|
+
// (for "no subcommand, just a file or empty argv") is registered with
|
|
5
|
+
// verb = null. dispatch(opts) finds the matching handler and runs it.
|
|
6
|
+
//
|
|
7
|
+
// Later chunks add a new verb in one place: router.register('verb',
|
|
8
|
+
// { handler }). No file other than this one and the entrypoint that
|
|
9
|
+
// builds the router needs to know about the new command's existence.
|
|
10
|
+
|
|
11
|
+
class CommandRouter {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.handlers = new Map(); // verb -> { handler, help }
|
|
14
|
+
this.defaultHandler = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
register(verb, def) {
|
|
18
|
+
if (!def || typeof def.handler !== 'function') {
|
|
19
|
+
throw new Error('router.register: handler is required');
|
|
20
|
+
}
|
|
21
|
+
if (verb === null || verb === undefined) {
|
|
22
|
+
this.defaultHandler = def;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (typeof verb !== 'string' || verb.length === 0) {
|
|
26
|
+
throw new Error('router.register: verb must be a non-empty string or null');
|
|
27
|
+
}
|
|
28
|
+
if (this.handlers.has(verb)) {
|
|
29
|
+
throw new Error('router.register: verb "' + verb + '" already registered');
|
|
30
|
+
}
|
|
31
|
+
this.handlers.set(verb, def);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
has(verb) {
|
|
35
|
+
return this.handlers.has(verb);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
verbs() {
|
|
39
|
+
return [...this.handlers.keys()];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Run the handler for opts.subcommand, or the default if no subcommand.
|
|
43
|
+
// Returns whatever the handler returns (typically a Promise).
|
|
44
|
+
async dispatch(opts) {
|
|
45
|
+
const verb = opts && opts.subcommand;
|
|
46
|
+
const def = (verb && this.handlers.get(verb)) || this.defaultHandler;
|
|
47
|
+
if (!def) throw new Error('router.dispatch: no handler for "' + verb + '" and no default');
|
|
48
|
+
return def.handler(opts);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { CommandRouter };
|