@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
@@ -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,92 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdirSync, existsSync, readdirSync, readFileSync, writeFileSync, rmSync, utimesSync } from "fs";
3
+ import { join } from "path";
4
+
5
+ const TEST_LOG_DIR = join(process.cwd(), "test-logs");
6
+
7
+ describe("Logger", () => {
8
+ beforeEach(async () => {
9
+ if (existsSync(TEST_LOG_DIR)) {
10
+ rmSync(TEST_LOG_DIR, { recursive: true, force: true });
11
+ }
12
+ mkdirSync(TEST_LOG_DIR, { recursive: true });
13
+ });
14
+
15
+ afterEach(() => {
16
+ if (existsSync(TEST_LOG_DIR)) {
17
+ rmSync(TEST_LOG_DIR, { recursive: true, force: true });
18
+ }
19
+ });
20
+
21
+ it("should write logs asynchronously", async () => {
22
+ const { createLogger, configureLogDir, flushLogs } = await import("../logger");
23
+ await configureLogDir(TEST_LOG_DIR);
24
+
25
+ const logger = createLogger("test");
26
+ logger.info("test message");
27
+
28
+ await flushLogs();
29
+ await new Promise((r) => setTimeout(r, 100));
30
+
31
+ const files = readdirSync(TEST_LOG_DIR);
32
+ expect(files.length).toBeGreaterThan(0);
33
+
34
+ const content = readFileSync(join(TEST_LOG_DIR, files[0]), "utf-8");
35
+ expect(content).toContain("test message");
36
+ });
37
+
38
+ it("should support multiple log levels", async () => {
39
+ const { createLogger, configureLogDir, flushLogs } = await import("../logger");
40
+ await configureLogDir(TEST_LOG_DIR);
41
+
42
+ const logger = createLogger("test");
43
+ logger.debug("debug msg");
44
+ logger.info("info msg");
45
+ logger.warn("warn msg");
46
+ logger.error("error msg");
47
+
48
+ await flushLogs();
49
+
50
+ const files = readdirSync(TEST_LOG_DIR);
51
+ const content = readFileSync(join(TEST_LOG_DIR, files[0]), "utf-8");
52
+ expect(content).toContain("info msg");
53
+ expect(content).toContain("warn msg");
54
+ expect(content).toContain("error msg");
55
+ });
56
+
57
+ it("should clean up old log files based on maxAgeDays", async () => {
58
+ const { configureLogDir } = await import("../logger");
59
+
60
+ const oldDate = new Date(Date.now() - 10 * 86400000).toISOString().slice(0, 10);
61
+ const oldPath = join(TEST_LOG_DIR, `${oldDate}.log`);
62
+ writeFileSync(oldPath, "old log");
63
+ const oldTime = new Date(Date.now() - 10 * 86400000);
64
+ utimesSync(oldPath, oldTime, oldTime);
65
+
66
+ const todayFile = new Date().toISOString().slice(0, 10);
67
+ writeFileSync(join(TEST_LOG_DIR, `${todayFile}.log`), "today log");
68
+
69
+ await configureLogDir(TEST_LOG_DIR, { maxAgeDays: 5 });
70
+
71
+ const files = readdirSync(TEST_LOG_DIR);
72
+ expect(files.find((f) => f.includes(oldDate))).toBeUndefined();
73
+ expect(files.find((f) => f.includes(todayFile))).toBeDefined();
74
+ });
75
+
76
+ it("should flush all pending writes", async () => {
77
+ const { createLogger, configureLogDir, flushLogs } = await import("../logger");
78
+ await configureLogDir(TEST_LOG_DIR);
79
+
80
+ const logger = createLogger("test");
81
+ for (let i = 0; i < 100; i++) {
82
+ logger.info(`msg ${i}`);
83
+ }
84
+
85
+ await flushLogs();
86
+
87
+ const files = readdirSync(TEST_LOG_DIR);
88
+ const content = readFileSync(join(TEST_LOG_DIR, files[0]), "utf-8");
89
+ expect(content).toContain("msg 0");
90
+ expect(content).toContain("msg 99");
91
+ });
92
+ });
@@ -1,130 +1,152 @@
1
- import { appendFile, mkdir as mkdirAsync, readdir as readdirAsync, unlink as unlinkAsync, stat as statAsync } from "fs/promises";
1
+ import {
2
+ appendFile,
3
+ mkdir as mkdirAsync,
4
+ readdir as readdirAsync,
5
+ unlink as unlinkAsync,
6
+ stat as statAsync,
7
+ } from "fs/promises";
2
8
  import { mkdirSync, existsSync } from "fs";
3
9
  import { join } from "path";
4
10
 
5
- export type LogModule = "server" | "gateway" | "system" | "chat" | "file" | "timer" | "git" | "feed";
11
+ export type LogModule =
12
+ | "server"
13
+ | "gateway"
14
+ | "system"
15
+ | "chat"
16
+ | "file"
17
+ | "timer"
18
+ | "git"
19
+ | "feed"
20
+ | "web-server";
6
21
  type LogLevel = "debug" | "info" | "warn" | "error";
7
22
 
8
23
  interface LogEntry {
9
- timestamp: string;
10
- level: LogLevel;
11
- module: LogModule;
12
- message: string;
13
- data?: Record<string, unknown>;
24
+ timestamp: string;
25
+ level: LogLevel;
26
+ module: LogModule;
27
+ message: string;
28
+ data?: Record<string, unknown>;
14
29
  }
15
30
 
16
31
  let _logDir: string | null = null;
17
32
  let _maxAgeDays = 30;
18
33
 
19
- export async function configureLogDir(dir: string, options?: { maxAgeDays?: number }): Promise<void> {
20
- _logDir = dir;
21
- if (options?.maxAgeDays !== undefined) {
22
- _maxAgeDays = options.maxAgeDays;
23
- }
24
- if (!existsSync(dir)) {
25
- mkdirSync(dir, { recursive: true });
26
- }
27
- await cleanOldLogs(dir, _maxAgeDays);
34
+ export async function configureLogDir(
35
+ dir: string,
36
+ options?: { maxAgeDays?: number }
37
+ ): Promise<void> {
38
+ _logDir = dir;
39
+ if (options?.maxAgeDays !== undefined) {
40
+ _maxAgeDays = options.maxAgeDays;
41
+ }
42
+ if (!existsSync(dir)) {
43
+ mkdirSync(dir, { recursive: true });
44
+ }
45
+ await cleanOldLogs(dir, _maxAgeDays);
28
46
  }
29
47
 
30
48
  function getLogDir(): string {
31
- if (!_logDir) {
32
- _logDir = "logs";
33
- if (!existsSync(_logDir)) {
34
- mkdirSync(_logDir, { recursive: true });
35
- }
36
- }
37
- return _logDir;
49
+ if (!_logDir) {
50
+ _logDir = "logs";
51
+ if (!existsSync(_logDir)) {
52
+ mkdirSync(_logDir, { recursive: true });
53
+ }
54
+ }
55
+ return _logDir;
38
56
  }
39
57
 
40
58
  async function cleanOldLogs(dir: string, maxAgeDays: number): Promise<void> {
41
- try {
42
- if (!existsSync(dir)) return;
43
- const files = await readdirAsync(dir);
44
- const cutoff = Date.now() - maxAgeDays * 86400000;
45
- for (const f of files) {
46
- if (!f.endsWith(".log")) continue;
47
- const filePath = join(dir, f);
48
- const s = await statAsync(filePath);
49
- if (s.mtimeMs < cutoff) {
50
- await unlinkAsync(filePath);
51
- }
52
- }
53
- } catch { /* ignore */ }
59
+ try {
60
+ if (!existsSync(dir)) return;
61
+ const files = await readdirAsync(dir);
62
+ const cutoff = Date.now() - maxAgeDays * 86400000;
63
+ for (const f of files) {
64
+ if (!f.endsWith(".log")) continue;
65
+ const filePath = join(dir, f);
66
+ const s = await statAsync(filePath);
67
+ if (s.mtimeMs < cutoff) {
68
+ await unlinkAsync(filePath);
69
+ }
70
+ }
71
+ } catch {
72
+ /* ignore */
73
+ }
54
74
  }
55
75
 
56
76
  function formatLine(entry: LogEntry): string {
57
- const base = `[${entry.timestamp}] [${entry.level.toUpperCase()}] [${entry.module}] ${entry.message}`;
58
- return entry.data ? `${base} ${JSON.stringify(entry.data)}` : base;
77
+ const base = `[${entry.timestamp}] [${entry.level.toUpperCase()}] [${entry.module}] ${entry.message}`;
78
+ return entry.data ? `${base} ${JSON.stringify(entry.data)}` : base;
59
79
  }
60
80
 
61
81
  let writeQueue: Promise<void> = Promise.resolve();
62
82
 
63
83
  function writeToFile(line: string): void {
64
- writeQueue = writeQueue.then(async () => {
65
- try {
66
- const date = new Date().toISOString().slice(0, 10);
67
- const dir = getLogDir();
68
- if (!existsSync(dir)) {
69
- await mkdirAsync(dir, { recursive: true });
70
- }
71
- await appendFile(join(dir, `${date}.log`), `${line}\n`);
72
- } catch { /* ignore */ }
73
- });
84
+ writeQueue = writeQueue.then(async () => {
85
+ try {
86
+ const date = new Date().toISOString().slice(0, 10);
87
+ const dir = getLogDir();
88
+ if (!existsSync(dir)) {
89
+ await mkdirAsync(dir, { recursive: true });
90
+ }
91
+ await appendFile(join(dir, `${date}.log`), `${line}\n`);
92
+ } catch {
93
+ /* ignore */
94
+ }
95
+ });
74
96
  }
75
97
 
76
98
  export function flushLogs(): Promise<void> {
77
- return writeQueue;
99
+ return writeQueue;
78
100
  }
79
101
 
80
102
  export class Logger {
81
- private readonly module: LogModule;
82
-
83
- constructor(module: LogModule) {
84
- this.module = module;
85
- }
86
-
87
- debug(message: string, data?: Record<string, unknown>): void {
88
- this.write("debug", message, data);
89
- }
90
-
91
- info(message: string, data?: Record<string, unknown>): void {
92
- this.write("info", message, data);
93
- }
94
-
95
- warn(message: string, data?: Record<string, unknown>): void {
96
- this.write("warn", message, data);
97
- }
98
-
99
- error(message: string, data?: Record<string, unknown>): void {
100
- this.write("error", message, data);
101
- }
102
-
103
- private write(level: LogLevel, message: string, data?: Record<string, unknown>): void {
104
- const entry: LogEntry = {
105
- timestamp: new Date().toISOString(),
106
- level,
107
- module: this.module,
108
- message,
109
- ...(data ? { data } : {}),
110
- };
111
-
112
- const line = formatLine(entry);
113
-
114
- if (level === "error") {
115
- console.error(line);
116
- } else if (level === "warn") {
117
- console.warn(line);
118
- } else {
119
- console.log(line);
120
- }
121
-
122
- if (process.env.NODE_ENV !== "production" || level !== "debug") {
123
- writeToFile(line);
124
- }
125
- }
103
+ private readonly module: LogModule;
104
+
105
+ constructor(module: LogModule) {
106
+ this.module = module;
107
+ }
108
+
109
+ debug(message: string, data?: Record<string, unknown>): void {
110
+ this.write("debug", message, data);
111
+ }
112
+
113
+ info(message: string, data?: Record<string, unknown>): void {
114
+ this.write("info", message, data);
115
+ }
116
+
117
+ warn(message: string, data?: Record<string, unknown>): void {
118
+ this.write("warn", message, data);
119
+ }
120
+
121
+ error(message: string, data?: Record<string, unknown>): void {
122
+ this.write("error", message, data);
123
+ }
124
+
125
+ private write(level: LogLevel, message: string, data?: Record<string, unknown>): void {
126
+ const entry: LogEntry = {
127
+ timestamp: new Date().toISOString(),
128
+ level,
129
+ module: this.module,
130
+ message,
131
+ ...(data ? { data } : {}),
132
+ };
133
+
134
+ const line = formatLine(entry);
135
+
136
+ if (level === "error") {
137
+ console.error(line);
138
+ } else if (level === "warn") {
139
+ console.warn(line);
140
+ } else {
141
+ console.log(line);
142
+ }
143
+
144
+ if (process.env.NODE_ENV !== "production" || level !== "debug") {
145
+ writeToFile(line);
146
+ }
147
+ }
126
148
  }
127
149
 
128
150
  export function createLogger(module: LogModule): Logger {
129
- return new Logger(module);
151
+ return new Logger(module);
130
152
  }