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.
- package/.env.example +30 -0
- package/LICENSE +661 -0
- package/README.md +170 -0
- package/bin/reviewzy +7 -0
- package/package.json +29 -0
- package/src/config.ts +76 -0
- package/src/daemon/app.ts +159 -0
- package/src/daemon/main.ts +103 -0
- package/src/daemon/origin.ts +60 -0
- package/src/dashboard/auth.ts +122 -0
- package/src/dashboard/mount.ts +373 -0
- package/src/dashboard/static/components.css +1116 -0
- package/src/dashboard/static/dashboard.css +182 -0
- package/src/dashboard/static/dashboard.js +169 -0
- package/src/dashboard/static/fonts/OFL-JetBrainsMono.txt +93 -0
- package/src/dashboard/static/fonts/OFL-Onest.txt +93 -0
- package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
- package/src/dashboard/static/htmx.min.js +2 -0
- package/src/dashboard/static/tokens.css +213 -0
- package/src/dashboard/views.tsx +1388 -0
- package/src/db/human-save.ts +242 -0
- package/src/db/migrate.ts +71 -0
- package/src/db/migrations.ts +149 -0
- package/src/db/queries.ts +425 -0
- package/src/db/store.ts +83 -0
- package/src/db/style-guide.ts +236 -0
- package/src/db/sweep.ts +88 -0
- package/src/mcp/await-approved.ts +156 -0
- package/src/mcp/fetch-approved.ts +132 -0
- package/src/mcp/file-entries.ts +219 -0
- package/src/mcp/list-entries.ts +107 -0
- package/src/mcp/mark-applied.ts +103 -0
- package/src/mcp/route.ts +88 -0
- package/src/mcp/server.ts +59 -0
- package/src/mcp/style-guide-resource.ts +40 -0
- package/src/notify.ts +142 -0
- package/src/shim/daemon.ts +318 -0
- package/src/shim/lockfile.ts +153 -0
- package/src/shim/main.ts +172 -0
- package/src/shim/semver.ts +29 -0
- package/src/version.ts +4 -0
- package/tsconfig.json +26 -0
package/src/shim/main.ts
ADDED
|
@@ -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
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
|
+
}
|