@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.
Files changed (206) hide show
  1. package/dist/src/scaffolder.d.ts +12 -1
  2. package/dist/src/scaffolder.js +40 -3
  3. package/dist/src/scaffolder.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/assistkick-product-system/package.json +1 -1
  6. package/templates/assistkick-product-system/packages/backend/package.json +1 -0
  7. package/templates/assistkick-product-system/packages/backend/src/mcp/permission_mcp_server.ts +196 -0
  8. package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +31 -7
  9. package/templates/assistkick-product-system/packages/backend/src/routes/auth.ts +15 -12
  10. package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.test.ts +95 -0
  11. package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.ts +97 -0
  12. package/templates/assistkick-product-system/packages/backend/src/routes/chat_permission.ts +94 -0
  13. package/templates/assistkick-product-system/packages/backend/src/routes/chat_sessions.ts +189 -0
  14. package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.test.ts +131 -0
  15. package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.ts +94 -0
  16. package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +12 -3
  17. package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +2 -2
  18. package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +391 -23
  19. package/templates/assistkick-product-system/packages/backend/src/routes/git_branches.test.ts +306 -0
  20. package/templates/assistkick-product-system/packages/backend/src/routes/git_connect.test.ts +133 -0
  21. package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +66 -9
  22. package/templates/assistkick-product-system/packages/backend/src/routes/preview.ts +204 -0
  23. package/templates/assistkick-product-system/packages/backend/src/routes/projects.test.ts +205 -0
  24. package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +37 -9
  25. package/templates/assistkick-product-system/packages/backend/src/routes/skills.test.ts +139 -0
  26. package/templates/assistkick-product-system/packages/backend/src/routes/skills.ts +95 -0
  27. package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +5 -4
  28. package/templates/assistkick-product-system/packages/backend/src/routes/users.ts +4 -4
  29. package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +8 -8
  30. package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +5 -5
  31. package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +6 -6
  32. package/templates/assistkick-product-system/packages/backend/src/server.ts +107 -27
  33. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +105 -203
  34. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +76 -266
  35. package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.test.ts +427 -0
  36. package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +345 -0
  37. package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.test.ts +170 -0
  38. package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.ts +106 -0
  39. package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.test.ts +217 -0
  40. package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.ts +188 -0
  41. package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.test.ts +1243 -0
  42. package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +894 -0
  43. package/templates/assistkick-product-system/packages/backend/src/services/coherence-review.ts +3 -3
  44. package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.test.ts +85 -0
  45. package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.ts +54 -0
  46. package/templates/assistkick-product-system/packages/backend/src/services/email_service.ts +13 -10
  47. package/templates/assistkick-product-system/packages/backend/src/services/init.ts +11 -3
  48. package/templates/assistkick-product-system/packages/backend/src/services/invitation_service.ts +1 -1
  49. package/templates/assistkick-product-system/packages/backend/src/services/password_reset_service.ts +1 -1
  50. package/templates/assistkick-product-system/packages/backend/src/services/permission_service.test.ts +243 -0
  51. package/templates/assistkick-product-system/packages/backend/src/services/permission_service.ts +259 -0
  52. package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.test.ts +172 -0
  53. package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.ts +225 -0
  54. package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +29 -0
  55. package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +17 -0
  56. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +255 -0
  57. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +300 -25
  58. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +44 -0
  59. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +62 -7
  60. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +77 -6
  61. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +149 -14
  62. package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +2 -1
  63. package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.test.ts +45 -0
  64. package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.ts +157 -0
  65. package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +4 -3
  66. package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +3 -3
  67. package/templates/assistkick-product-system/packages/frontend/package.json +5 -0
  68. package/templates/assistkick-product-system/packages/frontend/src/App.tsx +2 -0
  69. package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +336 -5
  70. package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +192 -12
  71. package/templates/assistkick-product-system/packages/frontend/src/components/AttachmentPreviewList.tsx +98 -0
  72. package/templates/assistkick-product-system/packages/frontend/src/components/AutocompleteDropdown.tsx +65 -0
  73. package/templates/assistkick-product-system/packages/frontend/src/components/ChatAttachButton.tsx +56 -0
  74. package/templates/assistkick-product-system/packages/frontend/src/components/ChatDropZone.tsx +80 -0
  75. package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageBubble.tsx +155 -0
  76. package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageContent.tsx +182 -0
  77. package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageInput.tsx +233 -0
  78. package/templates/assistkick-product-system/packages/frontend/src/components/ChatSessionSidebar.tsx +218 -0
  79. package/templates/assistkick-product-system/packages/frontend/src/components/ChatStopButton.tsx +32 -0
  80. package/templates/assistkick-product-system/packages/frontend/src/components/ChatTodoSidebar.tsx +113 -0
  81. package/templates/assistkick-product-system/packages/frontend/src/components/ChatView.tsx +842 -0
  82. package/templates/assistkick-product-system/packages/frontend/src/components/CommitMessageModal.tsx +82 -0
  83. package/templates/assistkick-product-system/packages/frontend/src/components/DiagramOverlay.tsx +160 -0
  84. package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +5 -5
  85. package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +9 -10
  86. package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +5 -5
  87. package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +112 -41
  88. package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +2 -2
  89. package/templates/assistkick-product-system/packages/frontend/src/components/HighlightedText.tsx +87 -0
  90. package/templates/assistkick-product-system/packages/frontend/src/components/ImageLightbox.tsx +192 -0
  91. package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +2 -2
  92. package/templates/assistkick-product-system/packages/frontend/src/components/MentionPill.tsx +33 -0
  93. package/templates/assistkick-product-system/packages/frontend/src/components/MermaidBlock.tsx +148 -0
  94. package/templates/assistkick-product-system/packages/frontend/src/components/PermissionDialog.tsx +91 -0
  95. package/templates/assistkick-product-system/packages/frontend/src/components/PermissionModeSelector.tsx +229 -0
  96. package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +249 -83
  97. package/templates/assistkick-product-system/packages/frontend/src/components/QueuedMessageBubble.tsx +38 -0
  98. package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +212 -117
  99. package/templates/assistkick-product-system/packages/frontend/src/components/SystemPromptAccordion.tsx +48 -0
  100. package/templates/assistkick-product-system/packages/frontend/src/components/TaskIcon.tsx +11 -0
  101. package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +25 -9
  102. package/templates/assistkick-product-system/packages/frontend/src/components/ToolDiffView.tsx +114 -0
  103. package/templates/assistkick-product-system/packages/frontend/src/components/ToolResultCard.tsx +87 -0
  104. package/templates/assistkick-product-system/packages/frontend/src/components/ToolUseCard.tsx +149 -0
  105. package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +25 -8
  106. package/templates/assistkick-product-system/packages/frontend/src/components/UnifiedGitWidget.tsx +722 -0
  107. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +2 -0
  108. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +2 -1
  109. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/ProgrammableNode.tsx +178 -0
  110. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +3 -0
  111. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +103 -9
  112. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +26 -2
  113. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +42 -1
  114. package/templates/assistkick-product-system/packages/frontend/src/hooks/useDocumentTitle.ts +11 -0
  115. package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +1 -0
  116. package/templates/assistkick-product-system/packages/frontend/src/hooks/use_chat_stream.ts +826 -0
  117. package/templates/assistkick-product-system/packages/frontend/src/hooks/use_file_tree_cache.ts +69 -0
  118. package/templates/assistkick-product-system/packages/frontend/src/hooks/use_mention_autocomplete.ts +284 -0
  119. package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.test.ts +183 -0
  120. package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.ts +150 -0
  121. package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.test.ts +305 -0
  122. package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.ts +113 -0
  123. package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.test.ts +157 -0
  124. package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.ts +95 -0
  125. package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.test.ts +65 -0
  126. package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.ts +110 -0
  127. package/templates/assistkick-product-system/packages/frontend/src/lib/message_queue.ts +66 -0
  128. package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.test.ts +124 -0
  129. package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.ts +112 -0
  130. package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +2 -0
  131. package/templates/assistkick-product-system/packages/frontend/src/routes/ChatRoute.tsx +8 -0
  132. package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +2 -0
  133. package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +0 -4
  134. package/templates/assistkick-product-system/packages/frontend/src/routes/DesignSystemRoute.tsx +2 -0
  135. package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +2 -0
  136. package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +2 -0
  137. package/templates/assistkick-product-system/packages/frontend/src/routes/KanbanRoute.tsx +2 -0
  138. package/templates/assistkick-product-system/packages/frontend/src/routes/TerminalRoute.tsx +2 -0
  139. package/templates/assistkick-product-system/packages/frontend/src/routes/UsersRoute.tsx +2 -0
  140. package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +2 -0
  141. package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +2 -0
  142. package/templates/assistkick-product-system/packages/frontend/src/routes/accept_invitation.tsx +2 -0
  143. package/templates/assistkick-product-system/packages/frontend/src/routes/forgot_password.tsx +2 -0
  144. package/templates/assistkick-product-system/packages/frontend/src/routes/login.tsx +2 -0
  145. package/templates/assistkick-product-system/packages/frontend/src/routes/register.tsx +2 -0
  146. package/templates/assistkick-product-system/packages/frontend/src/routes/reset_password.tsx +2 -0
  147. package/templates/assistkick-product-system/packages/frontend/src/stores/useAttachmentStore.ts +66 -0
  148. package/templates/assistkick-product-system/packages/frontend/src/stores/useChatSessionStore.ts +107 -0
  149. package/templates/assistkick-product-system/packages/frontend/src/stores/useMessageQueueStore.ts +110 -0
  150. package/templates/assistkick-product-system/packages/frontend/src/stores/usePreviewStore.ts +78 -0
  151. package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +7 -0
  152. package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +6 -1
  153. package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +30 -357
  154. package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.test.ts +115 -0
  155. package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.ts +91 -0
  156. package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.test.ts +30 -0
  157. package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.ts +3 -0
  158. package/templates/assistkick-product-system/packages/shared/db/migrations/0015_magenta_jazinda.sql +1 -0
  159. package/templates/assistkick-product-system/packages/shared/db/migrations/0016_giant_xorn.sql +1 -0
  160. package/templates/assistkick-product-system/packages/shared/db/migrations/0017_sloppy_mentor.sql +6 -0
  161. package/templates/assistkick-product-system/packages/shared/db/migrations/0018_vengeful_kabuki.sql +9 -0
  162. package/templates/assistkick-product-system/packages/shared/db/migrations/0019_careful_sentinels.sql +8 -0
  163. package/templates/assistkick-product-system/packages/shared/db/migrations/0020_clever_spot.sql +27 -0
  164. package/templates/assistkick-product-system/packages/shared/db/migrations/0021_graceful_hex.sql +1 -0
  165. package/templates/assistkick-product-system/packages/shared/db/migrations/0022_short_kingpin.sql +1 -0
  166. package/templates/assistkick-product-system/packages/shared/db/migrations/0023_ambiguous_sharon_carter.sql +1 -0
  167. package/templates/assistkick-product-system/packages/shared/db/migrations/0024_fat_unus.sql +1 -0
  168. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0015_snapshot.json +1552 -0
  169. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0016_snapshot.json +1560 -0
  170. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0017_snapshot.json +1598 -0
  171. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0018_snapshot.json +1657 -0
  172. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0019_snapshot.json +1709 -0
  173. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0020_snapshot.json +1733 -0
  174. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0021_snapshot.json +1740 -0
  175. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0022_snapshot.json +1755 -0
  176. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0023_snapshot.json +1762 -0
  177. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0024_snapshot.json +1769 -0
  178. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +70 -0
  179. package/templates/assistkick-product-system/packages/shared/db/schema.ts +40 -1
  180. package/templates/assistkick-product-system/packages/shared/lib/claude-service.test.ts +236 -0
  181. package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +46 -5
  182. package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +65 -39
  183. package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.test.ts +173 -0
  184. package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.ts +213 -0
  185. package/templates/assistkick-product-system/packages/shared/lib/validator.test.ts +70 -0
  186. package/templates/assistkick-product-system/packages/shared/lib/validator.ts +17 -1
  187. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +803 -27
  188. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +502 -68
  189. package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +4 -4
  190. package/templates/assistkick-product-system/packages/shared/package.json +2 -1
  191. package/templates/assistkick-product-system/packages/shared/test_fixtures/hanging_stream.mjs +46 -0
  192. package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +44 -0
  193. package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +7 -0
  194. package/templates/assistkick-product-system/packages/shared/tools/remove_node.ts +2 -1
  195. package/templates/assistkick-product-system/packages/shared/tools/resolve_question.ts +2 -1
  196. package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -1
  197. package/templates/assistkick-product-system/tests/message_queue.test.ts +178 -0
  198. package/templates/assistkick-product-system/tests/message_queue_per_session.test.ts +143 -0
  199. package/templates/skills/assistkick-bootstrap/SKILL.md +26 -26
  200. package/templates/skills/assistkick-code-reviewer/SKILL.md +45 -46
  201. package/templates/skills/assistkick-db-explorer/SKILL.md +13 -13
  202. package/templates/skills/assistkick-debugger/SKILL.md +23 -23
  203. package/templates/skills/assistkick-developer/SKILL.md +59 -63
  204. package/templates/skills/assistkick-interview/SKILL.md +26 -26
  205. package/templates/skills/assistkick-video-composition-agent/SKILL.md +231 -0
  206. package/templates/skills/assistkick-video-script-writer/SKILL.md +136 -0
@@ -0,0 +1,259 @@
1
+ /**
2
+ * PermissionService — manages Chat v2 tool permission rules.
3
+ *
4
+ * Three permission decision types:
5
+ * - "allow_once" → ephemeral, not stored
6
+ * - "allow_session" → held in memory for the Claude session lifetime
7
+ * - "allow_always" → persisted in DB across sessions
8
+ * - "deny" → reject the tool call
9
+ *
10
+ * Also manages pending permission requests: the MCP server posts a request,
11
+ * which is held until the frontend user responds via WebSocket.
12
+ */
13
+
14
+ import { eq, and } from 'drizzle-orm';
15
+ import { chatPermissionRules } from '@assistkick/shared/db/schema.js';
16
+
17
+ export type PermissionDecision = 'allow_once' | 'allow_session' | 'allow_always' | 'deny';
18
+
19
+ export interface PermissionRequest {
20
+ requestId: string;
21
+ claudeSessionId: string;
22
+ toolName: string;
23
+ input: Record<string, unknown>;
24
+ }
25
+
26
+ export interface PermissionResponse {
27
+ decision: PermissionDecision;
28
+ }
29
+
30
+ export interface PendingRequest {
31
+ request: PermissionRequest;
32
+ resolve: (response: PermissionResponse) => void;
33
+ reject: (err: Error) => void;
34
+ timeoutHandle: ReturnType<typeof setTimeout>;
35
+ }
36
+
37
+ export interface PermissionServiceDeps {
38
+ getDb: () => any;
39
+ log: (tag: string, ...args: unknown[]) => void;
40
+ }
41
+
42
+ const PERMISSION_TIMEOUT_MS = 120_000; // 2 minutes
43
+
44
+ export class PermissionService {
45
+ private readonly getDb: PermissionServiceDeps['getDb'];
46
+ private readonly log: PermissionServiceDeps['log'];
47
+
48
+ /** In-memory session-scoped permissions: Map<claudeSessionId, Set<toolName>> */
49
+ private readonly sessionPermissions = new Map<string, Set<string>>();
50
+
51
+ /** Pending permission requests: Map<requestId, PendingRequest> */
52
+ private readonly pendingRequests = new Map<string, PendingRequest>();
53
+
54
+ /** Callback invoked when a new permission request needs to reach the frontend */
55
+ private onPermissionRequest: ((request: PermissionRequest) => void) | null = null;
56
+
57
+ constructor({ getDb, log }: PermissionServiceDeps) {
58
+ this.getDb = getDb;
59
+ this.log = log;
60
+ }
61
+
62
+ /**
63
+ * Register a callback that is invoked when a permission request arrives
64
+ * from the MCP server and needs to be forwarded to the frontend.
65
+ */
66
+ setPermissionRequestHandler = (handler: (request: PermissionRequest) => void): void => {
67
+ this.onPermissionRequest = handler;
68
+ };
69
+
70
+ /**
71
+ * Check if a tool is already allowed (via DB "always" rules or session memory).
72
+ */
73
+ isToolAllowed = async (projectId: string, claudeSessionId: string, toolName: string): Promise<boolean> => {
74
+ // Check session-scoped permissions first (fast, in-memory)
75
+ const sessionTools = this.sessionPermissions.get(claudeSessionId);
76
+ if (sessionTools?.has(toolName)) return true;
77
+
78
+ // Check DB for "always allow" rules
79
+ const db = this.getDb();
80
+ const rows = await db
81
+ .select()
82
+ .from(chatPermissionRules)
83
+ .where(and(
84
+ eq(chatPermissionRules.projectId, projectId),
85
+ eq(chatPermissionRules.toolName, toolName),
86
+ ))
87
+ .limit(1);
88
+
89
+ return rows.length > 0;
90
+ };
91
+
92
+ /**
93
+ * Request permission from the user for a tool invocation.
94
+ * If the tool is already allowed, resolves immediately.
95
+ * Otherwise, queues a pending request and waits for user response.
96
+ */
97
+ requestPermission = async (
98
+ projectId: string,
99
+ claudeSessionId: string,
100
+ toolName: string,
101
+ input: Record<string, unknown>,
102
+ ): Promise<PermissionResponse> => {
103
+ // Check if already allowed
104
+ const allowed = await this.isToolAllowed(projectId, claudeSessionId, toolName);
105
+ if (allowed) {
106
+ this.log('PERMISSION', `Tool "${toolName}" already allowed for session ${claudeSessionId}`);
107
+ return { decision: 'allow_always' };
108
+ }
109
+
110
+ // Create a pending request
111
+ const requestId = `perm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
112
+ const request: PermissionRequest = { requestId, claudeSessionId, toolName, input };
113
+
114
+ return new Promise<PermissionResponse>((resolve, reject) => {
115
+ const timeoutHandle = setTimeout(() => {
116
+ this.pendingRequests.delete(requestId);
117
+ this.log('PERMISSION', `Permission request ${requestId} timed out`);
118
+ reject(new Error('Permission request timed out'));
119
+ }, PERMISSION_TIMEOUT_MS);
120
+
121
+ this.pendingRequests.set(requestId, { request, resolve, reject, timeoutHandle });
122
+
123
+ // Notify the frontend
124
+ if (this.onPermissionRequest) {
125
+ this.onPermissionRequest(request);
126
+ } else {
127
+ clearTimeout(timeoutHandle);
128
+ this.pendingRequests.delete(requestId);
129
+ reject(new Error('No permission request handler registered'));
130
+ }
131
+ });
132
+ };
133
+
134
+ /**
135
+ * Resolve a pending permission request with the user's decision.
136
+ * Called when the frontend sends a permission_response via WebSocket.
137
+ */
138
+ resolvePermission = async (
139
+ requestId: string,
140
+ projectId: string,
141
+ decision: PermissionDecision,
142
+ ): Promise<boolean> => {
143
+ const pending = this.pendingRequests.get(requestId);
144
+ if (!pending) {
145
+ this.log('PERMISSION', `No pending request found for ${requestId}`);
146
+ return false;
147
+ }
148
+
149
+ clearTimeout(pending.timeoutHandle);
150
+ this.pendingRequests.delete(requestId);
151
+
152
+ const { claudeSessionId, toolName } = pending.request;
153
+ this.log('PERMISSION', `Permission for "${toolName}": ${decision} (session ${claudeSessionId})`);
154
+
155
+ // Persist based on decision
156
+ if (decision === 'allow_always') {
157
+ await this.addAlwaysRule(projectId, toolName);
158
+ } else if (decision === 'allow_session') {
159
+ this.addSessionRule(claudeSessionId, toolName);
160
+ }
161
+
162
+ pending.resolve({ decision });
163
+ return true;
164
+ };
165
+
166
+ /**
167
+ * Cancel all pending requests for a given Claude session.
168
+ * Called when the WebSocket disconnects or CLI process ends.
169
+ */
170
+ cancelPendingRequests = (claudeSessionId: string): void => {
171
+ for (const [requestId, pending] of this.pendingRequests) {
172
+ if (pending.request.claudeSessionId === claudeSessionId) {
173
+ clearTimeout(pending.timeoutHandle);
174
+ pending.reject(new Error('Permission request cancelled — session ended'));
175
+ this.pendingRequests.delete(requestId);
176
+ this.log('PERMISSION', `Cancelled pending request ${requestId} for session ${claudeSessionId}`);
177
+ }
178
+ }
179
+ };
180
+
181
+ /**
182
+ * Clear session-scoped permissions for a Claude session.
183
+ */
184
+ clearSessionPermissions = (claudeSessionId: string): void => {
185
+ this.sessionPermissions.delete(claudeSessionId);
186
+ };
187
+
188
+ /**
189
+ * Get all "always allow" rules for a project.
190
+ */
191
+ getAlwaysRules = async (projectId: string): Promise<string[]> => {
192
+ const db = this.getDb();
193
+ const rows = await db
194
+ .select({ toolName: chatPermissionRules.toolName })
195
+ .from(chatPermissionRules)
196
+ .where(eq(chatPermissionRules.projectId, projectId));
197
+ return rows.map((r: { toolName: string }) => r.toolName);
198
+ };
199
+
200
+ /**
201
+ * Add an "always allow" rule for a tool in a project.
202
+ */
203
+ addAlwaysRule = async (projectId: string, toolName: string): Promise<void> => {
204
+ const db = this.getDb();
205
+ const id = `cpr_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
206
+ const existing = await db
207
+ .select()
208
+ .from(chatPermissionRules)
209
+ .where(and(
210
+ eq(chatPermissionRules.projectId, projectId),
211
+ eq(chatPermissionRules.toolName, toolName),
212
+ ))
213
+ .limit(1);
214
+
215
+ if (existing.length === 0) {
216
+ await db.insert(chatPermissionRules).values({
217
+ id,
218
+ projectId,
219
+ toolName,
220
+ createdAt: new Date().toISOString(),
221
+ });
222
+ this.log('PERMISSION', `Added "always allow" rule for "${toolName}" in project ${projectId}`);
223
+ }
224
+ };
225
+
226
+ /**
227
+ * Remove an "always allow" rule for a tool in a project.
228
+ */
229
+ removeAlwaysRule = async (projectId: string, toolName: string): Promise<void> => {
230
+ const db = this.getDb();
231
+ await db
232
+ .delete(chatPermissionRules)
233
+ .where(and(
234
+ eq(chatPermissionRules.projectId, projectId),
235
+ eq(chatPermissionRules.toolName, toolName),
236
+ ));
237
+ this.log('PERMISSION', `Removed "always allow" rule for "${toolName}" in project ${projectId}`);
238
+ };
239
+
240
+ /**
241
+ * Add a session-scoped permission.
242
+ */
243
+ private addSessionRule = (claudeSessionId: string, toolName: string): void => {
244
+ let tools = this.sessionPermissions.get(claudeSessionId);
245
+ if (!tools) {
246
+ tools = new Set();
247
+ this.sessionPermissions.set(claudeSessionId, tools);
248
+ }
249
+ tools.add(toolName);
250
+ this.log('PERMISSION', `Added session rule for "${toolName}" in session ${claudeSessionId}`);
251
+ };
252
+
253
+ /**
254
+ * Get count of pending requests (for diagnostics).
255
+ */
256
+ getPendingCount = (): number => {
257
+ return this.pendingRequests.size;
258
+ };
259
+ }
@@ -0,0 +1,172 @@
1
+ import { describe, it, mock, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { PreviewServerManager } from './preview_server_manager.ts';
4
+ import type { DevCommandDetector, DetectedCommand } from './dev_command_detector.ts';
5
+
6
+ const createMockDetector = (result: DetectedCommand | null = { scriptName: 'dev', command: 'vite' }): DevCommandDetector => {
7
+ return {
8
+ detect: mock.fn(async () => result),
9
+ log: () => {},
10
+ } as any;
11
+ };
12
+
13
+ describe('PreviewServerManager', () => {
14
+ let manager: PreviewServerManager;
15
+ let mockDetector: DevCommandDetector;
16
+
17
+ beforeEach(() => {
18
+ mockDetector = createMockDetector();
19
+ manager = new PreviewServerManager({ devCommandDetector: mockDetector, log: () => {} });
20
+ });
21
+
22
+ describe('start', () => {
23
+ it('rejects duplicate app names', async () => {
24
+ // Start a server with a custom command that exits quickly
25
+ await manager.start('test-app', '/tmp', 'echo hello');
26
+
27
+ await assert.rejects(
28
+ () => manager.start('test-app', '/tmp', 'echo hello'),
29
+ { message: 'Preview app "test-app" is already running' },
30
+ );
31
+
32
+ manager.stopAll();
33
+ });
34
+
35
+ it('throws when no dev script is detected and no custom command provided', async () => {
36
+ const nullDetector = createMockDetector(null);
37
+ const mgr = new PreviewServerManager({ devCommandDetector: nullDetector, log: () => {} });
38
+
39
+ await assert.rejects(
40
+ () => mgr.start('no-scripts', '/tmp'),
41
+ { message: /No dev server script found/ },
42
+ );
43
+ });
44
+
45
+ it('starts a preview app with custom command', async () => {
46
+ const app = await manager.start('my-app', '/tmp', 'echo started');
47
+
48
+ assert.equal(app.appName, 'my-app');
49
+ assert.equal(app.command, 'echo started');
50
+ assert.equal(app.scriptName, 'custom');
51
+ assert.equal(app.isPublic, false);
52
+ assert.ok(app.port >= 4100 && app.port <= 4999);
53
+
54
+ manager.stopAll();
55
+ });
56
+
57
+ it('auto-detects command when none provided', async () => {
58
+ const app = await manager.start('detect-app', '/tmp');
59
+
60
+ assert.equal(app.scriptName, 'dev');
61
+ assert.equal(app.command, 'vite');
62
+
63
+ manager.stopAll();
64
+ });
65
+ });
66
+
67
+ describe('stop', () => {
68
+ it('returns false for unknown app', () => {
69
+ const result = manager.stop('nonexistent');
70
+ assert.equal(result, false);
71
+ });
72
+
73
+ it('stops a running app and removes it from the list', async () => {
74
+ await manager.start('stop-me', '/tmp', 'sleep 60');
75
+
76
+ assert.equal(manager.list().length, 1);
77
+ const result = manager.stop('stop-me');
78
+ assert.equal(result, true);
79
+ assert.equal(manager.list().length, 0);
80
+ });
81
+ });
82
+
83
+ describe('toggleAccess', () => {
84
+ it('toggles an app to public', async () => {
85
+ await manager.start('toggle-app', '/tmp', 'sleep 60');
86
+
87
+ assert.equal(manager.get('toggle-app')?.isPublic, false);
88
+ manager.toggleAccess('toggle-app', true);
89
+ assert.equal(manager.get('toggle-app')?.isPublic, true);
90
+
91
+ manager.stopAll();
92
+ });
93
+
94
+ it('returns false for unknown app', () => {
95
+ const result = manager.toggleAccess('nonexistent', true);
96
+ assert.equal(result, false);
97
+ });
98
+ });
99
+
100
+ describe('list', () => {
101
+ it('returns empty array when no apps running', () => {
102
+ assert.deepEqual(manager.list(), []);
103
+ });
104
+
105
+ it('lists all running apps', async () => {
106
+ await manager.start('app-1', '/tmp', 'sleep 60');
107
+ await manager.start('app-2', '/tmp', 'sleep 60');
108
+
109
+ const apps = manager.list();
110
+ assert.equal(apps.length, 2);
111
+ const names = apps.map(a => a.appName).sort();
112
+ assert.deepEqual(names, ['app-1', 'app-2']);
113
+
114
+ manager.stopAll();
115
+ });
116
+ });
117
+
118
+ describe('get', () => {
119
+ it('returns undefined for unknown app', () => {
120
+ assert.equal(manager.get('unknown'), undefined);
121
+ });
122
+
123
+ it('returns app info for running app', async () => {
124
+ await manager.start('info-app', '/tmp', 'sleep 60');
125
+
126
+ const app = manager.get('info-app');
127
+ assert.ok(app);
128
+ assert.equal(app.appName, 'info-app');
129
+
130
+ manager.stopAll();
131
+ });
132
+ });
133
+
134
+ describe('port allocation', () => {
135
+ it('assigns different ports to different apps', async () => {
136
+ await manager.start('port-1', '/tmp', 'sleep 60');
137
+ await manager.start('port-2', '/tmp', 'sleep 60');
138
+
139
+ const ports = manager.list().map(a => a.port);
140
+ assert.notEqual(ports[0], ports[1]);
141
+
142
+ manager.stopAll();
143
+ });
144
+ });
145
+
146
+ describe('resetIdleTimer', () => {
147
+ it('updates lastAccessedAt', async () => {
148
+ await manager.start('idle-app', '/tmp', 'sleep 60');
149
+
150
+ const before = manager.get('idle-app')!.lastAccessedAt;
151
+ // Small delay to ensure timestamp differs
152
+ await new Promise(r => setTimeout(r, 10));
153
+ manager.resetIdleTimer('idle-app');
154
+ const after = manager.get('idle-app')!.lastAccessedAt;
155
+
156
+ assert.ok(after.getTime() >= before.getTime());
157
+
158
+ manager.stopAll();
159
+ });
160
+ });
161
+
162
+ describe('stopAll', () => {
163
+ it('stops all running apps', async () => {
164
+ await manager.start('all-1', '/tmp', 'sleep 60');
165
+ await manager.start('all-2', '/tmp', 'sleep 60');
166
+
167
+ assert.equal(manager.list().length, 2);
168
+ manager.stopAll();
169
+ assert.equal(manager.list().length, 0);
170
+ });
171
+ });
172
+ });
@@ -0,0 +1,225 @@
1
+ /**
2
+ * PreviewServerManager — manages lifecycle of dev preview servers.
3
+ * Spawns dev servers as child processes, tracks them by app name,
4
+ * handles idle timeout shutdown, and port allocation.
5
+ */
6
+
7
+ import { spawn, type ChildProcess } from 'node:child_process';
8
+ import type { DevCommandDetector, DetectedCommand } from './dev_command_detector.js';
9
+
10
+ const IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
11
+ const PORT_RANGE_START = 4100;
12
+ const PORT_RANGE_END = 4999;
13
+
14
+ export interface PreviewApp {
15
+ appName: string;
16
+ port: number;
17
+ projectDir: string;
18
+ command: string;
19
+ scriptName: string;
20
+ isPublic: boolean;
21
+ startedAt: Date;
22
+ lastAccessedAt: Date;
23
+ pid: number | undefined;
24
+ }
25
+
26
+ interface PreviewServerManagerDeps {
27
+ devCommandDetector: DevCommandDetector;
28
+ log: (tag: string, ...args: unknown[]) => void;
29
+ }
30
+
31
+ export class PreviewServerManager {
32
+ private readonly devCommandDetector: DevCommandDetector;
33
+ private readonly log: PreviewServerManagerDeps['log'];
34
+ private readonly apps: Map<string, PreviewApp> = new Map();
35
+ private readonly processes: Map<string, ChildProcess> = new Map();
36
+ private readonly idleTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
37
+ private nextPort: number = PORT_RANGE_START;
38
+
39
+ constructor({ devCommandDetector, log }: PreviewServerManagerDeps) {
40
+ this.devCommandDetector = devCommandDetector;
41
+ this.log = log;
42
+ }
43
+
44
+ /** Allocate the next available port. */
45
+ private allocatePort = (): number => {
46
+ const usedPorts = new Set([...this.apps.values()].map(a => a.port));
47
+ let port = this.nextPort;
48
+ while (usedPorts.has(port) && port <= PORT_RANGE_END) {
49
+ port++;
50
+ }
51
+ if (port > PORT_RANGE_END) {
52
+ // Wrap around and search from start
53
+ port = PORT_RANGE_START;
54
+ while (usedPorts.has(port) && port <= PORT_RANGE_END) {
55
+ port++;
56
+ }
57
+ }
58
+ this.nextPort = port + 1;
59
+ if (this.nextPort > PORT_RANGE_END) {
60
+ this.nextPort = PORT_RANGE_START;
61
+ }
62
+ return port;
63
+ };
64
+
65
+ /** Reset the idle timer for an app. */
66
+ resetIdleTimer = (appName: string): void => {
67
+ const existing = this.idleTimers.get(appName);
68
+ if (existing) {
69
+ clearTimeout(existing);
70
+ }
71
+
72
+ const app = this.apps.get(appName);
73
+ if (app) {
74
+ app.lastAccessedAt = new Date();
75
+ }
76
+
77
+ const timer = setTimeout(() => {
78
+ this.log('PREVIEW', `App "${appName}" idle for ${IDLE_TIMEOUT_MS / 1000}s — shutting down`);
79
+ this.stop(appName);
80
+ }, IDLE_TIMEOUT_MS);
81
+
82
+ this.idleTimers.set(appName, timer);
83
+ };
84
+
85
+ /**
86
+ * Start a preview dev server for the given app name and project directory.
87
+ * Auto-detects the dev command from package.json.
88
+ * Returns the preview app info or throws on failure.
89
+ */
90
+ start = async (appName: string, projectDir: string, customCommand?: string): Promise<PreviewApp> => {
91
+ if (this.apps.has(appName)) {
92
+ throw new Error(`Preview app "${appName}" is already running`);
93
+ }
94
+
95
+ let detected: DetectedCommand | null = null;
96
+ let command: string;
97
+ let scriptName: string;
98
+
99
+ if (customCommand) {
100
+ command = customCommand;
101
+ scriptName = 'custom';
102
+ } else {
103
+ detected = await this.devCommandDetector.detect(projectDir);
104
+ if (!detected) {
105
+ throw new Error(`No dev server script found in ${projectDir}/package.json`);
106
+ }
107
+ command = detected.command;
108
+ scriptName = detected.scriptName;
109
+ }
110
+
111
+ const port = this.allocatePort();
112
+
113
+ this.log('PREVIEW', `Starting "${appName}" on port ${port}: ${command} (cwd: ${projectDir})`);
114
+
115
+ // Spawn the dev server with PORT env var set
116
+ const child = spawn('sh', ['-c', command], {
117
+ cwd: projectDir,
118
+ env: {
119
+ ...process.env,
120
+ PORT: String(port),
121
+ BROWSER: 'none', // Prevent auto-opening browser
122
+ HOST: '0.0.0.0',
123
+ },
124
+ stdio: ['ignore', 'pipe', 'pipe'],
125
+ detached: false,
126
+ });
127
+
128
+ const app: PreviewApp = {
129
+ appName,
130
+ port,
131
+ projectDir,
132
+ command,
133
+ scriptName,
134
+ isPublic: false,
135
+ startedAt: new Date(),
136
+ lastAccessedAt: new Date(),
137
+ pid: child.pid,
138
+ };
139
+
140
+ this.apps.set(appName, app);
141
+ this.processes.set(appName, child);
142
+
143
+ // Log stdout/stderr from the dev server
144
+ child.stdout?.on('data', (data: Buffer) => {
145
+ this.log('PREVIEW', `[${appName}] ${data.toString().trimEnd()}`);
146
+ });
147
+ child.stderr?.on('data', (data: Buffer) => {
148
+ this.log('PREVIEW', `[${appName}] ${data.toString().trimEnd()}`);
149
+ });
150
+
151
+ // Clean up on unexpected exit
152
+ child.on('exit', (code, signal) => {
153
+ this.log('PREVIEW', `App "${appName}" exited (code=${code}, signal=${signal})`);
154
+ this.cleanup(appName);
155
+ });
156
+
157
+ // Start idle timer
158
+ this.resetIdleTimer(appName);
159
+
160
+ return app;
161
+ };
162
+
163
+ /** Stop a running preview app. */
164
+ stop = (appName: string): boolean => {
165
+ const child = this.processes.get(appName);
166
+ if (!child) {
167
+ return false;
168
+ }
169
+
170
+ this.log('PREVIEW', `Stopping app "${appName}" (pid=${child.pid})`);
171
+
172
+ // Kill the process group if possible
173
+ try {
174
+ if (child.pid) {
175
+ process.kill(-child.pid, 'SIGTERM');
176
+ }
177
+ } catch {
178
+ // Process group kill failed, try direct kill
179
+ child.kill('SIGTERM');
180
+ }
181
+
182
+ this.cleanup(appName);
183
+ return true;
184
+ };
185
+
186
+ /** Clean up tracking state for an app. */
187
+ private cleanup = (appName: string): void => {
188
+ this.apps.delete(appName);
189
+ this.processes.delete(appName);
190
+
191
+ const timer = this.idleTimers.get(appName);
192
+ if (timer) {
193
+ clearTimeout(timer);
194
+ this.idleTimers.delete(appName);
195
+ }
196
+ };
197
+
198
+ /** Toggle an app between public and private access. */
199
+ toggleAccess = (appName: string, isPublic: boolean): boolean => {
200
+ const app = this.apps.get(appName);
201
+ if (!app) {
202
+ return false;
203
+ }
204
+ app.isPublic = isPublic;
205
+ this.log('PREVIEW', `App "${appName}" access set to ${isPublic ? 'public' : 'private'}`);
206
+ return true;
207
+ };
208
+
209
+ /** Get info about a specific app. */
210
+ get = (appName: string): PreviewApp | undefined => {
211
+ return this.apps.get(appName);
212
+ };
213
+
214
+ /** List all running preview apps. */
215
+ list = (): PreviewApp[] => {
216
+ return [...this.apps.values()];
217
+ };
218
+
219
+ /** Stop all running preview apps (for server shutdown). */
220
+ stopAll = (): void => {
221
+ for (const appName of [...this.apps.keys()]) {
222
+ this.stop(appName);
223
+ }
224
+ };
225
+ }
@@ -200,6 +200,35 @@ describe('ProjectService', () => {
200
200
  });
201
201
  });
202
202
 
203
+ describe('updatePreviewCommand', () => {
204
+ it('updates preview command for an existing project', async () => {
205
+ db._setSelectResults([[{ id: 'proj_001', name: 'Test', isDefault: 0, archivedAt: null, previewCommand: null, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }]]);
206
+
207
+ const result = await service.updatePreviewCommand('proj_001', 'npm run dev');
208
+
209
+ assert.equal(result.previewCommand, 'npm run dev');
210
+ assert.equal(db.update.mock.calls.length, 1);
211
+ });
212
+
213
+ it('clears preview command when set to null', async () => {
214
+ db._setSelectResults([[{ id: 'proj_001', name: 'Test', isDefault: 0, archivedAt: null, previewCommand: 'npm run dev', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }]]);
215
+
216
+ const result = await service.updatePreviewCommand('proj_001', null);
217
+
218
+ assert.equal(result.previewCommand, null);
219
+ assert.equal(db.update.mock.calls.length, 1);
220
+ });
221
+
222
+ it('throws when project not found', async () => {
223
+ db._setSelectResults([[]]);
224
+
225
+ await assert.rejects(
226
+ () => service.updatePreviewCommand('proj_999', 'npm run dev'),
227
+ { message: 'Project not found' },
228
+ );
229
+ });
230
+ });
231
+
203
232
  describe('restore', () => {
204
233
  it('restores an archived project', async () => {
205
234
  db._setSelectResults([[{ id: 'proj_001', name: 'Test', isDefault: 0, archivedAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }]]);