tide-commander 0.52.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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +364 -0
  3. package/dist/assets/characters/Textures/colormap.png +0 -0
  4. package/dist/assets/characters/character-female-a.glb +0 -0
  5. package/dist/assets/characters/character-female-b.glb +0 -0
  6. package/dist/assets/characters/character-female-c.glb +0 -0
  7. package/dist/assets/characters/character-female-d.glb +0 -0
  8. package/dist/assets/characters/character-female-e.glb +0 -0
  9. package/dist/assets/characters/character-female-f.glb +0 -0
  10. package/dist/assets/characters/character-male-a-processed.gltf +11862 -0
  11. package/dist/assets/characters/character-male-a.glb +0 -0
  12. package/dist/assets/characters/character-male-b.glb +0 -0
  13. package/dist/assets/characters/character-male-c.glb +0 -0
  14. package/dist/assets/characters/character-male-d.glb +0 -0
  15. package/dist/assets/characters/character-male-e.glb +0 -0
  16. package/dist/assets/characters/character-male-f.glb +0 -0
  17. package/dist/assets/icons/icon-192.png +0 -0
  18. package/dist/assets/icons/icon-512.png +0 -0
  19. package/dist/assets/landing-Cc0MDBAK.css +1 -0
  20. package/dist/assets/main-BIpLsrUu.css +1 -0
  21. package/dist/assets/main-DMTRw3br.js +276 -0
  22. package/dist/assets/textures/concrete_floor_worn_001_diff_1k.jpg +0 -0
  23. package/dist/assets/textures/logo-blanco.png +0 -0
  24. package/dist/assets/vendor-react-uS-d4TUT.js +17 -0
  25. package/dist/assets/vendor-three-4iQNXcoo.js +3828 -0
  26. package/dist/assets/web-BZdi2lG9.js +1 -0
  27. package/dist/assets/web-yHsOO1Qb.js +1 -0
  28. package/dist/index.html +38 -0
  29. package/dist/manifest.json +39 -0
  30. package/dist/src/packages/landing/index.html +463 -0
  31. package/dist/src/packages/server/app.js +87 -0
  32. package/dist/src/packages/server/auth/index.js +121 -0
  33. package/dist/src/packages/server/claude/backend.js +578 -0
  34. package/dist/src/packages/server/claude/index.js +8 -0
  35. package/dist/src/packages/server/claude/runner/internal-events.js +22 -0
  36. package/dist/src/packages/server/claude/runner/process-lifecycle.js +208 -0
  37. package/dist/src/packages/server/claude/runner/recovery-store.js +72 -0
  38. package/dist/src/packages/server/claude/runner/resource-monitor.js +51 -0
  39. package/dist/src/packages/server/claude/runner/restart-policy.js +69 -0
  40. package/dist/src/packages/server/claude/runner/stdout-pipeline.js +153 -0
  41. package/dist/src/packages/server/claude/runner/watchdog.js +114 -0
  42. package/dist/src/packages/server/claude/runner.js +310 -0
  43. package/dist/src/packages/server/claude/session-loader.js +898 -0
  44. package/dist/src/packages/server/claude/types.js +5 -0
  45. package/dist/src/packages/server/cli.js +113 -0
  46. package/dist/src/packages/server/codex/backend.js +119 -0
  47. package/dist/src/packages/server/codex/index.js +2 -0
  48. package/dist/src/packages/server/codex/json-event-parser.js +612 -0
  49. package/dist/src/packages/server/data/builtin-skills/bitbucket-pr.js +298 -0
  50. package/dist/src/packages/server/data/builtin-skills/full-notifications.js +49 -0
  51. package/dist/src/packages/server/data/builtin-skills/git-captain.js +304 -0
  52. package/dist/src/packages/server/data/builtin-skills/index.js +61 -0
  53. package/dist/src/packages/server/data/builtin-skills/pm2-logs.js +354 -0
  54. package/dist/src/packages/server/data/builtin-skills/send-message-to-agent.js +51 -0
  55. package/dist/src/packages/server/data/builtin-skills/server-logs.js +124 -0
  56. package/dist/src/packages/server/data/builtin-skills/streaming-exec.js +94 -0
  57. package/dist/src/packages/server/data/builtin-skills/types.js +4 -0
  58. package/dist/src/packages/server/data/builtin-skills.js +6 -0
  59. package/dist/src/packages/server/data/index.js +890 -0
  60. package/dist/src/packages/server/data/snapshots.js +371 -0
  61. package/dist/src/packages/server/index.js +96 -0
  62. package/dist/src/packages/server/prompts/tide-commander.js +13 -0
  63. package/dist/src/packages/server/routes/agents.js +406 -0
  64. package/dist/src/packages/server/routes/config.js +347 -0
  65. package/dist/src/packages/server/routes/custom-models.js +170 -0
  66. package/dist/src/packages/server/routes/exec.js +269 -0
  67. package/dist/src/packages/server/routes/files.js +995 -0
  68. package/dist/src/packages/server/routes/index.js +38 -0
  69. package/dist/src/packages/server/routes/notifications.js +81 -0
  70. package/dist/src/packages/server/routes/permissions.js +115 -0
  71. package/dist/src/packages/server/routes/snapshots.js +224 -0
  72. package/dist/src/packages/server/routes/stt.js +99 -0
  73. package/dist/src/packages/server/routes/tts.js +166 -0
  74. package/dist/src/packages/server/routes/voice-assistant.js +310 -0
  75. package/dist/src/packages/server/runtime/claude-runtime-provider.js +10 -0
  76. package/dist/src/packages/server/runtime/codex-runtime-provider.js +11 -0
  77. package/dist/src/packages/server/runtime/index.js +2 -0
  78. package/dist/src/packages/server/runtime/types.js +6 -0
  79. package/dist/src/packages/server/services/agent-lifecycle-service.js +82 -0
  80. package/dist/src/packages/server/services/agent-service.js +410 -0
  81. package/dist/src/packages/server/services/boss-message-service.js +430 -0
  82. package/dist/src/packages/server/services/boss-service.js +553 -0
  83. package/dist/src/packages/server/services/building-service.js +867 -0
  84. package/dist/src/packages/server/services/claude-service.js +5 -0
  85. package/dist/src/packages/server/services/custom-class-service.js +323 -0
  86. package/dist/src/packages/server/services/database-service.js +914 -0
  87. package/dist/src/packages/server/services/docker-service.js +865 -0
  88. package/dist/src/packages/server/services/fileTracker.js +242 -0
  89. package/dist/src/packages/server/services/index.js +21 -0
  90. package/dist/src/packages/server/services/permission-service.js +258 -0
  91. package/dist/src/packages/server/services/pm2-service.js +435 -0
  92. package/dist/src/packages/server/services/runtime-command-execution.js +168 -0
  93. package/dist/src/packages/server/services/runtime-events.js +357 -0
  94. package/dist/src/packages/server/services/runtime-service.js +308 -0
  95. package/dist/src/packages/server/services/runtime-status-sync.js +104 -0
  96. package/dist/src/packages/server/services/runtime-subagents.js +50 -0
  97. package/dist/src/packages/server/services/runtime-watchdog.js +74 -0
  98. package/dist/src/packages/server/services/secrets-service.js +206 -0
  99. package/dist/src/packages/server/services/skill-service.js +508 -0
  100. package/dist/src/packages/server/services/subordinate-context-service.js +223 -0
  101. package/dist/src/packages/server/services/supervisor-claude.js +132 -0
  102. package/dist/src/packages/server/services/supervisor-prompts.js +80 -0
  103. package/dist/src/packages/server/services/supervisor-service.js +659 -0
  104. package/dist/src/packages/server/services/work-plan-service.js +476 -0
  105. package/dist/src/packages/server/setup.js +86 -0
  106. package/dist/src/packages/server/utils/index.js +4 -0
  107. package/dist/src/packages/server/utils/logger.js +302 -0
  108. package/dist/src/packages/server/utils/string.js +39 -0
  109. package/dist/src/packages/server/utils/tool-formatting.js +139 -0
  110. package/dist/src/packages/server/utils/unicode.js +46 -0
  111. package/dist/src/packages/server/websocket/handler.js +290 -0
  112. package/dist/src/packages/server/websocket/handlers/agent-handler.js +515 -0
  113. package/dist/src/packages/server/websocket/handlers/boss-handler.js +116 -0
  114. package/dist/src/packages/server/websocket/handlers/boss-response-handler.js +250 -0
  115. package/dist/src/packages/server/websocket/handlers/building-handler.js +298 -0
  116. package/dist/src/packages/server/websocket/handlers/command-handler.js +217 -0
  117. package/dist/src/packages/server/websocket/handlers/custom-class-handler.js +68 -0
  118. package/dist/src/packages/server/websocket/handlers/database-handler.js +223 -0
  119. package/dist/src/packages/server/websocket/handlers/notification-handler.js +25 -0
  120. package/dist/src/packages/server/websocket/handlers/permission-handler.js +21 -0
  121. package/dist/src/packages/server/websocket/handlers/secrets-handler.js +61 -0
  122. package/dist/src/packages/server/websocket/handlers/skill-handler.js +148 -0
  123. package/dist/src/packages/server/websocket/handlers/supervisor-handler.js +44 -0
  124. package/dist/src/packages/server/websocket/handlers/sync-handler.js +19 -0
  125. package/dist/src/packages/server/websocket/handlers/types.js +4 -0
  126. package/dist/src/packages/server/websocket/listeners/boss-listeners.js +21 -0
  127. package/dist/src/packages/server/websocket/listeners/index.js +32 -0
  128. package/dist/src/packages/server/websocket/listeners/permission-listeners.js +19 -0
  129. package/dist/src/packages/server/websocket/listeners/runtime-listeners.js +196 -0
  130. package/dist/src/packages/server/websocket/listeners/skill-listeners.js +51 -0
  131. package/dist/src/packages/server/websocket/listeners/supervisor-listeners.js +37 -0
  132. package/dist/src/packages/shared/agent-types.js +54 -0
  133. package/dist/src/packages/shared/building-types.js +43 -0
  134. package/dist/src/packages/shared/common-types.js +1 -0
  135. package/dist/src/packages/shared/database-types.js +8 -0
  136. package/dist/src/packages/shared/types/snapshot.js +7 -0
  137. package/dist/src/packages/shared/types.js +12 -0
  138. package/dist/src/packages/shared/websocket-messages.js +1 -0
  139. package/dist/sw.js +37 -0
  140. package/package.json +90 -0
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Config Export/Import Routes
3
+ * Handles exporting and importing Tide Commander configuration
4
+ */
5
+ import { Router } from 'express';
6
+ import { createReadStream, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
7
+ import { readFile, writeFile, mkdir, rm } from 'fs/promises';
8
+ import { join, dirname } from 'path';
9
+ import os from 'os';
10
+ import archiver from 'archiver';
11
+ import { Extract } from 'unzip-stream';
12
+ import { createLogger } from '../utils/index.js';
13
+ const log = createLogger('ConfigRoutes');
14
+ const router = Router();
15
+ // Config directories
16
+ const HOME_CONFIG_DIR = join(os.homedir(), '.tide-commander');
17
+ const DATA_CONFIG_DIR = join(os.homedir(), '.local', 'share', 'tide-commander');
18
+ const CONFIG_CATEGORIES = [
19
+ {
20
+ id: 'agents',
21
+ name: 'Agents',
22
+ description: 'Agent positions, names, and settings',
23
+ files: ['agents.json'],
24
+ sourceDir: 'data',
25
+ },
26
+ {
27
+ id: 'areas',
28
+ name: 'Areas',
29
+ description: 'Drawing areas and zones',
30
+ files: ['areas.json'],
31
+ sourceDir: 'data',
32
+ },
33
+ {
34
+ id: 'buildings',
35
+ name: 'Buildings',
36
+ description: 'Building configurations and PM2 settings',
37
+ files: ['buildings.json'],
38
+ sourceDir: 'data',
39
+ },
40
+ {
41
+ id: 'skills',
42
+ name: 'Skills',
43
+ description: 'Custom skills and their assignments',
44
+ files: ['skills.json'],
45
+ sourceDir: 'data',
46
+ },
47
+ {
48
+ id: 'custom-classes',
49
+ name: 'Custom Agent Classes',
50
+ description: 'Custom agent class definitions and instructions',
51
+ files: ['custom-agent-classes.json'],
52
+ sourceDir: 'data',
53
+ },
54
+ {
55
+ id: 'class-instructions',
56
+ name: 'Class Instructions',
57
+ description: 'Markdown instruction files for custom classes',
58
+ files: ['class-instructions/*'],
59
+ sourceDir: 'home',
60
+ },
61
+ {
62
+ id: 'prompts',
63
+ name: 'Agent Prompts',
64
+ description: 'Individual agent prompt files',
65
+ files: ['prompts/*'],
66
+ sourceDir: 'home',
67
+ },
68
+ {
69
+ id: 'custom-models',
70
+ name: 'Custom 3D Models',
71
+ description: 'GLB model files for custom agent classes',
72
+ files: ['custom-models/*'],
73
+ sourceDir: 'home',
74
+ },
75
+ {
76
+ id: 'hooks',
77
+ name: 'Hooks',
78
+ description: 'Hook scripts and settings',
79
+ files: ['hooks/*', 'hook-settings.json'],
80
+ sourceDir: 'home',
81
+ },
82
+ {
83
+ id: 'permissions',
84
+ name: 'Remembered Permissions',
85
+ description: 'Saved permission decisions',
86
+ files: ['remembered-permissions.json'],
87
+ sourceDir: 'home',
88
+ },
89
+ {
90
+ id: 'secrets',
91
+ name: 'Secrets',
92
+ description: 'Encrypted secrets (keys not included)',
93
+ files: ['secrets.json'],
94
+ sourceDir: 'data',
95
+ },
96
+ ];
97
+ /**
98
+ * Get list of available config categories
99
+ */
100
+ router.get('/categories', (_req, res) => {
101
+ const categories = CONFIG_CATEGORIES.map(cat => ({
102
+ id: cat.id,
103
+ name: cat.name,
104
+ description: cat.description,
105
+ }));
106
+ res.json(categories);
107
+ });
108
+ /**
109
+ * Helper to get files matching a pattern
110
+ */
111
+ function getFilesForPattern(baseDir, pattern) {
112
+ const files = [];
113
+ if (pattern.endsWith('/*')) {
114
+ // Directory pattern - get all files in directory
115
+ const dirName = pattern.slice(0, -2);
116
+ const dirPath = join(baseDir, dirName);
117
+ if (existsSync(dirPath) && statSync(dirPath).isDirectory()) {
118
+ const entries = readdirSync(dirPath);
119
+ for (const entry of entries) {
120
+ const entryPath = join(dirPath, entry);
121
+ if (statSync(entryPath).isFile()) {
122
+ files.push(join(dirName, entry));
123
+ }
124
+ }
125
+ }
126
+ }
127
+ else {
128
+ // Single file pattern
129
+ const filePath = join(baseDir, pattern);
130
+ if (existsSync(filePath) && statSync(filePath).isFile()) {
131
+ files.push(pattern);
132
+ }
133
+ }
134
+ return files;
135
+ }
136
+ /**
137
+ * Export selected config categories as a ZIP file
138
+ * GET /api/config/export?categories=agents,buildings,skills
139
+ */
140
+ router.get('/export', async (req, res) => {
141
+ try {
142
+ const categoriesParam = req.query.categories;
143
+ const selectedIds = categoriesParam ? categoriesParam.split(',') : CONFIG_CATEGORIES.map(c => c.id);
144
+ // Validate categories
145
+ const selectedCategories = CONFIG_CATEGORIES.filter(c => selectedIds.includes(c.id));
146
+ if (selectedCategories.length === 0) {
147
+ res.status(400).json({ error: 'No valid categories selected' });
148
+ return;
149
+ }
150
+ log.log(`Exporting config categories: ${selectedCategories.map(c => c.id).join(', ')}`);
151
+ // Set response headers for ZIP download
152
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
153
+ const filename = `tide-commander-config-${timestamp}.zip`;
154
+ res.setHeader('Content-Type', 'application/zip');
155
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
156
+ // Create ZIP archive
157
+ const archive = archiver('zip', { zlib: { level: 9 } });
158
+ archive.on('error', (err) => {
159
+ log.error('Archive error:', err);
160
+ if (!res.headersSent) {
161
+ res.status(500).json({ error: 'Failed to create archive' });
162
+ }
163
+ });
164
+ // Pipe archive to response
165
+ archive.pipe(res);
166
+ // Add manifest file
167
+ const manifest = {
168
+ version: '1.0',
169
+ exportedAt: new Date().toISOString(),
170
+ categories: selectedCategories.map(c => c.id),
171
+ };
172
+ archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
173
+ // Add files for each selected category
174
+ for (const category of selectedCategories) {
175
+ const baseDir = category.sourceDir === 'home' ? HOME_CONFIG_DIR : DATA_CONFIG_DIR;
176
+ for (const pattern of category.files) {
177
+ const files = getFilesForPattern(baseDir, pattern);
178
+ for (const file of files) {
179
+ const filePath = join(baseDir, file);
180
+ const archivePath = join(category.sourceDir, file);
181
+ archive.file(filePath, { name: archivePath });
182
+ }
183
+ }
184
+ }
185
+ await archive.finalize();
186
+ log.log(`Config export completed: ${filename}`);
187
+ }
188
+ catch (error) {
189
+ log.error('Export error:', error);
190
+ if (!res.headersSent) {
191
+ res.status(500).json({ error: error.message });
192
+ }
193
+ }
194
+ });
195
+ /**
196
+ * Preview what's in an uploaded config ZIP
197
+ * POST /api/config/preview (multipart/form-data with 'file' field)
198
+ */
199
+ router.post('/preview', async (req, res) => {
200
+ try {
201
+ if (!req.body || !Buffer.isBuffer(req.body)) {
202
+ res.status(400).json({ error: 'No file uploaded' });
203
+ return;
204
+ }
205
+ // Create temp directory for extraction
206
+ const tempDir = join(os.tmpdir(), `tide-config-preview-${Date.now()}`);
207
+ mkdirSync(tempDir, { recursive: true });
208
+ try {
209
+ // Write buffer to temp file and extract
210
+ const tempZip = join(tempDir, 'upload.zip');
211
+ await writeFile(tempZip, req.body);
212
+ // Extract ZIP
213
+ await new Promise((resolve, reject) => {
214
+ createReadStream(tempZip)
215
+ .pipe(Extract({ path: tempDir }))
216
+ .on('close', resolve)
217
+ .on('error', reject);
218
+ });
219
+ // Read manifest
220
+ const manifestPath = join(tempDir, 'manifest.json');
221
+ if (!existsSync(manifestPath)) {
222
+ res.status(400).json({ error: 'Invalid config file: missing manifest.json' });
223
+ return;
224
+ }
225
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
226
+ // Scan for available categories
227
+ const availableCategories = [];
228
+ for (const category of CONFIG_CATEGORIES) {
229
+ if (manifest.categories?.includes(category.id)) {
230
+ const baseDir = join(tempDir, category.sourceDir);
231
+ let fileCount = 0;
232
+ for (const pattern of category.files) {
233
+ const files = getFilesForPattern(baseDir, pattern);
234
+ fileCount += files.length;
235
+ }
236
+ if (fileCount > 0) {
237
+ availableCategories.push({
238
+ id: category.id,
239
+ name: category.name,
240
+ description: category.description,
241
+ fileCount,
242
+ });
243
+ }
244
+ }
245
+ }
246
+ res.json({
247
+ version: manifest.version,
248
+ exportedAt: manifest.exportedAt,
249
+ categories: availableCategories,
250
+ });
251
+ }
252
+ finally {
253
+ // Cleanup temp directory
254
+ await rm(tempDir, { recursive: true, force: true });
255
+ }
256
+ }
257
+ catch (error) {
258
+ log.error('Preview error:', error);
259
+ res.status(500).json({ error: error.message });
260
+ }
261
+ });
262
+ /**
263
+ * Import config from uploaded ZIP
264
+ * POST /api/config/import?categories=agents,buildings (multipart/form-data with 'file' field)
265
+ */
266
+ router.post('/import', async (req, res) => {
267
+ try {
268
+ if (!req.body || !Buffer.isBuffer(req.body)) {
269
+ res.status(400).json({ error: 'No file uploaded' });
270
+ return;
271
+ }
272
+ const categoriesParam = req.query.categories;
273
+ if (!categoriesParam) {
274
+ res.status(400).json({ error: 'No categories specified' });
275
+ return;
276
+ }
277
+ const selectedIds = categoriesParam.split(',');
278
+ const selectedCategories = CONFIG_CATEGORIES.filter(c => selectedIds.includes(c.id));
279
+ if (selectedCategories.length === 0) {
280
+ res.status(400).json({ error: 'No valid categories selected' });
281
+ return;
282
+ }
283
+ log.log(`Importing config categories: ${selectedCategories.map(c => c.id).join(', ')}`);
284
+ // Create temp directory for extraction
285
+ const tempDir = join(os.tmpdir(), `tide-config-import-${Date.now()}`);
286
+ mkdirSync(tempDir, { recursive: true });
287
+ try {
288
+ // Write buffer to temp file and extract
289
+ const tempZip = join(tempDir, 'upload.zip');
290
+ await writeFile(tempZip, req.body);
291
+ // Extract ZIP
292
+ await new Promise((resolve, reject) => {
293
+ createReadStream(tempZip)
294
+ .pipe(Extract({ path: tempDir }))
295
+ .on('close', resolve)
296
+ .on('error', reject);
297
+ });
298
+ // Verify manifest
299
+ const manifestPath = join(tempDir, 'manifest.json');
300
+ if (!existsSync(manifestPath)) {
301
+ res.status(400).json({ error: 'Invalid config file: missing manifest.json' });
302
+ return;
303
+ }
304
+ const imported = [];
305
+ // Import each selected category
306
+ for (const category of selectedCategories) {
307
+ const sourceBaseDir = join(tempDir, category.sourceDir);
308
+ const targetBaseDir = category.sourceDir === 'home' ? HOME_CONFIG_DIR : DATA_CONFIG_DIR;
309
+ const importedFiles = [];
310
+ for (const pattern of category.files) {
311
+ const files = getFilesForPattern(sourceBaseDir, pattern);
312
+ for (const file of files) {
313
+ const sourcePath = join(sourceBaseDir, file);
314
+ const targetPath = join(targetBaseDir, file);
315
+ // Ensure target directory exists
316
+ const targetDir = dirname(targetPath);
317
+ if (!existsSync(targetDir)) {
318
+ await mkdir(targetDir, { recursive: true });
319
+ }
320
+ // Copy file
321
+ const content = await readFile(sourcePath);
322
+ await writeFile(targetPath, content);
323
+ importedFiles.push(file);
324
+ }
325
+ }
326
+ if (importedFiles.length > 0) {
327
+ imported.push({ category: category.id, files: importedFiles });
328
+ }
329
+ }
330
+ log.log(`Config import completed: ${imported.map(i => `${i.category}(${i.files.length})`).join(', ')}`);
331
+ res.json({
332
+ success: true,
333
+ imported,
334
+ message: 'Config imported successfully. Restart Tide Commander to apply changes.',
335
+ });
336
+ }
337
+ finally {
338
+ // Cleanup temp directory
339
+ await rm(tempDir, { recursive: true, force: true });
340
+ }
341
+ }
342
+ catch (error) {
343
+ log.error('Import error:', error);
344
+ res.status(500).json({ error: error.message });
345
+ }
346
+ });
347
+ export default router;
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Custom Models Routes
3
+ * REST API endpoints for custom 3D model operations
4
+ */
5
+ import { Router } from 'express';
6
+ import * as fs from 'fs';
7
+ import { logger } from '../utils/logger.js';
8
+ import { saveCustomModel, hasCustomModel, getCustomModelPath, getCustomClass, updateCustomClass, } from '../services/custom-class-service.js';
9
+ const log = logger.files;
10
+ const router = Router();
11
+ // Maximum file size: 50MB
12
+ const MAX_FILE_SIZE = 50 * 1024 * 1024;
13
+ /**
14
+ * POST /api/custom-models/upload/:classId
15
+ * Upload a custom GLB model for a class
16
+ *
17
+ * Headers:
18
+ * Content-Type: application/octet-stream or model/gltf-binary
19
+ * X-Filename: optional original filename
20
+ *
21
+ * Body: Raw GLB file data
22
+ *
23
+ * Returns: { success: true, path: string, size: number }
24
+ */
25
+ router.post('/upload/:classId', async (req, res) => {
26
+ try {
27
+ const { classId } = req.params;
28
+ if (!classId) {
29
+ res.status(400).json({ error: 'Missing classId parameter' });
30
+ return;
31
+ }
32
+ // Verify the class exists
33
+ const customClass = getCustomClass(classId);
34
+ if (!customClass) {
35
+ res.status(404).json({ error: 'Custom class not found' });
36
+ return;
37
+ }
38
+ // Collect body data
39
+ const chunks = [];
40
+ let totalSize = 0;
41
+ req.on('data', (chunk) => {
42
+ totalSize += chunk.length;
43
+ if (totalSize > MAX_FILE_SIZE) {
44
+ res.status(413).json({ error: 'File too large (max 50MB)' });
45
+ req.destroy();
46
+ return;
47
+ }
48
+ chunks.push(chunk);
49
+ });
50
+ req.on('end', () => {
51
+ if (res.headersSent)
52
+ return; // Already responded with error
53
+ const buffer = Buffer.concat(chunks);
54
+ // Validate GLB magic number (glTF binary starts with 'glTF')
55
+ if (buffer.length < 4 || buffer.toString('ascii', 0, 4) !== 'glTF') {
56
+ res.status(400).json({ error: 'Invalid GLB file format' });
57
+ return;
58
+ }
59
+ // Save the model
60
+ const modelPath = saveCustomModel(classId, buffer);
61
+ // Update the class with the custom model path
62
+ updateCustomClass(classId, {
63
+ customModelPath: modelPath,
64
+ model: undefined, // Clear built-in model selection
65
+ });
66
+ log.log(`Uploaded custom model for class ${classId} (${buffer.length} bytes)`);
67
+ res.json({
68
+ success: true,
69
+ path: modelPath,
70
+ size: buffer.length,
71
+ });
72
+ });
73
+ req.on('error', (err) => {
74
+ log.error('Model upload error:', err);
75
+ if (!res.headersSent) {
76
+ res.status(500).json({ error: 'Upload failed' });
77
+ }
78
+ });
79
+ }
80
+ catch (err) {
81
+ log.error('Failed to upload custom model:', err);
82
+ res.status(500).json({ error: err.message });
83
+ }
84
+ });
85
+ /**
86
+ * GET /api/custom-models/:classId
87
+ * Serve a custom model file
88
+ *
89
+ * Returns: GLB file binary data
90
+ */
91
+ router.get('/:classId', async (req, res) => {
92
+ try {
93
+ const { classId } = req.params;
94
+ if (!classId) {
95
+ res.status(400).json({ error: 'Missing classId parameter' });
96
+ return;
97
+ }
98
+ const modelPath = getCustomModelPath(classId);
99
+ if (!modelPath) {
100
+ res.status(404).json({ error: 'Custom model not found' });
101
+ return;
102
+ }
103
+ const stats = fs.statSync(modelPath);
104
+ res.setHeader('Content-Type', 'model/gltf-binary');
105
+ res.setHeader('Content-Length', stats.size);
106
+ res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour
107
+ const stream = fs.createReadStream(modelPath);
108
+ stream.pipe(res);
109
+ }
110
+ catch (err) {
111
+ log.error('Failed to serve custom model:', err);
112
+ res.status(500).json({ error: err.message });
113
+ }
114
+ });
115
+ /**
116
+ * DELETE /api/custom-models/:classId
117
+ * Delete a custom model and revert class to default model
118
+ */
119
+ router.delete('/:classId', async (req, res) => {
120
+ try {
121
+ const { classId } = req.params;
122
+ if (!classId) {
123
+ res.status(400).json({ error: 'Missing classId parameter' });
124
+ return;
125
+ }
126
+ const customClass = getCustomClass(classId);
127
+ if (!customClass) {
128
+ res.status(404).json({ error: 'Custom class not found' });
129
+ return;
130
+ }
131
+ const modelPath = getCustomModelPath(classId);
132
+ if (modelPath && fs.existsSync(modelPath)) {
133
+ fs.unlinkSync(modelPath);
134
+ log.log(`Deleted custom model for class ${classId}`);
135
+ }
136
+ // Update class to use default model
137
+ updateCustomClass(classId, {
138
+ customModelPath: undefined,
139
+ model: 'character-male-a.glb', // Revert to default
140
+ animationMapping: undefined,
141
+ availableAnimations: undefined,
142
+ modelScale: undefined,
143
+ });
144
+ res.json({ success: true });
145
+ }
146
+ catch (err) {
147
+ log.error('Failed to delete custom model:', err);
148
+ res.status(500).json({ error: err.message });
149
+ }
150
+ });
151
+ /**
152
+ * GET /api/custom-models/:classId/exists
153
+ * Check if a custom model exists for a class
154
+ */
155
+ router.get('/:classId/exists', async (req, res) => {
156
+ try {
157
+ const { classId } = req.params;
158
+ if (!classId) {
159
+ res.status(400).json({ error: 'Missing classId parameter' });
160
+ return;
161
+ }
162
+ const exists = hasCustomModel(classId);
163
+ res.json({ exists, classId });
164
+ }
165
+ catch (err) {
166
+ log.error('Failed to check custom model:', err);
167
+ res.status(500).json({ error: err.message });
168
+ }
169
+ });
170
+ export default router;