@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
@@ -1,150 +1,149 @@
1
1
  import { apiClient } from "../lib/api-client";
2
2
 
3
3
  export interface DropEntry {
4
- name: string;
5
- relativePath: string;
6
- file?: File;
7
- isDirectory: boolean;
8
- children?: DropEntry[];
4
+ name: string;
5
+ relativePath: string;
6
+ file?: File;
7
+ isDirectory: boolean;
8
+ children?: DropEntry[];
9
9
  }
10
10
 
11
11
  /**
12
12
  * 递归读取 webkitGetAsEntry,返回扁平文件列表 + 目录结构
13
13
  */
14
- function readEntry(
15
- entry: FileSystemEntry,
16
- path: string,
17
- ): Promise<DropEntry> {
18
- if (entry.isFile) {
19
- return new Promise((resolve) => {
20
- (entry as FileSystemFileEntry).file((file) => {
21
- resolve({ name: entry.name, relativePath: path, file, isDirectory: false });
22
- });
23
- });
24
- }
25
- // Directory
26
- return new Promise((resolve) => {
27
- const reader = (entry as FileSystemDirectoryEntry).createReader();
28
- const children: DropEntry[] = [];
14
+ function readEntry(entry: FileSystemEntry, path: string): Promise<DropEntry> {
15
+ if (entry.isFile) {
16
+ return new Promise((resolve) => {
17
+ (entry as FileSystemFileEntry).file((file) => {
18
+ resolve({ name: entry.name, relativePath: path, file, isDirectory: false });
19
+ });
20
+ });
21
+ }
22
+ // Directory
23
+ return new Promise((resolve) => {
24
+ const reader = (entry as FileSystemDirectoryEntry).createReader();
25
+ const children: DropEntry[] = [];
29
26
 
30
- const readBatch = () => {
31
- reader.readEntries(async (entries) => {
32
- if (entries.length === 0) {
33
- resolve({ name: entry.name, relativePath: path, isDirectory: true, children });
34
- return;
35
- }
36
- for (const e of entries) {
37
- children.push(await readEntry(e, `${path}/${e.name}`));
38
- }
39
- readBatch(); // readEntries may not return all entries in one call
40
- });
41
- };
42
- readBatch();
43
- });
27
+ const readBatch = () => {
28
+ reader.readEntries(async (entries) => {
29
+ if (entries.length === 0) {
30
+ resolve({ name: entry.name, relativePath: path, isDirectory: true, children });
31
+ return;
32
+ }
33
+ for (const e of entries) {
34
+ children.push(await readEntry(e, `${path}/${e.name}`));
35
+ }
36
+ readBatch(); // readEntries may not return all entries in one call
37
+ });
38
+ };
39
+ readBatch();
40
+ });
44
41
  }
45
42
 
46
43
  /**
47
44
  * 从 DataTransfer 递归读取所有文件和目录
48
45
  */
49
46
  export async function readDropItems(dataTransfer: DataTransfer): Promise<DropEntry[]> {
50
- const items = dataTransfer.items;
51
- if (!items) return [];
47
+ const items = dataTransfer.items;
48
+ if (!items) return [];
52
49
 
53
- const entries: DropEntry[] = [];
54
- const tasks: Promise<void>[] = [];
50
+ const entries: DropEntry[] = [];
51
+ const tasks: Promise<void>[] = [];
55
52
 
56
- for (let i = 0; i < items.length; i++) {
57
- const item = items[i];
58
- // Try webkitGetAsEntry (Chrome, Edge, Safari)
59
- const entry = item.webkitGetAsEntry?.();
60
- if (entry) {
61
- tasks.push(
62
- readEntry(entry, entry.name).then<void>((e) => { entries.push(e); }),
63
- );
64
- }
65
- }
53
+ for (let i = 0; i < items.length; i++) {
54
+ const item = items[i]!;
55
+ // Try webkitGetAsEntry (Chrome, Edge, Safari)
56
+ const entry = item.webkitGetAsEntry?.();
57
+ if (entry) {
58
+ tasks.push(
59
+ readEntry(entry, entry.name).then<void>((e) => {
60
+ entries.push(e);
61
+ })
62
+ );
63
+ }
64
+ }
66
65
 
67
- await Promise.all(tasks);
68
- return entries;
66
+ await Promise.all(tasks);
67
+ return entries;
69
68
  }
70
69
 
71
70
  /**
72
71
  * Web 端:递归上传 entries 到目标目录
73
72
  */
74
73
  export async function uploadEntriesWeb(entries: DropEntry[], destDir: string): Promise<number> {
75
- let count = 0;
74
+ let count = 0;
76
75
 
77
- async function process(entry: DropEntry, currentDir: string): Promise<void> {
78
- if (entry.isDirectory) {
79
- // Create directory
80
- const dirPath = `${currentDir}/${entry.name}`;
81
- await apiClient.call("file.createDir", { dirPath: currentDir, name: entry.name });
82
- count++;
83
- if (entry.children) {
84
- for (const child of entry.children) {
85
- await process(child, dirPath);
86
- }
87
- }
88
- } else if (entry.file) {
89
- // Upload file via HTTP
90
- const filePath = `${currentDir}/${entry.name}`;
91
- const arrayBuffer = await entry.file.arrayBuffer();
92
- const baseUrl = apiClient.getBaseUrl();
93
- const token = apiClient.getAuthToken();
94
- const res = await fetch(
95
- `${baseUrl}/file/upload?path=${encodeURIComponent(filePath)}&token=${token}`,
96
- { method: "POST", body: arrayBuffer },
97
- );
98
- if (!res.ok) throw new Error(`Upload failed: ${entry.name}`);
99
- count++;
100
- }
101
- }
76
+ async function process(entry: DropEntry, currentDir: string): Promise<void> {
77
+ if (entry.isDirectory) {
78
+ // Create directory
79
+ const dirPath = `${currentDir}/${entry.name}`;
80
+ await apiClient.call("file.createDir", { dirPath: currentDir, name: entry.name });
81
+ count++;
82
+ if (entry.children) {
83
+ for (const child of entry.children) {
84
+ await process(child, dirPath);
85
+ }
86
+ }
87
+ } else if (entry.file) {
88
+ // Upload file via HTTP
89
+ const filePath = `${currentDir}/${entry.name}`;
90
+ const arrayBuffer = await entry.file.arrayBuffer();
91
+ const baseUrl = apiClient.getBaseUrl();
92
+ const token = apiClient.getAuthToken();
93
+ const res = await fetch(
94
+ `${baseUrl}/file/upload?path=${encodeURIComponent(filePath)}&token=${token}`,
95
+ { method: "POST", body: arrayBuffer }
96
+ );
97
+ if (!res.ok) throw new Error(`Upload failed: ${entry.name}`);
98
+ count++;
99
+ }
100
+ }
102
101
 
103
- for (const entry of entries) {
104
- await process(entry, destDir);
105
- }
106
- return count;
102
+ for (const entry of entries) {
103
+ await process(entry, destDir);
104
+ }
105
+ return count;
107
106
  }
108
107
 
109
108
  /**
110
109
  * 桌面端:通过 RPC file.copy 直接复制
111
110
  */
112
111
  export async function importFilesDesktop(entries: DropEntry[], destDir: string): Promise<number> {
113
- let count = 0;
112
+ let count = 0;
114
113
 
115
- async function process(entry: DropEntry, currentDir: string): Promise<void> {
116
- if (entry.isDirectory) {
117
- const dirPath = `${currentDir}/${entry.name}`;
118
- await apiClient.call("file.createDir", { dirPath: currentDir, name: entry.name });
119
- count++;
120
- if (entry.children) {
121
- for (const child of entry.children) {
122
- await process(child, dirPath);
123
- }
124
- }
125
- } else if (entry.file) {
126
- // Desktop: File object has .path property (Electron/Electrobun)
127
- const srcPath = (entry.file as File & { path?: string }).path;
128
- if (srcPath) {
129
- await apiClient.call("file.copy", { srcPath, destDir: currentDir });
130
- } else {
131
- // Fallback: no path available, upload via HTTP
132
- const filePath = `${currentDir}/${entry.name}`;
133
- const arrayBuffer = await entry.file.arrayBuffer();
134
- const baseUrl = apiClient.getBaseUrl();
135
- const token = apiClient.getAuthToken();
136
- const res = await fetch(
137
- `${baseUrl}/file/upload?path=${encodeURIComponent(filePath)}&token=${token}`,
138
- { method: "POST", body: arrayBuffer },
139
- );
140
- if (!res.ok) throw new Error(`Upload failed: ${entry.name}`);
141
- }
142
- count++;
143
- }
144
- }
114
+ async function process(entry: DropEntry, currentDir: string): Promise<void> {
115
+ if (entry.isDirectory) {
116
+ const dirPath = `${currentDir}/${entry.name}`;
117
+ await apiClient.call("file.createDir", { dirPath: currentDir, name: entry.name });
118
+ count++;
119
+ if (entry.children) {
120
+ for (const child of entry.children) {
121
+ await process(child, dirPath);
122
+ }
123
+ }
124
+ } else if (entry.file) {
125
+ // Desktop: File object has .path property (Electron/Electrobun)
126
+ const srcPath = (entry.file as File & { path?: string }).path;
127
+ if (srcPath) {
128
+ await apiClient.call("file.copy", { srcPath, destDir: currentDir });
129
+ } else {
130
+ // Fallback: no path available, upload via HTTP
131
+ const filePath = `${currentDir}/${entry.name}`;
132
+ const arrayBuffer = await entry.file.arrayBuffer();
133
+ const baseUrl = apiClient.getBaseUrl();
134
+ const token = apiClient.getAuthToken();
135
+ const res = await fetch(
136
+ `${baseUrl}/file/upload?path=${encodeURIComponent(filePath)}&token=${token}`,
137
+ { method: "POST", body: arrayBuffer }
138
+ );
139
+ if (!res.ok) throw new Error(`Upload failed: ${entry.name}`);
140
+ }
141
+ count++;
142
+ }
143
+ }
145
144
 
146
- for (const entry of entries) {
147
- await process(entry, destDir);
148
- }
149
- return count;
145
+ for (const entry of entries) {
146
+ await process(entry, destDir);
147
+ }
148
+ return count;
150
149
  }
@@ -3,41 +3,43 @@
3
3
  * Values are read from environment variables with sensible defaults.
4
4
  */
5
5
 
6
+ import { randomUUID } from "crypto";
7
+
8
+ if (process.env.NODE_ENV === "production" && !process.env.AUTH_TOKEN) {
9
+ throw new Error(
10
+ "[FATAL] AUTH_TOKEN environment variable must be set in production mode. " +
11
+ "Example: AUTH_TOKEN=$(openssl rand -hex 32) bun src/server.ts"
12
+ );
13
+ }
14
+
6
15
  export function parseEnvInt(
7
- key: string,
8
- value: string | undefined,
9
- defaultValue: number,
10
- min: number,
11
- max: number,
16
+ key: string,
17
+ value: string | undefined,
18
+ defaultValue: number,
19
+ min: number,
20
+ max: number
12
21
  ): number {
13
- if (value === undefined || value === "") return defaultValue;
14
- const parsed = parseInt(value, 10);
15
- if (isNaN(parsed) || parsed < min || parsed > max) {
16
- console.warn(
17
- `[config] Invalid ${key}: "${value}", using default: ${defaultValue}`,
18
- );
19
- return defaultValue;
20
- }
21
- return parsed;
22
+ if (value === undefined || value === "") return defaultValue;
23
+ const parsed = parseInt(value, 10);
24
+ if (isNaN(parsed) || parsed < min || parsed > max) {
25
+ process.stderr.write(`[config] Invalid ${key}: "${value}", using default: ${defaultValue}\n`);
26
+ return defaultValue;
27
+ }
28
+ return parsed;
22
29
  }
23
30
 
24
31
  export const config = {
25
- port: parseEnvInt("PORT", process.env.PORT, 3100, 1024, 65535),
26
- authToken: process.env.AUTH_TOKEN || "pi-agent-template-token",
27
- maxUploadSize: parseEnvInt(
28
- "MAX_UPLOAD_SIZE",
29
- process.env.MAX_UPLOAD_SIZE,
30
- 50 * 1024 * 1024,
31
- 0,
32
- 1024 * 1024 * 1024,
33
- ),
34
- logDir: process.env.LOG_DIR || "logs",
35
- enableBash: process.env.ENABLE_BASH !== "false",
36
- corsOrigin: process.env.CORS_ORIGIN || "http://localhost:5173",
32
+ port: parseEnvInt("PORT", process.env.PORT, 3100, 1024, 65535),
33
+ authToken: process.env.AUTH_TOKEN || `dev-${randomUUID()}`,
34
+ maxUploadSize: parseEnvInt(
35
+ "MAX_UPLOAD_SIZE",
36
+ process.env.MAX_UPLOAD_SIZE,
37
+ 50 * 1024 * 1024,
38
+ 0,
39
+ 1024 * 1024 * 1024
40
+ ),
41
+ logDir: process.env.LOG_DIR || "logs",
42
+ enableBash: process.env.ENABLE_BASH !== "false",
43
+ enableWebService: process.env.ENABLE_WEB_SERVICE === "true",
44
+ corsOrigin: process.env.CORS_ORIGIN || "http://localhost:5173",
37
45
  } as const;
38
-
39
- if (process.env.NODE_ENV === "production" && !process.env.AUTH_TOKEN) {
40
- console.warn(
41
- "[security] WARNING: Using default AUTH_TOKEN in production. Set AUTH_TOKEN environment variable.",
42
- );
43
- }
@@ -4,12 +4,10 @@
4
4
  * Writes actual port to .server-port for dev orchestration.
5
5
  */
6
6
 
7
- import { createServer } from "http";
8
7
  import { writeFileSync, unlinkSync, existsSync } from "fs";
9
8
  import { join, resolve, basename } from "path";
10
9
  import { config } from "./server-config";
11
- import { createHttpHandler } from "./gateway/http-routes";
12
- import { createWsHandler } from "./gateway/ws-handler";
10
+ import { createWebServer, getLocalIP } from "./shared/lib/web-server";
13
11
  import { createLogger, configureLogDir } from "./shared/lib/logger";
14
12
  import { registerPort, unregisterPort, formatRegistryForOutput } from "./shared/lib/port-registry";
15
13
  import { discoverMethodNames } from "./shared/register-all-handlers";
@@ -22,68 +20,58 @@ const PROJECT_ROOT = resolve(import.meta.dir, "..");
22
20
  const PROJECT_NAME = basename(PROJECT_ROOT);
23
21
 
24
22
  function cleanupPortFile() {
25
- try { if (existsSync(PORT_FILE)) unlinkSync(PORT_FILE); } catch {}
26
- unregisterPort(PROJECT_ROOT);
23
+ try {
24
+ if (existsSync(PORT_FILE)) unlinkSync(PORT_FILE);
25
+ } catch {}
26
+ unregisterPort(PROJECT_ROOT);
27
27
  }
28
28
 
29
29
  function writePortFile(port: number) {
30
- writeFileSync(PORT_FILE, String(port), "utf-8");
30
+ writeFileSync(PORT_FILE, String(port), "utf-8");
31
31
  }
32
32
 
33
33
  process.on("exit", cleanupPortFile);
34
- process.on("SIGINT", () => { cleanupPortFile(); process.exit(0); });
35
- process.on("SIGTERM", () => { cleanupPortFile(); process.exit(0); });
34
+ process.on("SIGINT", () => {
35
+ cleanupPortFile();
36
+ process.exit(0);
37
+ });
38
+ process.on("SIGTERM", () => {
39
+ cleanupPortFile();
40
+ process.exit(0);
41
+ });
36
42
 
37
- const httpServer = createServer();
38
- const wss = createWsHandler(httpServer, { config });
43
+ async function start() {
44
+ const { port, close } = await createWebServer({
45
+ port: config.port,
46
+ authToken: config.authToken,
47
+ maxUploadSize: config.maxUploadSize,
48
+ corsOrigin: config.corsOrigin,
49
+ });
39
50
 
40
- httpServer.on("request", createHttpHandler({
41
- config,
42
- getWebSocketClientCount: () => wss.clients.size,
43
- }));
51
+ process.on("SIGINT", () => {
52
+ close();
53
+ cleanupPortFile();
54
+ process.exit(0);
55
+ });
56
+ process.on("SIGTERM", () => {
57
+ close();
58
+ cleanupPortFile();
59
+ process.exit(0);
60
+ });
44
61
 
45
- function checkPort(port: number): Promise<number> {
46
- return new Promise((resolve, reject) => {
47
- const testServer = createServer();
48
- testServer.once("error", (err: NodeJS.ErrnoException) => {
49
- testServer.close();
50
- reject(err);
51
- });
52
- testServer.once("listening", () => {
53
- testServer.close();
54
- resolve(port);
55
- });
56
- testServer.listen(port);
57
- });
58
- }
62
+ writePortFile(port);
63
+ registerPort(PROJECT_ROOT, port, PROJECT_NAME);
59
64
 
60
- async function findAvailablePort(startPort: number, maxRetries: number = 10): Promise<number> {
61
- for (let port = startPort; port < startPort + maxRetries; port++) {
62
- try {
63
- await checkPort(port);
64
- return port;
65
- } catch {
66
- log.info(`Port ${port} in use, trying ${port + 1}...`);
67
- }
68
- }
69
- throw new Error(`No available port found after ${maxRetries} retries`);
70
- }
71
-
72
- async function start() {
73
- const port = await findAvailablePort(config.port);
74
- httpServer.listen(port, () => {
75
- writePortFile(port);
76
- registerPort(PROJECT_ROOT, port, PROJECT_NAME);
77
- log.info(`HTTP + WebSocket server running on http://localhost:${port}`);
78
- log.info(`WebSocket: ws://localhost:${port}/ws (auth required)`);
79
- log.info(`Available RPC methods: ${discoverMethodNames().join(", ")}`);
80
- log.info("File endpoints: GET /file/{path}, GET /info/{path}");
81
- // eslint-disable-next-line no-console
82
- console.log("\n" + formatRegistryForOutput() + "\n");
83
- });
65
+ const localIp = getLocalIP();
66
+ log.info(`HTTP + WebSocket server running on http://localhost:${port}`);
67
+ log.info(`Local network access: http://${localIp}:${port}`);
68
+ log.info(`WebSocket: ws://localhost:${port}/ws (auth required)`);
69
+ log.info(`Available RPC methods: ${discoverMethodNames().join(", ")}`);
70
+ log.info("File endpoints: GET /file/{path}, GET /info/{path}");
71
+ log.info(formatRegistryForOutput());
84
72
  }
85
73
 
86
74
  start().catch((err) => {
87
- log.error("Server failed to start", { error: err.message });
88
- process.exit(1);
75
+ log.error("Server failed to start", { error: err.message });
76
+ process.exit(1);
89
77
  });
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { unlink } from "fs/promises";
5
+ import { existsSync } from "fs";
6
+
7
+ vi.mock("../../lib/logger", () => ({
8
+ createLogger: () => ({
9
+ info: vi.fn(),
10
+ error: vi.fn(),
11
+ warn: vi.fn(),
12
+ debug: vi.fn(),
13
+ }),
14
+ }));
15
+
16
+ function createMockServer() {
17
+ const handlers = new Map<string, (params: unknown) => Promise<unknown>>();
18
+ return {
19
+ register: vi.fn((method: string, handler: (params: unknown) => Promise<unknown>) => {
20
+ handlers.set(method, handler);
21
+ }),
22
+ emitEvent: vi.fn(),
23
+ handlers,
24
+ async call(method: string, params: unknown) {
25
+ const h = handlers.get(method);
26
+ if (!h) throw new Error(`No handler for ${method}`);
27
+ return h(params);
28
+ },
29
+ };
30
+ }
31
+
32
+ const DESKTOP_HISTORY_PATH = join(homedir(), ".pi-agent", "chat-history-desktop.json");
33
+
34
+ describe("Chat Handler 并发安全", () => {
35
+ beforeEach(() => {
36
+ vi.resetModules();
37
+ });
38
+
39
+ afterEach(async () => {
40
+ if (existsSync(DESKTOP_HISTORY_PATH)) {
41
+ await unlink(DESKTOP_HISTORY_PATH).catch(() => {});
42
+ }
43
+ });
44
+
45
+ it("两个独立的 RPCServer 应注册各自的 handler", async () => {
46
+ const { register } = await import("../chat");
47
+ const serverA = createMockServer();
48
+ const serverB = createMockServer();
49
+
50
+ register(serverA as any, { platform: "desktop" });
51
+ register(serverB as any, { platform: "web" });
52
+
53
+ expect(serverA.register).toHaveBeenCalledWith("chat.list", expect.any(Function));
54
+ expect(serverA.register).toHaveBeenCalledWith("chat.send", expect.any(Function));
55
+ expect(serverB.register).toHaveBeenCalledWith("chat.list", expect.any(Function));
56
+ expect(serverB.register).toHaveBeenCalledWith("chat.send", expect.any(Function));
57
+ });
58
+
59
+ it("server A 的消息不影响 server B 的消息列表", async () => {
60
+ const { register } = await import("../chat");
61
+ const serverA = createMockServer();
62
+ const serverB = createMockServer();
63
+
64
+ register(serverA as any, { platform: "desktop" });
65
+ register(serverB as any, { platform: "web" });
66
+
67
+ await serverA.call("chat.send", { content: "hello from desktop" });
68
+ await serverB.call("chat.send", { content: "hello from web" });
69
+
70
+ const resultA = (await serverA.call("chat.list", { limit: 50 })) as {
71
+ messages: { content: string }[];
72
+ };
73
+ const resultB = (await serverB.call("chat.list", { limit: 50 })) as {
74
+ messages: { content: string }[];
75
+ };
76
+
77
+ const desktopContents = resultA.messages.map((m) => m.content);
78
+ const webContents = resultB.messages.map((m) => m.content);
79
+
80
+ expect(desktopContents).toContain("hello from desktop");
81
+ expect(webContents).toContain("hello from web");
82
+ expect(desktopContents).not.toContain("hello from web");
83
+ expect(webContents).not.toContain("hello from desktop");
84
+ });
85
+
86
+ it("同一 server 内的消息按顺序写入", async () => {
87
+ const { register } = await import("../chat");
88
+ const server = createMockServer();
89
+ register(server as any, { platform: "desktop" });
90
+
91
+ await server.call("chat.send", { content: "msg1" });
92
+ await server.call("chat.send", { content: "msg2" });
93
+ await server.call("chat.send", { content: "msg3" });
94
+
95
+ const result = (await server.call("chat.list", { limit: 50 })) as {
96
+ messages: { role: string; content: string }[];
97
+ };
98
+
99
+ const userMsgs = result.messages.filter((m) => m.role === "user").map((m) => m.content);
100
+ expect(userMsgs).toEqual(["msg1", "msg2", "msg3"]);
101
+ });
102
+
103
+ it("存储路径应包含平台标识(desktop/web)或唯一 ID", async () => {
104
+ const { getStoragePathFor } = await import("../chat");
105
+ const desktopPath = getStoragePathFor("desktop");
106
+ const webPath = getStoragePathFor("web");
107
+
108
+ expect(desktopPath).toContain("desktop");
109
+ expect(webPath).not.toBe(desktopPath);
110
+ expect(webPath).toMatch(/web/);
111
+ });
112
+
113
+ it("desktop 使用固定路径保留历史", async () => {
114
+ const { getStoragePathFor } = await import("../chat");
115
+ const path1 = getStoragePathFor("desktop");
116
+ const path2 = getStoragePathFor("desktop");
117
+ expect(path1).toBe(path2);
118
+ expect(path1).toContain("chat-history-desktop.json");
119
+ });
120
+
121
+ it("web 每次生成不同的路径", async () => {
122
+ const { getStoragePathFor } = await import("../chat");
123
+ const path1 = getStoragePathFor("web");
124
+ const path2 = getStoragePathFor("web");
125
+ expect(path1).not.toBe(path2);
126
+ });
127
+ });