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