sdocs-dev 1.6.2 → 1.13.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.
@@ -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 };