@hienlh/ppm 0.9.0-beta.8 → 0.9.1

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/CHANGELOG.md +238 -0
  2. package/bun.lock +17 -0
  3. package/dist/web/assets/api-settings-BUvk6Saw.js +1 -0
  4. package/dist/web/assets/arrow-up-BYhx9ckd.js +1 -0
  5. package/dist/web/assets/browser-tab-CrkhFCaw.js +1 -0
  6. package/dist/web/assets/chat-tab-C6jpiwh7.js +8 -0
  7. package/dist/web/assets/chevron-right-5HgK6l7K.js +1 -0
  8. package/dist/web/assets/code-editor-CBIPzlP2.js +2 -0
  9. package/dist/web/assets/columns-2-cEVJHYd7.js +1 -0
  10. package/dist/web/assets/createLucideIcon-PuMiQgHl.js +1 -0
  11. package/dist/web/assets/{csv-preview-DLqYtXxt.js → csv-preview-ncSOnJSC.js} +2 -2
  12. package/dist/web/assets/database-viewer-BqOJR_zi.js +1 -0
  13. package/dist/web/assets/diff-viewer-CcLyp4eY.js +4 -0
  14. package/dist/web/assets/{dist-CALwEtco.js → dist-DIV6WgAG.js} +1 -1
  15. package/dist/web/assets/{dist-DGDPTxs1.js → dist-ovWkrgO-.js} +1 -1
  16. package/dist/web/assets/extension-webview-NiZ7Ybvv.js +3 -0
  17. package/dist/web/assets/git-graph-CoTvMrIo.js +1 -0
  18. package/dist/web/assets/index-C8byznLO.js +37 -0
  19. package/dist/web/assets/index-KwC2YrG4.css +2 -0
  20. package/dist/web/assets/jsx-runtime-kMwlnEGE.js +1 -0
  21. package/dist/web/assets/keybindings-store-DPYzBe_M.js +1 -0
  22. package/dist/web/assets/{markdown-renderer-DklUd_Gv.js → markdown-renderer-DPLdR9xc.js} +4 -4
  23. package/dist/web/assets/postgres-viewer-BeiK4lCa.js +1 -0
  24. package/dist/web/assets/settings-tab-D3AvU4lu.js +1 -0
  25. package/dist/web/assets/sqlite-viewer-nA2sD4Yv.js +1 -0
  26. package/dist/web/assets/tab-store-BOgTrqRr.js +1 -0
  27. package/dist/web/assets/table-DFevCOMd.js +1 -0
  28. package/dist/web/assets/tag-CXMT0QB6.js +1 -0
  29. package/dist/web/assets/{terminal-tab-CqRuiIFn.js → terminal-tab-BBi0pEji.js} +2 -2
  30. package/dist/web/assets/{use-monaco-theme-Dcz3aLAE.js → use-monaco-theme-B5pG2d1w.js} +1 -1
  31. package/dist/web/index.html +8 -8
  32. package/dist/web/monacoeditorwork/css.worker.bundle.js +122 -122
  33. package/dist/web/monacoeditorwork/editor.worker.bundle.js +78 -78
  34. package/dist/web/monacoeditorwork/html.worker.bundle.js +110 -110
  35. package/dist/web/monacoeditorwork/json.worker.bundle.js +108 -108
  36. package/dist/web/monacoeditorwork/ts.worker.bundle.js +81 -81
  37. package/dist/web/sw.js +1 -1
  38. package/docs/code-standards.md +128 -1
  39. package/docs/codebase-summary.md +79 -12
  40. package/docs/extension-development-guide.md +532 -0
  41. package/docs/project-changelog.md +51 -1
  42. package/docs/project-roadmap.md +9 -3
  43. package/docs/streaming-input-guide.md +267 -0
  44. package/docs/system-architecture.md +432 -3
  45. package/package.json +6 -3
  46. package/packages/ext-database/package.json +41 -0
  47. package/packages/ext-database/src/connection-tree.ts +142 -0
  48. package/packages/ext-database/src/extension.ts +346 -0
  49. package/packages/ext-database/src/query-panel.ts +120 -0
  50. package/packages/ext-database/src/table-viewer-panel.ts +410 -0
  51. package/packages/ext-database/tsconfig.json +8 -0
  52. package/packages/vscode-compat/package.json +16 -0
  53. package/packages/vscode-compat/src/commands.ts +39 -0
  54. package/packages/vscode-compat/src/context.ts +65 -0
  55. package/packages/vscode-compat/src/disposable.ts +21 -0
  56. package/packages/vscode-compat/src/env.ts +20 -0
  57. package/packages/vscode-compat/src/event-emitter.ts +28 -0
  58. package/packages/vscode-compat/src/index.ts +93 -0
  59. package/packages/vscode-compat/src/not-supported.ts +15 -0
  60. package/packages/vscode-compat/src/types.ts +167 -0
  61. package/packages/vscode-compat/src/uri.ts +65 -0
  62. package/packages/vscode-compat/src/window.ts +229 -0
  63. package/packages/vscode-compat/src/workspace.ts +76 -0
  64. package/packages/vscode-compat/tsconfig.json +10 -0
  65. package/snapshot-state.md +1526 -0
  66. package/src/cli/commands/autostart.ts +1 -1
  67. package/src/cli/commands/ext-cmd.ts +121 -0
  68. package/src/cli/commands/restart.ts +9 -1
  69. package/src/cli/commands/status.ts +19 -0
  70. package/src/index.ts +5 -3
  71. package/src/providers/claude-agent-sdk.ts +221 -17
  72. package/src/providers/cli-provider-base.ts +6 -0
  73. package/src/server/index.ts +55 -155
  74. package/src/server/routes/chat.ts +81 -11
  75. package/src/server/routes/extensions.ts +81 -0
  76. package/src/server/routes/project-scoped.ts +2 -0
  77. package/src/server/routes/settings.ts +27 -0
  78. package/src/server/routes/workspace.ts +35 -0
  79. package/src/server/ws/chat.ts +9 -3
  80. package/src/server/ws/extensions.ts +175 -0
  81. package/src/services/account-selector.service.ts +14 -5
  82. package/src/services/account.service.ts +20 -15
  83. package/src/services/claude-usage.service.ts +29 -24
  84. package/src/services/cloud-ws.service.ts +228 -0
  85. package/src/services/cloud.service.ts +11 -6
  86. package/src/services/contribution-registry.ts +110 -0
  87. package/src/services/db.service.ts +181 -4
  88. package/src/services/extension-host-worker.ts +160 -0
  89. package/src/services/extension-installer.ts +112 -0
  90. package/src/services/extension-manifest.ts +65 -0
  91. package/src/services/extension-rpc-handlers.ts +235 -0
  92. package/src/services/extension-rpc.ts +105 -0
  93. package/src/services/extension.service.ts +228 -0
  94. package/src/services/mcp-config.service.ts +15 -6
  95. package/src/services/supervisor.ts +271 -25
  96. package/src/types/api.ts +1 -0
  97. package/src/types/chat.ts +4 -0
  98. package/src/types/extension-messages.ts +64 -0
  99. package/src/types/extension.ts +131 -0
  100. package/src/web/app.tsx +69 -48
  101. package/src/web/components/chat/account-rotation-settings.tsx +163 -0
  102. package/src/web/components/chat/chat-history-bar.tsx +106 -10
  103. package/src/web/components/chat/chat-tab.tsx +15 -10
  104. package/src/web/components/chat/chat-welcome.tsx +148 -0
  105. package/src/web/components/chat/message-list.tsx +19 -6
  106. package/src/web/components/chat/session-picker.tsx +80 -32
  107. package/src/web/components/chat/usage-badge.tsx +68 -8
  108. package/src/web/components/editor/editor-breadcrumb.tsx +20 -29
  109. package/src/web/components/extensions/extension-inputbox.tsx +92 -0
  110. package/src/web/components/extensions/extension-quickpick.tsx +194 -0
  111. package/src/web/components/extensions/extension-tree-view.tsx +240 -0
  112. package/src/web/components/extensions/extension-webview.tsx +83 -0
  113. package/src/web/components/layout/command-palette.tsx +22 -2
  114. package/src/web/components/layout/editor-panel.tsx +163 -18
  115. package/src/web/components/layout/mobile-nav.tsx +2 -1
  116. package/src/web/components/layout/sidebar.tsx +21 -3
  117. package/src/web/components/layout/status-bar.tsx +64 -0
  118. package/src/web/components/layout/tab-bar.tsx +2 -0
  119. package/src/web/components/layout/tab-content.tsx +5 -0
  120. package/src/web/components/layout/upgrade-banner.tsx +15 -5
  121. package/src/web/components/settings/change-password-section.tsx +128 -0
  122. package/src/web/components/settings/extension-manager-section.tsx +214 -0
  123. package/src/web/components/settings/settings-tab.tsx +9 -2
  124. package/src/web/components/shared/connection-lost-overlay.tsx +89 -0
  125. package/src/web/hooks/use-chat.ts +28 -0
  126. package/src/web/hooks/use-extension-ws.ts +181 -0
  127. package/src/web/hooks/use-global-keybindings.ts +18 -2
  128. package/src/web/hooks/use-server-reload.ts +9 -0
  129. package/src/web/hooks/use-url-sync.ts +173 -21
  130. package/src/web/stores/connection-store.ts +39 -0
  131. package/src/web/stores/extension-store.ts +204 -0
  132. package/src/web/stores/panel-store.ts +63 -9
  133. package/src/web/stores/panel-utils.ts +145 -3
  134. package/src/web/stores/settings-store.ts +7 -2
  135. package/src/web/stores/tab-store.ts +2 -1
  136. package/test-session-ops.mjs +444 -0
  137. package/test-tokens.mjs +212 -0
  138. package/tsconfig.json +3 -1
  139. package/dist/web/assets/api-settings-D21InCnR.js +0 -1
  140. package/dist/web/assets/arrow-up--LjUXLEt.js +0 -1
  141. package/dist/web/assets/browser-tab-BEe89aSD.js +0 -1
  142. package/dist/web/assets/chat-tab-9lqvWozA.js +0 -7
  143. package/dist/web/assets/chevron-right-CHnjJt4E.js +0 -1
  144. package/dist/web/assets/code-editor-COAIZx-B.js +0 -2
  145. package/dist/web/assets/columns-2-DbesTfa7.js +0 -1
  146. package/dist/web/assets/database-viewer-aRR9n_Ui.js +0 -1
  147. package/dist/web/assets/diff-viewer-C4KMvpHr.js +0 -4
  148. package/dist/web/assets/dist-CVTST7Gc.js +0 -1
  149. package/dist/web/assets/git-graph-CfJjl4E3.js +0 -1
  150. package/dist/web/assets/index-Db8uky1a.css +0 -2
  151. package/dist/web/assets/index-DxZuwBDe.js +0 -37
  152. package/dist/web/assets/jsx-runtime-BRW_vwa9.js +0 -1
  153. package/dist/web/assets/keybindings-store-_uWVCZMv.js +0 -1
  154. package/dist/web/assets/postgres-viewer-DEAvAyaX.js +0 -1
  155. package/dist/web/assets/settings-tab-BQedc-No.js +0 -1
  156. package/dist/web/assets/sqlite-viewer-BPA5idzT.js +0 -1
  157. package/dist/web/assets/tab-store-DhK6EpBT.js +0 -1
  158. package/dist/web/assets/table-CQVQM2SB.js +0 -1
  159. package/dist/web/assets/tag-Q2dZiSPX.js +0 -1
@@ -0,0 +1,65 @@
1
+ import { resolve } from "node:path";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { readdir } from "node:fs/promises";
4
+ import type { ExtensionManifest } from "../types/extension.ts";
5
+
6
+ /** Parse a package.json object into an ExtensionManifest (or null if invalid) */
7
+ export function parseManifest(pkg: Record<string, unknown>): ExtensionManifest | null {
8
+ const name = pkg.name as string | undefined;
9
+ const version = pkg.version as string | undefined;
10
+ const main = pkg.main as string | undefined;
11
+ if (!name || !version || !main) return null;
12
+
13
+ const ppmField = pkg.ppm as Record<string, unknown> | undefined;
14
+ return {
15
+ id: name,
16
+ version,
17
+ main,
18
+ displayName: (ppmField?.displayName as string) || (pkg.displayName as string) || name,
19
+ description: pkg.description as string | undefined,
20
+ icon: (ppmField?.icon as string) || undefined,
21
+ engines: pkg.engines as ExtensionManifest["engines"],
22
+ activationEvents: pkg.activationEvents as string[] | undefined,
23
+ contributes: pkg.contributes as ExtensionManifest["contributes"],
24
+ ppm: ppmField as ExtensionManifest["ppm"],
25
+ permissions: pkg.permissions as string[] | undefined,
26
+ };
27
+ }
28
+
29
+ /** Read and parse manifest from a directory containing package.json */
30
+ export function readManifestAt(dir: string): ExtensionManifest | null {
31
+ const pkgPath = resolve(dir, "package.json");
32
+ if (!existsSync(pkgPath)) return null;
33
+ try {
34
+ const raw = JSON.parse(readFileSync(pkgPath, "utf-8"));
35
+ return parseManifest(raw);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /** Scan extensions directory for all valid manifests */
42
+ export async function discoverManifests(extensionsDir: string): Promise<ExtensionManifest[]> {
43
+ const manifests: ExtensionManifest[] = [];
44
+ if (!existsSync(extensionsDir)) return manifests;
45
+
46
+ const entries = await readdir(extensionsDir, { withFileTypes: true });
47
+ for (const entry of entries) {
48
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
49
+ if (entry.name === "node_modules" || entry.name === "package.json") continue;
50
+
51
+ // Handle scoped packages (@scope/name)
52
+ const entryPath = resolve(extensionsDir, entry.name);
53
+ if (entry.name.startsWith("@")) {
54
+ const scopedEntries = await readdir(entryPath, { withFileTypes: true });
55
+ for (const scoped of scopedEntries) {
56
+ const manifest = readManifestAt(resolve(entryPath, scoped.name));
57
+ if (manifest) manifests.push(manifest);
58
+ }
59
+ } else {
60
+ const manifest = readManifestAt(entryPath);
61
+ if (manifest) manifests.push(manifest);
62
+ }
63
+ }
64
+ return manifests;
65
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Main-side RPC handlers for vscode-compat API calls from the Worker.
3
+ * Each handler runs in the main process, accessing PPM services directly.
4
+ * UI-facing calls are forwarded to browser clients via the WS bridge.
5
+ */
6
+ import type { RpcChannel } from "./extension-rpc.ts";
7
+ import { contributionRegistry } from "./contribution-registry.ts";
8
+ import { broadcastExtMsg, requestFromBrowser } from "../server/ws/extensions.ts";
9
+
10
+ let requestIdCounter = 0;
11
+ function nextRequestId(): string {
12
+ return `req_${++requestIdCounter}_${Date.now()}`;
13
+ }
14
+
15
+ /** Register all vscode-compat RPC handlers on the given RPC channel */
16
+ export function registerVscodeCompatHandlers(rpc: RpcChannel): void {
17
+ // --- commands ---
18
+ rpc.onRequest("commands:execute", async (params) => {
19
+ const [command, ...args] = params as [string, ...unknown[]];
20
+ // Try contribution registry commands first (future: route to Worker handler)
21
+ const cmds = contributionRegistry.getCommands();
22
+ const found = cmds.find((c) => c.command === command);
23
+ if (!found) throw new Error(`Command not found: ${command}`);
24
+ // For now, command execution goes back to the Worker (round-trip)
25
+ return { executed: true, command };
26
+ });
27
+
28
+ rpc.onRequest("commands:list", async (params) => {
29
+ const [_filterInternal] = params as [boolean];
30
+ return contributionRegistry.getCommands().map((c) => c.command);
31
+ });
32
+
33
+ // --- window messages (forwarded to browser via WS bridge) ---
34
+ rpc.onRequest("window:showMessage", async (params) => {
35
+ const [level, message, items] = params as [string, string, string[]];
36
+ console.log(`[Ext:${level}] ${message}`);
37
+ if (items.length > 0) {
38
+ const requestId = nextRequestId();
39
+ const action = await requestFromBrowser<string | null>(
40
+ { type: "notification", id: requestId, level: level as "info" | "warn" | "error", message, actions: items },
41
+ requestId,
42
+ );
43
+ return action ?? undefined;
44
+ }
45
+ // No action items — just broadcast notification
46
+ broadcastExtMsg({ type: "notification", id: nextRequestId(), level: level as "info" | "warn" | "error", message });
47
+ return undefined;
48
+ });
49
+
50
+ rpc.onRequest("window:showQuickPick", async (params) => {
51
+ const [items, options] = params as [unknown[], unknown];
52
+ const requestId = nextRequestId();
53
+ const selected = await requestFromBrowser<unknown[] | null>(
54
+ {
55
+ type: "quickpick:show", requestId,
56
+ items: items as { label: string; description?: string; detail?: string; picked?: boolean }[],
57
+ options: (options ?? {}) as { placeholder?: string; canPickMany?: boolean },
58
+ },
59
+ requestId,
60
+ );
61
+ return selected;
62
+ });
63
+
64
+ rpc.onRequest("window:showInputBox", async (params) => {
65
+ const [options] = params as [unknown];
66
+ const requestId = nextRequestId();
67
+ const value = await requestFromBrowser<string | null>(
68
+ {
69
+ type: "inputbox:show", requestId,
70
+ options: (options ?? {}) as { prompt?: string; value?: string; placeholder?: string; password?: boolean },
71
+ },
72
+ requestId,
73
+ );
74
+ return value;
75
+ });
76
+
77
+ // --- status bar (forwarded to browser via WS bridge) ---
78
+ rpc.onRequest("window:statusbar:update", async (params) => {
79
+ const [item] = params as [{ id: string; text: string; tooltip?: string; command?: string; alignment: "left" | "right"; priority: number; extensionId?: string }];
80
+ broadcastExtMsg({ type: "statusbar:update", item });
81
+ return { ok: true };
82
+ });
83
+
84
+ rpc.onRequest("window:statusbar:remove", async (params) => {
85
+ const [itemId] = params as [string];
86
+ broadcastExtMsg({ type: "statusbar:remove", itemId });
87
+ return { ok: true };
88
+ });
89
+
90
+ // --- webview panels (forwarded to browser via WS bridge) ---
91
+ rpc.onRequest("window:webview:create", async (params) => {
92
+ const [panelId, extensionId, viewType, title] = params as [string, string, string, string];
93
+ broadcastExtMsg({ type: "webview:create", panelId, extensionId, viewType, title });
94
+ return { ok: true };
95
+ });
96
+
97
+ rpc.onRequest("window:webview:html", async (params) => {
98
+ const [panelId, html] = params as [string, string];
99
+ broadcastExtMsg({ type: "webview:html", panelId, html });
100
+ return { ok: true };
101
+ });
102
+
103
+ rpc.onRequest("window:webview:dispose", async (params) => {
104
+ const [panelId] = params as [string];
105
+ broadcastExtMsg({ type: "webview:dispose", panelId });
106
+ return { ok: true };
107
+ });
108
+
109
+ rpc.onRequest("window:webview:postMessage", async (params) => {
110
+ const [panelId, message] = params as [string, unknown];
111
+ broadcastExtMsg({ type: "webview:postMessage", panelId, message });
112
+ return { ok: true };
113
+ });
114
+
115
+ // --- tree views (forwarded to browser via WS bridge) ---
116
+ rpc.onRequest("window:tree:update", async (params) => {
117
+ const [viewId, items] = params as [string, unknown[]];
118
+ broadcastExtMsg({ type: "tree:update", viewId, items: items as any });
119
+ return { ok: true };
120
+ });
121
+
122
+ rpc.onRequest("window:tree:refresh", async (params) => {
123
+ const [viewId] = params as [string];
124
+ broadcastExtMsg({ type: "tree:refresh", viewId });
125
+ return { ok: true };
126
+ });
127
+
128
+ // --- workspace config ---
129
+ rpc.onRequest("workspace:config:get", async (params) => {
130
+ const [key] = params as [string];
131
+ // Read from PPM config service
132
+ try {
133
+ const { configService } = await import("./config.service.ts");
134
+ const config = configService.getAll() as unknown as Record<string, unknown>;
135
+ return getNestedValue(config, key) ?? null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ });
140
+
141
+ rpc.onRequest("workspace:config:update", async (params) => {
142
+ const [key, value, _target] = params as [string, unknown, unknown];
143
+ try {
144
+ const { configService } = await import("./config.service.ts");
145
+ configService.set(key as any, value);
146
+ } catch (e) {
147
+ console.error(`[Ext:config] update error for ${key}:`, e);
148
+ }
149
+ return { ok: true };
150
+ });
151
+
152
+ // --- workspace fs (path-restricted) ---
153
+
154
+ /** Validate path is within allowed roots. Throws if path escapes. */
155
+ async function assertSafePath(filePath: string): Promise<string> {
156
+ const { resolve, relative } = await import("node:path");
157
+ const resolved = resolve(filePath);
158
+ // Allow: CWD (project root) and ~/.ppm/extensions/ (extension storage)
159
+ const { homedir } = await import("node:os");
160
+ const allowedRoots = [resolve(process.cwd()), resolve(homedir(), ".ppm", "extensions")];
161
+ const isSafe = allowedRoots.some((root) => {
162
+ const rel = relative(root, resolved);
163
+ return !rel.startsWith("..") && !rel.startsWith("/");
164
+ });
165
+ if (!isSafe) throw new Error(`Path outside allowed scope: ${filePath}`);
166
+ return resolved;
167
+ }
168
+
169
+ rpc.onRequest("workspace:fs:readFile", async (params) => {
170
+ const [filePath] = params as [string];
171
+ const safePath = await assertSafePath(filePath);
172
+ const { readFileSync } = await import("node:fs");
173
+ const content = readFileSync(safePath);
174
+ return Buffer.from(content).toString("base64");
175
+ });
176
+
177
+ rpc.onRequest("workspace:fs:writeFile", async (params) => {
178
+ const [filePath, base64Content] = params as [string, string];
179
+ const safePath = await assertSafePath(filePath);
180
+ const { writeFileSync } = await import("node:fs");
181
+ writeFileSync(safePath, Buffer.from(base64Content, "base64"));
182
+ return { ok: true };
183
+ });
184
+
185
+ rpc.onRequest("workspace:fs:stat", async (params) => {
186
+ const [filePath] = params as [string];
187
+ const safePath = await assertSafePath(filePath);
188
+ const { statSync } = await import("node:fs");
189
+ const stat = statSync(safePath);
190
+ return {
191
+ type: stat.isDirectory() ? 2 : 1,
192
+ size: stat.size,
193
+ mtime: stat.mtimeMs,
194
+ };
195
+ });
196
+
197
+ rpc.onRequest("workspace:fs:readDirectory", async (params) => {
198
+ const [dirPath] = params as [string];
199
+ const safePath = await assertSafePath(dirPath);
200
+ const { readdirSync, statSync } = await import("node:fs");
201
+ const { resolve } = await import("node:path");
202
+ const entries = readdirSync(safePath);
203
+ return entries.map((name) => {
204
+ try {
205
+ const full = resolve(safePath, name);
206
+ const s = statSync(full);
207
+ return [name, s.isDirectory() ? 2 : 1] as [string, number];
208
+ } catch {
209
+ return [name, 0] as [string, number];
210
+ }
211
+ });
212
+ });
213
+
214
+ rpc.onRequest("workspace:findFiles", async (params) => {
215
+ const [pattern, maxResults] = params as [string, number];
216
+ const glob = new Bun.Glob(pattern);
217
+ const results: string[] = [];
218
+ for await (const path of glob.scan({ cwd: process.cwd() })) {
219
+ results.push(path);
220
+ if (results.length >= maxResults) break;
221
+ }
222
+ return results;
223
+ });
224
+ }
225
+
226
+ /** Get a nested value from an object by dot-separated key */
227
+ function getNestedValue(obj: Record<string, unknown>, key: string): unknown {
228
+ const parts = key.split(".");
229
+ let current: unknown = obj;
230
+ for (const part of parts) {
231
+ if (current == null || typeof current !== "object") return undefined;
232
+ current = (current as Record<string, unknown>)[part];
233
+ }
234
+ return current;
235
+ }
@@ -0,0 +1,105 @@
1
+ import type { RpcRequest, RpcResponse, RpcEvent, RpcMessage } from "../types/extension.ts";
2
+
3
+ const RPC_TIMEOUT = 10_000; // 10s per request
4
+
5
+ type RpcHandler = (params: unknown[]) => unknown | Promise<unknown>;
6
+
7
+ /**
8
+ * Typed RPC channel over Worker postMessage.
9
+ * Used by both main process (ExtensionService) and worker (ExtensionHost).
10
+ */
11
+ export class RpcChannel {
12
+ private nextId = 1;
13
+ private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
14
+ private handlers = new Map<string, RpcHandler>();
15
+ private eventHandlers = new Map<string, Set<(data: unknown) => void>>();
16
+ private postFn: (msg: RpcMessage) => void;
17
+
18
+ constructor(postFn: (msg: RpcMessage) => void) {
19
+ this.postFn = postFn;
20
+ }
21
+
22
+ /** Send a request and wait for response (with timeout) */
23
+ sendRequest<T = unknown>(method: string, ...params: unknown[]): Promise<T> {
24
+ const id = this.nextId++;
25
+ return new Promise<T>((resolve, reject) => {
26
+ const timer = setTimeout(() => {
27
+ this.pending.delete(id);
28
+ reject(new Error(`RPC timeout: ${method} (${RPC_TIMEOUT}ms)`));
29
+ }, RPC_TIMEOUT);
30
+
31
+ this.pending.set(id, {
32
+ resolve: resolve as (v: unknown) => void,
33
+ reject,
34
+ timer,
35
+ });
36
+
37
+ this.postFn({ type: "request", id, method, params });
38
+ });
39
+ }
40
+
41
+ /** Fire an event (no response expected) */
42
+ sendEvent(event: string, data: unknown): void {
43
+ this.postFn({ type: "event", event, data });
44
+ }
45
+
46
+ /** Register a handler for incoming requests */
47
+ onRequest(method: string, handler: RpcHandler): void {
48
+ this.handlers.set(method, handler);
49
+ }
50
+
51
+ /** Register a handler for incoming events */
52
+ onEvent(event: string, handler: (data: unknown) => void): void {
53
+ if (!this.eventHandlers.has(event)) this.eventHandlers.set(event, new Set());
54
+ this.eventHandlers.get(event)!.add(handler);
55
+ }
56
+
57
+ /** Process an incoming message (call from message event listener) */
58
+ async handleMessage(msg: RpcMessage): Promise<void> {
59
+ if (msg.type === "response") {
60
+ const pending = this.pending.get(msg.id);
61
+ if (!pending) return;
62
+ this.pending.delete(msg.id);
63
+ clearTimeout(pending.timer);
64
+ if (msg.error) pending.reject(new Error(msg.error));
65
+ else pending.resolve(msg.result);
66
+ return;
67
+ }
68
+
69
+ if (msg.type === "request") {
70
+ const handler = this.handlers.get(msg.method);
71
+ const response: RpcResponse = { type: "response", id: msg.id };
72
+ if (!handler) {
73
+ response.error = `No handler for method: ${msg.method}`;
74
+ } else {
75
+ try {
76
+ response.result = await handler(msg.params);
77
+ } catch (e) {
78
+ response.error = e instanceof Error ? e.message : String(e);
79
+ }
80
+ }
81
+ this.postFn(response);
82
+ return;
83
+ }
84
+
85
+ if (msg.type === "event") {
86
+ const handlers = this.eventHandlers.get(msg.event);
87
+ if (handlers) {
88
+ for (const h of handlers) {
89
+ try { h(msg.data); } catch (e) { console.error(`[RPC] Event handler error (${msg.event}):`, e); }
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ /** Clean up all pending requests */
96
+ dispose(): void {
97
+ for (const [, p] of this.pending) {
98
+ clearTimeout(p.timer);
99
+ p.reject(new Error("RPC channel disposed"));
100
+ }
101
+ this.pending.clear();
102
+ this.handlers.clear();
103
+ this.eventHandlers.clear();
104
+ }
105
+ }
@@ -0,0 +1,228 @@
1
+ import { resolve } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { existsSync } from "node:fs";
4
+ import type { ExtensionManifest, ExtensionInfo, RpcMessage } from "../types/extension.ts";
5
+ import { getExtensions, getExtensionById, insertExtension, getExtensionStorage, setExtensionStorageValue } from "./db.service.ts";
6
+ import { contributionRegistry } from "./contribution-registry.ts";
7
+ import { RpcChannel } from "./extension-rpc.ts";
8
+ import { parseManifest, discoverManifests } from "./extension-manifest.ts";
9
+ import { installExtension, removeExtension, devLinkExtension, ensureExtensionsDir } from "./extension-installer.ts";
10
+ import { registerVscodeCompatHandlers } from "./extension-rpc-handlers.ts";
11
+
12
+ const PPM_DIR = process.env.PPM_HOME || resolve(homedir(), ".ppm");
13
+ const EXTENSIONS_DIR = resolve(PPM_DIR, "extensions");
14
+
15
+ class ExtensionService {
16
+ private worker: Worker | null = null;
17
+ private rpc: RpcChannel | null = null;
18
+ private activatedIds = new Set<string>();
19
+ private workerReady = false;
20
+ private installing = new Set<string>();
21
+
22
+ // --- Worker lifecycle ---
23
+
24
+ private ensureWorker(): { worker: Worker; rpc: RpcChannel } {
25
+ if (this.worker && this.rpc) return { worker: this.worker, rpc: this.rpc };
26
+
27
+ const workerPath = new URL("./extension-host-worker.ts", import.meta.url).href;
28
+ this.worker = new Worker(workerPath, { type: "module" });
29
+ this.rpc = new RpcChannel((msg) => this.worker!.postMessage(msg));
30
+
31
+ this.rpc.onRequest("storage:set", async (params) => {
32
+ const [extId, scope, key, value] = params as [string, string, string, string | null];
33
+ setExtensionStorageValue(extId, scope, key, value);
34
+ return { ok: true };
35
+ });
36
+
37
+ // Register vscode-compat API handlers (commands, window, workspace, fs)
38
+ registerVscodeCompatHandlers(this.rpc);
39
+
40
+ this.rpc.onEvent("worker:ready", () => {
41
+ this.workerReady = true;
42
+ console.log("[ExtService] Extension host worker ready");
43
+ });
44
+
45
+ this.worker.addEventListener("message", (event: MessageEvent<RpcMessage>) => {
46
+ this.rpc!.handleMessage(event.data);
47
+ });
48
+ this.worker.addEventListener("error", (event) => {
49
+ console.error("[ExtService] Worker error:", event.message);
50
+ });
51
+
52
+ return { worker: this.worker, rpc: this.rpc };
53
+ }
54
+
55
+ private async terminateWorker(): Promise<void> {
56
+ if (this.rpc) { this.rpc.dispose(); this.rpc = null; }
57
+ if (this.worker) { this.worker.terminate(); this.worker = null; }
58
+ this.workerReady = false;
59
+ this.activatedIds.clear();
60
+ contributionRegistry.clear();
61
+ }
62
+
63
+ // --- Public API ---
64
+
65
+ parseManifest(pkg: Record<string, unknown>): ExtensionManifest | null {
66
+ return parseManifest(pkg);
67
+ }
68
+
69
+ async discover(): Promise<ExtensionManifest[]> {
70
+ ensureExtensionsDir(EXTENSIONS_DIR);
71
+ return discoverManifests(EXTENSIONS_DIR);
72
+ }
73
+
74
+ async install(name: string): Promise<ExtensionManifest> {
75
+ if (this.installing.has(name)) throw new Error(`Already installing ${name}`);
76
+ this.installing.add(name);
77
+ try {
78
+ return await installExtension(name, EXTENSIONS_DIR);
79
+ } finally {
80
+ this.installing.delete(name);
81
+ }
82
+ }
83
+
84
+ async remove(id: string): Promise<void> {
85
+ if (this.activatedIds.has(id)) await this.deactivate(id);
86
+ await removeExtension(id, EXTENSIONS_DIR);
87
+ contributionRegistry.unregister(id);
88
+ }
89
+
90
+ async activate(id: string): Promise<void> {
91
+ if (this.activatedIds.has(id)) return;
92
+
93
+ const row = getExtensionById(id);
94
+ if (!row) throw new Error(`Extension ${id} not found in DB`);
95
+ if (!row.enabled) throw new Error(`Extension ${id} is disabled`);
96
+
97
+ const manifest: ExtensionManifest = JSON.parse(row.manifest);
98
+ const extDir = resolve(EXTENSIONS_DIR, "node_modules", id);
99
+ const entryPath = resolve(extDir, manifest.main);
100
+ if (!existsSync(entryPath)) throw new Error(`Entry point not found: ${entryPath}`);
101
+
102
+ const { rpc } = this.ensureWorker();
103
+
104
+ // Hydrate persisted state so extensions can read it after activation
105
+ const globalStorage = getExtensionStorage(id, "global");
106
+ const workspaceStorage = getExtensionStorage(id, "workspace");
107
+ const storedState = {
108
+ global: Object.fromEntries(globalStorage.map((r) => [r.key, r.value])),
109
+ workspace: Object.fromEntries(workspaceStorage.map((r) => [r.key, r.value])),
110
+ };
111
+
112
+ // Pass server base URL so extensions can make fetch() calls in the Worker
113
+ const { configService: cfg } = await import("./config.service.ts");
114
+ const port = cfg.get("port") ?? 8080;
115
+ const baseUrl = `http://localhost:${port}`;
116
+
117
+ const result = await rpc.sendRequest<{ ok: boolean; error?: string }>(
118
+ "ext:activate", id, entryPath, extDir, storedState, baseUrl,
119
+ );
120
+ if (!result.ok) throw new Error(`Failed to activate ${id}: ${result.error}`);
121
+
122
+ this.activatedIds.add(id);
123
+ if (manifest.contributes) contributionRegistry.register(id, manifest.contributes);
124
+ this.broadcastContributions();
125
+ console.log(`[ExtService] Activated ${id}`);
126
+ }
127
+
128
+ async deactivate(id: string): Promise<void> {
129
+ if (!this.activatedIds.has(id)) return;
130
+ if (this.rpc) {
131
+ try { await this.rpc.sendRequest("ext:deactivate", id); } catch (e) {
132
+ console.error(`[ExtService] Error deactivating ${id}:`, e);
133
+ }
134
+ }
135
+ this.activatedIds.delete(id);
136
+ contributionRegistry.unregister(id);
137
+ this.broadcastContributions();
138
+ console.log(`[ExtService] Deactivated ${id}`);
139
+ }
140
+
141
+ list(): ExtensionInfo[] {
142
+ return getExtensions().map((row) => ({
143
+ id: row.id,
144
+ version: row.version,
145
+ displayName: row.display_name || row.id,
146
+ description: row.description || "",
147
+ icon: row.icon || "",
148
+ enabled: row.enabled === 1,
149
+ activated: this.activatedIds.has(row.id),
150
+ manifest: JSON.parse(row.manifest) as ExtensionManifest,
151
+ }));
152
+ }
153
+
154
+ get(id: string): ExtensionInfo | null {
155
+ const row = getExtensionById(id);
156
+ if (!row) return null;
157
+ return {
158
+ id: row.id,
159
+ version: row.version,
160
+ displayName: row.display_name || row.id,
161
+ description: row.description || "",
162
+ icon: row.icon || "",
163
+ enabled: row.enabled === 1,
164
+ activated: this.activatedIds.has(row.id),
165
+ manifest: JSON.parse(row.manifest) as ExtensionManifest,
166
+ };
167
+ }
168
+
169
+ async setEnabled(id: string, enabled: boolean): Promise<void> {
170
+ const row = getExtensionById(id);
171
+ if (!row) throw new Error(`Extension ${id} not found`);
172
+ const { updateExtension } = await import("./db.service.ts");
173
+ updateExtension(id, { enabled: enabled ? 1 : 0 });
174
+ if (enabled && !this.activatedIds.has(id)) await this.activate(id);
175
+ else if (!enabled && this.activatedIds.has(id)) await this.deactivate(id);
176
+ }
177
+
178
+ async devLink(localPath: string): Promise<ExtensionManifest> {
179
+ const manifest = devLinkExtension(localPath, EXTENSIONS_DIR);
180
+ // Auto-activate after dev-link (DB record is created with enabled=1)
181
+ if (!this.activatedIds.has(manifest.id)) {
182
+ try { await this.activate(manifest.id); } catch (e) {
183
+ console.error(`[ExtService] Auto-activate after dev-link failed:`, e);
184
+ }
185
+ }
186
+ return manifest;
187
+ }
188
+
189
+ async startup(): Promise<void> {
190
+ ensureExtensionsDir(EXTENSIONS_DIR);
191
+ const manifests = await this.discover();
192
+ for (const m of manifests) {
193
+ if (!getExtensionById(m.id)) {
194
+ insertExtension({
195
+ id: m.id, version: m.version,
196
+ display_name: m.displayName ?? null, description: m.description ?? null,
197
+ icon: m.icon ?? null, enabled: 1, manifest: JSON.stringify(m),
198
+ });
199
+ }
200
+ }
201
+ for (const row of getExtensions()) {
202
+ if (row.enabled !== 1) continue;
203
+ try { await this.activate(row.id); } catch (e) {
204
+ console.error(`[ExtService] Failed to activate ${row.id} on startup:`, e);
205
+ }
206
+ }
207
+ }
208
+
209
+ async shutdown(): Promise<void> {
210
+ for (const id of [...this.activatedIds]) {
211
+ try { await this.deactivate(id); } catch {}
212
+ }
213
+ await this.terminateWorker();
214
+ }
215
+
216
+ isActivated(id: string): boolean { return this.activatedIds.has(id); }
217
+ getExtensionsDir(): string { return EXTENSIONS_DIR; }
218
+
219
+ /** Push current contributions to all connected browser clients */
220
+ private broadcastContributions(): void {
221
+ try {
222
+ const { broadcastExtMsg } = require("../server/ws/extensions.ts");
223
+ broadcastExtMsg({ type: "contributions:update", contributions: contributionRegistry.getAll() });
224
+ } catch {}
225
+ }
226
+ }
227
+
228
+ export const extensionService = new ExtensionService();
@@ -27,13 +27,22 @@ export class McpConfigService {
27
27
 
28
28
  /** List all MCP servers as Record (SDK-compatible format) */
29
29
  list(): Record<string, McpServerConfig> {
30
- const rows = this.db.query("SELECT name, config FROM mcp_servers ORDER BY name").all() as { name: string; config: string }[];
31
- const result: Record<string, McpServerConfig> = {};
32
- for (const row of rows) {
33
- const parsed = safeParse(row.config, row.name);
34
- if (parsed) result[row.name] = parsed;
30
+ try {
31
+ const rows = this.db.query("SELECT name, config FROM mcp_servers ORDER BY name").all() as { name: string; config: string }[];
32
+ const result: Record<string, McpServerConfig> = {};
33
+ for (const row of rows) {
34
+ const parsed = safeParse(row.config, row.name);
35
+ if (parsed) result[row.name] = parsed;
36
+ }
37
+ return result;
38
+ } catch (e) {
39
+ const msg = (e as Error).message ?? String(e);
40
+ if (msg.includes("no such table")) {
41
+ console.warn("[mcp] mcp_servers table not found — returning empty list");
42
+ return {};
43
+ }
44
+ throw e;
35
45
  }
36
- return result;
37
46
  }
38
47
 
39
48
  /** List as array with metadata (for UI) */