@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.
Files changed (159) hide show
  1. package/.claude/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/.env.example +1 -0
  4. package/.github/workflows/release.yml +46 -0
  5. package/README.md +349 -0
  6. package/bun.lock +1217 -0
  7. package/components.json +21 -0
  8. package/docs/code-standards.md +574 -0
  9. package/docs/codebase-summary.md +294 -0
  10. package/docs/deployment-guide.md +631 -0
  11. package/docs/design-guidelines.md +661 -0
  12. package/docs/project-overview-pdr.md +142 -0
  13. package/docs/project-roadmap.md +400 -0
  14. package/docs/system-architecture.md +459 -0
  15. package/package.json +68 -0
  16. package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
  17. package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
  18. package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
  19. package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
  20. package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
  21. package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
  22. package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
  23. package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
  24. package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
  25. package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
  26. package/plans/260314-2009-ppm-implementation/plan.md +202 -0
  27. package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
  28. package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
  29. package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
  30. package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
  31. package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
  32. package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
  33. package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
  34. package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
  35. package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
  36. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
  37. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
  38. package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
  39. package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
  40. package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
  41. package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
  42. package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
  43. package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
  44. package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
  45. package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
  46. package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
  47. package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
  48. package/ppm.example.yaml +14 -0
  49. package/repomix-output.xml +23745 -0
  50. package/scripts/build.ts +13 -0
  51. package/src/cli/commands/chat-cmd.ts +259 -0
  52. package/src/cli/commands/config-cmd.ts +121 -0
  53. package/src/cli/commands/git-cmd.ts +315 -0
  54. package/src/cli/commands/init.ts +57 -0
  55. package/src/cli/commands/open.ts +19 -0
  56. package/src/cli/commands/projects.ts +100 -0
  57. package/src/cli/commands/start.ts +3 -0
  58. package/src/cli/commands/stop.ts +33 -0
  59. package/src/cli/utils/project-resolver.ts +27 -0
  60. package/src/index.ts +59 -0
  61. package/src/providers/claude-agent-sdk.ts +499 -0
  62. package/src/providers/claude-binary-finder.ts +256 -0
  63. package/src/providers/claude-code-cli.ts +413 -0
  64. package/src/providers/claude-process-registry.ts +106 -0
  65. package/src/providers/mock-provider.ts +171 -0
  66. package/src/providers/provider.interface.ts +10 -0
  67. package/src/providers/registry.ts +45 -0
  68. package/src/server/helpers/resolve-project.ts +22 -0
  69. package/src/server/index.ts +181 -0
  70. package/src/server/middleware/auth.ts +30 -0
  71. package/src/server/routes/chat.ts +153 -0
  72. package/src/server/routes/files.ts +168 -0
  73. package/src/server/routes/git.ts +261 -0
  74. package/src/server/routes/project-scoped.ts +27 -0
  75. package/src/server/routes/projects.ts +57 -0
  76. package/src/server/routes/static.ts +26 -0
  77. package/src/server/ws/chat.ts +130 -0
  78. package/src/server/ws/terminal.ts +89 -0
  79. package/src/services/chat.service.ts +110 -0
  80. package/src/services/claude-usage.service.ts +113 -0
  81. package/src/services/config.service.ts +90 -0
  82. package/src/services/file.service.ts +261 -0
  83. package/src/services/git-dirs.service.ts +112 -0
  84. package/src/services/git.service.ts +372 -0
  85. package/src/services/project.service.ts +107 -0
  86. package/src/services/slash-items.service.ts +184 -0
  87. package/src/services/terminal.service.ts +212 -0
  88. package/src/types/api.ts +37 -0
  89. package/src/types/chat.ts +92 -0
  90. package/src/types/config.ts +41 -0
  91. package/src/types/git.ts +50 -0
  92. package/src/types/project.ts +18 -0
  93. package/src/types/terminal.ts +20 -0
  94. package/src/web/app.tsx +168 -0
  95. package/src/web/components/auth/login-screen.tsx +88 -0
  96. package/src/web/components/chat/attachment-chips.tsx +55 -0
  97. package/src/web/components/chat/chat-placeholder.tsx +10 -0
  98. package/src/web/components/chat/chat-tab.tsx +301 -0
  99. package/src/web/components/chat/file-picker.tsx +126 -0
  100. package/src/web/components/chat/message-input.tsx +420 -0
  101. package/src/web/components/chat/message-list.tsx +838 -0
  102. package/src/web/components/chat/session-picker.tsx +139 -0
  103. package/src/web/components/chat/slash-command-picker.tsx +135 -0
  104. package/src/web/components/chat/usage-badge.tsx +186 -0
  105. package/src/web/components/editor/code-editor.tsx +329 -0
  106. package/src/web/components/editor/diff-viewer.tsx +276 -0
  107. package/src/web/components/editor/editor-placeholder.tsx +10 -0
  108. package/src/web/components/explorer/file-actions.tsx +191 -0
  109. package/src/web/components/explorer/file-tree.tsx +298 -0
  110. package/src/web/components/git/git-graph.tsx +727 -0
  111. package/src/web/components/git/git-placeholder.tsx +55 -0
  112. package/src/web/components/git/git-status-panel.tsx +850 -0
  113. package/src/web/components/layout/mobile-drawer.tsx +137 -0
  114. package/src/web/components/layout/mobile-nav.tsx +103 -0
  115. package/src/web/components/layout/sidebar.tsx +90 -0
  116. package/src/web/components/layout/tab-bar.tsx +152 -0
  117. package/src/web/components/layout/tab-content.tsx +85 -0
  118. package/src/web/components/projects/dir-suggest.tsx +152 -0
  119. package/src/web/components/projects/project-list.tsx +187 -0
  120. package/src/web/components/settings/settings-tab.tsx +57 -0
  121. package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
  122. package/src/web/components/terminal/terminal-tab.tsx +133 -0
  123. package/src/web/components/ui/button.tsx +64 -0
  124. package/src/web/components/ui/context-menu.tsx +250 -0
  125. package/src/web/components/ui/dialog.tsx +156 -0
  126. package/src/web/components/ui/dropdown-menu.tsx +257 -0
  127. package/src/web/components/ui/input.tsx +21 -0
  128. package/src/web/components/ui/scroll-area.tsx +56 -0
  129. package/src/web/components/ui/separator.tsx +26 -0
  130. package/src/web/components/ui/sonner.tsx +40 -0
  131. package/src/web/components/ui/tabs.tsx +91 -0
  132. package/src/web/components/ui/tooltip.tsx +57 -0
  133. package/src/web/hooks/use-chat.ts +420 -0
  134. package/src/web/hooks/use-terminal.ts +182 -0
  135. package/src/web/hooks/use-url-sync.ts +66 -0
  136. package/src/web/hooks/use-websocket.ts +48 -0
  137. package/src/web/index.html +16 -0
  138. package/src/web/lib/api-client.ts +90 -0
  139. package/src/web/lib/file-support.ts +68 -0
  140. package/src/web/lib/utils.ts +6 -0
  141. package/src/web/lib/ws-client.ts +100 -0
  142. package/src/web/main.tsx +10 -0
  143. package/src/web/public/icon-192.svg +5 -0
  144. package/src/web/public/icon-512.svg +5 -0
  145. package/src/web/stores/file-store.ts +81 -0
  146. package/src/web/stores/project-store.ts +50 -0
  147. package/src/web/stores/settings-store.ts +65 -0
  148. package/src/web/stores/tab-store.ts +187 -0
  149. package/src/web/styles/globals.css +227 -0
  150. package/src/web/vite-env.d.ts +1 -0
  151. package/tests/integration/api/chat-routes.test.ts +95 -0
  152. package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
  153. package/tests/integration/ws/chat-websocket.test.ts +312 -0
  154. package/tests/test-setup.ts +5 -0
  155. package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
  156. package/tests/unit/providers/mock-provider.test.ts +143 -0
  157. package/tests/unit/services/chat-service.test.ts +100 -0
  158. package/tsconfig.json +32 -0
  159. package/vite.config.ts +62 -0
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Registry for tracking active Claude CLI processes.
3
+ * Mirrors opcode's ProcessRegistry — enables cancel/kill support.
4
+ */
5
+
6
+ export interface ProcessInfo {
7
+ runId: number;
8
+ sessionId: string;
9
+ pid: number;
10
+ startedAt: Date;
11
+ projectPath: string;
12
+ prompt: string;
13
+ model: string;
14
+ }
15
+
16
+ interface ProcessHandle {
17
+ info: ProcessInfo;
18
+ proc: ReturnType<typeof Bun.spawn> | null;
19
+ }
20
+
21
+ class ProcessRegistry {
22
+ private processes = new Map<string, ProcessHandle>(); // sessionId -> handle
23
+
24
+ /** Register a running Claude CLI process */
25
+ register(
26
+ sessionId: string,
27
+ proc: ReturnType<typeof Bun.spawn>,
28
+ meta: { projectPath?: string; prompt: string; model?: string },
29
+ ): void {
30
+ const pid = proc.pid;
31
+ this.processes.set(sessionId, {
32
+ info: {
33
+ runId: Date.now(),
34
+ sessionId,
35
+ pid,
36
+ startedAt: new Date(),
37
+ projectPath: meta.projectPath ?? process.cwd(),
38
+ prompt: meta.prompt,
39
+ model: meta.model ?? "default",
40
+ },
41
+ proc,
42
+ });
43
+ }
44
+
45
+ /** Unregister a process (called when it completes) */
46
+ unregister(sessionId: string): void {
47
+ this.processes.delete(sessionId);
48
+ }
49
+
50
+ /** Get process info for a session */
51
+ get(sessionId: string): ProcessInfo | null {
52
+ return this.processes.get(sessionId)?.info ?? null;
53
+ }
54
+
55
+ /** List all running sessions */
56
+ listRunning(): ProcessInfo[] {
57
+ return Array.from(this.processes.values()).map((h) => h.info);
58
+ }
59
+
60
+ /**
61
+ * Kill a running process. Mirrors opcode's graceful shutdown:
62
+ * SIGTERM → wait 2s → SIGKILL
63
+ */
64
+ async kill(sessionId: string): Promise<boolean> {
65
+ const handle = this.processes.get(sessionId);
66
+ if (!handle?.proc) return false;
67
+
68
+ const { proc } = handle;
69
+ const pid = proc.pid;
70
+
71
+ try {
72
+ // Try graceful SIGTERM first
73
+ proc.kill("SIGTERM");
74
+
75
+ // Wait up to 2s for exit
76
+ const exited = await Promise.race([
77
+ proc.exited.then(() => true),
78
+ new Promise<false>((r) => setTimeout(() => r(false), 2000)),
79
+ ]);
80
+
81
+ if (!exited) {
82
+ // Force kill with SIGKILL
83
+ try {
84
+ proc.kill("SIGKILL");
85
+ } catch {
86
+ // Fallback: system kill command
87
+ Bun.spawnSync(["kill", "-KILL", String(pid)]);
88
+ }
89
+ }
90
+
91
+ this.processes.delete(sessionId);
92
+ return true;
93
+ } catch {
94
+ this.processes.delete(sessionId);
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /** Check if a session has a running process */
100
+ isRunning(sessionId: string): boolean {
101
+ return this.processes.has(sessionId);
102
+ }
103
+ }
104
+
105
+ /** Singleton process registry */
106
+ export const processRegistry = new ProcessRegistry();
@@ -0,0 +1,171 @@
1
+ import type {
2
+ AIProvider,
3
+ Session,
4
+ SessionConfig,
5
+ SessionInfo,
6
+ ChatEvent,
7
+ ChatMessage,
8
+ } from "./provider.interface.ts";
9
+
10
+ const MOCK_RESPONSES = [
11
+ "I can help you with that! Let me take a look at the code.",
12
+ "Here's what I found after analyzing the project structure.",
13
+ "I'll make those changes for you. Let me update the file.",
14
+ "That looks like a good approach. Here's my suggestion:",
15
+ "I've reviewed the code and here are my findings.",
16
+ ];
17
+
18
+ /**
19
+ * Mock AI provider for development/testing.
20
+ * Simulates streaming chat responses without needing a real API key.
21
+ */
22
+ export class MockProvider implements AIProvider {
23
+ id = "mock";
24
+ name = "Mock AI (Dev)";
25
+
26
+ private sessions = new Map<string, Session>();
27
+ private messageHistory = new Map<string, ChatMessage[]>();
28
+ /** Active abort controllers for cancel support */
29
+ private activeAborts = new Map<string, AbortController>();
30
+
31
+ async createSession(config: SessionConfig): Promise<Session> {
32
+ const id = crypto.randomUUID();
33
+ const session: Session = {
34
+ id,
35
+ providerId: this.id,
36
+ title: config.title ?? "New Chat",
37
+ projectName: config.projectName,
38
+ createdAt: new Date().toISOString(),
39
+ };
40
+ this.sessions.set(id, session);
41
+ this.messageHistory.set(id, []);
42
+ return session;
43
+ }
44
+
45
+ async resumeSession(sessionId: string): Promise<Session> {
46
+ const session = this.sessions.get(sessionId);
47
+ if (!session) throw new Error(`Session ${sessionId} not found`);
48
+ return session;
49
+ }
50
+
51
+ async listSessions(): Promise<SessionInfo[]> {
52
+ return Array.from(this.sessions.values()).map((s) => ({
53
+ id: s.id,
54
+ providerId: s.providerId,
55
+ title: s.title,
56
+ projectName: s.projectName,
57
+ createdAt: s.createdAt,
58
+ }));
59
+ }
60
+
61
+ async deleteSession(sessionId: string): Promise<void> {
62
+ this.sessions.delete(sessionId);
63
+ this.messageHistory.delete(sessionId);
64
+ }
65
+
66
+ async *sendMessage(
67
+ sessionId: string,
68
+ message: string,
69
+ ): AsyncIterable<ChatEvent> {
70
+ const session = this.sessions.get(sessionId);
71
+ if (!session) {
72
+ yield { type: "error", message: "Session not found" };
73
+ return;
74
+ }
75
+
76
+ // Update title from first message
77
+ if (session.title === "New Chat") {
78
+ session.title = message.slice(0, 50) + (message.length > 50 ? "..." : "");
79
+ }
80
+
81
+ // Store user message
82
+ const history = this.messageHistory.get(sessionId) ?? [];
83
+ history.push({
84
+ id: crypto.randomUUID(),
85
+ role: "user",
86
+ content: message,
87
+ timestamp: new Date().toISOString(),
88
+ });
89
+
90
+ // Track abort controller for this session
91
+ const abortController = new AbortController();
92
+ this.activeAborts.set(sessionId, abortController);
93
+
94
+ // Simulate thinking delay
95
+ await sleep(300);
96
+
97
+ // Pick a response
98
+ const responseText =
99
+ MOCK_RESPONSES[Math.floor(Math.random() * MOCK_RESPONSES.length)] ??
100
+ MOCK_RESPONSES[0]!;
101
+
102
+ // Simulate tool use for messages containing "file" or "code"
103
+ const lowerMsg = message.toLowerCase();
104
+ if (lowerMsg.includes("file") || lowerMsg.includes("code")) {
105
+ yield { type: "tool_use", tool: "Read", input: { path: "src/index.ts" } };
106
+ await sleep(200);
107
+ yield { type: "tool_result", output: '// Main entry point\nconsole.log("Hello");' };
108
+ await sleep(100);
109
+ }
110
+
111
+ // Simulate approval request for messages containing "delete" or "remove"
112
+ if (lowerMsg.includes("delete") || lowerMsg.includes("remove")) {
113
+ yield {
114
+ type: "approval_request",
115
+ requestId: crypto.randomUUID(),
116
+ tool: "Bash",
117
+ input: { command: "rm -rf /tmp/test" },
118
+ };
119
+ // In real usage, we'd wait for approval response.
120
+ // Mock just continues after a delay.
121
+ await sleep(500);
122
+ }
123
+
124
+ // Stream response text word by word
125
+ const words = responseText.split(" ");
126
+ for (let i = 0; i < words.length; i++) {
127
+ if (abortController.signal.aborted) break;
128
+ const chunk = (i === 0 ? "" : " ") + words[i];
129
+ yield { type: "text", content: chunk };
130
+ await sleep(50);
131
+ }
132
+
133
+ // Add a mock code block for variety
134
+ if (lowerMsg.includes("code") || lowerMsg.includes("example")) {
135
+ yield {
136
+ type: "text",
137
+ content: "\n\n```typescript\nfunction hello() {\n console.log('Hello from PPM!');\n}\n```\n",
138
+ };
139
+ await sleep(50);
140
+ }
141
+
142
+ // Store assistant message
143
+ history.push({
144
+ id: crypto.randomUUID(),
145
+ role: "assistant",
146
+ content: responseText,
147
+ timestamp: new Date().toISOString(),
148
+ });
149
+ this.messageHistory.set(sessionId, history);
150
+
151
+ this.activeAborts.delete(sessionId);
152
+ yield { type: "done", sessionId };
153
+ }
154
+
155
+ /** Abort an active query for a session (for cancel support) */
156
+ abortQuery(sessionId: string): void {
157
+ const controller = this.activeAborts.get(sessionId);
158
+ if (controller) {
159
+ controller.abort();
160
+ this.activeAborts.delete(sessionId);
161
+ }
162
+ }
163
+
164
+ getMessages(sessionId: string): ChatMessage[] {
165
+ return this.messageHistory.get(sessionId) ?? [];
166
+ }
167
+ }
168
+
169
+ function sleep(ms: number): Promise<void> {
170
+ return new Promise((resolve) => setTimeout(resolve, ms));
171
+ }
@@ -0,0 +1,10 @@
1
+ export type {
2
+ AIProvider,
3
+ Session,
4
+ SessionConfig,
5
+ SessionInfo,
6
+ ChatEvent,
7
+ ChatMessage,
8
+ ToolApprovalHandler,
9
+ UsageInfo,
10
+ } from "../types/chat.ts";
@@ -0,0 +1,45 @@
1
+ import type { AIProvider } from "./provider.interface.ts";
2
+ import { MockProvider } from "./mock-provider.ts";
3
+ import { ClaudeCodeCliProvider } from "./claude-code-cli.ts";
4
+ import { ClaudeAgentSdkProvider } from "./claude-agent-sdk.ts";
5
+
6
+ export interface ProviderInfo {
7
+ id: string;
8
+ name: string;
9
+ }
10
+
11
+ class ProviderRegistry {
12
+ private providers = new Map<string, AIProvider>();
13
+ private defaultId: string | null = null;
14
+
15
+ register(provider: AIProvider): void {
16
+ this.providers.set(provider.id, provider);
17
+ if (!this.defaultId) {
18
+ this.defaultId = provider.id;
19
+ }
20
+ }
21
+
22
+ get(id: string): AIProvider | undefined {
23
+ return this.providers.get(id);
24
+ }
25
+
26
+ list(): ProviderInfo[] {
27
+ return Array.from(this.providers.values()).map((p) => ({
28
+ id: p.id,
29
+ name: p.name,
30
+ }));
31
+ }
32
+
33
+ getDefault(): AIProvider {
34
+ if (!this.defaultId) throw new Error("No providers registered");
35
+ const provider = this.providers.get(this.defaultId);
36
+ if (!provider) throw new Error("Default provider not found");
37
+ return provider;
38
+ }
39
+ }
40
+
41
+ /** Singleton registry — first registered = default */
42
+ export const providerRegistry = new ProviderRegistry();
43
+ providerRegistry.register(new ClaudeAgentSdkProvider()); // default — real streaming, multi-turn
44
+ providerRegistry.register(new ClaudeCodeCliProvider()); // fallback — spawns claude CLI
45
+ providerRegistry.register(new MockProvider()); // testing only
@@ -0,0 +1,22 @@
1
+ import { resolve } from "node:path";
2
+ import { configService } from "../../services/config.service.ts";
3
+
4
+ /**
5
+ * Resolve a project name or path to an absolute filesystem path.
6
+ * Name lookup first, then path fallback with security validation.
7
+ */
8
+ export function resolveProjectPath(nameOrPath: string): string {
9
+ const projects = configService.get("projects");
10
+
11
+ // Try name lookup first
12
+ const byName = projects.find((p) => p.name === nameOrPath);
13
+ if (byName) return resolve(byName.path);
14
+
15
+ // Path fallback — must be within a registered project
16
+ const abs = resolve(nameOrPath);
17
+ const allowed = projects.some(
18
+ (p) => abs === resolve(p.path) || abs.startsWith(resolve(p.path) + "/"),
19
+ );
20
+ if (!allowed) throw new Error(`Project not found: ${nameOrPath}`);
21
+ return abs;
22
+ }
@@ -0,0 +1,181 @@
1
+ import { Hono } from "hono";
2
+ import { cors } from "hono/cors";
3
+ import { configService } from "../services/config.service.ts";
4
+ import { authMiddleware } from "./middleware/auth.ts";
5
+ import { projectRoutes } from "./routes/projects.ts";
6
+ import { staticRoutes } from "./routes/static.ts";
7
+ import { projectScopedRouter } from "./routes/project-scoped.ts";
8
+ import { terminalWebSocket } from "./ws/terminal.ts";
9
+ import { chatWebSocket } from "./ws/chat.ts";
10
+ import { ok } from "../types/api.ts";
11
+
12
+ export const app = new Hono();
13
+
14
+ // CORS for dev
15
+ app.use("*", cors());
16
+
17
+ // Health check (before auth)
18
+ app.get("/api/health", (c) => c.json(ok({ status: "running" })));
19
+
20
+ // Auth check endpoint (behind auth middleware)
21
+ app.use("/api/*", authMiddleware);
22
+ app.get("/api/auth/check", (c) => c.json(ok(true)));
23
+
24
+ // API routes
25
+ app.route("/api/projects", projectRoutes);
26
+ app.route("/api/project/:projectName", projectScopedRouter);
27
+
28
+ // Static files / SPA fallback (non-API routes)
29
+ app.route("/", staticRoutes);
30
+
31
+ export async function startServer(options: {
32
+ port?: string;
33
+ daemon?: boolean;
34
+ config?: string;
35
+ }) {
36
+ // Load config
37
+ configService.load(options.config);
38
+ const port = parseInt(options.port ?? String(configService.get("port")), 10);
39
+ const host = configService.get("host");
40
+
41
+ if (options.daemon) {
42
+ // Daemon mode: spawn detached child process, write PID file
43
+ const { resolve } = await import("node:path");
44
+ const { homedir } = await import("node:os");
45
+ const { writeFileSync, mkdirSync, existsSync } = await import("node:fs");
46
+
47
+ const ppmDir = resolve(homedir(), ".ppm");
48
+ if (!existsSync(ppmDir)) mkdirSync(ppmDir, { recursive: true });
49
+ const pidFile = resolve(ppmDir, "ppm.pid");
50
+
51
+ const child = Bun.spawn({
52
+ cmd: ["bun", "run", import.meta.dir + "/index.ts", "__serve__", String(port), host, options.config ?? ""],
53
+ stdio: ["ignore", "ignore", "ignore"],
54
+ env: process.env,
55
+ });
56
+
57
+ // Unref so parent can exit
58
+ child.unref();
59
+ writeFileSync(pidFile, String(child.pid));
60
+ console.log(`PPM daemon started (PID: ${child.pid}) on http://${host}:${port}`);
61
+ console.log(`PID file: ${pidFile}`);
62
+ process.exit(0);
63
+ }
64
+
65
+ // Foreground mode — with WebSocket support
66
+ const server = Bun.serve({
67
+ port,
68
+ hostname: host,
69
+ fetch(req, server) {
70
+ const url = new URL(req.url);
71
+
72
+ // WebSocket upgrade: /ws/project/:projectName/terminal/:id
73
+ if (url.pathname.startsWith("/ws/project/")) {
74
+ const parts = url.pathname.split("/");
75
+ // parts: ["", "ws", "project", projectName, type, id]
76
+ const projectName = parts[3] ?? "";
77
+ const wsType = parts[4] ?? "";
78
+ const id = parts[5] ?? "";
79
+
80
+ if (wsType === "terminal") {
81
+ const upgraded = server.upgrade(req, {
82
+ data: { type: "terminal", id, projectName },
83
+ });
84
+ if (upgraded) return undefined;
85
+ return new Response("WebSocket upgrade failed", { status: 400 });
86
+ }
87
+
88
+ if (wsType === "chat") {
89
+ const sessionId = id;
90
+ const upgraded = server.upgrade(req, {
91
+ data: { type: "chat", sessionId, projectName },
92
+ });
93
+ if (upgraded) return undefined;
94
+ return new Response("WebSocket upgrade failed", { status: 400 });
95
+ }
96
+ }
97
+
98
+ // Fall through to Hono for all other requests
99
+ return app.fetch(req, server);
100
+ },
101
+ websocket: {
102
+ open(ws: any) {
103
+ if (ws.data?.type === "chat") chatWebSocket.open(ws);
104
+ else terminalWebSocket.open(ws);
105
+ },
106
+ message(ws: any, msg: any) {
107
+ if (ws.data?.type === "chat") chatWebSocket.message(ws, msg);
108
+ else terminalWebSocket.message(ws, msg);
109
+ },
110
+ close(ws: any) {
111
+ if (ws.data?.type === "chat") chatWebSocket.close(ws);
112
+ else terminalWebSocket.close(ws);
113
+ },
114
+ } as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
115
+ });
116
+
117
+ console.log(`PPM server running on http://${host}:${server.port}`);
118
+ console.log(`Auth: ${configService.get("auth").enabled ? "enabled" : "disabled"}`);
119
+ if (configService.get("auth").enabled) {
120
+ console.log(`Token: ${configService.get("auth").token}`);
121
+ }
122
+ }
123
+
124
+ // Internal entry point for daemon child process
125
+ if (process.argv.includes("__serve__")) {
126
+ const idx = process.argv.indexOf("__serve__");
127
+ const port = parseInt(process.argv[idx + 1] ?? "8080", 10);
128
+ const host = process.argv[idx + 2] ?? "0.0.0.0";
129
+ const configPath = process.argv[idx + 3] || undefined;
130
+
131
+ configService.load(configPath);
132
+
133
+ Bun.serve({
134
+ port,
135
+ hostname: host,
136
+ fetch(req, server) {
137
+ const url = new URL(req.url);
138
+
139
+ // WebSocket upgrade: /ws/project/:projectName/terminal/:id
140
+ if (url.pathname.startsWith("/ws/project/")) {
141
+ const parts = url.pathname.split("/");
142
+ const projectName = parts[3] ?? "";
143
+ const wsType = parts[4] ?? "";
144
+ const id = parts[5] ?? "";
145
+
146
+ if (wsType === "terminal") {
147
+ const upgraded = server.upgrade(req, {
148
+ data: { type: "terminal", id, projectName },
149
+ });
150
+ if (upgraded) return undefined;
151
+ return new Response("WebSocket upgrade failed", { status: 400 });
152
+ }
153
+
154
+ if (wsType === "chat") {
155
+ const sessionId = id;
156
+ const upgraded = server.upgrade(req, {
157
+ data: { type: "chat", sessionId, projectName },
158
+ });
159
+ if (upgraded) return undefined;
160
+ return new Response("WebSocket upgrade failed", { status: 400 });
161
+ }
162
+ }
163
+
164
+ return app.fetch(req, server);
165
+ },
166
+ websocket: {
167
+ open(ws: any) {
168
+ if (ws.data?.type === "chat") chatWebSocket.open(ws);
169
+ else terminalWebSocket.open(ws);
170
+ },
171
+ message(ws: any, msg: any) {
172
+ if (ws.data?.type === "chat") chatWebSocket.message(ws, msg);
173
+ else terminalWebSocket.message(ws, msg);
174
+ },
175
+ close(ws: any) {
176
+ if (ws.data?.type === "chat") chatWebSocket.close(ws);
177
+ else terminalWebSocket.close(ws);
178
+ },
179
+ } as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
180
+ });
181
+ }
@@ -0,0 +1,30 @@
1
+ import type { Context, Next } from "hono";
2
+ import { configService } from "../../services/config.service.ts";
3
+ import { err } from "../../types/api.ts";
4
+
5
+ /** Auth middleware — checks Bearer token against config */
6
+ export async function authMiddleware(c: Context, next: Next) {
7
+ const authConfig = configService.get("auth");
8
+
9
+ // Skip auth if disabled
10
+ if (!authConfig.enabled) {
11
+ return next();
12
+ }
13
+
14
+ // Allow health check without auth
15
+ if (c.req.path === "/api/health") {
16
+ return next();
17
+ }
18
+
19
+ const header = c.req.header("Authorization");
20
+ if (!header || !header.startsWith("Bearer ")) {
21
+ return c.json(err("Unauthorized"), 401);
22
+ }
23
+
24
+ const token = header.slice(7);
25
+ if (token !== authConfig.token) {
26
+ return c.json(err("Unauthorized"), 401);
27
+ }
28
+
29
+ return next();
30
+ }