@hienlh/ppm 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/.claude/agent-memory/tester/MEMORY.md +3 -0
- package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/.env.example +1 -0
- package/.github/workflows/release.yml +46 -0
- package/README.md +349 -0
- package/bun.lock +1217 -0
- package/components.json +21 -0
- package/docs/code-standards.md +574 -0
- package/docs/codebase-summary.md +294 -0
- package/docs/deployment-guide.md +631 -0
- package/docs/design-guidelines.md +661 -0
- package/docs/project-overview-pdr.md +142 -0
- package/docs/project-roadmap.md +400 -0
- package/docs/system-architecture.md +459 -0
- package/package.json +68 -0
- package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
- package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
- package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
- package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
- package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
- package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
- package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
- package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
- package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
- package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
- package/plans/260314-2009-ppm-implementation/plan.md +202 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
- package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
- package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
- package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
- package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
- package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
- package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
- package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
- package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
- package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
- package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
- package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
- package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
- package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
- package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
- package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
- package/ppm.example.yaml +14 -0
- package/repomix-output.xml +23745 -0
- package/scripts/build.ts +13 -0
- package/src/cli/commands/chat-cmd.ts +259 -0
- package/src/cli/commands/config-cmd.ts +121 -0
- package/src/cli/commands/git-cmd.ts +315 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/open.ts +19 -0
- package/src/cli/commands/projects.ts +100 -0
- package/src/cli/commands/start.ts +3 -0
- package/src/cli/commands/stop.ts +33 -0
- package/src/cli/utils/project-resolver.ts +27 -0
- package/src/index.ts +59 -0
- package/src/providers/claude-agent-sdk.ts +499 -0
- package/src/providers/claude-binary-finder.ts +256 -0
- package/src/providers/claude-code-cli.ts +413 -0
- package/src/providers/claude-process-registry.ts +106 -0
- package/src/providers/mock-provider.ts +171 -0
- package/src/providers/provider.interface.ts +10 -0
- package/src/providers/registry.ts +45 -0
- package/src/server/helpers/resolve-project.ts +22 -0
- package/src/server/index.ts +181 -0
- package/src/server/middleware/auth.ts +30 -0
- package/src/server/routes/chat.ts +153 -0
- package/src/server/routes/files.ts +168 -0
- package/src/server/routes/git.ts +261 -0
- package/src/server/routes/project-scoped.ts +27 -0
- package/src/server/routes/projects.ts +57 -0
- package/src/server/routes/static.ts +26 -0
- package/src/server/ws/chat.ts +130 -0
- package/src/server/ws/terminal.ts +89 -0
- package/src/services/chat.service.ts +110 -0
- package/src/services/claude-usage.service.ts +113 -0
- package/src/services/config.service.ts +90 -0
- package/src/services/file.service.ts +261 -0
- package/src/services/git-dirs.service.ts +112 -0
- package/src/services/git.service.ts +372 -0
- package/src/services/project.service.ts +107 -0
- package/src/services/slash-items.service.ts +184 -0
- package/src/services/terminal.service.ts +212 -0
- package/src/types/api.ts +37 -0
- package/src/types/chat.ts +92 -0
- package/src/types/config.ts +41 -0
- package/src/types/git.ts +50 -0
- package/src/types/project.ts +18 -0
- package/src/types/terminal.ts +20 -0
- package/src/web/app.tsx +168 -0
- package/src/web/components/auth/login-screen.tsx +88 -0
- package/src/web/components/chat/attachment-chips.tsx +55 -0
- package/src/web/components/chat/chat-placeholder.tsx +10 -0
- package/src/web/components/chat/chat-tab.tsx +301 -0
- package/src/web/components/chat/file-picker.tsx +126 -0
- package/src/web/components/chat/message-input.tsx +420 -0
- package/src/web/components/chat/message-list.tsx +838 -0
- package/src/web/components/chat/session-picker.tsx +139 -0
- package/src/web/components/chat/slash-command-picker.tsx +135 -0
- package/src/web/components/chat/usage-badge.tsx +186 -0
- package/src/web/components/editor/code-editor.tsx +329 -0
- package/src/web/components/editor/diff-viewer.tsx +276 -0
- package/src/web/components/editor/editor-placeholder.tsx +10 -0
- package/src/web/components/explorer/file-actions.tsx +191 -0
- package/src/web/components/explorer/file-tree.tsx +298 -0
- package/src/web/components/git/git-graph.tsx +727 -0
- package/src/web/components/git/git-placeholder.tsx +55 -0
- package/src/web/components/git/git-status-panel.tsx +850 -0
- package/src/web/components/layout/mobile-drawer.tsx +137 -0
- package/src/web/components/layout/mobile-nav.tsx +103 -0
- package/src/web/components/layout/sidebar.tsx +90 -0
- package/src/web/components/layout/tab-bar.tsx +152 -0
- package/src/web/components/layout/tab-content.tsx +85 -0
- package/src/web/components/projects/dir-suggest.tsx +152 -0
- package/src/web/components/projects/project-list.tsx +187 -0
- package/src/web/components/settings/settings-tab.tsx +57 -0
- package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
- package/src/web/components/terminal/terminal-tab.tsx +133 -0
- package/src/web/components/ui/button.tsx +64 -0
- package/src/web/components/ui/context-menu.tsx +250 -0
- package/src/web/components/ui/dialog.tsx +156 -0
- package/src/web/components/ui/dropdown-menu.tsx +257 -0
- package/src/web/components/ui/input.tsx +21 -0
- package/src/web/components/ui/scroll-area.tsx +56 -0
- package/src/web/components/ui/separator.tsx +26 -0
- package/src/web/components/ui/sonner.tsx +40 -0
- package/src/web/components/ui/tabs.tsx +91 -0
- package/src/web/components/ui/tooltip.tsx +57 -0
- package/src/web/hooks/use-chat.ts +420 -0
- package/src/web/hooks/use-terminal.ts +182 -0
- package/src/web/hooks/use-url-sync.ts +66 -0
- package/src/web/hooks/use-websocket.ts +48 -0
- package/src/web/index.html +16 -0
- package/src/web/lib/api-client.ts +90 -0
- package/src/web/lib/file-support.ts +68 -0
- package/src/web/lib/utils.ts +6 -0
- package/src/web/lib/ws-client.ts +100 -0
- package/src/web/main.tsx +10 -0
- package/src/web/public/icon-192.svg +5 -0
- package/src/web/public/icon-512.svg +5 -0
- package/src/web/stores/file-store.ts +81 -0
- package/src/web/stores/project-store.ts +50 -0
- package/src/web/stores/settings-store.ts +65 -0
- package/src/web/stores/tab-store.ts +187 -0
- package/src/web/styles/globals.css +227 -0
- package/src/web/vite-env.d.ts +1 -0
- package/tests/integration/api/chat-routes.test.ts +95 -0
- package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
- package/tests/integration/ws/chat-websocket.test.ts +312 -0
- package/tests/test-setup.ts +5 -0
- package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
- package/tests/unit/providers/mock-provider.test.ts +143 -0
- package/tests/unit/services/chat-service.test.ts +100 -0
- package/tsconfig.json +32 -0
- package/vite.config.ts +62 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { resolve, join, basename } from "node:path";
|
|
3
|
+
import { mkdirSync, existsSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { chatService } from "../../services/chat.service.ts";
|
|
6
|
+
import { providerRegistry } from "../../providers/registry.ts";
|
|
7
|
+
import { listSlashItems } from "../../services/slash-items.service.ts";
|
|
8
|
+
import { fetchClaudeUsage } from "../../services/claude-usage.service.ts";
|
|
9
|
+
import { ok, err } from "../../types/api.ts";
|
|
10
|
+
|
|
11
|
+
type Env = { Variables: { projectPath: string; projectName: string } };
|
|
12
|
+
|
|
13
|
+
export const chatRoutes = new Hono<Env>();
|
|
14
|
+
|
|
15
|
+
/** GET /chat/slash-items — list available slash commands and skills for the project */
|
|
16
|
+
chatRoutes.get("/slash-items", (c) => {
|
|
17
|
+
try {
|
|
18
|
+
const projectPath = c.get("projectPath");
|
|
19
|
+
const items = listSlashItems(projectPath);
|
|
20
|
+
return c.json(ok(items));
|
|
21
|
+
} catch (e) {
|
|
22
|
+
return c.json(err((e as Error).message), 500);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** GET /chat/usage — get current usage/rate-limit info via ccburn */
|
|
27
|
+
chatRoutes.get("/usage", async (c) => {
|
|
28
|
+
try {
|
|
29
|
+
const usage = await fetchClaudeUsage();
|
|
30
|
+
return c.json(ok({
|
|
31
|
+
fiveHour: usage.session?.utilization,
|
|
32
|
+
sevenDay: usage.weekly?.utilization,
|
|
33
|
+
fiveHourResetsAt: usage.session?.resetsAt,
|
|
34
|
+
sevenDayResetsAt: usage.weekly?.resetsAt,
|
|
35
|
+
// Extra detail for popup
|
|
36
|
+
session: usage.session,
|
|
37
|
+
weekly: usage.weekly,
|
|
38
|
+
weeklyOpus: usage.weeklyOpus,
|
|
39
|
+
weeklySonnet: usage.weeklySonnet,
|
|
40
|
+
}));
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return c.json(err((e as Error).message), 500);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** GET /chat/providers — list available AI providers */
|
|
47
|
+
chatRoutes.get("/providers", (c) => {
|
|
48
|
+
try {
|
|
49
|
+
return c.json(ok(providerRegistry.list()));
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return c.json(err((e as Error).message), 500);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** GET /chat/sessions — list chat sessions filtered by project from context */
|
|
56
|
+
chatRoutes.get("/sessions", async (c) => {
|
|
57
|
+
try {
|
|
58
|
+
const projectPath = c.get("projectPath");
|
|
59
|
+
const providerId = c.req.query("providerId");
|
|
60
|
+
const sessions = await chatService.listSessions(providerId, projectPath);
|
|
61
|
+
return c.json(ok(sessions));
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return c.json(err((e as Error).message), 500);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** GET /chat/sessions/:id/messages — get message history */
|
|
68
|
+
chatRoutes.get("/sessions/:id/messages", async (c) => {
|
|
69
|
+
try {
|
|
70
|
+
const id = c.req.param("id");
|
|
71
|
+
const providerId = c.req.query("providerId") ?? "claude-sdk";
|
|
72
|
+
const messages = await chatService.getMessages(providerId, id);
|
|
73
|
+
return c.json(ok(messages));
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return c.json(err((e as Error).message), 500);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** POST /chat/sessions — create a new session for the project in context */
|
|
80
|
+
chatRoutes.post("/sessions", async (c) => {
|
|
81
|
+
try {
|
|
82
|
+
const projectName = c.get("projectName");
|
|
83
|
+
const projectPath = c.get("projectPath");
|
|
84
|
+
const body = await c.req.json<{ providerId?: string; title?: string }>();
|
|
85
|
+
const session = await chatService.createSession(body.providerId, {
|
|
86
|
+
projectName,
|
|
87
|
+
projectPath,
|
|
88
|
+
title: body.title,
|
|
89
|
+
});
|
|
90
|
+
return c.json(ok(session), 201);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return c.json(err((e as Error).message), 400);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** DELETE /chat/sessions/:id — delete a session */
|
|
97
|
+
chatRoutes.delete("/sessions/:id", async (c) => {
|
|
98
|
+
try {
|
|
99
|
+
const id = c.req.param("id");
|
|
100
|
+
const providerId = c.req.query("providerId") ?? "claude-sdk";
|
|
101
|
+
await chatService.deleteSession(providerId, id);
|
|
102
|
+
return c.json(ok({ deleted: id }));
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return c.json(err((e as Error).message), 404);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/** POST /chat/upload — upload files for chat attachments, returns server-side paths */
|
|
109
|
+
chatRoutes.post("/upload", async (c) => {
|
|
110
|
+
try {
|
|
111
|
+
const body = await c.req.parseBody({ all: true });
|
|
112
|
+
const files = Array.isArray(body["files"]) ? body["files"] : body["files"] ? [body["files"]] : [];
|
|
113
|
+
if (files.length === 0) return c.json(err("No files provided"), 400);
|
|
114
|
+
|
|
115
|
+
const uploadDir = resolve(tmpdir(), "ppm-uploads");
|
|
116
|
+
if (!existsSync(uploadDir)) mkdirSync(uploadDir, { recursive: true });
|
|
117
|
+
|
|
118
|
+
const results: Array<{ name: string; path: string; type: string; size: number }> = [];
|
|
119
|
+
for (const entry of files) {
|
|
120
|
+
if (!(entry instanceof File)) continue;
|
|
121
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
122
|
+
const safeName = entry.name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
123
|
+
const dest = join(uploadDir, `${id}-${safeName}`);
|
|
124
|
+
const buf = await entry.arrayBuffer();
|
|
125
|
+
await Bun.write(dest, buf);
|
|
126
|
+
results.push({ name: entry.name, path: dest, type: entry.type, size: entry.size });
|
|
127
|
+
}
|
|
128
|
+
return c.json(ok(results), 201);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return c.json(err((e as Error).message), 500);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/** GET /chat/uploads/:filename — serve uploaded files (images, etc.) for preview */
|
|
135
|
+
chatRoutes.get("/uploads/:filename", async (c) => {
|
|
136
|
+
try {
|
|
137
|
+
const filename = c.req.param("filename");
|
|
138
|
+
// Sanitize: only allow simple filenames, no path traversal
|
|
139
|
+
if (!filename || filename.includes("/") || filename.includes("..")) {
|
|
140
|
+
return c.json(err("Invalid filename"), 400);
|
|
141
|
+
}
|
|
142
|
+
const uploadDir = resolve(tmpdir(), "ppm-uploads");
|
|
143
|
+
const filePath = join(uploadDir, filename);
|
|
144
|
+
if (!existsSync(filePath)) return c.json(err("Not found"), 404);
|
|
145
|
+
|
|
146
|
+
const file = Bun.file(filePath);
|
|
147
|
+
return new Response(file.stream(), {
|
|
148
|
+
headers: { "Content-Type": file.type || "application/octet-stream" },
|
|
149
|
+
});
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return c.json(err((e as Error).message), 500);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
5
|
+
import {
|
|
6
|
+
fileService,
|
|
7
|
+
SecurityError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
ValidationError,
|
|
10
|
+
} from "../../services/file.service.ts";
|
|
11
|
+
import { ok, err } from "../../types/api.ts";
|
|
12
|
+
|
|
13
|
+
type Env = { Variables: { projectPath: string; projectName: string } };
|
|
14
|
+
|
|
15
|
+
export const fileRoutes = new Hono<Env>();
|
|
16
|
+
|
|
17
|
+
/** Map error type to HTTP status code */
|
|
18
|
+
function errorStatus(e: unknown): ContentfulStatusCode {
|
|
19
|
+
if (e instanceof SecurityError) return 403;
|
|
20
|
+
if (e instanceof NotFoundError) return 404;
|
|
21
|
+
if (e instanceof ValidationError) return 400;
|
|
22
|
+
return 500;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** GET /files/tree?depth=3 */
|
|
26
|
+
fileRoutes.get("/tree", (c) => {
|
|
27
|
+
try {
|
|
28
|
+
const projectPath = c.get("projectPath");
|
|
29
|
+
const depth = parseInt(c.req.query("depth") ?? "3", 10);
|
|
30
|
+
const tree = fileService.getTree(projectPath, depth);
|
|
31
|
+
return c.json(ok(tree));
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return c.json(err((e as Error).message), errorStatus(e));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** GET /files/raw?path=... — serve file directly as binary (for PDF viewer, images, etc.) */
|
|
38
|
+
fileRoutes.get("/raw", (c) => {
|
|
39
|
+
try {
|
|
40
|
+
const projectPath = c.get("projectPath");
|
|
41
|
+
const filePath = c.req.query("path");
|
|
42
|
+
if (!filePath) return c.json(err("Missing query parameter: path"), 400);
|
|
43
|
+
|
|
44
|
+
// Resolve safely (reuse service's security check)
|
|
45
|
+
const absPath = resolve(projectPath, filePath);
|
|
46
|
+
if (!absPath.startsWith(projectPath)) {
|
|
47
|
+
return c.json(err("Access denied"), 403);
|
|
48
|
+
}
|
|
49
|
+
if (!existsSync(absPath)) return c.json(err("File not found"), 404);
|
|
50
|
+
|
|
51
|
+
const file = Bun.file(absPath);
|
|
52
|
+
return new Response(file.stream(), {
|
|
53
|
+
headers: {
|
|
54
|
+
"Content-Type": file.type || "application/octet-stream",
|
|
55
|
+
"Content-Disposition": "inline",
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return c.json(err((e as Error).message), errorStatus(e));
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** GET /files/read?path=... */
|
|
64
|
+
fileRoutes.get("/read", (c) => {
|
|
65
|
+
try {
|
|
66
|
+
const projectPath = c.get("projectPath");
|
|
67
|
+
const filePath = c.req.query("path");
|
|
68
|
+
if (!filePath) {
|
|
69
|
+
return c.json(err("Missing query parameter: path"), 400);
|
|
70
|
+
}
|
|
71
|
+
const result = fileService.readFile(projectPath, filePath);
|
|
72
|
+
return c.json(ok(result));
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return c.json(err((e as Error).message), errorStatus(e));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** PUT /files/write — body: { path, content } */
|
|
79
|
+
fileRoutes.put("/write", async (c) => {
|
|
80
|
+
try {
|
|
81
|
+
const projectPath = c.get("projectPath");
|
|
82
|
+
const body = await c.req.json<{ path: string; content: string }>();
|
|
83
|
+
if (!body.path || body.content === undefined) {
|
|
84
|
+
return c.json(err("Missing required fields: path, content"), 400);
|
|
85
|
+
}
|
|
86
|
+
fileService.writeFile(projectPath, body.path, body.content);
|
|
87
|
+
return c.json(ok({ written: body.path }));
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return c.json(err((e as Error).message), errorStatus(e));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** POST /files/create — body: { path, type } */
|
|
94
|
+
fileRoutes.post("/create", async (c) => {
|
|
95
|
+
try {
|
|
96
|
+
const projectPath = c.get("projectPath");
|
|
97
|
+
const body = await c.req.json<{ path: string; type: "file" | "directory" }>();
|
|
98
|
+
if (!body.path || !body.type) {
|
|
99
|
+
return c.json(err("Missing required fields: path, type"), 400);
|
|
100
|
+
}
|
|
101
|
+
fileService.createFile(projectPath, body.path, body.type);
|
|
102
|
+
return c.json(ok({ created: body.path, type: body.type }), 201);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return c.json(err((e as Error).message), errorStatus(e));
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/** DELETE /files/delete — body: { path } */
|
|
109
|
+
fileRoutes.delete("/delete", async (c) => {
|
|
110
|
+
try {
|
|
111
|
+
const projectPath = c.get("projectPath");
|
|
112
|
+
const body = await c.req.json<{ path: string }>();
|
|
113
|
+
if (!body.path) {
|
|
114
|
+
return c.json(err("Missing required field: path"), 400);
|
|
115
|
+
}
|
|
116
|
+
fileService.deleteFile(projectPath, body.path);
|
|
117
|
+
return c.json(ok({ deleted: body.path }));
|
|
118
|
+
} catch (e) {
|
|
119
|
+
return c.json(err((e as Error).message), errorStatus(e));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** GET /files/compare?file1=path1&file2=path2 */
|
|
124
|
+
fileRoutes.get("/compare", (c) => {
|
|
125
|
+
try {
|
|
126
|
+
const projectPath = c.get("projectPath");
|
|
127
|
+
const file1 = c.req.query("file1");
|
|
128
|
+
const file2 = c.req.query("file2");
|
|
129
|
+
if (!file1 || !file2) {
|
|
130
|
+
return c.json(err("Missing query parameters: file1, file2"), 400);
|
|
131
|
+
}
|
|
132
|
+
const original = fileService.readFile(projectPath, file1);
|
|
133
|
+
const modified = fileService.readFile(projectPath, file2);
|
|
134
|
+
return c.json(ok({ original: original.content, modified: modified.content }));
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return c.json(err((e as Error).message), errorStatus(e));
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/** POST /files/rename — body: { oldPath, newPath } */
|
|
141
|
+
fileRoutes.post("/rename", async (c) => {
|
|
142
|
+
try {
|
|
143
|
+
const projectPath = c.get("projectPath");
|
|
144
|
+
const body = await c.req.json<{ oldPath: string; newPath: string }>();
|
|
145
|
+
if (!body.oldPath || !body.newPath) {
|
|
146
|
+
return c.json(err("Missing required fields: oldPath, newPath"), 400);
|
|
147
|
+
}
|
|
148
|
+
fileService.renameFile(projectPath, body.oldPath, body.newPath);
|
|
149
|
+
return c.json(ok({ renamed: { from: body.oldPath, to: body.newPath } }));
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return c.json(err((e as Error).message), errorStatus(e));
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/** POST /files/move — body: { source, destination } */
|
|
156
|
+
fileRoutes.post("/move", async (c) => {
|
|
157
|
+
try {
|
|
158
|
+
const projectPath = c.get("projectPath");
|
|
159
|
+
const body = await c.req.json<{ source: string; destination: string }>();
|
|
160
|
+
if (!body.source || !body.destination) {
|
|
161
|
+
return c.json(err("Missing required fields: source, destination"), 400);
|
|
162
|
+
}
|
|
163
|
+
fileService.moveFile(projectPath, body.source, body.destination);
|
|
164
|
+
return c.json(ok({ moved: { from: body.source, to: body.destination } }));
|
|
165
|
+
} catch (e) {
|
|
166
|
+
return c.json(err((e as Error).message), errorStatus(e));
|
|
167
|
+
}
|
|
168
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { gitService } from "../../services/git.service.ts";
|
|
3
|
+
import { ok, err } from "../../types/api.ts";
|
|
4
|
+
|
|
5
|
+
type Env = { Variables: { projectPath: string; projectName: string } };
|
|
6
|
+
|
|
7
|
+
export const gitRoutes = new Hono<Env>();
|
|
8
|
+
|
|
9
|
+
/** GET /git/status */
|
|
10
|
+
gitRoutes.get("/status", async (c) => {
|
|
11
|
+
try {
|
|
12
|
+
const projectPath = c.get("projectPath");
|
|
13
|
+
const status = await gitService.status(projectPath);
|
|
14
|
+
return c.json(ok(status));
|
|
15
|
+
} catch (e) {
|
|
16
|
+
return c.json(err((e as Error).message), 500);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** GET /git/diff?ref1=&ref2= */
|
|
21
|
+
gitRoutes.get("/diff", async (c) => {
|
|
22
|
+
try {
|
|
23
|
+
const projectPath = c.get("projectPath");
|
|
24
|
+
const ref1 = c.req.query("ref1") || undefined;
|
|
25
|
+
const ref2 = c.req.query("ref2") || undefined;
|
|
26
|
+
const diff = await gitService.diff(projectPath, ref1, ref2);
|
|
27
|
+
return c.json(ok({ diff }));
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return c.json(err((e as Error).message), 500);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** GET /git/diff-stat?ref1=&ref2= — file list with +/- counts */
|
|
34
|
+
gitRoutes.get("/diff-stat", async (c) => {
|
|
35
|
+
try {
|
|
36
|
+
const projectPath = c.get("projectPath");
|
|
37
|
+
const ref1 = c.req.query("ref1") || undefined;
|
|
38
|
+
const ref2 = c.req.query("ref2") || undefined;
|
|
39
|
+
const files = await gitService.diffStat(projectPath, ref1, ref2);
|
|
40
|
+
return c.json(ok(files));
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return c.json(err((e as Error).message), 500);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** GET /git/file-diff?file=&ref= */
|
|
47
|
+
gitRoutes.get("/file-diff", async (c) => {
|
|
48
|
+
try {
|
|
49
|
+
const projectPath = c.get("projectPath");
|
|
50
|
+
const file = c.req.query("file");
|
|
51
|
+
if (!file) return c.json(err("Missing query: file"), 400);
|
|
52
|
+
const ref = c.req.query("ref") || undefined;
|
|
53
|
+
const diff = await gitService.fileDiff(projectPath, file, ref);
|
|
54
|
+
return c.json(ok({ diff }));
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return c.json(err((e as Error).message), 500);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** GET /git/graph?max=200 */
|
|
61
|
+
gitRoutes.get("/graph", async (c) => {
|
|
62
|
+
try {
|
|
63
|
+
const projectPath = c.get("projectPath");
|
|
64
|
+
const max = parseInt(c.req.query("max") ?? "200", 10);
|
|
65
|
+
const data = await gitService.graphData(projectPath, max);
|
|
66
|
+
return c.json(ok(data));
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return c.json(err((e as Error).message), 500);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** GET /git/branches */
|
|
73
|
+
gitRoutes.get("/branches", async (c) => {
|
|
74
|
+
try {
|
|
75
|
+
const projectPath = c.get("projectPath");
|
|
76
|
+
const branches = await gitService.branches(projectPath);
|
|
77
|
+
return c.json(ok(branches));
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return c.json(err((e as Error).message), 500);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** GET /git/pr-url?branch= */
|
|
84
|
+
gitRoutes.get("/pr-url", async (c) => {
|
|
85
|
+
try {
|
|
86
|
+
const projectPath = c.get("projectPath");
|
|
87
|
+
const branch = c.req.query("branch");
|
|
88
|
+
if (!branch) return c.json(err("Missing query: branch"), 400);
|
|
89
|
+
const url = await gitService.getCreatePrUrl(projectPath, branch);
|
|
90
|
+
return c.json(ok({ url }));
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return c.json(err((e as Error).message), 500);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** POST /git/discard { files } — discard unstaged changes (checkout tracked, clean untracked) */
|
|
97
|
+
gitRoutes.post("/discard", async (c) => {
|
|
98
|
+
try {
|
|
99
|
+
const projectPath = c.get("projectPath");
|
|
100
|
+
const { files } = await c.req.json<{ files: string[] }>();
|
|
101
|
+
if (!files?.length) return c.json(err("Missing: files"), 400);
|
|
102
|
+
await gitService.discardChanges(projectPath, files);
|
|
103
|
+
return c.json(ok({ discarded: files }));
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return c.json(err((e as Error).message), 500);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** POST /git/stage { files } */
|
|
110
|
+
gitRoutes.post("/stage", async (c) => {
|
|
111
|
+
try {
|
|
112
|
+
const projectPath = c.get("projectPath");
|
|
113
|
+
const { files } = await c.req.json<{ files: string[] }>();
|
|
114
|
+
if (!files?.length) return c.json(err("Missing: files"), 400);
|
|
115
|
+
await gitService.stage(projectPath, files);
|
|
116
|
+
return c.json(ok({ staged: files }));
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return c.json(err((e as Error).message), 500);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** POST /git/unstage { files } */
|
|
123
|
+
gitRoutes.post("/unstage", async (c) => {
|
|
124
|
+
try {
|
|
125
|
+
const projectPath = c.get("projectPath");
|
|
126
|
+
const { files } = await c.req.json<{ files: string[] }>();
|
|
127
|
+
if (!files?.length) return c.json(err("Missing: files"), 400);
|
|
128
|
+
await gitService.unstage(projectPath, files);
|
|
129
|
+
return c.json(ok({ unstaged: files }));
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return c.json(err((e as Error).message), 500);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** POST /git/commit { message } */
|
|
136
|
+
gitRoutes.post("/commit", async (c) => {
|
|
137
|
+
try {
|
|
138
|
+
const projectPath = c.get("projectPath");
|
|
139
|
+
const { message } = await c.req.json<{ message: string }>();
|
|
140
|
+
if (!message) return c.json(err("Missing: message"), 400);
|
|
141
|
+
const hash = await gitService.commit(projectPath, message);
|
|
142
|
+
return c.json(ok({ hash }));
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return c.json(err((e as Error).message), 500);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/** POST /git/push { remote?, branch? } */
|
|
149
|
+
gitRoutes.post("/push", async (c) => {
|
|
150
|
+
try {
|
|
151
|
+
const projectPath = c.get("projectPath");
|
|
152
|
+
const { remote, branch } = await c.req.json<{ remote?: string; branch?: string }>();
|
|
153
|
+
await gitService.push(projectPath, remote, branch);
|
|
154
|
+
return c.json(ok({ pushed: true }));
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return c.json(err((e as Error).message), 500);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/** POST /git/pull { remote?, branch? } */
|
|
161
|
+
gitRoutes.post("/pull", async (c) => {
|
|
162
|
+
try {
|
|
163
|
+
const projectPath = c.get("projectPath");
|
|
164
|
+
const { remote, branch } = await c.req.json<{ remote?: string; branch?: string }>();
|
|
165
|
+
await gitService.pull(projectPath, remote, branch);
|
|
166
|
+
return c.json(ok({ pulled: true }));
|
|
167
|
+
} catch (e) {
|
|
168
|
+
return c.json(err((e as Error).message), 500);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** POST /git/branch/create { name, from? } */
|
|
173
|
+
gitRoutes.post("/branch/create", async (c) => {
|
|
174
|
+
try {
|
|
175
|
+
const projectPath = c.get("projectPath");
|
|
176
|
+
const { name, from } = await c.req.json<{ name: string; from?: string }>();
|
|
177
|
+
if (!name) return c.json(err("Missing: name"), 400);
|
|
178
|
+
await gitService.createBranch(projectPath, name, from);
|
|
179
|
+
return c.json(ok({ created: name }));
|
|
180
|
+
} catch (e) {
|
|
181
|
+
return c.json(err((e as Error).message), 500);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/** POST /git/checkout { ref } */
|
|
186
|
+
gitRoutes.post("/checkout", async (c) => {
|
|
187
|
+
try {
|
|
188
|
+
const projectPath = c.get("projectPath");
|
|
189
|
+
const { ref } = await c.req.json<{ ref: string }>();
|
|
190
|
+
if (!ref) return c.json(err("Missing: ref"), 400);
|
|
191
|
+
await gitService.checkout(projectPath, ref);
|
|
192
|
+
return c.json(ok({ checkedOut: ref }));
|
|
193
|
+
} catch (e) {
|
|
194
|
+
return c.json(err((e as Error).message), 500);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/** POST /git/branch/delete { name, force? } */
|
|
199
|
+
gitRoutes.post("/branch/delete", async (c) => {
|
|
200
|
+
try {
|
|
201
|
+
const projectPath = c.get("projectPath");
|
|
202
|
+
const { name, force } = await c.req.json<{ name: string; force?: boolean }>();
|
|
203
|
+
if (!name) return c.json(err("Missing: name"), 400);
|
|
204
|
+
await gitService.deleteBranch(projectPath, name, force);
|
|
205
|
+
return c.json(ok({ deleted: name }));
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return c.json(err((e as Error).message), 500);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/** POST /git/merge { source } */
|
|
212
|
+
gitRoutes.post("/merge", async (c) => {
|
|
213
|
+
try {
|
|
214
|
+
const projectPath = c.get("projectPath");
|
|
215
|
+
const { source } = await c.req.json<{ source: string }>();
|
|
216
|
+
if (!source) return c.json(err("Missing: source"), 400);
|
|
217
|
+
await gitService.merge(projectPath, source);
|
|
218
|
+
return c.json(ok({ merged: source }));
|
|
219
|
+
} catch (e) {
|
|
220
|
+
return c.json(err((e as Error).message), 500);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
/** POST /git/cherry-pick { hash } */
|
|
225
|
+
gitRoutes.post("/cherry-pick", async (c) => {
|
|
226
|
+
try {
|
|
227
|
+
const projectPath = c.get("projectPath");
|
|
228
|
+
const { hash } = await c.req.json<{ hash: string }>();
|
|
229
|
+
if (!hash) return c.json(err("Missing: hash"), 400);
|
|
230
|
+
await gitService.cherryPick(projectPath, hash);
|
|
231
|
+
return c.json(ok({ cherryPicked: hash }));
|
|
232
|
+
} catch (e) {
|
|
233
|
+
return c.json(err((e as Error).message), 500);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
/** POST /git/revert { hash } */
|
|
238
|
+
gitRoutes.post("/revert", async (c) => {
|
|
239
|
+
try {
|
|
240
|
+
const projectPath = c.get("projectPath");
|
|
241
|
+
const { hash } = await c.req.json<{ hash: string }>();
|
|
242
|
+
if (!hash) return c.json(err("Missing: hash"), 400);
|
|
243
|
+
await gitService.revert(projectPath, hash);
|
|
244
|
+
return c.json(ok({ reverted: hash }));
|
|
245
|
+
} catch (e) {
|
|
246
|
+
return c.json(err((e as Error).message), 500);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
/** POST /git/tag { name, hash? } */
|
|
251
|
+
gitRoutes.post("/tag", async (c) => {
|
|
252
|
+
try {
|
|
253
|
+
const projectPath = c.get("projectPath");
|
|
254
|
+
const { name, hash } = await c.req.json<{ name: string; hash?: string }>();
|
|
255
|
+
if (!name) return c.json(err("Missing: name"), 400);
|
|
256
|
+
await gitService.createTag(projectPath, name, hash);
|
|
257
|
+
return c.json(ok({ tagged: name }));
|
|
258
|
+
} catch (e) {
|
|
259
|
+
return c.json(err((e as Error).message), 500);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { resolveProjectPath } from "../helpers/resolve-project.ts";
|
|
3
|
+
import { chatRoutes } from "./chat.ts";
|
|
4
|
+
import { gitRoutes } from "./git.ts";
|
|
5
|
+
import { fileRoutes } from "./files.ts";
|
|
6
|
+
|
|
7
|
+
type Env = { Variables: { projectPath: string; projectName: string } };
|
|
8
|
+
|
|
9
|
+
export const projectScopedRouter = new Hono<Env>();
|
|
10
|
+
|
|
11
|
+
/** Middleware: resolve :projectName param to absolute project path */
|
|
12
|
+
projectScopedRouter.use("*", async (c, next) => {
|
|
13
|
+
const name = c.req.param("projectName");
|
|
14
|
+
if (!name) return c.json({ ok: false, error: "Missing project name" }, 400);
|
|
15
|
+
try {
|
|
16
|
+
const projectPath = resolveProjectPath(name);
|
|
17
|
+
c.set("projectPath", projectPath);
|
|
18
|
+
c.set("projectName", name);
|
|
19
|
+
await next();
|
|
20
|
+
} catch (e) {
|
|
21
|
+
return c.json({ ok: false, error: (e as Error).message }, 404);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
projectScopedRouter.route("/chat", chatRoutes);
|
|
26
|
+
projectScopedRouter.route("/git", gitRoutes);
|
|
27
|
+
projectScopedRouter.route("/files", fileRoutes);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { projectService } from "../../services/project.service.ts";
|
|
3
|
+
import { searchGitDirs } from "../../services/git-dirs.service.ts";
|
|
4
|
+
import { ok, err } from "../../types/api.ts";
|
|
5
|
+
|
|
6
|
+
export const projectRoutes = new Hono();
|
|
7
|
+
|
|
8
|
+
/** GET /api/projects — list all registered projects */
|
|
9
|
+
projectRoutes.get("/", (c) => {
|
|
10
|
+
try {
|
|
11
|
+
const projects = projectService.list();
|
|
12
|
+
return c.json(ok(projects));
|
|
13
|
+
} catch (e) {
|
|
14
|
+
return c.json(err((e as Error).message), 500);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** POST /api/projects — add a project { path, name? } */
|
|
19
|
+
projectRoutes.post("/", async (c) => {
|
|
20
|
+
try {
|
|
21
|
+
const body = await c.req.json<{ path: string; name?: string }>();
|
|
22
|
+
if (!body.path) {
|
|
23
|
+
return c.json(err("Missing required field: path"), 400);
|
|
24
|
+
}
|
|
25
|
+
const project = projectService.add(body.path, body.name);
|
|
26
|
+
return c.json(ok(project), 201);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return c.json(err((e as Error).message), 400);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* GET /api/projects/suggest-dirs?path=/some/dir&q=search
|
|
34
|
+
* Deep-scan `path` (default: home dir) for directories containing .git.
|
|
35
|
+
* Results are cached for 5 minutes. Use `q` to filter by name/path.
|
|
36
|
+
*/
|
|
37
|
+
projectRoutes.get("/suggest-dirs", (c) => {
|
|
38
|
+
try {
|
|
39
|
+
const root = c.req.query("path") || undefined;
|
|
40
|
+
const query = c.req.query("q") ?? "";
|
|
41
|
+
const results = searchGitDirs(query, root);
|
|
42
|
+
return c.json(ok(results));
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return c.json(err((e as Error).message), 500);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** DELETE /api/projects/:name — remove a project by name */
|
|
49
|
+
projectRoutes.delete("/:name", (c) => {
|
|
50
|
+
try {
|
|
51
|
+
const name = c.req.param("name");
|
|
52
|
+
projectService.remove(name);
|
|
53
|
+
return c.json(ok({ removed: name }));
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return c.json(err((e as Error).message), 404);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { serveStatic } from "hono/bun";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const staticRoutes = new Hono();
|
|
7
|
+
|
|
8
|
+
const DIST_DIR = resolve(import.meta.dir, "../../../dist/web");
|
|
9
|
+
|
|
10
|
+
/** Serve static files from dist/web/ with SPA fallback */
|
|
11
|
+
staticRoutes.use(
|
|
12
|
+
"*",
|
|
13
|
+
serveStatic({
|
|
14
|
+
root: existsSync(DIST_DIR) ? DIST_DIR : undefined,
|
|
15
|
+
rewriteRequestPath: (path) => path,
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
/** SPA fallback — serve index.html for all unmatched routes */
|
|
20
|
+
staticRoutes.get("*", (c) => {
|
|
21
|
+
const indexPath = resolve(DIST_DIR, "index.html");
|
|
22
|
+
if (existsSync(indexPath)) {
|
|
23
|
+
return c.html(Bun.file(indexPath).text());
|
|
24
|
+
}
|
|
25
|
+
return c.text("Frontend not built. Run: bun run build:web", 404);
|
|
26
|
+
});
|