@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
package/scripts/build.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { $ } from "bun";
|
|
2
|
+
|
|
3
|
+
console.log("Building PPM...");
|
|
4
|
+
|
|
5
|
+
// 1. Build frontend (Vite)
|
|
6
|
+
console.log("\n[1/2] Building frontend...");
|
|
7
|
+
await $`bun run vite build --config vite.config.ts`;
|
|
8
|
+
|
|
9
|
+
// 2. Compile backend + embedded frontend into single binary
|
|
10
|
+
console.log("\n[2/2] Compiling binary...");
|
|
11
|
+
await $`bun build src/index.ts --compile --outfile dist/ppm`;
|
|
12
|
+
|
|
13
|
+
console.log("\nBuild complete! Binary at dist/ppm");
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import * as readline from "node:readline";
|
|
3
|
+
import type { ChatEvent } from "../../types/chat.ts";
|
|
4
|
+
|
|
5
|
+
const C = {
|
|
6
|
+
reset: "\x1b[0m",
|
|
7
|
+
bold: "\x1b[1m",
|
|
8
|
+
green: "\x1b[32m",
|
|
9
|
+
red: "\x1b[31m",
|
|
10
|
+
yellow: "\x1b[33m",
|
|
11
|
+
cyan: "\x1b[36m",
|
|
12
|
+
dim: "\x1b[2m",
|
|
13
|
+
magenta: "\x1b[35m",
|
|
14
|
+
blue: "\x1b[34m",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function printTable(headers: string[], rows: string[][]): void {
|
|
18
|
+
const colWidths = headers.map((h, i) =>
|
|
19
|
+
Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)),
|
|
20
|
+
);
|
|
21
|
+
const sep = colWidths.map((w) => "-".repeat(w + 2)).join("+");
|
|
22
|
+
const headerLine = headers
|
|
23
|
+
.map((h, i) => ` ${h.padEnd(colWidths[i]!)} `)
|
|
24
|
+
.join("|");
|
|
25
|
+
console.log(`+${sep}+`);
|
|
26
|
+
console.log(`|${C.bold}${headerLine}${C.reset}|`);
|
|
27
|
+
console.log(`+${sep}+`);
|
|
28
|
+
for (const row of rows) {
|
|
29
|
+
const line = row.map((cell, i) => ` ${(cell ?? "").padEnd(colWidths[i]!)} `).join("|");
|
|
30
|
+
console.log(`|${line}|`);
|
|
31
|
+
}
|
|
32
|
+
console.log(`+${sep}+`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function streamEvents(
|
|
36
|
+
events: AsyncIterable<ChatEvent>,
|
|
37
|
+
onApproval?: (requestId: string, tool: string, input: unknown) => Promise<boolean>,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
for await (const event of events) {
|
|
40
|
+
switch (event.type) {
|
|
41
|
+
case "text":
|
|
42
|
+
process.stdout.write(event.content);
|
|
43
|
+
break;
|
|
44
|
+
case "tool_use":
|
|
45
|
+
process.stdout.write(`\n${C.dim}[Tool: ${event.tool}]${C.reset}\n`);
|
|
46
|
+
break;
|
|
47
|
+
case "tool_result":
|
|
48
|
+
// silent in non-interactive mode
|
|
49
|
+
break;
|
|
50
|
+
case "approval_request":
|
|
51
|
+
if (onApproval) {
|
|
52
|
+
const approved = await onApproval(event.requestId, event.tool, event.input);
|
|
53
|
+
if (!approved) {
|
|
54
|
+
process.stdout.write(`${C.yellow}[Tool denied]${C.reset}\n`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
case "error":
|
|
59
|
+
process.stderr.write(`\n${C.red}Error: ${event.message}${C.reset}\n`);
|
|
60
|
+
break;
|
|
61
|
+
case "done":
|
|
62
|
+
process.stdout.write("\n");
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function promptApproval(tool: string, input: unknown): Promise<boolean> {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
const rl = readline.createInterface({
|
|
71
|
+
input: process.stdin,
|
|
72
|
+
output: process.stdout,
|
|
73
|
+
});
|
|
74
|
+
const inputStr = typeof input === "object" ? JSON.stringify(input) : String(input);
|
|
75
|
+
rl.question(
|
|
76
|
+
`${C.yellow}[Tool: ${tool}]${C.reset} ${C.dim}${inputStr.slice(0, 80)}${C.reset}\nAllow? (y/n): `,
|
|
77
|
+
(answer) => {
|
|
78
|
+
rl.close();
|
|
79
|
+
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function registerChatCommands(program: Command): void {
|
|
86
|
+
const chat = program.command("chat").description("Manage AI chat sessions");
|
|
87
|
+
|
|
88
|
+
chat
|
|
89
|
+
.command("list")
|
|
90
|
+
.description("List all chat sessions")
|
|
91
|
+
.option("-p, --project <name>", "Filter by project name")
|
|
92
|
+
.action(async (options: { project?: string }) => {
|
|
93
|
+
try {
|
|
94
|
+
const { chatService } = await import("../../services/chat.service.ts");
|
|
95
|
+
const sessions = await chatService.listSessions();
|
|
96
|
+
|
|
97
|
+
const filtered = options.project
|
|
98
|
+
? sessions.filter((s) => s.projectName === options.project)
|
|
99
|
+
: sessions;
|
|
100
|
+
|
|
101
|
+
if (filtered.length === 0) {
|
|
102
|
+
console.log(`${C.yellow}No sessions found.${C.reset}`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const rows = filtered.map((s) => [
|
|
107
|
+
s.id.slice(0, 8) + "...",
|
|
108
|
+
s.providerId,
|
|
109
|
+
s.title || "(untitled)",
|
|
110
|
+
s.projectName ?? "-",
|
|
111
|
+
new Date(s.createdAt).toLocaleString(),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
printTable(["ID", "Provider", "Title", "Project", "Date"], rows);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(`${C.red}Error:${C.reset}`, (err as Error).message);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
chat
|
|
122
|
+
.command("create")
|
|
123
|
+
.description("Create a new chat session")
|
|
124
|
+
.option("-p, --project <name>", "Project name or path")
|
|
125
|
+
.option("--provider <provider>", "AI provider (default: claude)")
|
|
126
|
+
.action(async (options: { project?: string; provider?: string }) => {
|
|
127
|
+
try {
|
|
128
|
+
const { chatService } = await import("../../services/chat.service.ts");
|
|
129
|
+
|
|
130
|
+
let projectName: string | undefined;
|
|
131
|
+
let projectPath: string | undefined;
|
|
132
|
+
if (options.project) {
|
|
133
|
+
const { resolveProject } = await import("../utils/project-resolver.ts");
|
|
134
|
+
const proj = resolveProject(options);
|
|
135
|
+
projectName = proj.name;
|
|
136
|
+
projectPath = proj.path;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const session = await chatService.createSession(options.provider, {
|
|
140
|
+
projectName,
|
|
141
|
+
projectPath,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
console.log(`${C.green}Created session:${C.reset} ${C.cyan}${session.id}${C.reset}`);
|
|
145
|
+
console.log(`Provider: ${session.providerId}`);
|
|
146
|
+
if (projectName) console.log(`Project: ${projectName}`);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.error(`${C.red}Error:${C.reset}`, (err as Error).message);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
chat
|
|
154
|
+
.command("send <session-id> <message>")
|
|
155
|
+
.description("Send a message and stream response to stdout")
|
|
156
|
+
.option("-p, --project <name>", "Project name or path")
|
|
157
|
+
.action(async (sessionId: string, message: string, options: { project?: string }) => {
|
|
158
|
+
try {
|
|
159
|
+
const { chatService } = await import("../../services/chat.service.ts");
|
|
160
|
+
const { providerRegistry } = await import("../../providers/registry.ts");
|
|
161
|
+
|
|
162
|
+
// Determine provider from session listing
|
|
163
|
+
const sessions = await chatService.listSessions();
|
|
164
|
+
const session = sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId));
|
|
165
|
+
if (!session) {
|
|
166
|
+
console.error(`${C.red}Error:${C.reset} Session not found: ${sessionId}`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const events = chatService.sendMessage(session.providerId, session.id, message);
|
|
171
|
+
await streamEvents(events);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.error(`${C.red}Error:${C.reset}`, (err as Error).message);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
chat
|
|
179
|
+
.command("resume <session-id>")
|
|
180
|
+
.description("Resume an interactive chat session")
|
|
181
|
+
.option("-p, --project <name>", "Project name or path")
|
|
182
|
+
.action(async (sessionId: string, _options: { project?: string }) => {
|
|
183
|
+
try {
|
|
184
|
+
const { chatService } = await import("../../services/chat.service.ts");
|
|
185
|
+
|
|
186
|
+
const sessions = await chatService.listSessions();
|
|
187
|
+
const session = sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId));
|
|
188
|
+
if (!session) {
|
|
189
|
+
console.error(`${C.red}Error:${C.reset} Session not found: ${sessionId}`);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
console.log(`${C.green}Resuming session:${C.reset} ${session.id}`);
|
|
194
|
+
console.log(`${C.dim}Type your message and press Enter. Ctrl+C to exit.${C.reset}\n`);
|
|
195
|
+
|
|
196
|
+
const rl = readline.createInterface({
|
|
197
|
+
input: process.stdin,
|
|
198
|
+
output: process.stdout,
|
|
199
|
+
terminal: true,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const askQuestion = (): void => {
|
|
203
|
+
rl.question(`${C.bold}${C.blue}You:${C.reset} `, async (userInput) => {
|
|
204
|
+
const trimmed = userInput.trim();
|
|
205
|
+
if (!trimmed) {
|
|
206
|
+
askQuestion();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
process.stdout.write(`${C.bold}${C.magenta}Claude:${C.reset} `);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const events = chatService.sendMessage(session.providerId, session.id, trimmed);
|
|
214
|
+
await streamEvents(events, async (_requestId, tool, input) => {
|
|
215
|
+
const approved = await promptApproval(tool, input);
|
|
216
|
+
return approved;
|
|
217
|
+
});
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error(`\n${C.red}Error:${C.reset}`, (err as Error).message);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
askQuestion();
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
rl.on("close", () => {
|
|
227
|
+
console.log(`\n${C.dim}Session ended.${C.reset}`);
|
|
228
|
+
process.exit(0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
askQuestion();
|
|
232
|
+
} catch (err) {
|
|
233
|
+
console.error(`${C.red}Error:${C.reset}`, (err as Error).message);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
chat
|
|
239
|
+
.command("delete <session-id>")
|
|
240
|
+
.description("Delete a chat session")
|
|
241
|
+
.action(async (sessionId: string) => {
|
|
242
|
+
try {
|
|
243
|
+
const { chatService } = await import("../../services/chat.service.ts");
|
|
244
|
+
|
|
245
|
+
const sessions = await chatService.listSessions();
|
|
246
|
+
const session = sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId));
|
|
247
|
+
if (!session) {
|
|
248
|
+
console.error(`${C.red}Error:${C.reset} Session not found: ${sessionId}`);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await chatService.deleteSession(session.providerId, session.id);
|
|
253
|
+
console.log(`${C.green}Deleted session:${C.reset} ${session.id}`);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
console.error(`${C.red}Error:${C.reset}`, (err as Error).message);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import type { PpmConfig } from "../../types/config.ts";
|
|
3
|
+
|
|
4
|
+
const C = {
|
|
5
|
+
reset: "\x1b[0m",
|
|
6
|
+
green: "\x1b[32m",
|
|
7
|
+
red: "\x1b[31m",
|
|
8
|
+
yellow: "\x1b[33m",
|
|
9
|
+
cyan: "\x1b[36m",
|
|
10
|
+
bold: "\x1b[1m",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type FlatConfig = Record<string, string | number | boolean>;
|
|
14
|
+
|
|
15
|
+
function flattenConfig(obj: unknown, prefix = ""): FlatConfig {
|
|
16
|
+
const result: FlatConfig = {};
|
|
17
|
+
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
|
18
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
19
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
20
|
+
Object.assign(result, flattenConfig(v, key));
|
|
21
|
+
} else {
|
|
22
|
+
result[key] = v as string | number | boolean;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function setNestedValue(obj: Record<string, unknown>, keyPath: string, value: unknown): void {
|
|
29
|
+
const parts = keyPath.split(".");
|
|
30
|
+
let current = obj;
|
|
31
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
32
|
+
const part = parts[i]!;
|
|
33
|
+
if (typeof current[part] !== "object" || current[part] === null) {
|
|
34
|
+
current[part] = {};
|
|
35
|
+
}
|
|
36
|
+
current = current[part] as Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
const last = parts[parts.length - 1]!;
|
|
39
|
+
// Coerce type based on existing value
|
|
40
|
+
const existing = current[last];
|
|
41
|
+
if (typeof existing === "number") {
|
|
42
|
+
current[last] = Number(value);
|
|
43
|
+
} else if (typeof existing === "boolean") {
|
|
44
|
+
current[last] = value === "true" || value === "1";
|
|
45
|
+
} else {
|
|
46
|
+
current[last] = value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getNestedValue(obj: unknown, keyPath: string): unknown {
|
|
51
|
+
const parts = keyPath.split(".");
|
|
52
|
+
let current: unknown = obj;
|
|
53
|
+
for (const part of parts) {
|
|
54
|
+
if (current === null || typeof current !== "object") return undefined;
|
|
55
|
+
current = (current as Record<string, unknown>)[part];
|
|
56
|
+
}
|
|
57
|
+
return current;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function registerConfigCommands(program: Command): void {
|
|
61
|
+
const config = program.command("config").description("Get or set PPM configuration");
|
|
62
|
+
|
|
63
|
+
config
|
|
64
|
+
.command("get <key>")
|
|
65
|
+
.description("Get a config value (e.g. port, auth.enabled)")
|
|
66
|
+
.action(async (key: string) => {
|
|
67
|
+
try {
|
|
68
|
+
const { configService } = await import("../../services/config.service.ts");
|
|
69
|
+
configService.load();
|
|
70
|
+
const all = configService.getAll();
|
|
71
|
+
const value = getNestedValue(all, key);
|
|
72
|
+
if (value === undefined) {
|
|
73
|
+
console.error(`${C.red}Error:${C.reset} Config key "${key}" not found`);
|
|
74
|
+
console.log(`\nAvailable keys:`);
|
|
75
|
+
const flat = flattenConfig(all);
|
|
76
|
+
for (const k of Object.keys(flat).sort()) {
|
|
77
|
+
console.log(` ${C.cyan}${k}${C.reset}`);
|
|
78
|
+
}
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
if (typeof value === "object") {
|
|
82
|
+
console.log(JSON.stringify(value, null, 2));
|
|
83
|
+
} else {
|
|
84
|
+
console.log(`${C.bold}${key}${C.reset} = ${C.green}${String(value)}${C.reset}`);
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error(`${C.red}Error:${C.reset}`, (err as Error).message);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
config
|
|
93
|
+
.command("set <key> <value>")
|
|
94
|
+
.description("Set a config value (e.g. port 9090)")
|
|
95
|
+
.action(async (key: string, value: string) => {
|
|
96
|
+
try {
|
|
97
|
+
const { configService } = await import("../../services/config.service.ts");
|
|
98
|
+
configService.load();
|
|
99
|
+
const all = configService.getAll() as unknown as Record<string, unknown>;
|
|
100
|
+
|
|
101
|
+
const existing = getNestedValue(all, key);
|
|
102
|
+
if (existing === undefined) {
|
|
103
|
+
console.error(`${C.red}Error:${C.reset} Config key "${key}" not found`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setNestedValue(all, key, value);
|
|
108
|
+
|
|
109
|
+
// Re-apply back to config service via top-level keys
|
|
110
|
+
const topKey = key.split(".")[0] as keyof PpmConfig;
|
|
111
|
+
configService.set(topKey, all[topKey] as PpmConfig[typeof topKey]);
|
|
112
|
+
configService.save();
|
|
113
|
+
|
|
114
|
+
console.log(`${C.green}Updated:${C.reset} ${key} = ${value}`);
|
|
115
|
+
console.log(`${C.cyan}Saved to:${C.reset} ${configService.getConfigPath()}`);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(`${C.red}Error:${C.reset}`, (err as Error).message);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|