reviewzy 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.
Files changed (46) hide show
  1. package/.env.example +30 -0
  2. package/LICENSE +661 -0
  3. package/README.md +170 -0
  4. package/bin/reviewzy +7 -0
  5. package/package.json +29 -0
  6. package/src/config.ts +76 -0
  7. package/src/daemon/app.ts +159 -0
  8. package/src/daemon/main.ts +103 -0
  9. package/src/daemon/origin.ts +60 -0
  10. package/src/dashboard/auth.ts +122 -0
  11. package/src/dashboard/mount.ts +373 -0
  12. package/src/dashboard/static/components.css +1116 -0
  13. package/src/dashboard/static/dashboard.css +182 -0
  14. package/src/dashboard/static/dashboard.js +169 -0
  15. package/src/dashboard/static/fonts/OFL-JetBrainsMono.txt +93 -0
  16. package/src/dashboard/static/fonts/OFL-Onest.txt +93 -0
  17. package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
  18. package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
  19. package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
  20. package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
  21. package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
  22. package/src/dashboard/static/htmx.min.js +2 -0
  23. package/src/dashboard/static/tokens.css +213 -0
  24. package/src/dashboard/views.tsx +1388 -0
  25. package/src/db/human-save.ts +242 -0
  26. package/src/db/migrate.ts +71 -0
  27. package/src/db/migrations.ts +149 -0
  28. package/src/db/queries.ts +425 -0
  29. package/src/db/store.ts +83 -0
  30. package/src/db/style-guide.ts +236 -0
  31. package/src/db/sweep.ts +88 -0
  32. package/src/mcp/await-approved.ts +156 -0
  33. package/src/mcp/fetch-approved.ts +132 -0
  34. package/src/mcp/file-entries.ts +219 -0
  35. package/src/mcp/list-entries.ts +107 -0
  36. package/src/mcp/mark-applied.ts +103 -0
  37. package/src/mcp/route.ts +88 -0
  38. package/src/mcp/server.ts +59 -0
  39. package/src/mcp/style-guide-resource.ts +40 -0
  40. package/src/notify.ts +142 -0
  41. package/src/shim/daemon.ts +318 -0
  42. package/src/shim/lockfile.ts +153 -0
  43. package/src/shim/main.ts +172 -0
  44. package/src/shim/semver.ts +29 -0
  45. package/src/version.ts +4 -0
  46. package/tsconfig.json +26 -0
@@ -0,0 +1,172 @@
1
+ import * as readline from "node:readline";
2
+ import { loadConfig } from "../config.ts";
3
+ import { ensureDaemon } from "./daemon.ts";
4
+ import { lockfilePath } from "./lockfile.ts";
5
+ import { VERSION } from "../version.ts";
6
+
7
+ /**
8
+ * The shim owns zero protocol logic: one stdin JSON-RPC line in, one POST to the daemon's `/mcp`,
9
+ * whatever the daemon answered back out on stdout. The only interpretation it does is mechanical
10
+ * transport mapping, because the Streamable HTTP transport demands headers a stdio frame does not
11
+ * carry: `Mcp-Method` from the frame's method, `MCP-Protocol-Version` from its `_meta` revision,
12
+ * `Mcp-Name` from `params.name`/`params.uri` on the named calls. Validation of all of it stays
13
+ * server-side, where the security control lives.
14
+ */
15
+ type LooseFrame = {
16
+ method?: unknown;
17
+ params?: { _meta?: Record<string, unknown>; name?: unknown; uri?: unknown } | undefined;
18
+ };
19
+
20
+ const NAMED_METHODS = new Set(["tools/call", "prompts/get"]);
21
+
22
+ /**
23
+ * The header sentinel the transport spec defines for values that cannot be plain ASCII. Tool names
24
+ * are only SHOULD-constrained to header-safe characters, so this is a real case, not decoration.
25
+ */
26
+ function headerValue(value: string): string {
27
+ return /^[\x20-\x7e]*$/.test(value) ? value : `=?base64?${Buffer.from(value, "utf8").toString("base64")}?=`;
28
+ }
29
+
30
+ function deriveHeaders(frame: LooseFrame | undefined): Record<string, string> {
31
+ const headers: Record<string, string> = {};
32
+ if (frame === undefined || typeof frame.method !== "string") return headers;
33
+
34
+ headers["mcp-method"] = frame.method;
35
+
36
+ const meta = frame.params?._meta;
37
+ const revision = meta?.["io.modelcontextprotocol/protocolVersion"];
38
+ if (typeof revision === "string") headers["mcp-protocol-version"] = revision;
39
+
40
+ const name = NAMED_METHODS.has(frame.method)
41
+ ? frame.params?.name
42
+ : frame.method === "resources/read"
43
+ ? frame.params?.uri
44
+ : undefined;
45
+ if (typeof name === "string") headers["mcp-name"] = headerValue(name);
46
+
47
+ return headers;
48
+ }
49
+
50
+ /**
51
+ * A transport failure must still reach the client as a JSON-RPC frame (stdout carries frames
52
+ * only), carrying the request's own id so the client can match it. A notification gets silence:
53
+ * the protocol never answers one, not even with an error.
54
+ */
55
+ function transportError(frame: LooseFrame | undefined, message: string): string | null {
56
+ // A notification is the ABSENCE of the `id` key; reading `id === null` as "no id" would answer
57
+ // an error frame to a message the protocol says can never be answered.
58
+ const isNotification = frame === undefined || !("id" in frame);
59
+ if (isNotification) {
60
+ console.error(`reviewzy: ${message} (notification dropped)`);
61
+ return null;
62
+ }
63
+ return JSON.stringify({
64
+ jsonrpc: "2.0",
65
+ id: (frame as { id: unknown }).id,
66
+ error: { code: -32603, message },
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Forwards one stdin line. Returns the response frame to write, or null when nothing may be
72
+ * written: a notification the daemon accepted, or any body-less reply.
73
+ */
74
+ export async function forwardLine(line: string, port: number, token?: string): Promise<string | null> {
75
+ let frame: LooseFrame | undefined;
76
+ try {
77
+ const parsed: unknown = JSON.parse(line);
78
+ if (typeof parsed === "object" && parsed !== null) frame = parsed as LooseFrame;
79
+ } catch {
80
+ // Not JSON: the daemon is the validator, and its error frame is the client's answer.
81
+ }
82
+
83
+ const headers: Record<string, string> = {
84
+ "content-type": "application/json",
85
+ accept: "application/json, text/event-stream",
86
+ ...deriveHeaders(frame),
87
+ };
88
+ if (token !== undefined) headers.authorization = `Bearer ${token}`;
89
+
90
+ let response: Response;
91
+ try {
92
+ response = await fetch(`http://127.0.0.1:${port}/mcp`, { method: "POST", headers, body: line });
93
+ } catch (error) {
94
+ return transportError(frame, `daemon unreachable on port ${port}: ${(error as Error).message}`);
95
+ }
96
+
97
+ // `202 Accepted` is the transport's "notification received": no response frame exists for it.
98
+ if (response.status === 202) return null;
99
+
100
+ const text = await response.text();
101
+ // A 200 with an empty body answers nothing; for a request, that is a transport error, not
102
+ // silence (a notification still gets silence, since transportError drops those).
103
+ if (text === "") {
104
+ return transportError(frame, `daemon replied http ${response.status} with an empty body`);
105
+ }
106
+
107
+ let parsed: unknown;
108
+ try {
109
+ parsed = JSON.parse(text);
110
+ } catch {
111
+ return transportError(frame, `daemon replied http ${response.status} with a non-json body`);
112
+ }
113
+ if (typeof parsed === "object" && parsed !== null && "jsonrpc" in parsed) return text;
114
+
115
+ // E.g. the daemon's 401 OAuth challenge body: a real reply, but not a frame; stdout must never
116
+ // carry it raw.
117
+ return transportError(frame, `daemon replied http ${response.status} with a non-json-rpc body`);
118
+ }
119
+
120
+ /**
121
+ * The published stdio entrypoint. Establishes the singleton daemon, then bridges stdin to it one
122
+ * line at a time until stdin closes; the daemon is detached, so it outlives the shim. All logging
123
+ * goes to stderr — stdout belongs to the protocol.
124
+ */
125
+ export async function runShim(): Promise<void> {
126
+ const config = loadConfig();
127
+
128
+ // A client that vanished mid-session turns stdout writes into EPIPE; without a handler that is
129
+ // an uncaught exception and a crash, and the daemon the shim bridged is left running with nobody
130
+ // told. Draining to stdin EOF and exiting cleanly is the portable shutdown.
131
+ process.stdout.on("error", () => {});
132
+
133
+ const handle = await ensureDaemon({
134
+ lockfile: lockfilePath(process.env),
135
+ port: config.REVIEWZY_PORT,
136
+ shimVersion: VERSION,
137
+ token: config.REVIEWZY_TOKEN,
138
+ env: process.env,
139
+ });
140
+ console.error(
141
+ `reviewzy: shim ${VERSION} bridging stdio to daemon ${handle.version} (pid ${handle.pid}, port ${handle.port})`,
142
+ );
143
+
144
+ const lines = readline.createInterface({ input: process.stdin, terminal: false });
145
+
146
+ // stdin EOF is the shim's exit signal, but an in-flight request must finish first: exiting the
147
+ // instant the pipe closes would drop a response frame for a request the client already sent.
148
+ let pending = 0;
149
+ lines.on("line", (line: string) => {
150
+ if (line.trim() === "") return;
151
+ pending += 1;
152
+ void forwardLine(line, handle.port, config.REVIEWZY_TOKEN)
153
+ .then((frame) => {
154
+ if (frame !== null) process.stdout.write(`${frame}\n`);
155
+ })
156
+ .catch((error: unknown) => {
157
+ console.error(`reviewzy: forwarding failed: ${(error as Error).message}`);
158
+ })
159
+ .finally(() => {
160
+ pending -= 1;
161
+ });
162
+ });
163
+
164
+ await new Promise<void>((resolve) => lines.once("close", () => resolve()));
165
+ const deadline = Date.now() + 5_000;
166
+ while (pending > 0 && Date.now() < deadline) {
167
+ await new Promise((resolve) => setTimeout(resolve, 10));
168
+ }
169
+ // The daemon stays up for the next session; only the bridge goes away.
170
+ process.exit(0);
171
+ }
172
+
@@ -0,0 +1,29 @@
1
+ export type Semver = { major: number; minor: number; patch: number };
2
+
3
+ /**
4
+ * Strict `major.minor.patch`, nothing else: the package version is the only string ever compared,
5
+ * and a looser grammar would silently rank `0.1.0-rc1` against `0.1.0` by string accident.
6
+ */
7
+ export function parseSemver(version: string): Semver | null {
8
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
9
+ if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) {
10
+ return null;
11
+ }
12
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
13
+ }
14
+
15
+ /**
16
+ * 1 when `a` outranks `b`, -1 when it ranks below, 0 when equal, null when either side does not
17
+ * parse. The null case is load-bearing: an unparseable daemon version must never trigger a
18
+ * version handoff, or a stray tag in `package.json` would drain a healthy daemon forever.
19
+ */
20
+ export function compareSemver(a: string, b: string): number | null {
21
+ const left = parseSemver(a);
22
+ const right = parseSemver(b);
23
+ if (left === null || right === null) return null;
24
+
25
+ for (const part of ["major", "minor", "patch"] as const) {
26
+ if (left[part] !== right[part]) return left[part] > right[part] ? 1 : -1;
27
+ }
28
+ return 0;
29
+ }
package/src/version.ts ADDED
@@ -0,0 +1,4 @@
1
+ import pkg from "../package.json" with { type: "json" };
2
+
3
+ export const NAME: string = pkg.name;
4
+ export const VERSION: string = pkg.version;
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "Preserve",
6
+ "moduleResolution": "bundler",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "jsxImportSource": "hono/jsx",
10
+ "allowJs": true,
11
+ "allowImportingTsExtensions": true,
12
+ "types": ["bun"],
13
+
14
+ "strict": true,
15
+ "noUncheckedIndexedAccess": true,
16
+ "noImplicitOverride": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "exactOptionalPropertyTypes": true,
19
+
20
+ "resolveJsonModule": true,
21
+ "verbatimModuleSyntax": true,
22
+ "skipLibCheck": true,
23
+ "noEmit": true
24
+ },
25
+ "include": ["src", "test"]
26
+ }