@docsworth/cli 0.0.0-stage → 0.3.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.
Files changed (103) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +238 -2
  3. package/dist/cli.d.ts +11 -0
  4. package/dist/cli.js +458 -0
  5. package/dist/core/acl.d.ts +37 -0
  6. package/dist/core/acl.js +136 -0
  7. package/dist/core/adopt.d.ts +68 -0
  8. package/dist/core/adopt.js +665 -0
  9. package/dist/core/audit-log.d.ts +133 -0
  10. package/dist/core/audit-log.js +290 -0
  11. package/dist/core/blocks.d.ts +35 -0
  12. package/dist/core/blocks.js +417 -0
  13. package/dist/core/codeowners.d.ts +62 -0
  14. package/dist/core/codeowners.js +133 -0
  15. package/dist/core/config.d.ts +101 -0
  16. package/dist/core/config.js +175 -0
  17. package/dist/core/coverage.d.ts +25 -0
  18. package/dist/core/coverage.js +41 -0
  19. package/dist/core/duration.d.ts +20 -0
  20. package/dist/core/duration.js +60 -0
  21. package/dist/core/edits.d.ts +13 -0
  22. package/dist/core/edits.js +20 -0
  23. package/dist/core/errors.d.ts +11 -0
  24. package/dist/core/errors.js +13 -0
  25. package/dist/core/frontmatter.d.ts +29 -0
  26. package/dist/core/frontmatter.js +49 -0
  27. package/dist/core/gate.d.ts +17 -0
  28. package/dist/core/gate.js +25 -0
  29. package/dist/core/gfm.d.ts +13 -0
  30. package/dist/core/gfm.js +244 -0
  31. package/dist/core/github.d.ts +152 -0
  32. package/dist/core/github.js +234 -0
  33. package/dist/core/host.d.ts +125 -0
  34. package/dist/core/host.js +22 -0
  35. package/dist/core/identity.d.ts +39 -0
  36. package/dist/core/identity.js +127 -0
  37. package/dist/core/init.d.ts +25 -0
  38. package/dist/core/init.js +86 -0
  39. package/dist/core/links.d.ts +82 -0
  40. package/dist/core/links.js +345 -0
  41. package/dist/core/lint.d.ts +16 -0
  42. package/dist/core/lint.js +149 -0
  43. package/dist/core/mcp/audit-transport.d.ts +16 -0
  44. package/dist/core/mcp/audit-transport.js +238 -0
  45. package/dist/core/mcp/server.d.ts +19 -0
  46. package/dist/core/mcp/server.js +216 -0
  47. package/dist/core/mcp/text.d.ts +14 -0
  48. package/dist/core/mcp/text.js +43 -0
  49. package/dist/core/mirror-view.d.ts +68 -0
  50. package/dist/core/mirror-view.js +367 -0
  51. package/dist/core/new-page.d.ts +52 -0
  52. package/dist/core/new-page.js +49 -0
  53. package/dist/core/policy.d.ts +10 -0
  54. package/dist/core/policy.js +30 -0
  55. package/dist/core/polish.d.ts +42 -0
  56. package/dist/core/polish.js +1000 -0
  57. package/dist/core/printable.d.ts +4 -0
  58. package/dist/core/printable.js +16 -0
  59. package/dist/core/references.d.ts +87 -0
  60. package/dist/core/references.js +1744 -0
  61. package/dist/core/repo.d.ts +46 -0
  62. package/dist/core/repo.js +118 -0
  63. package/dist/core/schema.d.ts +50 -0
  64. package/dist/core/schema.js +51 -0
  65. package/dist/core/search.d.ts +32 -0
  66. package/dist/core/search.js +132 -0
  67. package/dist/core/serve/auth.d.ts +23 -0
  68. package/dist/core/serve/auth.js +38 -0
  69. package/dist/core/serve/server.d.ts +21 -0
  70. package/dist/core/serve/server.js +138 -0
  71. package/dist/core/serve/sessions.d.ts +40 -0
  72. package/dist/core/serve/sessions.js +203 -0
  73. package/dist/core/serve/token.d.ts +10 -0
  74. package/dist/core/serve/token.js +96 -0
  75. package/dist/core/service.d.ts +473 -0
  76. package/dist/core/service.js +973 -0
  77. package/dist/core/sla.d.ts +16 -0
  78. package/dist/core/sla.js +20 -0
  79. package/dist/core/stale.d.ts +16 -0
  80. package/dist/core/stale.js +24 -0
  81. package/dist/core/tool-registry.d.ts +31 -0
  82. package/dist/core/tool-registry.js +80 -0
  83. package/dist/core/url.d.ts +4 -0
  84. package/dist/core/url.js +31 -0
  85. package/dist/core/verify-pages.d.ts +19 -0
  86. package/dist/core/verify-pages.js +110 -0
  87. package/dist/core/version.d.ts +22 -0
  88. package/dist/core/version.js +32 -0
  89. package/dist/core/writes.d.ts +58 -0
  90. package/dist/core/writes.js +113 -0
  91. package/dist/web/assets/index-8J-FUbb0.js +29 -0
  92. package/dist/web/assets/index-BGpAZzeM.css +1 -0
  93. package/dist/web/index.html +14 -0
  94. package/docs/format-spec.md +349 -0
  95. package/package.json +75 -4
  96. package/plugin/.claude-plugin/plugin.json +8 -0
  97. package/plugin/.mcp.json +8 -0
  98. package/plugin/bin/docsworth-mcp.mjs +46 -0
  99. package/plugin/skills/draft-runbook/SKILL.md +75 -0
  100. package/plugin/skills/polish-page/SKILL.md +64 -0
  101. package/plugin/skills/update-after-incident/SKILL.md +59 -0
  102. package/plugin/skills/verify-page/SKILL.md +46 -0
  103. package/templates/github/knowledge.yml +51 -0
@@ -0,0 +1,40 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { Identity } from "../acl.js";
3
+ import { type AuditLog } from "../audit-log.js";
4
+ import { type IdentitySource, KnowledgeService, RepoLoader } from "../service.js";
5
+ export interface SessionOptions {
6
+ root: string;
7
+ identity: IdentitySource;
8
+ /** The identity proven at server start, to attribute the first event of each session. */
9
+ who: Identity;
10
+ audit: AuditLog | null;
11
+ includeQueries: boolean;
12
+ now: () => Date;
13
+ maxSessions?: number;
14
+ idleMs?: number;
15
+ log?: (msg: string) => void;
16
+ }
17
+ export declare const MAX_SESSIONS = 16;
18
+ export declare const SESSION_IDLE_MS: number;
19
+ export declare class SessionTable {
20
+ private readonly opts;
21
+ private readonly sessions;
22
+ /** Sessions being set up: counted against the cap before they have an id. */
23
+ private starting;
24
+ /** One load of the repo for every session; its watcher marks it stale on any change. */
25
+ readonly loader: RepoLoader;
26
+ private readonly sweeper;
27
+ private readonly max;
28
+ private readonly idleMs;
29
+ constructor(opts: SessionOptions);
30
+ get size(): number;
31
+ /** The services of live sessions (tests). */
32
+ services(): KnowledgeService[];
33
+ handle(req: IncomingMessage, res: ServerResponse): Promise<void>;
34
+ private open;
35
+ /** The session that has had no request in flight for longest, if any. */
36
+ private idlest;
37
+ /** Close sessions idle longer than the limit, never one with a request in flight. */
38
+ sweep(now?: number): Promise<void>;
39
+ close(): Promise<void>;
40
+ }
@@ -0,0 +1,203 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
4
+ import { actorOf } from "../audit-log.js";
5
+ import { auditTransport } from "../mcp/audit-transport.js";
6
+ import { createMcpServer } from "../mcp/server.js";
7
+ import { KnowledgeService, RepoLoader } from "../service.js";
8
+ export const MAX_SESSIONS = 16;
9
+ /** An initialize request is a few hundred bytes; a first request bigger than this isn't one. */
10
+ const MAX_START_BYTES = 64 * 1024;
11
+ export const SESSION_IDLE_MS = 30 * 60_000;
12
+ function json(res, status, body) {
13
+ res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
14
+ }
15
+ const NOT_A_START = { status: 400, error: { code: "invalid", message: "start a session with an initialize request" } };
16
+ /**
17
+ * A first request's body, if it can start a session: the headers the transport requires,
18
+ * and exactly one initialize request. Checked before a slot is counted or reclaimed, so a
19
+ * request the transport would refuse can't close someone's idle session for nothing.
20
+ */
21
+ async function startBody(req) {
22
+ const accept = req.headers.accept ?? "";
23
+ if (!accept.includes("application/json") || !accept.includes("text/event-stream"))
24
+ return { status: 406, error: { code: "invalid", message: "the client must accept both application/json and text/event-stream" } };
25
+ if (!(req.headers["content-type"] ?? "").includes("application/json"))
26
+ return { status: 415, error: { code: "invalid", message: "the body must be application/json" } };
27
+ const chunks = [];
28
+ let size = 0;
29
+ for await (const chunk of req) {
30
+ size += chunk.length;
31
+ if (size > MAX_START_BYTES)
32
+ return NOT_A_START;
33
+ chunks.push(chunk);
34
+ }
35
+ let body;
36
+ try {
37
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
38
+ }
39
+ catch {
40
+ return NOT_A_START;
41
+ }
42
+ const only = Array.isArray(body) ? (body.length === 1 ? body[0] : undefined) : body;
43
+ return isInitializeRequest(only) ? { body } : NOT_A_START;
44
+ }
45
+ export class SessionTable {
46
+ opts;
47
+ sessions = new Map();
48
+ /** Sessions being set up: counted against the cap before they have an id. */
49
+ starting = 0;
50
+ /** One load of the repo for every session; its watcher marks it stale on any change. */
51
+ loader;
52
+ sweeper;
53
+ max;
54
+ idleMs;
55
+ constructor(opts) {
56
+ this.opts = opts;
57
+ this.max = opts.maxSessions ?? MAX_SESSIONS;
58
+ this.idleMs = opts.idleMs ?? SESSION_IDLE_MS;
59
+ // One load and one watcher for every session: a change marks the load stale, and the
60
+ // next call from any session builds a new one, never in the middle of a write that is
61
+ // still landing. It watches pages only. The config and CODEOWNERS (the policy, admins,
62
+ // owners) sit outside the knowledge root, and every call already stats them, so a
63
+ // tightened policy applies to an open session's next call (test/serve.test.ts,
64
+ // "applies a tightened policy").
65
+ this.loader = new RepoLoader(opts.root);
66
+ // A new load is built inside one session's call; every session with nothing in flight
67
+ // moves onto it now, and a busy one when its last request ends (handle). So however
68
+ // the repo changes, the sessions hold one load between them, not one each.
69
+ this.loader.onLoad = () => {
70
+ for (const s of this.sessions.values())
71
+ if (s.active === 0)
72
+ s.service.release();
73
+ };
74
+ this.loader.watch();
75
+ this.sweeper = setInterval(() => void this.sweep(), Math.min(60_000, this.idleMs));
76
+ this.sweeper.unref();
77
+ }
78
+ get size() {
79
+ return this.sessions.size;
80
+ }
81
+ /** The services of live sessions (tests). */
82
+ services() {
83
+ return [...this.sessions.values()].map((s) => s.service);
84
+ }
85
+ async handle(req, res) {
86
+ const sid = req.headers["mcp-session-id"];
87
+ if (sid !== undefined) {
88
+ const session = typeof sid === "string" ? this.sessions.get(sid) : undefined;
89
+ if (!session)
90
+ return json(res, 404, { error: { code: "not_found", message: "no such session; it may have expired: start a new one" } });
91
+ session.active++;
92
+ try {
93
+ return await session.http.handleRequest(req, res);
94
+ }
95
+ finally {
96
+ session.active--;
97
+ session.lastUsed = Date.now();
98
+ if (session.active === 0)
99
+ session.service.release();
100
+ }
101
+ }
102
+ if (req.method !== "POST")
103
+ return json(res, 400, { error: NOT_A_START.error });
104
+ const first = await startBody(req);
105
+ if (!("body" in first))
106
+ return json(res, first.status, { error: first.error });
107
+ // Checked and counted with no await in between, so concurrent starts can't pass the cap.
108
+ // A full server makes room by closing the session idle longest: a closed tab never
109
+ // tells the server, so refusing would lock the user out until it expired. Only when
110
+ // every slot is busy (answering, or still starting) is a new one refused.
111
+ let reclaimed;
112
+ if (this.sessions.size + this.starting >= this.max) {
113
+ reclaimed = this.idlest();
114
+ if (!reclaimed)
115
+ return json(res, 503, { error: { code: "unavailable", message: `this server is answering ${this.max} sessions at once; try again in a moment` } });
116
+ this.sessions.delete(reclaimed.http.sessionId); // out of the table before any await
117
+ }
118
+ // This start's slot: counted in `starting` until its session joins the table, then
119
+ // there instead (never in both, or a start beside a slow first answer finds no room).
120
+ this.starting++;
121
+ let reserved = true;
122
+ const joined = () => {
123
+ if (reserved)
124
+ this.starting--;
125
+ reserved = false;
126
+ };
127
+ try {
128
+ if (reclaimed)
129
+ await reclaimed.server.close().catch((err) => this.opts.log?.(`closing an idle session failed: ${err.message}`));
130
+ const session = await this.open(joined);
131
+ try {
132
+ await session.http.handleRequest(req, res, first.body); // the body is read: hand it over
133
+ }
134
+ finally {
135
+ // Busy from birth: the session joins the table while this request is still
136
+ // being answered, so a sweep in between must not take it.
137
+ session.active--;
138
+ session.lastUsed = Date.now();
139
+ if (session.active === 0)
140
+ session.service.release(); // as for any request (above)
141
+ }
142
+ // A first request that wasn't an initialize gets the SDK's error and no session.
143
+ if (!session.http.sessionId)
144
+ await session.server.close();
145
+ }
146
+ finally {
147
+ joined();
148
+ }
149
+ }
150
+ async open(joined) {
151
+ const o = this.opts;
152
+ const service = new KnowledgeService(o.root, o.identity, o.now, this.loader);
153
+ const http = new StreamableHTTPServerTransport({
154
+ sessionIdGenerator: randomUUID,
155
+ enableJsonResponse: true,
156
+ onsessioninitialized: (id) => {
157
+ this.sessions.set(id, session);
158
+ joined();
159
+ },
160
+ });
161
+ const transport = o.audit
162
+ ? auditTransport(http, o.audit, () => o.identity.get(), {
163
+ includeQueries: o.includeQueries,
164
+ initialActor: actorOf(o.who),
165
+ known: () => service.knownValues(),
166
+ snapshot: () => service.snapshot(),
167
+ log: o.log,
168
+ })
169
+ : http;
170
+ // On the wrapper, not on `http`: the audit transport takes over the inner onclose.
171
+ // Set before connect, which chains the server's own handler after this one.
172
+ transport.onclose = () => {
173
+ if (http.sessionId)
174
+ this.sessions.delete(http.sessionId);
175
+ service.close();
176
+ };
177
+ const server = createMcpServer(service, { who: o.who, whoami: () => o.identity.get(), surface: "http" });
178
+ const session = { http, server, service, lastUsed: Date.now(), active: 1 };
179
+ await server.connect(transport);
180
+ return session;
181
+ }
182
+ /** The session that has had no request in flight for longest, if any. */
183
+ idlest() {
184
+ let found;
185
+ for (const s of this.sessions.values())
186
+ if (s.active === 0 && (!found || s.lastUsed < found.lastUsed))
187
+ found = s;
188
+ return found;
189
+ }
190
+ /** Close sessions idle longer than the limit, never one with a request in flight. */
191
+ async sweep(now = Date.now()) {
192
+ const idle = [...this.sessions.values()].filter((s) => s.active === 0 && now - s.lastUsed > this.idleMs);
193
+ // Out of the table first, so a start can't pick one of these to reclaim as well.
194
+ for (const s of idle)
195
+ this.sessions.delete(s.http.sessionId);
196
+ await Promise.all(idle.map((s) => s.server.close()));
197
+ }
198
+ async close() {
199
+ clearInterval(this.sweeper);
200
+ this.loader.close();
201
+ await Promise.all([...this.sessions.values()].map((s) => s.server.close()));
202
+ }
203
+ }
@@ -0,0 +1,10 @@
1
+ import { UserError } from "../errors.js";
2
+ export declare const TOKEN_FILE = "serve-token";
3
+ export declare class TokenError extends UserError {
4
+ }
5
+ /** The serve token kept in `dir`, created on first use, or replaced with `rotate`. */
6
+ export declare function loadToken(dir: string, opts?: {
7
+ rotate?: boolean;
8
+ }): string;
9
+ /** Constant-time comparison of a presented token with the expected one. */
10
+ export declare function tokenMatches(expected: string, presented: string | undefined): boolean;
@@ -0,0 +1,96 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { chmodSync, closeSync, constants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { UserError } from "../errors.js";
5
+ // The bearer token `docsworth serve` checks on every /mcp request. It persists in this
6
+ // user's state directory (outside the repo, never committable), so a paired browser
7
+ // extension keeps working across restarts; `--rotate-token` replaces it. The file is
8
+ // handled like the audit log's: a 0700 directory that isn't a symlink, a 0600 file opened
9
+ // without following links, and writes that are atomic.
10
+ export const TOKEN_FILE = "serve-token";
11
+ const TOKEN_SHAPE = /^[A-Za-z0-9_-]{43}$/; // 32 random bytes, base64url
12
+ export class TokenError extends UserError {
13
+ }
14
+ function ensureDir(dir) {
15
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
16
+ const st = lstatSync(dir);
17
+ if (st.isSymbolicLink() || !st.isDirectory())
18
+ throw new TokenError(`${dir} is a symlink or not a directory; refusing to keep the serve token there`);
19
+ if ((st.mode & 0o077) !== 0)
20
+ chmodSync(dir, 0o700);
21
+ }
22
+ /** The token in `file`, or null if there is none yet. */
23
+ function readToken(file) {
24
+ let fd;
25
+ try {
26
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
27
+ }
28
+ catch (err) {
29
+ const code = err.code;
30
+ if (code === "ENOENT")
31
+ return null;
32
+ if (code === "ELOOP")
33
+ throw new TokenError(`${file} is a symlink; refusing to read the serve token through it`);
34
+ throw err;
35
+ }
36
+ try {
37
+ const st = fstatSync(fd);
38
+ if (!st.isFile())
39
+ throw new TokenError(`${file} is not a regular file; refusing to use it as the serve token`);
40
+ // A token others could read is a token others have: refuse it rather than quietly fix it.
41
+ if ((st.mode & 0o077) !== 0)
42
+ throw new TokenError(`${file} can be read by other users; run \`docsworth serve --rotate-token\` to replace it`);
43
+ const token = readFileSync(fd, "utf8").trim();
44
+ if (!TOKEN_SHAPE.test(token))
45
+ throw new TokenError(`${file} doesn't hold a serve token; run \`docsworth serve --rotate-token\``);
46
+ return token;
47
+ }
48
+ finally {
49
+ closeSync(fd);
50
+ }
51
+ }
52
+ /**
53
+ * Write a new token to `file` through a fresh 0600 file in the same directory. When the
54
+ * token is first created, the new file is linked into place, so of two servers starting
55
+ * at once only one token wins and both read it; a rotation renames over the old one.
56
+ */
57
+ function writeToken(dir, file, replace) {
58
+ const tmp = join(dir, `.${TOKEN_FILE}.${process.pid}.${randomBytes(4).toString("hex")}`);
59
+ const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
60
+ try {
61
+ writeSync(fd, `${randomBytes(32).toString("base64url")}\n`);
62
+ }
63
+ finally {
64
+ closeSync(fd);
65
+ }
66
+ if (replace)
67
+ return renameSync(tmp, file); // replaces a symlink itself, never its target
68
+ try {
69
+ linkSync(tmp, file);
70
+ }
71
+ catch (err) {
72
+ if (err.code !== "EEXIST")
73
+ throw err; // another start won
74
+ }
75
+ finally {
76
+ unlinkSync(tmp);
77
+ }
78
+ }
79
+ /** The serve token kept in `dir`, created on first use, or replaced with `rotate`. */
80
+ export function loadToken(dir, opts = {}) {
81
+ ensureDir(dir);
82
+ const file = join(dir, TOKEN_FILE);
83
+ if (opts.rotate)
84
+ writeToken(dir, file, true);
85
+ else if (readToken(file) === null)
86
+ writeToken(dir, file, false);
87
+ return readToken(file);
88
+ }
89
+ /** Constant-time comparison of a presented token with the expected one. */
90
+ export function tokenMatches(expected, presented) {
91
+ if (typeof presented !== "string")
92
+ return false;
93
+ const a = Buffer.from(expected);
94
+ const b = Buffer.from(presented);
95
+ return a.length === b.length && timingSafeEqual(a, b);
96
+ }