diffprism 0.34.1 → 0.36.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/README.md +47 -188
- package/dist/bin.js +213 -227
- package/dist/chunk-DHCVZGHE.js +501 -0
- package/dist/chunk-ITPHDFOS.js +1283 -0
- package/dist/chunk-JSBRDJBE.js +30 -0
- package/dist/{chunk-VASCXEMN.js → chunk-OR6PCPZX.js} +22 -2791
- package/dist/chunk-QGWYCEJN.js +448 -0
- package/dist/chunk-UYZ3A2PB.js +231 -0
- package/dist/demo-JH5YOKTZ.js +10 -0
- package/dist/mcp-server.js +77 -280
- package/dist/src-AMCPIYDZ.js +19 -0
- package/dist/src-JMPTSU3P.js +27 -0
- package/package.json +1 -1
- package/ui-dist/assets/index-CNIXSkOg.js +325 -0
- package/ui-dist/index.html +1 -1
- package/ui-dist/assets/index-BfqEajZq.js +0 -325
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// packages/git/src/local.ts
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import { readFileSync } from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
function getGitDiff(ref, options) {
|
|
6
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
7
|
+
try {
|
|
8
|
+
execSync("git --version", { cwd, stdio: "pipe" });
|
|
9
|
+
} catch {
|
|
10
|
+
throw new Error(
|
|
11
|
+
"git is not available. Please install git and make sure it is on your PATH."
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
execSync("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
|
|
16
|
+
} catch {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`The directory "${cwd}" is not inside a git repository.`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
let command;
|
|
22
|
+
let includeUntracked = false;
|
|
23
|
+
switch (ref) {
|
|
24
|
+
case "staged":
|
|
25
|
+
command = "git diff --staged --no-color";
|
|
26
|
+
break;
|
|
27
|
+
case "unstaged":
|
|
28
|
+
command = "git diff --no-color";
|
|
29
|
+
includeUntracked = true;
|
|
30
|
+
break;
|
|
31
|
+
case "all":
|
|
32
|
+
command = "git diff HEAD --no-color";
|
|
33
|
+
includeUntracked = true;
|
|
34
|
+
break;
|
|
35
|
+
default:
|
|
36
|
+
command = `git diff --no-color ${ref}`;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
let output;
|
|
40
|
+
try {
|
|
41
|
+
output = execSync(command, {
|
|
42
|
+
cwd,
|
|
43
|
+
encoding: "utf-8",
|
|
44
|
+
maxBuffer: 50 * 1024 * 1024
|
|
45
|
+
// 50 MB
|
|
46
|
+
});
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
49
|
+
throw new Error(`git diff failed: ${message}`);
|
|
50
|
+
}
|
|
51
|
+
if (includeUntracked) {
|
|
52
|
+
output += getUntrackedDiffs(cwd);
|
|
53
|
+
}
|
|
54
|
+
return output;
|
|
55
|
+
}
|
|
56
|
+
function getCurrentBranch(options) {
|
|
57
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
58
|
+
try {
|
|
59
|
+
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
60
|
+
cwd,
|
|
61
|
+
encoding: "utf-8",
|
|
62
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
63
|
+
}).trim();
|
|
64
|
+
} catch {
|
|
65
|
+
return "unknown";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function listBranches(options) {
|
|
69
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
70
|
+
try {
|
|
71
|
+
const output = execSync(
|
|
72
|
+
"git branch -a --format='%(refname:short)' --sort=-committerdate",
|
|
73
|
+
{ cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
74
|
+
).trim();
|
|
75
|
+
if (!output) return { local: [], remote: [] };
|
|
76
|
+
const local = [];
|
|
77
|
+
const remote = [];
|
|
78
|
+
for (const line of output.split("\n")) {
|
|
79
|
+
const name = line.trim();
|
|
80
|
+
if (!name) continue;
|
|
81
|
+
if (name.endsWith("/HEAD")) continue;
|
|
82
|
+
if (name.includes("/")) {
|
|
83
|
+
remote.push(name);
|
|
84
|
+
} else {
|
|
85
|
+
local.push(name);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { local, remote };
|
|
89
|
+
} catch {
|
|
90
|
+
return { local: [], remote: [] };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function listCommits(options) {
|
|
94
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
95
|
+
const limit = options?.limit ?? 50;
|
|
96
|
+
try {
|
|
97
|
+
const output = execSync(
|
|
98
|
+
`git log --format='%H<<>>%h<<>>%s<<>>%an<<>>%aI' -n ${limit}`,
|
|
99
|
+
{ cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
100
|
+
).trim();
|
|
101
|
+
if (!output) return [];
|
|
102
|
+
const commits = [];
|
|
103
|
+
for (const line of output.split("\n")) {
|
|
104
|
+
const parts = line.split("<<>>");
|
|
105
|
+
if (parts.length < 5) continue;
|
|
106
|
+
commits.push({
|
|
107
|
+
hash: parts[0],
|
|
108
|
+
shortHash: parts[1],
|
|
109
|
+
subject: parts[2],
|
|
110
|
+
author: parts[3],
|
|
111
|
+
date: parts[4]
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return commits;
|
|
115
|
+
} catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function detectWorktree(options) {
|
|
120
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
121
|
+
try {
|
|
122
|
+
const gitDir = execSync("git rev-parse --git-dir", {
|
|
123
|
+
cwd,
|
|
124
|
+
encoding: "utf-8",
|
|
125
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
126
|
+
}).trim();
|
|
127
|
+
const gitCommonDir = execSync("git rev-parse --git-common-dir", {
|
|
128
|
+
cwd,
|
|
129
|
+
encoding: "utf-8",
|
|
130
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
131
|
+
}).trim();
|
|
132
|
+
const resolvedGitDir = path.resolve(cwd, gitDir);
|
|
133
|
+
const resolvedCommonDir = path.resolve(cwd, gitCommonDir);
|
|
134
|
+
const isWorktree = resolvedGitDir !== resolvedCommonDir;
|
|
135
|
+
if (!isWorktree) {
|
|
136
|
+
return { isWorktree: false };
|
|
137
|
+
}
|
|
138
|
+
const worktreePath = execSync("git rev-parse --show-toplevel", {
|
|
139
|
+
cwd,
|
|
140
|
+
encoding: "utf-8",
|
|
141
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
142
|
+
}).trim();
|
|
143
|
+
const mainWorktreePath = path.dirname(resolvedCommonDir);
|
|
144
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
145
|
+
cwd,
|
|
146
|
+
encoding: "utf-8",
|
|
147
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
148
|
+
}).trim();
|
|
149
|
+
return {
|
|
150
|
+
isWorktree: true,
|
|
151
|
+
worktreePath,
|
|
152
|
+
mainWorktreePath,
|
|
153
|
+
branch: branch === "HEAD" ? void 0 : branch
|
|
154
|
+
};
|
|
155
|
+
} catch {
|
|
156
|
+
return { isWorktree: false };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function getUntrackedDiffs(cwd) {
|
|
160
|
+
let untrackedList;
|
|
161
|
+
try {
|
|
162
|
+
untrackedList = execSync(
|
|
163
|
+
"git ls-files --others --exclude-standard",
|
|
164
|
+
{ cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
165
|
+
).trim();
|
|
166
|
+
} catch {
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
if (!untrackedList) return "";
|
|
170
|
+
const files = untrackedList.split("\n");
|
|
171
|
+
let result = "";
|
|
172
|
+
for (const file of files) {
|
|
173
|
+
const absPath = path.resolve(cwd, file);
|
|
174
|
+
let content;
|
|
175
|
+
try {
|
|
176
|
+
content = readFileSync(absPath, "utf-8");
|
|
177
|
+
} catch {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const lines = content.split("\n");
|
|
181
|
+
const hasTrailingNewline = content.length > 0 && content[content.length - 1] === "\n";
|
|
182
|
+
const contentLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
|
|
183
|
+
result += `diff --git a/${file} b/${file}
|
|
184
|
+
`;
|
|
185
|
+
result += "new file mode 100644\n";
|
|
186
|
+
result += "--- /dev/null\n";
|
|
187
|
+
result += `+++ b/${file}
|
|
188
|
+
`;
|
|
189
|
+
result += `@@ -0,0 +1,${contentLines.length} @@
|
|
190
|
+
`;
|
|
191
|
+
for (const line of contentLines) {
|
|
192
|
+
result += `+${line}
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
if (!hasTrailingNewline) {
|
|
196
|
+
result += "\\n";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// packages/git/src/parser.ts
|
|
203
|
+
import path2 from "path";
|
|
204
|
+
var EXTENSION_MAP = {
|
|
205
|
+
ts: "typescript",
|
|
206
|
+
tsx: "typescript",
|
|
207
|
+
js: "javascript",
|
|
208
|
+
jsx: "javascript",
|
|
209
|
+
py: "python",
|
|
210
|
+
rb: "ruby",
|
|
211
|
+
go: "go",
|
|
212
|
+
rs: "rust",
|
|
213
|
+
java: "java",
|
|
214
|
+
c: "c",
|
|
215
|
+
h: "c",
|
|
216
|
+
cpp: "cpp",
|
|
217
|
+
hpp: "cpp",
|
|
218
|
+
cc: "cpp",
|
|
219
|
+
cs: "csharp",
|
|
220
|
+
md: "markdown",
|
|
221
|
+
json: "json",
|
|
222
|
+
yaml: "yaml",
|
|
223
|
+
yml: "yaml",
|
|
224
|
+
html: "html",
|
|
225
|
+
css: "css",
|
|
226
|
+
scss: "scss",
|
|
227
|
+
sql: "sql",
|
|
228
|
+
sh: "shell",
|
|
229
|
+
bash: "shell",
|
|
230
|
+
zsh: "shell"
|
|
231
|
+
};
|
|
232
|
+
var FILENAME_MAP = {
|
|
233
|
+
Dockerfile: "dockerfile",
|
|
234
|
+
Makefile: "makefile"
|
|
235
|
+
};
|
|
236
|
+
function detectLanguage(filePath) {
|
|
237
|
+
const basename = path2.basename(filePath);
|
|
238
|
+
if (FILENAME_MAP[basename]) {
|
|
239
|
+
return FILENAME_MAP[basename];
|
|
240
|
+
}
|
|
241
|
+
const ext = basename.includes(".") ? basename.slice(basename.lastIndexOf(".") + 1) : "";
|
|
242
|
+
return EXTENSION_MAP[ext] ?? "text";
|
|
243
|
+
}
|
|
244
|
+
function stripPrefix(raw) {
|
|
245
|
+
if (raw === "/dev/null") return raw;
|
|
246
|
+
return raw.replace(/^[ab]\//, "");
|
|
247
|
+
}
|
|
248
|
+
function parseDiff(rawDiff, baseRef, headRef) {
|
|
249
|
+
if (!rawDiff.trim()) {
|
|
250
|
+
return { baseRef, headRef, files: [] };
|
|
251
|
+
}
|
|
252
|
+
const files = [];
|
|
253
|
+
const lines = rawDiff.split("\n");
|
|
254
|
+
let i = 0;
|
|
255
|
+
while (i < lines.length) {
|
|
256
|
+
if (!lines[i].startsWith("diff --git ")) {
|
|
257
|
+
i++;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
let oldPath;
|
|
261
|
+
let newPath;
|
|
262
|
+
let status = "modified";
|
|
263
|
+
let binary = false;
|
|
264
|
+
let renameFrom;
|
|
265
|
+
let renameTo;
|
|
266
|
+
const diffLine = lines[i];
|
|
267
|
+
const gitPathMatch = diffLine.match(/^diff --git a\/(.*) b\/(.*)$/);
|
|
268
|
+
if (gitPathMatch) {
|
|
269
|
+
oldPath = gitPathMatch[1];
|
|
270
|
+
newPath = gitPathMatch[2];
|
|
271
|
+
}
|
|
272
|
+
i++;
|
|
273
|
+
while (i < lines.length && !lines[i].startsWith("diff --git ")) {
|
|
274
|
+
const line = lines[i];
|
|
275
|
+
if (line.startsWith("--- ")) {
|
|
276
|
+
const raw = line.slice(4);
|
|
277
|
+
oldPath = stripPrefix(raw);
|
|
278
|
+
if (raw === "/dev/null") {
|
|
279
|
+
status = "added";
|
|
280
|
+
}
|
|
281
|
+
} else if (line.startsWith("+++ ")) {
|
|
282
|
+
const raw = line.slice(4);
|
|
283
|
+
newPath = stripPrefix(raw);
|
|
284
|
+
if (raw === "/dev/null") {
|
|
285
|
+
status = "deleted";
|
|
286
|
+
}
|
|
287
|
+
} else if (line.startsWith("rename from ")) {
|
|
288
|
+
renameFrom = line.slice("rename from ".length);
|
|
289
|
+
status = "renamed";
|
|
290
|
+
} else if (line.startsWith("rename to ")) {
|
|
291
|
+
renameTo = line.slice("rename to ".length);
|
|
292
|
+
status = "renamed";
|
|
293
|
+
} else if (line.startsWith("new file mode")) {
|
|
294
|
+
status = "added";
|
|
295
|
+
} else if (line.startsWith("deleted file mode")) {
|
|
296
|
+
status = "deleted";
|
|
297
|
+
} else if (line.startsWith("Binary files") || line === "GIT binary patch") {
|
|
298
|
+
binary = true;
|
|
299
|
+
if (line.includes("/dev/null") && line.includes(" and b/")) {
|
|
300
|
+
status = "added";
|
|
301
|
+
} else if (line.includes("a/") && line.includes("/dev/null")) {
|
|
302
|
+
status = "deleted";
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (line.startsWith("@@ ")) {
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
i++;
|
|
309
|
+
}
|
|
310
|
+
const filePath = status === "deleted" ? oldPath ?? newPath ?? "unknown" : newPath ?? oldPath ?? "unknown";
|
|
311
|
+
const fileOldPath = status === "renamed" ? renameFrom ?? oldPath : oldPath;
|
|
312
|
+
const hunks = [];
|
|
313
|
+
let additions = 0;
|
|
314
|
+
let deletions = 0;
|
|
315
|
+
while (i < lines.length && !lines[i].startsWith("diff --git ")) {
|
|
316
|
+
const line = lines[i];
|
|
317
|
+
if (line.startsWith("@@ ")) {
|
|
318
|
+
const hunkMatch = line.match(
|
|
319
|
+
/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
|
320
|
+
);
|
|
321
|
+
if (!hunkMatch) {
|
|
322
|
+
i++;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const oldStart = parseInt(hunkMatch[1], 10);
|
|
326
|
+
const oldLines = hunkMatch[2] !== void 0 ? parseInt(hunkMatch[2], 10) : 1;
|
|
327
|
+
const newStart = parseInt(hunkMatch[3], 10);
|
|
328
|
+
const newLines = hunkMatch[4] !== void 0 ? parseInt(hunkMatch[4], 10) : 1;
|
|
329
|
+
const changes = [];
|
|
330
|
+
let oldLineNum = oldStart;
|
|
331
|
+
let newLineNum = newStart;
|
|
332
|
+
i++;
|
|
333
|
+
while (i < lines.length) {
|
|
334
|
+
const changeLine = lines[i];
|
|
335
|
+
if (changeLine.startsWith("@@ ") || changeLine.startsWith("diff --git ")) {
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
if (changeLine.startsWith("\")) {
|
|
339
|
+
i++;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (changeLine.startsWith("+")) {
|
|
343
|
+
changes.push({
|
|
344
|
+
type: "add",
|
|
345
|
+
lineNumber: newLineNum,
|
|
346
|
+
content: changeLine.slice(1)
|
|
347
|
+
});
|
|
348
|
+
newLineNum++;
|
|
349
|
+
additions++;
|
|
350
|
+
} else if (changeLine.startsWith("-")) {
|
|
351
|
+
changes.push({
|
|
352
|
+
type: "delete",
|
|
353
|
+
lineNumber: oldLineNum,
|
|
354
|
+
content: changeLine.slice(1)
|
|
355
|
+
});
|
|
356
|
+
oldLineNum++;
|
|
357
|
+
deletions++;
|
|
358
|
+
} else {
|
|
359
|
+
changes.push({
|
|
360
|
+
type: "context",
|
|
361
|
+
lineNumber: newLineNum,
|
|
362
|
+
content: changeLine.length > 0 ? changeLine.slice(1) : ""
|
|
363
|
+
});
|
|
364
|
+
oldLineNum++;
|
|
365
|
+
newLineNum++;
|
|
366
|
+
}
|
|
367
|
+
i++;
|
|
368
|
+
}
|
|
369
|
+
hunks.push({ oldStart, oldLines, newStart, newLines, changes });
|
|
370
|
+
} else {
|
|
371
|
+
i++;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const diffFile = {
|
|
375
|
+
path: filePath,
|
|
376
|
+
status,
|
|
377
|
+
hunks,
|
|
378
|
+
language: detectLanguage(filePath),
|
|
379
|
+
binary,
|
|
380
|
+
additions,
|
|
381
|
+
deletions
|
|
382
|
+
};
|
|
383
|
+
if (status === "renamed" && fileOldPath) {
|
|
384
|
+
diffFile.oldPath = fileOldPath;
|
|
385
|
+
}
|
|
386
|
+
files.push(diffFile);
|
|
387
|
+
}
|
|
388
|
+
return { baseRef, headRef, files };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// packages/git/src/index.ts
|
|
392
|
+
function getDiff(ref, options) {
|
|
393
|
+
if (ref === "working-copy") {
|
|
394
|
+
return getWorkingCopyDiff(options);
|
|
395
|
+
}
|
|
396
|
+
const rawDiff = getGitDiff(ref, options);
|
|
397
|
+
let baseRef;
|
|
398
|
+
let headRef;
|
|
399
|
+
if (ref === "staged") {
|
|
400
|
+
baseRef = "HEAD";
|
|
401
|
+
headRef = "staged";
|
|
402
|
+
} else if (ref === "unstaged") {
|
|
403
|
+
baseRef = "staged";
|
|
404
|
+
headRef = "working tree";
|
|
405
|
+
} else if (ref.includes("..")) {
|
|
406
|
+
const [base, head] = ref.split("..");
|
|
407
|
+
baseRef = base;
|
|
408
|
+
headRef = head;
|
|
409
|
+
} else {
|
|
410
|
+
baseRef = ref;
|
|
411
|
+
headRef = "HEAD";
|
|
412
|
+
}
|
|
413
|
+
const diffSet = parseDiff(rawDiff, baseRef, headRef);
|
|
414
|
+
return { diffSet, rawDiff };
|
|
415
|
+
}
|
|
416
|
+
function getWorkingCopyDiff(options) {
|
|
417
|
+
const stagedRaw = getGitDiff("staged", options);
|
|
418
|
+
const unstagedRaw = getGitDiff("unstaged", options);
|
|
419
|
+
const stagedDiffSet = parseDiff(stagedRaw, "HEAD", "staged");
|
|
420
|
+
const unstagedDiffSet = parseDiff(unstagedRaw, "staged", "working tree");
|
|
421
|
+
const stagedFiles = stagedDiffSet.files.map((f) => ({
|
|
422
|
+
...f,
|
|
423
|
+
stage: "staged"
|
|
424
|
+
}));
|
|
425
|
+
const unstagedFiles = unstagedDiffSet.files.map((f) => ({
|
|
426
|
+
...f,
|
|
427
|
+
stage: "unstaged"
|
|
428
|
+
}));
|
|
429
|
+
const rawDiff = [stagedRaw, unstagedRaw].filter(Boolean).join("");
|
|
430
|
+
return {
|
|
431
|
+
diffSet: {
|
|
432
|
+
baseRef: "HEAD",
|
|
433
|
+
headRef: "working tree",
|
|
434
|
+
files: [...stagedFiles, ...unstagedFiles]
|
|
435
|
+
},
|
|
436
|
+
rawDiff
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export {
|
|
441
|
+
getGitDiff,
|
|
442
|
+
getCurrentBranch,
|
|
443
|
+
listBranches,
|
|
444
|
+
listCommits,
|
|
445
|
+
detectWorktree,
|
|
446
|
+
parseDiff,
|
|
447
|
+
getDiff
|
|
448
|
+
};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ensureServer,
|
|
3
|
+
submitReviewToServer
|
|
4
|
+
} from "./chunk-ITPHDFOS.js";
|
|
5
|
+
import {
|
|
6
|
+
parseDiff
|
|
7
|
+
} from "./chunk-QGWYCEJN.js";
|
|
8
|
+
import {
|
|
9
|
+
analyze
|
|
10
|
+
} from "./chunk-DHCVZGHE.js";
|
|
11
|
+
|
|
12
|
+
// cli/src/demo-data/sample-diff.ts
|
|
13
|
+
var sampleDiff = `diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts
|
|
14
|
+
new file mode 100644
|
|
15
|
+
index 0000000..a1b2c3d
|
|
16
|
+
--- /dev/null
|
|
17
|
+
+++ b/src/middleware/auth.ts
|
|
18
|
+
@@ -0,0 +1,52 @@
|
|
19
|
+
+import jwt from "jsonwebtoken";
|
|
20
|
+
+import type { Request, Response, NextFunction } from "express";
|
|
21
|
+
+
|
|
22
|
+
+const JWT_SECRET = process.env.JWT_SECRET ?? "dev-secret";
|
|
23
|
+
+const TOKEN_EXPIRY = "24h";
|
|
24
|
+
+
|
|
25
|
+
+export interface AuthPayload {
|
|
26
|
+
+ userId: string;
|
|
27
|
+
+ email: string;
|
|
28
|
+
+ role: "admin" | "user" | "viewer";
|
|
29
|
+
+}
|
|
30
|
+
+
|
|
31
|
+
+/**
|
|
32
|
+
+ * Verify JWT token from Authorization header.
|
|
33
|
+
+ * Attaches decoded payload to req.auth on success.
|
|
34
|
+
+ */
|
|
35
|
+
+export function authenticate(
|
|
36
|
+
+ req: Request,
|
|
37
|
+
+ res: Response,
|
|
38
|
+
+ next: NextFunction,
|
|
39
|
+
+): void {
|
|
40
|
+
+ const header = req.headers.authorization;
|
|
41
|
+
+
|
|
42
|
+
+ if (!header?.startsWith("Bearer ")) {
|
|
43
|
+
+ res.status(401).json({ error: "Missing or invalid Authorization header" });
|
|
44
|
+
+ return;
|
|
45
|
+
+ }
|
|
46
|
+
+
|
|
47
|
+
+ const token = header.slice(7);
|
|
48
|
+
+
|
|
49
|
+
+ try {
|
|
50
|
+
+ const decoded = jwt.verify(token, JWT_SECRET) as AuthPayload;
|
|
51
|
+
+ (req as Request & { auth: AuthPayload }).auth = decoded;
|
|
52
|
+
+ next();
|
|
53
|
+
+ } catch (err) {
|
|
54
|
+
+ if (err instanceof jwt.TokenExpiredError) {
|
|
55
|
+
+ res.status(401).json({ error: "Token expired" });
|
|
56
|
+
+ return;
|
|
57
|
+
+ }
|
|
58
|
+
+ // TODO: Add refresh token support
|
|
59
|
+
+ console.log("Auth error:", err);
|
|
60
|
+
+ res.status(401).json({ error: "Invalid token" });
|
|
61
|
+
+ }
|
|
62
|
+
+}
|
|
63
|
+
+
|
|
64
|
+
+/**
|
|
65
|
+
+ * Generate a signed JWT for the given user payload.
|
|
66
|
+
+ */
|
|
67
|
+
+export function generateToken(payload: AuthPayload): string {
|
|
68
|
+
+ return jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });
|
|
69
|
+
+}
|
|
70
|
+
diff --git a/src/routes/users.ts b/src/routes/users.ts
|
|
71
|
+
index d4e5f6a..b7c8d9e 100644
|
|
72
|
+
--- a/src/routes/users.ts
|
|
73
|
+
+++ b/src/routes/users.ts
|
|
74
|
+
@@ -1,6 +1,7 @@
|
|
75
|
+
import { Router } from "express";
|
|
76
|
+
import { db } from "../db/client.js";
|
|
77
|
+
import { validateBody } from "../util/validate.js";
|
|
78
|
+
+import { authenticate, type AuthPayload } from "../middleware/auth.js";
|
|
79
|
+
|
|
80
|
+
const router = Router();
|
|
81
|
+
|
|
82
|
+
@@ -12,6 +13,22 @@
|
|
83
|
+
res.json(users);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
+// Protected routes \u2014 require valid JWT
|
|
87
|
+
+router.use(authenticate);
|
|
88
|
+
+
|
|
89
|
+
+router.get("/me", (req, res) => {
|
|
90
|
+
+ const auth = (req as Request & { auth: AuthPayload }).auth;
|
|
91
|
+
+ const user = db.users.findById(auth.userId);
|
|
92
|
+
+
|
|
93
|
+
+ if (!user) {
|
|
94
|
+
+ res.status(404).json({ error: "User not found" });
|
|
95
|
+
+ return;
|
|
96
|
+
+ }
|
|
97
|
+
+
|
|
98
|
+
+ const { passwordHash, ...profile } = user;
|
|
99
|
+
+ res.json(profile);
|
|
100
|
+
+});
|
|
101
|
+
+
|
|
102
|
+
router.post("/", validateBody("createUser"), async (req, res) => {
|
|
103
|
+
const { email, name, role } = req.body;
|
|
104
|
+
|
|
105
|
+
@@ -22,7 +39,7 @@
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
- const user = await db.users.create({ email, name, role });
|
|
110
|
+
+ const user = await db.users.create({ email, name, role: role ?? "user" });
|
|
111
|
+
res.status(201).json(user);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
diff --git a/src/middleware/__tests__/auth.test.ts b/src/middleware/__tests__/auth.test.ts
|
|
115
|
+
new file mode 100644
|
|
116
|
+
index 0000000..e1f2a3b
|
|
117
|
+
--- /dev/null
|
|
118
|
+
+++ b/src/middleware/__tests__/auth.test.ts
|
|
119
|
+
@@ -0,0 +1,64 @@
|
|
120
|
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
121
|
+
+import { authenticate, generateToken } from "../auth.js";
|
|
122
|
+
+
|
|
123
|
+
+function createMockReq(authHeader?: string) {
|
|
124
|
+
+ return {
|
|
125
|
+
+ headers: { authorization: authHeader },
|
|
126
|
+
+ } as unknown as Request;
|
|
127
|
+
+}
|
|
128
|
+
+
|
|
129
|
+
+function createMockRes() {
|
|
130
|
+
+ const res = {
|
|
131
|
+
+ status: vi.fn().mockReturnThis(),
|
|
132
|
+
+ json: vi.fn().mockReturnThis(),
|
|
133
|
+
+ };
|
|
134
|
+
+ return res as unknown as Response;
|
|
135
|
+
+}
|
|
136
|
+
+
|
|
137
|
+
+describe("authenticate middleware", () => {
|
|
138
|
+
+ const validPayload = { userId: "u1", email: "test@example.com", role: "user" as const };
|
|
139
|
+
+
|
|
140
|
+
+ it("rejects requests without Authorization header", () => {
|
|
141
|
+
+ const req = createMockReq();
|
|
142
|
+
+ const res = createMockRes();
|
|
143
|
+
+ const next = vi.fn();
|
|
144
|
+
+
|
|
145
|
+
+ authenticate(req as any, res as any, next);
|
|
146
|
+
+
|
|
147
|
+
+ expect(res.status).toHaveBeenCalledWith(401);
|
|
148
|
+
+ expect(next).not.toHaveBeenCalled();
|
|
149
|
+
+ });
|
|
150
|
+
+
|
|
151
|
+
+ it("rejects requests with invalid token", () => {
|
|
152
|
+
+ const req = createMockReq("Bearer invalid-token");
|
|
153
|
+
+ const res = createMockRes();
|
|
154
|
+
+ const next = vi.fn();
|
|
155
|
+
+
|
|
156
|
+
+ authenticate(req as any, res as any, next);
|
|
157
|
+
+
|
|
158
|
+
+ expect(res.status).toHaveBeenCalledWith(401);
|
|
159
|
+
+ expect(next).not.toHaveBeenCalled();
|
|
160
|
+
+ });
|
|
161
|
+
+
|
|
162
|
+
+ it("passes valid tokens and attaches auth payload", () => {
|
|
163
|
+
+ const token = generateToken(validPayload);
|
|
164
|
+
+ const req = createMockReq(\`Bearer \${token}\`);
|
|
165
|
+
+ const res = createMockRes();
|
|
166
|
+
+ const next = vi.fn();
|
|
167
|
+
+
|
|
168
|
+
+ authenticate(req as any, res as any, next);
|
|
169
|
+
+
|
|
170
|
+
+ expect(next).toHaveBeenCalled();
|
|
171
|
+
+ expect((req as any).auth).toMatchObject({
|
|
172
|
+
+ userId: "u1",
|
|
173
|
+
+ email: "test@example.com",
|
|
174
|
+
+ });
|
|
175
|
+
+ });
|
|
176
|
+
+});
|
|
177
|
+
+
|
|
178
|
+
+describe("generateToken", () => {
|
|
179
|
+
+ it("returns a string token", () => {
|
|
180
|
+
+ const token = generateToken({ userId: "u1", email: "a@b.com", role: "admin" });
|
|
181
|
+
+ expect(typeof token).toBe("string");
|
|
182
|
+
+ expect(token.split(".")).toHaveLength(3); // JWT has 3 parts
|
|
183
|
+
+ });
|
|
184
|
+
+});
|
|
185
|
+
`;
|
|
186
|
+
|
|
187
|
+
// cli/src/commands/demo.ts
|
|
188
|
+
async function demo(flags) {
|
|
189
|
+
try {
|
|
190
|
+
console.log("Starting DiffPrism demo...\n");
|
|
191
|
+
const diffSet = parseDiff(sampleDiff, "main", "feature/add-auth");
|
|
192
|
+
const briefing = analyze(diffSet);
|
|
193
|
+
console.log(
|
|
194
|
+
`${diffSet.files.length} files, +${diffSet.files.reduce((s, f) => s + f.additions, 0)} -${diffSet.files.reduce((s, f) => s + f.deletions, 0)}`
|
|
195
|
+
);
|
|
196
|
+
const payload = {
|
|
197
|
+
reviewId: "",
|
|
198
|
+
diffSet,
|
|
199
|
+
rawDiff: sampleDiff,
|
|
200
|
+
briefing,
|
|
201
|
+
metadata: {
|
|
202
|
+
title: "Add user authentication middleware",
|
|
203
|
+
reasoning: "Added JWT-based auth middleware to protect API routes. Included token validation, error handling for expired tokens, and unit tests for the middleware.",
|
|
204
|
+
currentBranch: "feature/add-auth"
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const serverInfo = await ensureServer({ dev: flags.dev });
|
|
208
|
+
const { result } = await submitReviewToServer(serverInfo, "demo", {
|
|
209
|
+
injectedPayload: payload,
|
|
210
|
+
projectPath: "demo",
|
|
211
|
+
diffRef: "demo"
|
|
212
|
+
});
|
|
213
|
+
console.log(`
|
|
214
|
+
Review submitted: ${result.decision}`);
|
|
215
|
+
if (result.comments.length > 0) {
|
|
216
|
+
console.log(`${result.comments.length} comment(s)`);
|
|
217
|
+
}
|
|
218
|
+
console.log("\nNext steps:");
|
|
219
|
+
console.log(" Run `npx diffprism setup` to configure for Claude Code");
|
|
220
|
+
console.log(" Run `npx diffprism review` in a git repo to review real changes\n");
|
|
221
|
+
process.exit(0);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
224
|
+
console.error(`Error: ${message}`);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export {
|
|
230
|
+
demo
|
|
231
|
+
};
|