@assistkick/create 1.9.0 → 1.11.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.
- package/dist/src/scaffolder.d.ts +12 -1
- package/dist/src/scaffolder.js +40 -3
- package/dist/src/scaffolder.js.map +1 -1
- package/package.json +1 -1
- package/templates/assistkick-product-system/package.json +1 -1
- package/templates/assistkick-product-system/packages/backend/package.json +1 -0
- package/templates/assistkick-product-system/packages/backend/src/mcp/permission_mcp_server.ts +196 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +31 -7
- package/templates/assistkick-product-system/packages/backend/src/routes/auth.ts +15 -12
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.test.ts +95 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.ts +97 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_permission.ts +94 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_sessions.ts +189 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.test.ts +131 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.ts +94 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +12 -3
- package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +2 -2
- package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +391 -23
- package/templates/assistkick-product-system/packages/backend/src/routes/git_branches.test.ts +306 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/git_connect.test.ts +133 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +66 -9
- package/templates/assistkick-product-system/packages/backend/src/routes/preview.ts +204 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/projects.test.ts +205 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +37 -9
- package/templates/assistkick-product-system/packages/backend/src/routes/skills.test.ts +139 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/skills.ts +95 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +5 -4
- package/templates/assistkick-product-system/packages/backend/src/routes/users.ts +4 -4
- package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +8 -8
- package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +5 -5
- package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +6 -6
- package/templates/assistkick-product-system/packages/backend/src/server.ts +107 -27
- package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +105 -203
- package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +76 -266
- package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.test.ts +427 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +345 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.test.ts +170 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.ts +106 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.test.ts +217 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.ts +188 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.test.ts +1243 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +894 -0
- package/templates/assistkick-product-system/packages/backend/src/services/coherence-review.ts +3 -3
- package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.test.ts +85 -0
- package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.ts +54 -0
- package/templates/assistkick-product-system/packages/backend/src/services/email_service.ts +13 -10
- package/templates/assistkick-product-system/packages/backend/src/services/init.ts +11 -3
- package/templates/assistkick-product-system/packages/backend/src/services/invitation_service.ts +1 -1
- package/templates/assistkick-product-system/packages/backend/src/services/password_reset_service.ts +1 -1
- package/templates/assistkick-product-system/packages/backend/src/services/permission_service.test.ts +243 -0
- package/templates/assistkick-product-system/packages/backend/src/services/permission_service.ts +259 -0
- package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.test.ts +172 -0
- package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.ts +225 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +29 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +17 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +255 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +300 -25
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +44 -0
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +62 -7
- package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +77 -6
- package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +149 -14
- package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +2 -1
- package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.test.ts +45 -0
- package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.ts +157 -0
- package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +4 -3
- package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +3 -3
- package/templates/assistkick-product-system/packages/frontend/package.json +5 -0
- package/templates/assistkick-product-system/packages/frontend/src/App.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +336 -5
- package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +192 -12
- package/templates/assistkick-product-system/packages/frontend/src/components/AttachmentPreviewList.tsx +98 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/AutocompleteDropdown.tsx +65 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatAttachButton.tsx +56 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatDropZone.tsx +80 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageBubble.tsx +155 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageContent.tsx +182 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageInput.tsx +233 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatSessionSidebar.tsx +218 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatStopButton.tsx +32 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatTodoSidebar.tsx +113 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatView.tsx +842 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/CommitMessageModal.tsx +82 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/DiagramOverlay.tsx +160 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +5 -5
- package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +9 -10
- package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +5 -5
- package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +112 -41
- package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +2 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/HighlightedText.tsx +87 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ImageLightbox.tsx +192 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +2 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/MentionPill.tsx +33 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/MermaidBlock.tsx +148 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/PermissionDialog.tsx +91 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/PermissionModeSelector.tsx +229 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +249 -83
- package/templates/assistkick-product-system/packages/frontend/src/components/QueuedMessageBubble.tsx +38 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +212 -117
- package/templates/assistkick-product-system/packages/frontend/src/components/SystemPromptAccordion.tsx +48 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/TaskIcon.tsx +11 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +25 -9
- package/templates/assistkick-product-system/packages/frontend/src/components/ToolDiffView.tsx +114 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ToolResultCard.tsx +87 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ToolUseCard.tsx +149 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +25 -8
- package/templates/assistkick-product-system/packages/frontend/src/components/UnifiedGitWidget.tsx +722 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +2 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/ProgrammableNode.tsx +178 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +3 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +103 -9
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +26 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +42 -1
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useDocumentTitle.ts +11 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +1 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/use_chat_stream.ts +826 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/use_file_tree_cache.ts +69 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/use_mention_autocomplete.ts +284 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.test.ts +183 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.ts +150 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.test.ts +305 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.ts +113 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.test.ts +157 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.ts +95 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.test.ts +65 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.ts +110 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/message_queue.ts +66 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.test.ts +124 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.ts +112 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/ChatRoute.tsx +8 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +0 -4
- package/templates/assistkick-product-system/packages/frontend/src/routes/DesignSystemRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/KanbanRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/TerminalRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/UsersRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/accept_invitation.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/forgot_password.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/login.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/register.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/reset_password.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useAttachmentStore.ts +66 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useChatSessionStore.ts +107 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useMessageQueueStore.ts +110 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/usePreviewStore.ts +78 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +7 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +6 -1
- package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +30 -357
- package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.test.ts +115 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.ts +91 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.test.ts +30 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.ts +3 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0015_magenta_jazinda.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0016_giant_xorn.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0017_sloppy_mentor.sql +6 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0018_vengeful_kabuki.sql +9 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0019_careful_sentinels.sql +8 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0020_clever_spot.sql +27 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0021_graceful_hex.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0022_short_kingpin.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0023_ambiguous_sharon_carter.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0024_fat_unus.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0015_snapshot.json +1552 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0016_snapshot.json +1560 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0017_snapshot.json +1598 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0018_snapshot.json +1657 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0019_snapshot.json +1709 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0020_snapshot.json +1733 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0021_snapshot.json +1740 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0022_snapshot.json +1755 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0023_snapshot.json +1762 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0024_snapshot.json +1769 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +70 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +40 -1
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.test.ts +236 -0
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +46 -5
- package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +65 -39
- package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.test.ts +173 -0
- package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.ts +213 -0
- package/templates/assistkick-product-system/packages/shared/lib/validator.test.ts +70 -0
- package/templates/assistkick-product-system/packages/shared/lib/validator.ts +17 -1
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +803 -27
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +502 -68
- package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +4 -4
- package/templates/assistkick-product-system/packages/shared/package.json +2 -1
- package/templates/assistkick-product-system/packages/shared/test_fixtures/hanging_stream.mjs +46 -0
- package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +44 -0
- package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +7 -0
- package/templates/assistkick-product-system/packages/shared/tools/remove_node.ts +2 -1
- package/templates/assistkick-product-system/packages/shared/tools/resolve_question.ts +2 -1
- package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -1
- package/templates/assistkick-product-system/tests/message_queue.test.ts +178 -0
- package/templates/assistkick-product-system/tests/message_queue_per_session.test.ts +143 -0
- package/templates/skills/assistkick-bootstrap/SKILL.md +26 -26
- package/templates/skills/assistkick-code-reviewer/SKILL.md +45 -46
- package/templates/skills/assistkick-db-explorer/SKILL.md +13 -13
- package/templates/skills/assistkick-debugger/SKILL.md +23 -23
- package/templates/skills/assistkick-developer/SKILL.md +59 -63
- package/templates/skills/assistkick-interview/SKILL.md +26 -26
- package/templates/skills/assistkick-video-composition-agent/SKILL.md +231 -0
- package/templates/skills/assistkick-video-script-writer/SKILL.md +136 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat file serve route — serves uploaded chat attachment files with caching.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/chat-files/:projectId/*
|
|
5
|
+
* Serves the file at .chat-uploads/<path> within the project workspace.
|
|
6
|
+
* Returns appropriate content-type and caching headers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Router } from 'express';
|
|
10
|
+
import { join, resolve, extname } from 'node:path';
|
|
11
|
+
import { existsSync, createReadStream } from 'node:fs';
|
|
12
|
+
import { stat } from 'node:fs/promises';
|
|
13
|
+
import type { ProjectWorkspaceService } from '../services/project_workspace_service.js';
|
|
14
|
+
|
|
15
|
+
interface ChatFilesRoutesDeps {
|
|
16
|
+
workspaceService: ProjectWorkspaceService;
|
|
17
|
+
log: (tag: string, ...args: unknown[]) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Map file extensions to MIME types for common image/file formats. */
|
|
21
|
+
const MIME_TYPES: Record<string, string> = {
|
|
22
|
+
'.png': 'image/png',
|
|
23
|
+
'.jpg': 'image/jpeg',
|
|
24
|
+
'.jpeg': 'image/jpeg',
|
|
25
|
+
'.gif': 'image/gif',
|
|
26
|
+
'.webp': 'image/webp',
|
|
27
|
+
'.svg': 'image/svg+xml',
|
|
28
|
+
'.bmp': 'image/bmp',
|
|
29
|
+
'.pdf': 'application/pdf',
|
|
30
|
+
'.txt': 'text/plain',
|
|
31
|
+
'.json': 'application/json',
|
|
32
|
+
'.csv': 'text/csv',
|
|
33
|
+
'.md': 'text/markdown',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const createChatFilesRoutes = ({ workspaceService, log }: ChatFilesRoutesDeps): Router => {
|
|
37
|
+
const router: Router = Router();
|
|
38
|
+
|
|
39
|
+
// GET /api/chat-files/:projectId/:fileName
|
|
40
|
+
// Files are always in .chat-uploads/ — the fileName is the timestamped unique name
|
|
41
|
+
router.get('/:projectId/:fileName', async (req, res) => {
|
|
42
|
+
const { projectId, fileName } = req.params;
|
|
43
|
+
const filePath = `.chat-uploads/${fileName}`;
|
|
44
|
+
|
|
45
|
+
if (!projectId || !fileName) {
|
|
46
|
+
res.status(400).json({ error: 'projectId and fileName are required' });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Reject path traversal in the filename
|
|
51
|
+
if (fileName.includes('/') || fileName.includes('\\') || fileName === '..' || fileName === '.') {
|
|
52
|
+
res.status(400).json({ error: 'Invalid file name' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const wsPath = workspaceService.getWorkspacePath(projectId);
|
|
57
|
+
if (!existsSync(wsPath)) {
|
|
58
|
+
res.status(404).json({ error: 'Project workspace not found' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const absolutePath = join(wsPath, filePath);
|
|
63
|
+
|
|
64
|
+
// Validate no path traversal
|
|
65
|
+
const resolved = resolve(absolutePath);
|
|
66
|
+
if (!resolved.startsWith(resolve(wsPath))) {
|
|
67
|
+
res.status(400).json({ error: 'Invalid file path' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!existsSync(absolutePath)) {
|
|
72
|
+
res.status(404).json({ error: 'File not found' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const fileStat = await stat(absolutePath);
|
|
78
|
+
const ext = extname(absolutePath).toLowerCase();
|
|
79
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
80
|
+
|
|
81
|
+
// Immutable cache: uploaded files have timestamped unique names so they never change
|
|
82
|
+
res.setHeader('Content-Type', contentType);
|
|
83
|
+
res.setHeader('Content-Length', fileStat.size);
|
|
84
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
85
|
+
res.setHeader('ETag', `"${fileStat.mtimeMs.toString(36)}-${fileStat.size.toString(36)}"`);
|
|
86
|
+
|
|
87
|
+
const stream = createReadStream(resolved);
|
|
88
|
+
stream.pipe(res);
|
|
89
|
+
} catch (err: unknown) {
|
|
90
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
91
|
+
log('CHAT_FILES', `Failed to serve file: ${msg}`);
|
|
92
|
+
res.status(500).json({ error: `Failed to serve file: ${msg}` });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return router;
|
|
97
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat permission routes — HTTP endpoints used by the MCP permission server.
|
|
3
|
+
*
|
|
4
|
+
* POST /api/chat/permission/request — MCP server calls this when Claude CLI
|
|
5
|
+
* needs permission. The request is held open until the user responds via WebSocket.
|
|
6
|
+
*
|
|
7
|
+
* GET /api/chat/permission/rules?projectId=... — list "always allow" rules
|
|
8
|
+
* POST /api/chat/permission/rules — add an "always allow" rule
|
|
9
|
+
* DELETE /api/chat/permission/rules — remove an "always allow" rule
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Router } from 'express';
|
|
13
|
+
import type { PermissionService } from '../services/permission_service.js';
|
|
14
|
+
|
|
15
|
+
export interface ChatPermissionRoutesDeps {
|
|
16
|
+
permissionService: PermissionService;
|
|
17
|
+
log: (tag: string, ...args: unknown[]) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const createChatPermissionRoutes = ({ permissionService, log }: ChatPermissionRoutesDeps): Router => {
|
|
21
|
+
const router = Router();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* POST /request — called by the MCP permission server.
|
|
25
|
+
* Long-polls until the user responds or times out.
|
|
26
|
+
*/
|
|
27
|
+
router.post('/request', async (req, res) => {
|
|
28
|
+
const { claudeSessionId, projectId, toolName, input } = req.body;
|
|
29
|
+
|
|
30
|
+
if (!claudeSessionId || !projectId || !toolName) {
|
|
31
|
+
res.status(400).json({ error: 'Missing required fields: claudeSessionId, projectId, toolName' });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const response = await permissionService.requestPermission(
|
|
37
|
+
projectId,
|
|
38
|
+
claudeSessionId,
|
|
39
|
+
toolName,
|
|
40
|
+
input || {},
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
res.json(response);
|
|
44
|
+
} catch (err: unknown) {
|
|
45
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
46
|
+
log('PERM_ROUTE', `Permission request failed: ${message}`);
|
|
47
|
+
res.status(408).json({ error: message });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* GET /rules?projectId=... — list "always allow" rules for a project.
|
|
53
|
+
*/
|
|
54
|
+
router.get('/rules', async (req, res) => {
|
|
55
|
+
const projectId = req.query.projectId as string;
|
|
56
|
+
if (!projectId) {
|
|
57
|
+
res.status(400).json({ error: 'Missing projectId query parameter' });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const rules = await permissionService.getAlwaysRules(projectId);
|
|
62
|
+
res.json({ rules });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* POST /rules — add an "always allow" rule.
|
|
67
|
+
*/
|
|
68
|
+
router.post('/rules', async (req, res) => {
|
|
69
|
+
const { projectId, toolName } = req.body;
|
|
70
|
+
if (!projectId || !toolName) {
|
|
71
|
+
res.status(400).json({ error: 'Missing required fields: projectId, toolName' });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await permissionService.addAlwaysRule(projectId, toolName);
|
|
76
|
+
res.json({ ok: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* DELETE /rules — remove an "always allow" rule.
|
|
81
|
+
*/
|
|
82
|
+
router.delete('/rules', async (req, res) => {
|
|
83
|
+
const { projectId, toolName } = req.body;
|
|
84
|
+
if (!projectId || !toolName) {
|
|
85
|
+
res.status(400).json({ error: 'Missing required fields: projectId, toolName' });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await permissionService.removeAlwaysRule(projectId, toolName);
|
|
90
|
+
res.json({ ok: true });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return router;
|
|
94
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat session routes — REST API for managing Chat v2 sessions.
|
|
3
|
+
* GET /api/chat-sessions?projectId=xxx — list sessions for a project
|
|
4
|
+
* POST /api/chat-sessions — create a new session
|
|
5
|
+
* PATCH /api/chat-sessions/:id — rename a session
|
|
6
|
+
* DELETE /api/chat-sessions/:id — delete a session
|
|
7
|
+
* GET /api/chat-sessions/:id/messages — load message history for a session
|
|
8
|
+
*
|
|
9
|
+
* All routes require authentication.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Router } from 'express';
|
|
13
|
+
import type { ChatSessionService } from '../services/chat_session_service.js';
|
|
14
|
+
import type { ChatMessageRepository } from '../services/chat_message_repository.js';
|
|
15
|
+
|
|
16
|
+
interface ChatSessionRoutesDeps {
|
|
17
|
+
chatSessionService: ChatSessionService;
|
|
18
|
+
chatMessageRepository: ChatMessageRepository;
|
|
19
|
+
log: (tag: string, ...args: unknown[]) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const createChatSessionRoutes = ({ chatSessionService, chatMessageRepository, log }: ChatSessionRoutesDeps): Router => {
|
|
23
|
+
const router: Router = Router();
|
|
24
|
+
|
|
25
|
+
// GET /api/chat-sessions?projectId=xxx
|
|
26
|
+
router.get('/', async (req, res) => {
|
|
27
|
+
const { projectId } = req.query;
|
|
28
|
+
if (!projectId || typeof projectId !== 'string') {
|
|
29
|
+
res.status(400).json({ error: 'projectId query parameter is required' });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const sessions = await chatSessionService.listSessions(projectId);
|
|
33
|
+
res.json({ sessions });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// POST /api/chat-sessions
|
|
37
|
+
router.post('/', async (req, res) => {
|
|
38
|
+
const { projectId } = req.body;
|
|
39
|
+
if (!projectId || typeof projectId !== 'string') {
|
|
40
|
+
res.status(400).json({ error: 'projectId is required' });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const session = await chatSessionService.createSession(projectId.trim());
|
|
45
|
+
log('CHAT_SESSION', `Created session "${session.name}" for project ${projectId}`);
|
|
46
|
+
res.status(201).json({ session });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// PATCH /api/chat-sessions/:id
|
|
50
|
+
router.patch('/:id', async (req, res) => {
|
|
51
|
+
const { id } = req.params;
|
|
52
|
+
const { name } = req.body;
|
|
53
|
+
if (!name || typeof name !== 'string') {
|
|
54
|
+
res.status(400).json({ error: 'name is required' });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const session = await chatSessionService.renameSession(id, name.trim());
|
|
59
|
+
if (!session) {
|
|
60
|
+
res.status(404).json({ error: 'Session not found' });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
res.json({ session });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// DELETE /api/chat-sessions/:id
|
|
67
|
+
router.delete('/:id', async (req, res) => {
|
|
68
|
+
const { id } = req.params;
|
|
69
|
+
await chatMessageRepository.deleteBySessionId(id);
|
|
70
|
+
const deleted = await chatSessionService.deleteSession(id);
|
|
71
|
+
if (!deleted) {
|
|
72
|
+
res.status(404).json({ error: 'Session not found' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
res.json({ ok: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// GET /api/chat-sessions/:id/messages
|
|
79
|
+
router.get('/:id/messages', async (req, res) => {
|
|
80
|
+
const { id } = req.params;
|
|
81
|
+
const messages = await chatMessageRepository.getMessages(id);
|
|
82
|
+
res.json({ messages });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// POST /api/chat-sessions/:id/compact-continue
|
|
86
|
+
// Clean messages (strip tool_use/tool_result), create a new session with continuation context.
|
|
87
|
+
router.post('/:id/compact-continue', async (req, res) => {
|
|
88
|
+
const { id } = req.params;
|
|
89
|
+
|
|
90
|
+
const oldSession = await chatSessionService.getSession(id);
|
|
91
|
+
if (!oldSession) {
|
|
92
|
+
res.status(404).json({ error: 'Session not found' });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Read all messages from the old session
|
|
97
|
+
const messages = await chatMessageRepository.getMessages(id);
|
|
98
|
+
|
|
99
|
+
// Extract text content and collect file paths from tool_use blocks
|
|
100
|
+
const cleanedExchanges: Array<{ role: string; text: string }> = [];
|
|
101
|
+
const touchedFiles = new Set<string>();
|
|
102
|
+
|
|
103
|
+
for (const msg of messages) {
|
|
104
|
+
let blocks: Array<Record<string, unknown>>;
|
|
105
|
+
try {
|
|
106
|
+
blocks = JSON.parse(msg.content);
|
|
107
|
+
} catch {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const textParts: string[] = [];
|
|
112
|
+
for (const block of blocks) {
|
|
113
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
114
|
+
const text = (block.text as string).trim();
|
|
115
|
+
if (text) textParts.push(text);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Extract file paths from tool_use blocks
|
|
119
|
+
if (block.type === 'tool_use') {
|
|
120
|
+
const input = block.input as Record<string, unknown> | undefined;
|
|
121
|
+
if (!input) continue;
|
|
122
|
+
const toolName = block.name as string | undefined;
|
|
123
|
+
|
|
124
|
+
// Read, Write, Edit, MultiEdit — file_path
|
|
125
|
+
if (input.file_path && typeof input.file_path === 'string') {
|
|
126
|
+
touchedFiles.add(input.file_path);
|
|
127
|
+
}
|
|
128
|
+
// Glob — pattern + path
|
|
129
|
+
if (toolName === 'Glob' && typeof input.pattern === 'string') {
|
|
130
|
+
// Not a specific file, but record the search path if given
|
|
131
|
+
if (typeof input.path === 'string') touchedFiles.add(input.path);
|
|
132
|
+
}
|
|
133
|
+
// Grep — path
|
|
134
|
+
if (toolName === 'Grep' && typeof input.path === 'string') {
|
|
135
|
+
touchedFiles.add(input.path);
|
|
136
|
+
}
|
|
137
|
+
// Bash — try to extract file paths from the command
|
|
138
|
+
if (toolName === 'Bash' && typeof input.command === 'string') {
|
|
139
|
+
const fileMatches = (input.command as string).match(/(?:^|\s)(\/[\w/.~-]+)/g);
|
|
140
|
+
if (fileMatches) {
|
|
141
|
+
for (const m of fileMatches) {
|
|
142
|
+
const p = m.trim();
|
|
143
|
+
// Only include paths that look like actual files (have an extension or known dirs)
|
|
144
|
+
if (p.includes('.') || p.includes('/src/') || p.includes('/packages/')) {
|
|
145
|
+
touchedFiles.add(p);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (textParts.length > 0) {
|
|
154
|
+
cleanedExchanges.push({ role: msg.role, text: textParts.join('\n\n') });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Build the continuation summary with conversation + touched files
|
|
159
|
+
const conversationLines = cleanedExchanges.map(
|
|
160
|
+
(e) => `**${e.role === 'user' ? 'User' : 'Assistant'}**: ${e.text}`,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const parts: string[] = [
|
|
164
|
+
`We are continuing a previous conversation. Here is the summary of what was discussed:\n\n` +
|
|
165
|
+
conversationLines.join('\n\n'),
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
if (touchedFiles.size > 0) {
|
|
169
|
+
const sortedFiles = [...touchedFiles].sort();
|
|
170
|
+
parts.push(
|
|
171
|
+
`\n\n## Files touched in the previous conversation\n\n` +
|
|
172
|
+
sortedFiles.map((f) => `- ${f}`).join('\n'),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const continuationPrompt = parts.join('');
|
|
177
|
+
|
|
178
|
+
// Create the new session with the continuation context stored
|
|
179
|
+
const newSession = await chatSessionService.createSession(oldSession.projectId, continuationPrompt);
|
|
180
|
+
|
|
181
|
+
log('CHAT_SESSION', `Compact-continue: old=${id} → new=${newSession.id}, ${cleanedExchanges.length} exchanges`);
|
|
182
|
+
|
|
183
|
+
res.status(201).json({
|
|
184
|
+
session: newSession,
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return router;
|
|
189
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, rm, readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { createChatUploadRoutes } from './chat_upload.ts';
|
|
8
|
+
import express from 'express';
|
|
9
|
+
import type { Server } from 'node:http';
|
|
10
|
+
|
|
11
|
+
const createTestApp = (workspacePath: string) => {
|
|
12
|
+
const workspaceService = {
|
|
13
|
+
getWorkspacePath: (_projectId: string) => workspacePath,
|
|
14
|
+
} as any;
|
|
15
|
+
const log = mock.fn();
|
|
16
|
+
const app = express();
|
|
17
|
+
app.use(express.json({ limit: '15mb' }));
|
|
18
|
+
const routes = createChatUploadRoutes({ workspaceService, log });
|
|
19
|
+
app.use('/api/chat-upload', routes);
|
|
20
|
+
return { app, log };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const request = (server: Server) => {
|
|
24
|
+
const addr = server.address() as { port: number };
|
|
25
|
+
const base = `http://127.0.0.1:${addr.port}`;
|
|
26
|
+
return {
|
|
27
|
+
post: async (path: string, body: any) => {
|
|
28
|
+
const res = await fetch(`${base}${path}`, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
});
|
|
33
|
+
return { status: res.status, body: await res.json() };
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('chat_upload route', () => {
|
|
39
|
+
let tmpDir: string;
|
|
40
|
+
let server: Server;
|
|
41
|
+
let req: ReturnType<typeof request>;
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'chat-upload-test-'));
|
|
45
|
+
const { app } = createTestApp(tmpDir);
|
|
46
|
+
server = await new Promise<Server>((resolve) => {
|
|
47
|
+
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
|
48
|
+
});
|
|
49
|
+
req = request(server);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(async () => {
|
|
53
|
+
server.close();
|
|
54
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('uploads a file and returns its relative path', async () => {
|
|
58
|
+
const content = Buffer.from('hello world').toString('base64');
|
|
59
|
+
const res = await req.post('/api/chat-upload', {
|
|
60
|
+
projectId: 'proj_test',
|
|
61
|
+
fileName: 'test.txt',
|
|
62
|
+
content,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.equal(res.status, 200);
|
|
66
|
+
assert.ok(res.body.path.startsWith('.chat-uploads/'));
|
|
67
|
+
assert.ok(res.body.path.endsWith('_test.txt'));
|
|
68
|
+
|
|
69
|
+
// Verify file was actually written
|
|
70
|
+
const filePath = join(tmpDir, res.body.path);
|
|
71
|
+
assert.ok(existsSync(filePath));
|
|
72
|
+
const fileContent = await readFile(filePath, 'utf-8');
|
|
73
|
+
assert.equal(fileContent, 'hello world');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects missing fields', async () => {
|
|
77
|
+
const res = await req.post('/api/chat-upload', {
|
|
78
|
+
projectId: 'proj_test',
|
|
79
|
+
});
|
|
80
|
+
assert.equal(res.status, 400);
|
|
81
|
+
assert.ok(res.body.error.includes('required'));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('sanitizes filenames with special characters', async () => {
|
|
85
|
+
const content = Buffer.from('data').toString('base64');
|
|
86
|
+
const res = await req.post('/api/chat-upload', {
|
|
87
|
+
projectId: 'proj_test',
|
|
88
|
+
fileName: '../../../etc/passwd',
|
|
89
|
+
content,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
assert.equal(res.status, 200);
|
|
93
|
+
// The path should not contain directory traversal
|
|
94
|
+
assert.ok(!res.body.path.includes('..'));
|
|
95
|
+
assert.ok(res.body.path.startsWith('.chat-uploads/'));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects when workspace does not exist', async () => {
|
|
99
|
+
// Create a new app pointing to nonexistent workspace
|
|
100
|
+
const { app: badApp } = createTestApp('/nonexistent/path');
|
|
101
|
+
const badServer = await new Promise<Server>((resolve) => {
|
|
102
|
+
const s = badApp.listen(0, '127.0.0.1', () => resolve(s));
|
|
103
|
+
});
|
|
104
|
+
const badReq = request(badServer);
|
|
105
|
+
|
|
106
|
+
const content = Buffer.from('data').toString('base64');
|
|
107
|
+
const res = await badReq.post('/api/chat-upload', {
|
|
108
|
+
projectId: 'proj_test',
|
|
109
|
+
fileName: 'test.txt',
|
|
110
|
+
content,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
assert.equal(res.status, 404);
|
|
114
|
+
assert.ok(res.body.error.includes('not found'));
|
|
115
|
+
badServer.close();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('creates the .chat-uploads directory if it does not exist', async () => {
|
|
119
|
+
const uploadsDir = join(tmpDir, '.chat-uploads');
|
|
120
|
+
assert.ok(!existsSync(uploadsDir));
|
|
121
|
+
|
|
122
|
+
const content = Buffer.from('test').toString('base64');
|
|
123
|
+
await req.post('/api/chat-upload', {
|
|
124
|
+
projectId: 'proj_test',
|
|
125
|
+
fileName: 'new.txt',
|
|
126
|
+
content,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
assert.ok(existsSync(uploadsDir));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat file upload route — accepts files for chat attachments.
|
|
3
|
+
*
|
|
4
|
+
* POST /api/chat-upload
|
|
5
|
+
* Body: { projectId, fileName, content (base64) }
|
|
6
|
+
* Response: { path (relative workspace path) }
|
|
7
|
+
*
|
|
8
|
+
* Files are saved to .chat-uploads/<timestamp>_<fileName> in the project workspace.
|
|
9
|
+
* The returned path is relative to the workspace root, suitable for
|
|
10
|
+
* inclusion in CLI prompts where Claude can read them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Router } from 'express';
|
|
14
|
+
import { join, resolve, basename } from 'node:path';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
17
|
+
import type { ProjectWorkspaceService } from '../services/project_workspace_service.js';
|
|
18
|
+
|
|
19
|
+
interface ChatUploadRoutesDeps {
|
|
20
|
+
workspaceService: ProjectWorkspaceService;
|
|
21
|
+
log: (tag: string, ...args: unknown[]) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Sanitize a filename — remove path separators and control characters. */
|
|
25
|
+
const sanitizeFileName = (name: string): string => {
|
|
26
|
+
return basename(name).replace(/[^\w.\-]/g, '_').slice(0, 200);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const UPLOAD_DIR = '.chat-uploads';
|
|
30
|
+
|
|
31
|
+
/** Max file size: 10 MB (base64 encoded ≈ 13.3 MB in body) */
|
|
32
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
export const createChatUploadRoutes = ({ workspaceService, log }: ChatUploadRoutesDeps): Router => {
|
|
35
|
+
const router: Router = Router();
|
|
36
|
+
|
|
37
|
+
router.post('/', async (req, res) => {
|
|
38
|
+
const { projectId, fileName, content } = req.body;
|
|
39
|
+
|
|
40
|
+
if (!projectId || !fileName || !content) {
|
|
41
|
+
res.status(400).json({ error: 'projectId, fileName, and content are required' });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (typeof content !== 'string') {
|
|
46
|
+
res.status(400).json({ error: 'content must be a base64 string' });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Decode and check size
|
|
51
|
+
const buffer = Buffer.from(content, 'base64');
|
|
52
|
+
if (buffer.length > MAX_FILE_SIZE) {
|
|
53
|
+
res.status(413).json({ error: `File too large. Maximum size is ${MAX_FILE_SIZE / (1024 * 1024)} MB` });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const wsPath = workspaceService.getWorkspacePath(projectId);
|
|
58
|
+
if (!existsSync(wsPath)) {
|
|
59
|
+
res.status(404).json({ error: 'Project workspace not found' });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const uploadDir = join(wsPath, UPLOAD_DIR);
|
|
65
|
+
if (!existsSync(uploadDir)) {
|
|
66
|
+
await mkdir(uploadDir, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const safeName = sanitizeFileName(fileName);
|
|
70
|
+
const uniqueName = `${Date.now()}_${safeName}`;
|
|
71
|
+
const filePath = join(uploadDir, uniqueName);
|
|
72
|
+
|
|
73
|
+
// Validate no path traversal
|
|
74
|
+
const resolved = resolve(filePath);
|
|
75
|
+
if (!resolved.startsWith(resolve(wsPath))) {
|
|
76
|
+
res.status(400).json({ error: 'Invalid file path' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await writeFile(filePath, buffer);
|
|
81
|
+
|
|
82
|
+
const relativePath = `${UPLOAD_DIR}/${uniqueName}`;
|
|
83
|
+
log('CHAT_UPLOAD', `Saved ${relativePath} (${buffer.length} bytes) for project ${projectId}`);
|
|
84
|
+
|
|
85
|
+
res.json({ path: relativePath });
|
|
86
|
+
} catch (err: unknown) {
|
|
87
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
88
|
+
log('CHAT_UPLOAD', `Upload failed: ${msg}`);
|
|
89
|
+
res.status(500).json({ error: `Upload failed: ${msg}` });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return router;
|
|
94
|
+
};
|
|
@@ -109,15 +109,24 @@ describe('File Routes', () => {
|
|
|
109
109
|
assert.equal(readmeEntry.type, 'file');
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
-
it('
|
|
112
|
+
it('excludes only .git directory, shows other dotfiles', async () => {
|
|
113
113
|
await mkdir(join(tmpDir, '.git'));
|
|
114
|
+
await mkdir(join(tmpDir, '.claude'));
|
|
114
115
|
await writeFile(join(tmpDir, '.gitignore'), 'node_modules');
|
|
116
|
+
await writeFile(join(tmpDir, '.env'), 'SECRET=123');
|
|
115
117
|
await writeFile(join(tmpDir, 'visible.ts'), 'export {}');
|
|
116
118
|
|
|
117
119
|
const { status, body } = await http.get('/api/projects/proj_1/files');
|
|
118
120
|
assert.equal(status, 200);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
const names = body.tree.map((e: any) => e.name);
|
|
122
|
+
// .git must be excluded
|
|
123
|
+
assert.ok(!names.includes('.git'), '.git should be excluded');
|
|
124
|
+
// Other dotfiles must be present
|
|
125
|
+
assert.ok(names.includes('.claude'), '.claude directory should be shown');
|
|
126
|
+
assert.ok(names.includes('.gitignore'), '.gitignore should be shown');
|
|
127
|
+
assert.ok(names.includes('.env'), '.env should be shown');
|
|
128
|
+
assert.ok(names.includes('visible.ts'), 'visible.ts should be shown');
|
|
129
|
+
assert.equal(body.tree.length, 4);
|
|
121
130
|
});
|
|
122
131
|
});
|
|
123
132
|
|
|
@@ -111,8 +111,8 @@ const buildTree = async (dirPath: string, basePath: string): Promise<TreeEntry[]
|
|
|
111
111
|
const tree: TreeEntry[] = [];
|
|
112
112
|
|
|
113
113
|
for (const entry of entries) {
|
|
114
|
-
//
|
|
115
|
-
if (entry.name
|
|
114
|
+
// Only skip .git directory — all other dotfiles are shown
|
|
115
|
+
if (entry.name === '.git') continue;
|
|
116
116
|
|
|
117
117
|
const fullPath = join(dirPath, entry.name);
|
|
118
118
|
const relPath = relative(basePath, fullPath);
|