@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,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,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
|
+
}
|