@dyyz1993/create-agent 2.0.0 → 2.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 (165) hide show
  1. package/package.json +1 -1
  2. package/src/commands/create.ts +31 -31
  3. package/src/commands/workspace.ts +112 -105
  4. package/src/lib/copy.ts +24 -1
  5. package/templates/agent/electron/main.js +46 -0
  6. package/templates/agent/electron/preload.js +5 -0
  7. package/templates/agent/electron-builder.json +40 -0
  8. package/templates/agent/eslint.config.mjs +17 -2
  9. package/templates/agent/package.json +69 -3
  10. package/templates/agent/src/__tests__/hybrid-mode.test.ts +126 -0
  11. package/templates/agent/src/__tests__/server-config-security.test.ts +55 -0
  12. package/templates/agent/src/bun/index.ts +96 -41
  13. package/templates/agent/src/bun/three.d.ts +1 -0
  14. package/templates/agent/src/gateway/__tests__/ws-handler-token.test.ts +118 -0
  15. package/templates/agent/src/gateway/http-routes.ts +1 -1
  16. package/templates/agent/src/gateway/ws-handler.ts +84 -56
  17. package/templates/agent/src/mainview/App.tsx +29 -26
  18. package/templates/agent/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
  19. package/templates/agent/src/mainview/__tests__/setup.ts +32 -29
  20. package/templates/agent/src/mainview/__tests__/theme-variables.test.ts +36 -0
  21. package/templates/agent/src/mainview/components/chat/ChatPanel.tsx +88 -88
  22. package/templates/agent/src/mainview/components/common/ErrorBoundary.tsx +1 -58
  23. package/templates/agent/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
  24. package/templates/agent/src/mainview/components/common/ThemeToggle.tsx +1 -0
  25. package/templates/agent/src/mainview/components/feed/FeedPanel.tsx +232 -210
  26. package/templates/agent/src/mainview/components/file-preview/VirtualizedCodeView.tsx +105 -81
  27. package/templates/agent/src/mainview/components/search/SearchPanel.tsx +427 -378
  28. package/templates/agent/src/mainview/components/todo/TodoPanel.tsx +149 -122
  29. package/templates/agent/src/mainview/hooks/use-input-history.ts +70 -61
  30. package/templates/agent/src/mainview/lib/api-client.ts +179 -172
  31. package/templates/agent/src/mainview/main.tsx +4 -10
  32. package/templates/agent/src/mainview/stores/use-feed-store.ts +107 -107
  33. package/templates/agent/src/mainview/utils/drop-handler.ts +114 -115
  34. package/templates/agent/src/server-config.ts +34 -32
  35. package/templates/agent/src/server.ts +42 -54
  36. package/templates/agent/src/shared/handlers/__tests__/chat-concurrency.test.ts +127 -0
  37. package/templates/agent/src/shared/handlers/__tests__/handler-isolation.test.ts +244 -0
  38. package/templates/agent/src/shared/handlers/bash.ts +65 -65
  39. package/templates/agent/src/shared/handlers/chat.ts +189 -155
  40. package/templates/agent/src/shared/handlers/debug.ts +12 -0
  41. package/templates/agent/src/shared/handlers/feed.ts +31 -32
  42. package/templates/agent/src/shared/handlers/git.ts +286 -243
  43. package/templates/agent/src/shared/handlers/index.ts +1 -0
  44. package/templates/agent/src/shared/handlers/rules.ts +31 -31
  45. package/templates/agent/src/shared/handlers/todo.ts +31 -31
  46. package/templates/agent/src/shared/http-routes.ts +250 -0
  47. package/templates/agent/src/shared/lib/__tests__/web-server.test.ts +190 -0
  48. package/templates/agent/src/shared/lib/bash-security.ts +43 -43
  49. package/templates/agent/src/shared/lib/logger.ts +117 -95
  50. package/templates/agent/src/shared/lib/web-server.ts +128 -0
  51. package/templates/agent/src/shared/modules/debug.ts +8 -0
  52. package/templates/agent/src/shared/rpc-schema.ts +16 -3
  53. package/templates/agent/test-upload.txt +0 -0
  54. package/templates/agent/tsconfig.ipc.json +14 -0
  55. package/templates/agent/tsconfig.json +13 -3
  56. package/templates/chat/eslint.config.mjs +15 -2
  57. package/templates/chat/package.json +13 -2
  58. package/templates/chat/src/__tests__/hybrid-mode.test.ts +125 -0
  59. package/templates/chat/src/__tests__/server-config-security.test.ts +55 -0
  60. package/templates/chat/src/__tests__/server-config.test.ts +46 -44
  61. package/templates/chat/src/bun/index.ts +94 -44
  62. package/templates/chat/src/bun/three.d.ts +1 -0
  63. package/templates/chat/src/gateway/http-routes.ts +1 -1
  64. package/templates/chat/src/gateway/ws-handler.ts +84 -56
  65. package/templates/chat/src/mainview/App.tsx +6 -0
  66. package/templates/chat/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
  67. package/templates/chat/src/mainview/__tests__/theme-variables.test.ts +36 -0
  68. package/templates/chat/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
  69. package/templates/chat/src/mainview/components/common/ThemeToggle.tsx +1 -0
  70. package/templates/chat/src/mainview/hooks/use-input-history.ts +70 -61
  71. package/templates/chat/src/mainview/lib/api-client.ts +180 -156
  72. package/templates/chat/src/mainview/lib/rpc-cache.ts +84 -0
  73. package/templates/chat/src/mainview/main.tsx +10 -6
  74. package/templates/chat/src/server-config.ts +33 -31
  75. package/templates/chat/src/server.ts +42 -60
  76. package/templates/chat/src/shared/handlers/chat.ts +196 -162
  77. package/templates/chat/src/shared/handlers/debug.ts +12 -0
  78. package/templates/chat/src/shared/handlers/index.ts +1 -0
  79. package/templates/chat/src/shared/http-routes.ts +250 -0
  80. package/templates/chat/src/shared/lib/__tests__/logger.test.ts +92 -0
  81. package/templates/chat/src/shared/lib/logger.ts +108 -95
  82. package/templates/chat/src/shared/lib/web-server.ts +128 -0
  83. package/templates/chat/src/shared/modules/debug.ts +8 -0
  84. package/templates/chat/src/shared/rpc-schema.ts +3 -2
  85. package/templates/chat/test-upload.txt +0 -0
  86. package/templates/chat/tsconfig.ipc.json +14 -0
  87. package/templates/chat/tsconfig.json +12 -2
  88. package/templates/general/eslint.config.mjs +15 -2
  89. package/templates/general/package.json +13 -2
  90. package/templates/general/src/__tests__/hybrid-mode.test.ts +125 -0
  91. package/templates/general/src/__tests__/server-config-security.test.ts +55 -0
  92. package/templates/general/src/__tests__/server-config.test.ts +46 -44
  93. package/templates/general/src/bun/index.ts +94 -44
  94. package/templates/general/src/bun/three.d.ts +1 -0
  95. package/templates/general/src/gateway/http-routes.ts +1 -1
  96. package/templates/general/src/gateway/ws-handler.ts +84 -56
  97. package/templates/general/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
  98. package/templates/general/src/mainview/__tests__/theme-variables.test.ts +36 -0
  99. package/templates/general/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
  100. package/templates/general/src/mainview/components/common/ThemeToggle.tsx +1 -0
  101. package/templates/general/src/mainview/components/feed/FeedPanel.tsx +241 -219
  102. package/templates/general/src/mainview/components/file-preview/VirtualizedCodeView.tsx +101 -81
  103. package/templates/general/src/mainview/components/layout/AppLayout.tsx +6 -0
  104. package/templates/general/src/mainview/components/search/SearchPanel.tsx +429 -378
  105. package/templates/general/src/mainview/hooks/use-input-history.ts +70 -61
  106. package/templates/general/src/mainview/lib/api-client.ts +180 -156
  107. package/templates/general/src/mainview/lib/rpc-cache.ts +84 -0
  108. package/templates/general/src/mainview/main.tsx +10 -6
  109. package/templates/general/src/mainview/stores/use-feed-store.ts +107 -107
  110. package/templates/general/src/mainview/utils/drop-handler.ts +114 -115
  111. package/templates/general/src/server-config.ts +33 -31
  112. package/templates/general/src/server.ts +42 -60
  113. package/templates/general/src/shared/handlers/__tests__/chat.test.ts +165 -160
  114. package/templates/general/src/shared/handlers/chat.ts +189 -155
  115. package/templates/general/src/shared/handlers/debug.ts +12 -0
  116. package/templates/general/src/shared/handlers/feed.ts +38 -40
  117. package/templates/general/src/shared/handlers/git.ts +286 -243
  118. package/templates/general/src/shared/handlers/index.ts +1 -0
  119. package/templates/general/src/shared/http-routes.ts +250 -0
  120. package/templates/general/src/shared/lib/__tests__/logger.test.ts +92 -0
  121. package/templates/general/src/shared/lib/logger.ts +117 -95
  122. package/templates/general/src/shared/lib/web-server.ts +128 -0
  123. package/templates/general/src/shared/modules/debug.ts +8 -0
  124. package/templates/general/src/shared/rpc-schema.ts +12 -2
  125. package/templates/general/test-upload.txt +0 -0
  126. package/templates/general/tsconfig.ipc.json +14 -0
  127. package/templates/general/tsconfig.json +12 -2
  128. package/templates/shared/__tests__/cors-security.test.ts +165 -0
  129. package/templates/shared/__tests__/file-path-security.test.ts +260 -0
  130. package/templates/shared/__tests__/http-routes.test.ts +174 -0
  131. package/templates/shared/__tests__/logger.test.ts +186 -0
  132. package/templates/shared/components/ErrorBoundary.tsx +59 -0
  133. package/templates/shared/env.d.ts +11 -0
  134. package/templates/shared/http-routes.ts +250 -0
  135. package/templates/shared/logger.ts +129 -0
  136. package/templates/shared/test-upload.txt +0 -0
  137. package/templates/shared/vite-base.config.ts +92 -0
  138. package/templates/shared/vitest-base.config.ts +42 -0
  139. package/templates/agent/eslint-plugin-rpc/index.js +0 -50
  140. package/templates/agent/eslint-plugin-rpc/package.json +0 -6
  141. package/templates/agent/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
  142. package/templates/agent/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
  143. package/templates/agent/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
  144. package/templates/agent/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
  145. package/templates/agent/eslint-plugin-rpc/rules/require-api-client.js +0 -58
  146. package/templates/agent/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
  147. package/templates/agent/eslint-plugin-rpc/rules/schema-merge-only.js +0 -55
  148. package/templates/chat/eslint-plugin-rpc/index.js +0 -50
  149. package/templates/chat/eslint-plugin-rpc/package.json +0 -6
  150. package/templates/chat/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
  151. package/templates/chat/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
  152. package/templates/chat/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
  153. package/templates/chat/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
  154. package/templates/chat/eslint-plugin-rpc/rules/require-api-client.js +0 -58
  155. package/templates/chat/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
  156. package/templates/chat/eslint-plugin-rpc/rules/schema-merge-only.js +0 -55
  157. package/templates/general/eslint-plugin-rpc/index.js +0 -50
  158. package/templates/general/eslint-plugin-rpc/package.json +0 -6
  159. package/templates/general/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
  160. package/templates/general/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
  161. package/templates/general/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
  162. package/templates/general/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
  163. package/templates/general/eslint-plugin-rpc/rules/require-api-client.js +0 -58
  164. package/templates/general/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
  165. package/templates/general/eslint-plugin-rpc/rules/schema-merge-only.js +0 -55
@@ -4,42 +4,42 @@ import type { RPCMethods, HandlerOptions } from "../rpc-schema";
4
4
  import type { TodoItem, TodoStatus } from "../modules/todo";
5
5
 
6
6
  type RegisterFn = <K extends keyof RPCMethods & string>(
7
- method: K,
8
- handler: (params: MethodParams<RPCMethods, K>) => Promise<MethodResult<RPCMethods, K>>,
7
+ method: K,
8
+ handler: (params: MethodParams<RPCMethods, K>) => Promise<MethodResult<RPCMethods, K>>
9
9
  ) => void;
10
10
 
11
- const items: TodoItem[] = [];
12
- let todoIdCounter = 1;
13
-
14
11
  export function register(server: RPCServer, _options: HandlerOptions): void {
15
- const r: RegisterFn = (method, handler) => {
16
- server.register(method, handler as (params: unknown) => Promise<unknown>);
17
- };
12
+ const items: TodoItem[] = [];
13
+ let todoIdCounter = 1;
14
+
15
+ const r: RegisterFn = (method, handler) => {
16
+ server.register(method, handler as (params: unknown) => Promise<unknown>);
17
+ };
18
18
 
19
- r("todo.list", async () => ({ items }));
19
+ r("todo.list", async () => ({ items }));
20
20
 
21
- r("todo.add", async (params) => {
22
- const item: TodoItem = {
23
- id: `todo-${todoIdCounter++}`,
24
- content: params.content,
25
- status: "pending",
26
- createdAt: Date.now(),
27
- };
28
- items.push(item);
29
- return { item };
30
- });
21
+ r("todo.add", async (params) => {
22
+ const item: TodoItem = {
23
+ id: `todo-${todoIdCounter++}`,
24
+ content: params.content,
25
+ status: "pending",
26
+ createdAt: Date.now(),
27
+ };
28
+ items.push(item);
29
+ return { item };
30
+ });
31
31
 
32
- r("todo.update", async (params) => {
33
- const item = items.find((i) => i.id === params.id);
34
- if (!item) throw new Error(`Todo ${params.id} not found`);
35
- item.status = params.status as TodoStatus;
36
- return { item };
37
- });
32
+ r("todo.update", async (params) => {
33
+ const item = items.find((i) => i.id === params.id);
34
+ if (!item) throw new Error(`Todo ${params.id} not found`);
35
+ item.status = params.status as TodoStatus;
36
+ return { item };
37
+ });
38
38
 
39
- r("todo.remove", async (params) => {
40
- const idx = items.findIndex((i) => i.id === params.id);
41
- if (idx === -1) return { success: false };
42
- items.splice(idx, 1);
43
- return { success: true };
44
- });
39
+ r("todo.remove", async (params) => {
40
+ const idx = items.findIndex((i) => i.id === params.id);
41
+ if (idx === -1) return { success: false };
42
+ items.splice(idx, 1);
43
+ return { success: true };
44
+ });
45
45
  }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * HTTP route handlers for the web gateway.
3
+ * Handles: /health, /info/{path}, /file/{path}, /file/upload
4
+ */
5
+
6
+ import type { IncomingMessage, ServerResponse } from "http";
7
+ import { stat, readFile, writeFile, mkdir } from "fs/promises";
8
+ import { existsSync } from "fs";
9
+ import { extname, basename, dirname, resolve } from "path";
10
+ import { timingSafeEqual } from "crypto";
11
+ import { createLogger } from "./lib/logger";
12
+
13
+ const log = createLogger("gateway");
14
+
15
+ function safeEqual(a: string, b: string): boolean {
16
+ const bufA = Buffer.from(a);
17
+ const bufB = Buffer.from(b);
18
+ if (bufA.length !== bufB.length) return false;
19
+ return timingSafeEqual(bufA, bufB);
20
+ }
21
+
22
+ const MIME_TYPES: Record<string, string> = {
23
+ ".html": "text/html",
24
+ ".css": "text/css",
25
+ ".js": "application/javascript",
26
+ ".json": "application/json",
27
+ ".png": "image/png",
28
+ ".jpg": "image/jpeg",
29
+ ".jpeg": "image/jpeg",
30
+ ".gif": "image/gif",
31
+ ".svg": "image/svg+xml",
32
+ ".ico": "image/x-icon",
33
+ ".txt": "text/plain",
34
+ ".md": "text/markdown",
35
+ ".ts": "text/plain",
36
+ ".tsx": "text/plain",
37
+ ".py": "text/plain",
38
+ ".pdf": "application/pdf",
39
+ ".zip": "application/zip",
40
+ ".mp4": "video/mp4",
41
+ ".mp3": "audio/mpeg",
42
+ ".wav": "audio/wav",
43
+ };
44
+
45
+ const ALLOWED_ROOTS = [resolve(process.cwd())];
46
+ function isPathAllowed(requestedPath: string): boolean {
47
+ const resolved = resolve(requestedPath);
48
+ return ALLOWED_ROOTS.some((root) => resolved === root || resolved.startsWith(root + "/"));
49
+ }
50
+
51
+ function verifyToken(req: IncomingMessage, authToken: string): boolean {
52
+ const auth = req.headers["authorization"];
53
+ if (auth && safeEqual(auth, `Bearer ${authToken}`)) return true;
54
+
55
+ if (req.url) {
56
+ try {
57
+ const url = new URL(req.url, "http://localhost");
58
+ const token = url.searchParams.get("token");
59
+ if (token && safeEqual(token, authToken)) return true;
60
+ } catch {
61
+ /* invalid URL */
62
+ }
63
+ }
64
+ return false;
65
+ }
66
+
67
+ export interface HttpRouteDeps {
68
+ config: {
69
+ readonly port: number;
70
+ readonly authToken: string;
71
+ readonly maxUploadSize: number;
72
+ readonly corsOrigin: string;
73
+ };
74
+ getWebSocketClientCount: () => number;
75
+ }
76
+
77
+ export function createHttpHandler(
78
+ deps: HttpRouteDeps
79
+ ): (req: IncomingMessage, res: ServerResponse) => void {
80
+ const { config: cfg, getWebSocketClientCount } = deps;
81
+
82
+ return async (req, res) => {
83
+ res.setHeader("Access-Control-Allow-Origin", cfg.corsOrigin);
84
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
85
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Range, Content-Type");
86
+ if (req.method === "OPTIONS") {
87
+ res.writeHead(204).end();
88
+ return;
89
+ }
90
+
91
+ if (!req.url) {
92
+ res.writeHead(400).end();
93
+ return;
94
+ }
95
+
96
+ const url = new URL(req.url, "http://localhost");
97
+
98
+ if (url.pathname === "/health") {
99
+ res.writeHead(200, { "Content-Type": "application/json" });
100
+ res.end(JSON.stringify({ status: "ok", clients: getWebSocketClientCount() }));
101
+ return;
102
+ }
103
+
104
+ if (!verifyToken(req, cfg.authToken)) {
105
+ log.warn("Auth failed", { path: url.pathname });
106
+ res.writeHead(401, { "Content-Type": "application/json" });
107
+ res.end(JSON.stringify({ error: "Unauthorized" }));
108
+ return;
109
+ }
110
+
111
+ if (url.pathname.startsWith("/info/")) {
112
+ await handleFileInfo(url.pathname.slice(6), res);
113
+ return;
114
+ }
115
+
116
+ if (url.pathname.startsWith("/file/")) {
117
+ if (url.pathname === "/file/upload" && req.method === "POST") {
118
+ await handleFileUpload(req, url.searchParams.get("path"), res, cfg.maxUploadSize);
119
+ return;
120
+ }
121
+ await handleFileContent(url.pathname.slice(6), req, res);
122
+ return;
123
+ }
124
+
125
+ res.writeHead(404);
126
+ res.end();
127
+ };
128
+ }
129
+
130
+ async function handleFileInfo(encodedPath: string, res: ServerResponse): Promise<void> {
131
+ const filePath = decodeURIComponent(encodedPath);
132
+ if (!isPathAllowed(filePath)) {
133
+ res.writeHead(403, { "Content-Type": "application/json" });
134
+ res.end(JSON.stringify({ error: "Path not allowed" }));
135
+ return;
136
+ }
137
+ try {
138
+ const s = await stat(filePath);
139
+ res.writeHead(200, { "Content-Type": "application/json" });
140
+ res.end(
141
+ JSON.stringify({
142
+ name: basename(filePath),
143
+ path: filePath,
144
+ size: s.size,
145
+ isDirectory: s.isDirectory(),
146
+ modified: s.mtime.toISOString(),
147
+ mimeType: s.isFile()
148
+ ? MIME_TYPES[extname(filePath)] || "application/octet-stream"
149
+ : undefined,
150
+ })
151
+ );
152
+ } catch {
153
+ res.writeHead(404, { "Content-Type": "application/json" });
154
+ res.end(JSON.stringify({ error: "File not found" }));
155
+ }
156
+ }
157
+
158
+ async function handleFileContent(
159
+ encodedPath: string,
160
+ req: IncomingMessage,
161
+ res: ServerResponse
162
+ ): Promise<void> {
163
+ const filePath = decodeURIComponent(encodedPath);
164
+ if (!isPathAllowed(filePath)) {
165
+ res.writeHead(403, { "Content-Type": "application/json" });
166
+ res.end(JSON.stringify({ error: "Path not allowed" }));
167
+ return;
168
+ }
169
+ try {
170
+ if (!existsSync(filePath)) {
171
+ res.writeHead(404, { "Content-Type": "application/json" });
172
+ res.end(JSON.stringify({ error: "File not found" }));
173
+ return;
174
+ }
175
+ const s = await stat(filePath);
176
+ const mimeType = MIME_TYPES[extname(filePath)] || "application/octet-stream";
177
+
178
+ const range = req.headers["range"];
179
+ if (range) {
180
+ const parts = range.replace(/bytes=/, "").split("-");
181
+ const start = parseInt(parts[0]!, 10);
182
+ const end = parts[1] ? parseInt(parts[1], 10) : s.size - 1;
183
+ const chunkSize = end - start + 1;
184
+
185
+ res.writeHead(206, {
186
+ "Content-Range": `bytes ${start}-${end}/${s.size}`,
187
+ "Accept-Ranges": "bytes",
188
+ "Content-Length": chunkSize,
189
+ "Content-Type": mimeType,
190
+ });
191
+ const buffer = await readFile(filePath);
192
+ res.end(buffer.subarray(start, end + 1));
193
+ } else {
194
+ res.writeHead(200, {
195
+ "Content-Length": s.size,
196
+ "Content-Type": mimeType,
197
+ "Accept-Ranges": "bytes",
198
+ });
199
+ const buffer = await readFile(filePath);
200
+ res.end(buffer);
201
+ }
202
+ log.info("File served", { path: filePath });
203
+ } catch {
204
+ res.writeHead(500, { "Content-Type": "application/json" });
205
+ res.end(JSON.stringify({ error: "Failed to read file" }));
206
+ }
207
+ }
208
+
209
+ async function handleFileUpload(
210
+ req: IncomingMessage,
211
+ destPath: string | null,
212
+ res: ServerResponse,
213
+ maxUploadSize: number
214
+ ): Promise<void> {
215
+ if (!destPath) {
216
+ res.writeHead(400, { "Content-Type": "application/json" });
217
+ res.end(JSON.stringify({ error: "Missing path parameter" }));
218
+ return;
219
+ }
220
+ if (!isPathAllowed(destPath)) {
221
+ res.writeHead(403, { "Content-Type": "application/json" });
222
+ res.end(JSON.stringify({ error: "Path not allowed" }));
223
+ return;
224
+ }
225
+ const contentLength = parseInt(req.headers["content-length"] || "0", 10);
226
+ if (contentLength > maxUploadSize) {
227
+ res.writeHead(413, { "Content-Type": "application/json" });
228
+ res.end(JSON.stringify({ error: `File too large, max ${maxUploadSize / 1024 / 1024}MB` }));
229
+ return;
230
+ }
231
+ try {
232
+ const chunks: Uint8Array[] = [];
233
+ for await (const chunk of req) {
234
+ const bytes =
235
+ typeof chunk === "string"
236
+ ? new TextEncoder().encode(chunk)
237
+ : new Uint8Array(chunk as ArrayBuffer);
238
+ chunks.push(bytes);
239
+ }
240
+ const body = Buffer.concat(chunks);
241
+ await mkdir(dirname(destPath), { recursive: true });
242
+ await writeFile(destPath, body);
243
+ log.info("File uploaded", { path: destPath, size: body.length });
244
+ res.writeHead(200, { "Content-Type": "application/json" });
245
+ res.end(JSON.stringify({ ok: true, path: destPath, size: body.length }));
246
+ } catch (err) {
247
+ res.writeHead(500, { "Content-Type": "application/json" });
248
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Upload failed" }));
249
+ }
250
+ }
@@ -0,0 +1,190 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import http from "http";
3
+ import { WebSocket } from "ws";
4
+ import {
5
+ createWebServer,
6
+ getLocalIP,
7
+ findAvailablePort,
8
+ type WebServerResult,
9
+ } from "../web-server";
10
+
11
+ function httpGet(url: string): Promise<{ status: number; body: string }> {
12
+ return new Promise((resolve, reject) => {
13
+ http
14
+ .get(url, (res) => {
15
+ let data = "";
16
+ res.on("data", (chunk) => {
17
+ data += chunk;
18
+ });
19
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, body: data }));
20
+ })
21
+ .on("error", reject);
22
+ });
23
+ }
24
+
25
+ describe("createWebServer 工厂函数", () => {
26
+ let webServer: WebServerResult | null = null;
27
+
28
+ afterEach(async () => {
29
+ if (webServer) {
30
+ await webServer.close();
31
+ webServer = null;
32
+ }
33
+ });
34
+
35
+ it("应返回 httpServer + wss + port + authToken", async () => {
36
+ webServer = await createWebServer({
37
+ port: 0,
38
+ authToken: "test-token-001",
39
+ maxUploadSize: 1024,
40
+ corsOrigin: "*",
41
+ });
42
+
43
+ expect(webServer.httpServer).toBeDefined();
44
+ expect(webServer.wss).toBeDefined();
45
+ expect(webServer.port).toBeGreaterThan(0);
46
+ expect(webServer.authToken).toBe("test-token-001");
47
+ expect(typeof webServer.close).toBe("function");
48
+ });
49
+
50
+ it("应启动 HTTP 服务并监听指定端口", async () => {
51
+ const testServer = http.createServer();
52
+ const port = await new Promise<number>((resolve) => {
53
+ testServer.listen(0, () => {
54
+ const addr = testServer.address();
55
+ resolve(typeof addr === "object" && addr ? addr.port : 3100);
56
+ });
57
+ });
58
+ await new Promise<void>((resolve) => testServer.close(() => resolve()));
59
+
60
+ webServer = await createWebServer({
61
+ port,
62
+ authToken: "test-token-002",
63
+ maxUploadSize: 1024,
64
+ corsOrigin: "*",
65
+ });
66
+
67
+ expect(webServer.port).toBe(port);
68
+
69
+ const { status, body } = await httpGet(`http://localhost:${webServer.port}/health`);
70
+ expect(status).toBe(200);
71
+ const json = JSON.parse(body);
72
+ expect(json.status).toBe("ok");
73
+ });
74
+
75
+ it("端口冲突时应自动递增到可用端口", async () => {
76
+ const blocker = http.createServer();
77
+ const blockerPort = await new Promise<number>((resolve) => {
78
+ blocker.listen(0, () => {
79
+ const addr = blocker.address();
80
+ resolve(typeof addr === "object" && addr ? addr.port : 3100);
81
+ });
82
+ });
83
+
84
+ webServer = await createWebServer({
85
+ port: blockerPort,
86
+ authToken: "test-token-003",
87
+ maxUploadSize: 1024,
88
+ corsOrigin: "*",
89
+ });
90
+
91
+ expect(webServer.port).not.toBe(blockerPort);
92
+ expect(webServer.port).toBeGreaterThan(blockerPort);
93
+
94
+ await new Promise<void>((resolve) => blocker.close(() => resolve()));
95
+ });
96
+
97
+ it("WS 客户端应能通过 token 连接", async () => {
98
+ webServer = await createWebServer({
99
+ port: 0,
100
+ authToken: "test-token-004",
101
+ maxUploadSize: 1024,
102
+ corsOrigin: "*",
103
+ });
104
+
105
+ const ws = new WebSocket(`ws://localhost:${webServer.port}/ws?token=${webServer.authToken}`);
106
+
107
+ await new Promise<void>((resolve, reject) => {
108
+ ws.on("open", () => {
109
+ ws.close();
110
+ resolve();
111
+ });
112
+ ws.on("error", reject);
113
+ });
114
+ });
115
+
116
+ it("无 token 的 WS 连接应被拒绝", async () => {
117
+ webServer = await createWebServer({
118
+ port: 0,
119
+ authToken: "test-token-005",
120
+ maxUploadSize: 1024,
121
+ corsOrigin: "*",
122
+ });
123
+
124
+ const ws = new WebSocket(`ws://localhost:${webServer.port}/ws`);
125
+
126
+ const closeCode = await new Promise<number>((resolve) => {
127
+ ws.on("close", (code) => resolve(code));
128
+ ws.on("error", () => {});
129
+ });
130
+ expect(closeCode).toBe(4001);
131
+ });
132
+
133
+ it("HTTP 请求需要 token 才能访问文件路由", async () => {
134
+ webServer = await createWebServer({
135
+ port: 0,
136
+ authToken: "test-token-006",
137
+ maxUploadSize: 1024,
138
+ corsOrigin: "*",
139
+ });
140
+
141
+ const { status } = await httpGet(`http://localhost:${webServer.port}/info/some/path`);
142
+ expect(status).toBe(401);
143
+ });
144
+
145
+ it("health 端点不需要 token", async () => {
146
+ webServer = await createWebServer({
147
+ port: 0,
148
+ authToken: "test-token-007",
149
+ maxUploadSize: 1024,
150
+ corsOrigin: "*",
151
+ });
152
+
153
+ const { status, body } = await httpGet(`http://localhost:${webServer.port}/health`);
154
+ expect(status).toBe(200);
155
+ const json = JSON.parse(body);
156
+ expect(json).toHaveProperty("status", "ok");
157
+ expect(json).toHaveProperty("clients");
158
+ });
159
+
160
+ it("关闭 webServer 应停止 HTTP 和 WS 服务", async () => {
161
+ webServer = await createWebServer({
162
+ port: 0,
163
+ authToken: "test-token-008",
164
+ maxUploadSize: 1024,
165
+ corsOrigin: "*",
166
+ });
167
+
168
+ const port = webServer.port;
169
+ await webServer.close();
170
+ webServer = null;
171
+
172
+ await expect(httpGet(`http://localhost:${port}/health`)).rejects.toThrow();
173
+ });
174
+ });
175
+
176
+ describe("getLocalIP", () => {
177
+ it("应返回非空字符串", () => {
178
+ const ip = getLocalIP();
179
+ expect(typeof ip).toBe("string");
180
+ expect(ip.length).toBeGreaterThan(0);
181
+ expect(ip).toMatch(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/);
182
+ });
183
+ });
184
+
185
+ describe("findAvailablePort", () => {
186
+ it("应返回一个可用端口", async () => {
187
+ const port = await findAvailablePort(40000);
188
+ expect(port).toBeGreaterThanOrEqual(40000);
189
+ });
190
+ });
@@ -1,64 +1,64 @@
1
1
  export interface CommandPolicy {
2
- enabled: boolean;
3
- blockedPatterns: RegExp[];
4
- allowedCommands: string[] | null;
2
+ enabled: boolean;
3
+ blockedPatterns: RegExp[];
4
+ allowedCommands: string[] | null;
5
5
  }
6
6
 
7
7
  let policy: CommandPolicy = {
8
- enabled: true,
9
- blockedPatterns: [
10
- /rm\s+-rf\s+(.*\s)?\/($|\s)/,
11
- /rm\s+-rf\s+--no-preserve-root/,
12
- /mkfs/,
13
- /dd\s+if=/,
14
- />\s*\/dev\//,
15
- /:()\s*\{.*\|.*&\s*\}/,
16
- /shutdown/,
17
- /reboot/,
18
- ],
19
- allowedCommands: null,
8
+ enabled: true,
9
+ blockedPatterns: [
10
+ /rm\s+-rf\s+(.*\s)?\/($|\s)/,
11
+ /rm\s+-rf\s+--no-preserve-root/,
12
+ /mkfs/,
13
+ /dd\s+if=/,
14
+ />\s*\/dev\//,
15
+ /:()\s*\{.*\|.*&\s*\}/,
16
+ /shutdown/,
17
+ /reboot/,
18
+ ],
19
+ allowedCommands: null,
20
20
  };
21
21
 
22
22
  export function setCommandPolicy(p: CommandPolicy): void {
23
- policy = p;
23
+ policy = p;
24
24
  }
25
25
 
26
26
  export function isCommandAllowed(command: string): boolean {
27
- if (!policy.enabled) return false;
27
+ if (!policy.enabled) return false;
28
28
 
29
- const trimmed = command.trim();
30
- if (!trimmed) return false;
29
+ const trimmed = command.trim();
30
+ if (!trimmed) return false;
31
31
 
32
- for (const pattern of policy.blockedPatterns) {
33
- if (pattern.test(trimmed)) return false;
34
- }
32
+ for (const pattern of policy.blockedPatterns) {
33
+ if (pattern.test(trimmed)) return false;
34
+ }
35
35
 
36
- if (policy.allowedCommands) {
37
- const baseCommand = trimmed.split(/\s+/)[0];
38
- return policy.allowedCommands.some(
39
- (allowed) => baseCommand === allowed || baseCommand.startsWith(allowed),
40
- );
41
- }
36
+ if (policy.allowedCommands) {
37
+ const baseCommand = trimmed.split(/\s+/)[0]!;
38
+ return policy.allowedCommands.some(
39
+ (allowed) => baseCommand === allowed || baseCommand.startsWith(allowed)
40
+ );
41
+ }
42
42
 
43
- return true;
43
+ return true;
44
44
  }
45
45
 
46
46
  export function validateCommand(command: string): string {
47
- if (!policy.enabled) {
48
- throw new Error("Bash execution is disabled");
49
- }
47
+ if (!policy.enabled) {
48
+ throw new Error("Bash execution is disabled");
49
+ }
50
50
 
51
- const trimmed = command.trim();
52
- if (!trimmed) {
53
- throw new Error("Command cannot be empty");
54
- }
51
+ const trimmed = command.trim();
52
+ if (!trimmed) {
53
+ throw new Error("Command cannot be empty");
54
+ }
55
55
 
56
- if (!isCommandAllowed(trimmed)) {
57
- if (policy.allowedCommands) {
58
- throw new Error(`Command not in whitelist: "${trimmed}"`);
59
- }
60
- throw new Error(`Command blocked for safety: "${trimmed}"`);
61
- }
56
+ if (!isCommandAllowed(trimmed)) {
57
+ if (policy.allowedCommands) {
58
+ throw new Error(`Command not in whitelist: "${trimmed}"`);
59
+ }
60
+ throw new Error(`Command blocked for safety: "${trimmed}"`);
61
+ }
62
62
 
63
- return trimmed;
63
+ return trimmed;
64
64
  }