codetrap 0.1.5 → 0.1.6

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,106 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, join } from "node:path";
4
+ import { CODETRAP_DIR } from "../lib/constants";
5
+ import { findProjectRoot, resolveScopePath } from "../lib/scope";
6
+
7
+ export const WEB_PROJECTS_FILE = "web-projects.json";
8
+ export const WEB_PROJECTS_VERSION = 1;
9
+
10
+ export interface WebProject {
11
+ root: string;
12
+ name: string;
13
+ last_opened_at: string;
14
+ }
15
+
16
+ export interface WebProjectRegistry {
17
+ version: typeof WEB_PROJECTS_VERSION;
18
+ projects: WebProject[];
19
+ }
20
+
21
+ export function webProjectsPath(home = homedir()): string {
22
+ return join(home, CODETRAP_DIR, WEB_PROJECTS_FILE);
23
+ }
24
+
25
+ export function loadWebProjectRegistry(home = homedir()): WebProjectRegistry {
26
+ const path = webProjectsPath(home);
27
+ if (!existsSync(path)) return emptyRegistry();
28
+ try {
29
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
30
+ return normalizeRegistry(parsed);
31
+ } catch (error) {
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ throw new Error(`Invalid codetrap web project registry at ${path}: ${message}`);
34
+ }
35
+ }
36
+
37
+ export function saveWebProjectRegistry(registry: WebProjectRegistry, home = homedir()): void {
38
+ const dir = join(home, CODETRAP_DIR);
39
+ mkdirSync(dir, { recursive: true });
40
+ writeFileSync(webProjectsPath(home), `${JSON.stringify(normalizeRegistry(registry), null, 2)}\n`);
41
+ }
42
+
43
+ export function resolveWebProjectRoot(path: string, home = homedir()): string {
44
+ const resolved = resolveScopePath(path);
45
+ const root = findProjectRoot(resolved, home);
46
+ if (!root) {
47
+ throw new Error(`No initialized codetrap project found at or above ${resolved}. Run 'codetrap init' first.`);
48
+ }
49
+ return root;
50
+ }
51
+
52
+ export function addWebProject(path: string, home = homedir(), now = new Date()): WebProject {
53
+ const root = resolveWebProjectRoot(path, home);
54
+ const registry = loadWebProjectRegistry(home);
55
+ const project: WebProject = {
56
+ root,
57
+ name: basename(root) || root,
58
+ last_opened_at: now.toISOString(),
59
+ };
60
+ const projects = [
61
+ project,
62
+ ...registry.projects.filter((item) => item.root !== root),
63
+ ].sort((a, b) => b.last_opened_at.localeCompare(a.last_opened_at));
64
+ saveWebProjectRegistry({ version: WEB_PROJECTS_VERSION, projects }, home);
65
+ return project;
66
+ }
67
+
68
+ function emptyRegistry(): WebProjectRegistry {
69
+ return { version: WEB_PROJECTS_VERSION, projects: [] };
70
+ }
71
+
72
+ function normalizeRegistry(value: unknown): WebProjectRegistry {
73
+ if (!isRecord(value) || !Array.isArray(value.projects)) return emptyRegistry();
74
+ const projects = value.projects
75
+ .map(normalizeProject)
76
+ .filter((project): project is WebProject => project !== null);
77
+ return {
78
+ version: WEB_PROJECTS_VERSION,
79
+ projects: uniqueProjects(projects).sort((a, b) => b.last_opened_at.localeCompare(a.last_opened_at)),
80
+ };
81
+ }
82
+
83
+ function normalizeProject(value: unknown): WebProject | null {
84
+ if (!isRecord(value) || typeof value.root !== "string") return null;
85
+ const root = resolveScopePath(value.root);
86
+ return {
87
+ root,
88
+ name: typeof value.name === "string" && value.name.trim() ? value.name.trim() : basename(root) || root,
89
+ last_opened_at: typeof value.last_opened_at === "string" ? value.last_opened_at : new Date(0).toISOString(),
90
+ };
91
+ }
92
+
93
+ function uniqueProjects(projects: WebProject[]): WebProject[] {
94
+ const seen = new Set<string>();
95
+ const out: WebProject[] = [];
96
+ for (const project of projects) {
97
+ if (seen.has(project.root)) continue;
98
+ seen.add(project.root);
99
+ out.push(project);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ function isRecord(value: unknown): value is Record<string, unknown> {
105
+ return typeof value === "object" && value !== null && !Array.isArray(value);
106
+ }
@@ -0,0 +1,441 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { CATEGORIES, SCOPES, SEVERITIES } from "../lib/constants";
3
+ import type { CandidateTrap } from "../domain/session";
4
+ import { TrapStore } from "../lib/store";
5
+ import { TrapOperations } from "../lib/trap-operations";
6
+ import { SessionOperations, type SessionAcceptResult } from "../lib/session-operations";
7
+ import { SessionStore } from "../lib/session-store";
8
+ import { WEB_INDEX_HTML } from "./static";
9
+ import {
10
+ addWebProject,
11
+ loadWebProjectRegistry,
12
+ resolveWebProjectRoot,
13
+ type WebProject,
14
+ } from "./project-registry";
15
+
16
+ export interface WebServerOptions {
17
+ cwd?: string;
18
+ project?: string;
19
+ host?: string;
20
+ port?: number;
21
+ token?: string;
22
+ home?: string;
23
+ }
24
+
25
+ type WebContext = {
26
+ token: string;
27
+ cwd: string;
28
+ home?: string;
29
+ currentProjectRoot: string | null;
30
+ };
31
+
32
+ type WebCandidateReview =
33
+ | { status: "pending"; label: string }
34
+ | {
35
+ status: "accepted";
36
+ label: string;
37
+ trap_id: number;
38
+ scope: string;
39
+ trap_present: true;
40
+ trap_status: string;
41
+ trap_title: string;
42
+ }
43
+ | {
44
+ status: "accepted_missing";
45
+ label: string;
46
+ trap_id?: number;
47
+ scope?: string;
48
+ trap_present: false;
49
+ }
50
+ | {
51
+ status: "rejected";
52
+ label: string;
53
+ rejected_at?: string;
54
+ rejection_reason?: string;
55
+ };
56
+
57
+ type WebCandidate = CandidateTrap & { review: WebCandidateReview };
58
+
59
+ export async function startWebServerFromArgs(args: string[], cwd = process.cwd()): Promise<void> {
60
+ const options = webServerOptionsFromArgs(args, cwd);
61
+ const token = options.token ?? randomBytes(18).toString("base64url");
62
+ const currentProjectRoot = registerInitialProject(options);
63
+ const handler = createWebHandler({
64
+ token,
65
+ cwd: options.cwd ?? cwd,
66
+ home: options.home,
67
+ currentProjectRoot,
68
+ });
69
+ const host = options.host ?? "127.0.0.1";
70
+ const server = serveOnAvailablePort({
71
+ host,
72
+ port: options.port ?? 4737,
73
+ fetch: handler,
74
+ });
75
+ const url = `http://${host}:${server.port}/?token=${encodeURIComponent(token)}`;
76
+ console.log(`codetrap web listening on ${url}`);
77
+ setInterval(() => undefined, 60_000);
78
+ await new Promise(() => {});
79
+ }
80
+
81
+ export function createWebHandler(context: WebContext): (request: Request) => Promise<Response> {
82
+ return async (request: Request): Promise<Response> => {
83
+ const url = new URL(request.url);
84
+ try {
85
+ if (url.pathname.startsWith("/api/")) {
86
+ authorize(request, context.token);
87
+ return await routeApi(request, url, context);
88
+ }
89
+ if (url.pathname === "/" || url.pathname === "/index.html") {
90
+ return htmlResponse(WEB_INDEX_HTML);
91
+ }
92
+ return jsonResponse({ error: "Not found" }, 404);
93
+ } catch (error) {
94
+ const status = error instanceof WebHttpError || error instanceof WebPayloadError ? error.status : 500;
95
+ const payload = error instanceof WebPayloadError
96
+ ? error.payload
97
+ : { error: error instanceof Error ? error.message : String(error) };
98
+ return jsonResponse(payload, status);
99
+ }
100
+ };
101
+ }
102
+
103
+ export function webServerOptionsFromArgs(args: string[], cwd = process.cwd()): WebServerOptions {
104
+ const options: WebServerOptions = { cwd };
105
+ for (let index = 0; index < args.length; index++) {
106
+ const arg = args[index];
107
+ if (!arg.startsWith("--")) continue;
108
+ const key = arg.slice(2);
109
+ const value = args[index + 1] && !args[index + 1].startsWith("--") ? args[++index] : "true";
110
+ if (key === "project") options.project = value;
111
+ if (key === "host") options.host = value;
112
+ if (key === "port") options.port = parsePort(value);
113
+ }
114
+ return options;
115
+ }
116
+
117
+ async function routeApi(request: Request, url: URL, context: WebContext): Promise<Response> {
118
+ if (request.method === "GET" && url.pathname === "/api/bootstrap") {
119
+ const registry = loadWebProjectRegistry(context.home);
120
+ return jsonResponse({
121
+ projects: registry.projects,
122
+ current_project_root: context.currentProjectRoot,
123
+ options: {
124
+ categories: [...CATEGORIES],
125
+ severities: [...SEVERITIES],
126
+ scopes: [...SCOPES],
127
+ },
128
+ });
129
+ }
130
+
131
+ if (request.method === "GET" && url.pathname === "/api/projects") {
132
+ return jsonResponse(loadWebProjectRegistry(context.home));
133
+ }
134
+
135
+ if (request.method === "POST" && url.pathname === "/api/projects") {
136
+ const body = await readJsonBody(request);
137
+ const path = stringBodyField(body, "path");
138
+ const project = addWebProject(path, context.home);
139
+ return jsonResponse({ project, projects: loadWebProjectRegistry(context.home).projects });
140
+ }
141
+
142
+ if (request.method === "GET" && url.pathname === "/api/sessions") {
143
+ const projectRoot = projectRootFromQuery(url, context);
144
+ const sessions = sessionOperations(projectRoot).sessions.listSessions({ status: "all", limit: 100 });
145
+ return jsonResponse({ project_root: projectRoot, sessions });
146
+ }
147
+
148
+ if (request.method === "GET" && url.pathname === "/api/candidates") {
149
+ const projectRoot = projectRootFromQuery(url, context);
150
+ const sessionId = requiredQuery(url, "session");
151
+ const ops = sessionOperations(projectRoot);
152
+ const session = ops.sessions.showSession(sessionId).session;
153
+ const document = ops.sessions.candidateDocument(sessionId);
154
+ return jsonResponse({
155
+ project_root: projectRoot,
156
+ session,
157
+ candidates: webCandidates(document.candidates, ops.traps),
158
+ });
159
+ }
160
+
161
+ if (request.method === "GET" && url.pathname === "/api/trap") {
162
+ const projectRoot = projectRootFromQuery(url, context);
163
+ const id = numberQuery(url, "id");
164
+ const scope = url.searchParams.get("scope") ?? undefined;
165
+ const details = trapOperations(projectRoot).getTrapDetails(id, scope);
166
+ if (!details) throw new WebHttpError(404, `Trap #${id} not found.`);
167
+ return jsonResponse(details);
168
+ }
169
+
170
+ if (request.method === "POST" && url.pathname === "/api/candidate/save") {
171
+ const body = await readJsonBody(request);
172
+ const projectRoot = projectRootFromBody(body, context);
173
+ const result = sessionOperations(projectRoot).sessions.saveCandidate({
174
+ candidateId: stringBodyField(body, "candidateId"),
175
+ sessionId: optionalStringBodyField(body, "sessionId"),
176
+ edit: recordBodyField(body, "trap"),
177
+ });
178
+ return jsonResponse({ success: true, session: result.session, candidate: result.candidate });
179
+ }
180
+
181
+ if (request.method === "POST" && url.pathname === "/api/candidate/accept") {
182
+ const body = await readJsonBody(request);
183
+ const projectRoot = projectRootFromBody(body, context);
184
+ const result = await sessionOperations(projectRoot).sessions.acceptCandidate({
185
+ candidateId: stringBodyField(body, "candidateId"),
186
+ sessionId: optionalStringBodyField(body, "sessionId"),
187
+ acceptAnyway: booleanBodyField(body, "acceptAnyway"),
188
+ supersedesId: optionalNumberBodyField(body, "supersedesId"),
189
+ });
190
+ if (!result.success) {
191
+ throw new WebPayloadError(409, conflictPayload(result));
192
+ }
193
+ return jsonResponse({
194
+ success: true,
195
+ session: result.session,
196
+ candidate: result.candidate,
197
+ trap_id: result.trap_id,
198
+ scope: result.scope,
199
+ evidence_id: result.evidence_id,
200
+ superseded_id: result.superseded_id,
201
+ });
202
+ }
203
+
204
+ if (request.method === "POST" && url.pathname === "/api/candidate/reject") {
205
+ const body = await readJsonBody(request);
206
+ const projectRoot = projectRootFromBody(body, context);
207
+ const result = sessionOperations(projectRoot).sessions.rejectCandidate({
208
+ candidateId: stringBodyField(body, "candidateId"),
209
+ sessionId: optionalStringBodyField(body, "sessionId"),
210
+ reason: optionalStringBodyField(body, "reason"),
211
+ });
212
+ return jsonResponse({ success: true, session: result.session, candidate: result.candidate });
213
+ }
214
+
215
+ throw new WebHttpError(404, "Not found");
216
+ }
217
+
218
+ function sessionOperations(projectRoot: string): { traps: TrapOperations; sessions: SessionOperations } {
219
+ const traps = trapOperations(projectRoot);
220
+ return {
221
+ traps,
222
+ sessions: new SessionOperations(new SessionStore(projectRoot), traps),
223
+ };
224
+ }
225
+
226
+ function trapOperations(projectRoot: string): TrapOperations {
227
+ return new TrapOperations(new TrapStore(projectRoot));
228
+ }
229
+
230
+ function webCandidates(candidates: CandidateTrap[], traps: TrapOperations): WebCandidate[] {
231
+ return candidates.map((candidate) => ({
232
+ ...candidate,
233
+ review: candidateReview(candidate, traps),
234
+ }));
235
+ }
236
+
237
+ function candidateReview(candidate: CandidateTrap, traps: TrapOperations): WebCandidateReview {
238
+ if (candidate.status === "proposed") {
239
+ return { status: "pending", label: "pending review" };
240
+ }
241
+
242
+ if (candidate.status === "rejected") {
243
+ return {
244
+ status: "rejected",
245
+ label: "rejected",
246
+ rejected_at: candidate.rejected_at,
247
+ rejection_reason: candidate.rejection_reason,
248
+ };
249
+ }
250
+
251
+ const trapId = candidate.accepted_trap_id;
252
+ const scope = candidate.accepted_scope ?? acceptedScopeFallback(candidate);
253
+ if (trapId === undefined) {
254
+ return {
255
+ status: "accepted_missing",
256
+ label: "accepted -> trap link missing",
257
+ scope,
258
+ trap_present: false,
259
+ };
260
+ }
261
+
262
+ const details = traps.getTrapDetails(trapId, scope);
263
+ if (!details) {
264
+ return {
265
+ status: "accepted_missing",
266
+ label: `accepted -> trap #${trapId} deleted`,
267
+ trap_id: trapId,
268
+ scope,
269
+ trap_present: false,
270
+ };
271
+ }
272
+
273
+ return {
274
+ status: "accepted",
275
+ label: `accepted -> trap #${trapId}`,
276
+ trap_id: trapId,
277
+ scope: details.scope,
278
+ trap_present: true,
279
+ trap_status: details.trap.status,
280
+ trap_title: details.trap.title,
281
+ };
282
+ }
283
+
284
+ function acceptedScopeFallback(candidate: CandidateTrap): string {
285
+ return candidate.trap.scope === "global" ? "global" : "project";
286
+ }
287
+
288
+ function registerInitialProject(options: WebServerOptions): string | null {
289
+ const path = options.project ?? options.cwd;
290
+ if (!path) return null;
291
+ try {
292
+ return addWebProject(path, options.home).root;
293
+ } catch (error) {
294
+ if (options.project) throw error;
295
+ return null;
296
+ }
297
+ }
298
+
299
+ function serveOnAvailablePort(args: {
300
+ host: string;
301
+ port: number;
302
+ fetch: (request: Request) => Promise<Response>;
303
+ }): { port: number; server: ReturnType<typeof Bun.serve> } {
304
+ for (let port = args.port; port < args.port + 50; port++) {
305
+ try {
306
+ const server = Bun.serve({
307
+ hostname: args.host,
308
+ port,
309
+ fetch: args.fetch,
310
+ });
311
+ return { port: server.port ?? port, server };
312
+ } catch (error) {
313
+ if (String(error).includes("EADDRINUSE")) continue;
314
+ throw error;
315
+ }
316
+ }
317
+ throw new Error(`No available port found starting at ${args.port}.`);
318
+ }
319
+
320
+ function authorize(request: Request, token: string): void {
321
+ if (request.headers.get("X-Codetrap-Token") !== token) {
322
+ throw new WebHttpError(401, "Unauthorized");
323
+ }
324
+ }
325
+
326
+ async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
327
+ const value = await request.json().catch(() => null);
328
+ if (!isRecord(value)) throw new WebHttpError(400, "JSON object body is required.");
329
+ return value;
330
+ }
331
+
332
+ function projectRootFromQuery(url: URL, context: WebContext): string {
333
+ const project = url.searchParams.get("project") ?? context.currentProjectRoot;
334
+ if (!project) throw new WebHttpError(400, "project is required.");
335
+ return resolveWebProjectRoot(project, context.home);
336
+ }
337
+
338
+ function projectRootFromBody(body: Record<string, unknown>, context: WebContext): string {
339
+ const project = optionalStringBodyField(body, "projectRoot") ?? context.currentProjectRoot;
340
+ if (!project) throw new WebHttpError(400, "projectRoot is required.");
341
+ return resolveWebProjectRoot(project, context.home);
342
+ }
343
+
344
+ function requiredQuery(url: URL, key: string): string {
345
+ const value = url.searchParams.get(key);
346
+ if (!value) throw new WebHttpError(400, `${key} is required.`);
347
+ return value;
348
+ }
349
+
350
+ function numberQuery(url: URL, key: string): number {
351
+ const value = Number.parseInt(requiredQuery(url, key), 10);
352
+ if (Number.isNaN(value)) throw new WebHttpError(400, `${key} must be a number.`);
353
+ return value;
354
+ }
355
+
356
+ function stringBodyField(body: Record<string, unknown>, key: string): string {
357
+ const value = optionalStringBodyField(body, key);
358
+ if (!value) throw new WebHttpError(400, `${key} is required.`);
359
+ return value;
360
+ }
361
+
362
+ function optionalStringBodyField(body: Record<string, unknown>, key: string): string | undefined {
363
+ const value = body[key];
364
+ if (value === undefined || value === null) return undefined;
365
+ if (typeof value !== "string") throw new WebHttpError(400, `${key} must be a string.`);
366
+ const trimmed = value.trim();
367
+ return trimmed || undefined;
368
+ }
369
+
370
+ function numberBodyField(body: Record<string, unknown>, key: string): number {
371
+ const value = optionalNumberBodyField(body, key);
372
+ if (value === undefined) throw new WebHttpError(400, `${key} is required.`);
373
+ return value;
374
+ }
375
+
376
+ function optionalNumberBodyField(body: Record<string, unknown>, key: string): number | undefined {
377
+ const value = body[key];
378
+ if (value === undefined || value === null || value === "") return undefined;
379
+ if (typeof value !== "number" || !Number.isInteger(value)) {
380
+ throw new WebHttpError(400, `${key} must be an integer.`);
381
+ }
382
+ return value;
383
+ }
384
+
385
+ function booleanBodyField(body: Record<string, unknown>, key: string): boolean {
386
+ const value = body[key];
387
+ return typeof value === "boolean" ? value : false;
388
+ }
389
+
390
+ function recordBodyField(body: Record<string, unknown>, key: string): Record<string, unknown> {
391
+ const value = body[key];
392
+ if (!isRecord(value)) throw new WebHttpError(400, `${key} must be an object.`);
393
+ return value;
394
+ }
395
+
396
+ function conflictPayload(result: Exclude<SessionAcceptResult, { success: true }>): Record<string, unknown> {
397
+ return {
398
+ success: false,
399
+ error: "Possible active trap conflict found.",
400
+ session_id: result.session_id,
401
+ candidate_id: result.candidate_id,
402
+ possible_conflicts: result.possible_conflicts,
403
+ };
404
+ }
405
+
406
+ function parsePort(value: string): number {
407
+ const port = Number.parseInt(value, 10);
408
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
409
+ throw new Error(`Invalid --port: ${value}`);
410
+ }
411
+ return port;
412
+ }
413
+
414
+ function jsonResponse(value: unknown, status = 200): Response {
415
+ return new Response(JSON.stringify(value, null, 2), {
416
+ status,
417
+ headers: { "content-type": "application/json; charset=utf-8" },
418
+ });
419
+ }
420
+
421
+ function htmlResponse(value: string): Response {
422
+ return new Response(value, {
423
+ headers: { "content-type": "text/html; charset=utf-8" },
424
+ });
425
+ }
426
+
427
+ class WebHttpError extends Error {
428
+ constructor(public readonly status: number, message: string) {
429
+ super(message);
430
+ }
431
+ }
432
+
433
+ class WebPayloadError extends Error {
434
+ constructor(public readonly status: number, public readonly payload: Record<string, unknown>) {
435
+ super(String(payload.error ?? "Request failed"));
436
+ }
437
+ }
438
+
439
+ function isRecord(value: unknown): value is Record<string, unknown> {
440
+ return typeof value === "object" && value !== null && !Array.isArray(value);
441
+ }