@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
@@ -1,63 +1,82 @@
1
1
  /**
2
2
  * GitWorkflow — handles worktree creation/cleanup, rebase, merge, branch management.
3
+ * Project-aware: uses workspacesDir + projectId to find the correct git repo.
3
4
  */
4
5
 
5
6
  import { join } from 'node:path';
7
+ import { existsSync, mkdirSync } from 'node:fs';
6
8
 
7
9
  export class GitWorkflow {
8
- constructor({ claudeService, projectRoot, worktreesDir, log }) {
10
+ constructor({ claudeService, workspacesDir, log }) {
9
11
  this.claudeService = claudeService;
10
- this.projectRoot = projectRoot;
11
- this.worktreesDir = worktreesDir;
12
+ this.workspacesDir = workspacesDir;
12
13
  this.log = log;
13
14
  }
14
15
 
16
+ /** Resolve the git repo root for a given project. */
17
+ private getProjectRoot = (projectId: string): string => {
18
+ return join(this.workspacesDir, projectId);
19
+ };
20
+
21
+ /** Resolve the worktrees directory for a given project. */
22
+ private getWorktreesDir = (projectId: string): string => {
23
+ return join(this.workspacesDir, projectId, '.worktrees');
24
+ };
25
+
15
26
  /**
16
27
  * Returns list of uncommitted changed files in the main repo, or empty array if clean.
17
28
  */
18
- getDirtyFiles = async () => {
19
- const result = await this.claudeService.spawnCommand('git', ['status', '--porcelain'], this.projectRoot);
29
+ getDirtyFiles = async (projectId: string) => {
30
+ const projectRoot = this.getProjectRoot(projectId);
31
+ const result = await this.claudeService.spawnCommand('git', ['status', '--porcelain'], projectRoot);
20
32
  if (!result.trim()) return [];
21
33
  return result.trim().split('\n').map(line => line.trim());
22
34
  };
23
35
 
24
- createWorktree = async (featureId) => {
25
- const worktreePath = join(this.worktreesDir, featureId);
36
+ createWorktree = async (featureId: string, projectId: string) => {
37
+ const projectRoot = this.getProjectRoot(projectId);
38
+ const worktreesDir = this.getWorktreesDir(projectId);
39
+ if (!existsSync(worktreesDir)) {
40
+ mkdirSync(worktreesDir, { recursive: true });
41
+ }
42
+ const worktreePath = join(worktreesDir, featureId);
26
43
  this.log('DEVELOP', `Creating worktree at ${worktreePath} (branch: feature/${featureId})`);
27
- this.log('DEVELOP', `PROJECT_ROOT=${this.projectRoot}`);
44
+ this.log('DEVELOP', `PROJECT_ROOT=${projectRoot}`);
28
45
 
29
46
  // Remove leftover worktree from a previous failed/aborted pipeline (only if it exists)
30
- const worktreeList = await this.claudeService.spawnCommand('git', ['worktree', 'list', '--porcelain'], this.projectRoot);
47
+ const worktreeList = await this.claudeService.spawnCommand('git', ['worktree', 'list', '--porcelain'], projectRoot);
31
48
  if (worktreeList.includes(worktreePath)) {
32
49
  this.log('DEVELOP', `Removing leftover worktree at ${worktreePath}`);
33
- await this.claudeService.spawnCommand('git', ['worktree', 'remove', '--force', worktreePath], this.projectRoot).catch(() => {});
50
+ await this.claudeService.spawnCommand('git', ['worktree', 'remove', '--force', worktreePath], projectRoot).catch((err: any) => { this.log('DEVELOP', 'cleanup failed (non-fatal):', err.message); });
34
51
  }
35
52
  // Delete leftover branch (only if it exists)
36
- const branchList = await this.claudeService.spawnCommand('git', ['branch', '--list', `feature/${featureId}`], this.projectRoot);
53
+ const branchList = await this.claudeService.spawnCommand('git', ['branch', '--list', `feature/${featureId}`], projectRoot);
37
54
  if (branchList.trim()) {
38
55
  this.log('DEVELOP', `Deleting leftover branch feature/${featureId}`);
39
- await this.claudeService.spawnCommand('git', ['branch', '-D', `feature/${featureId}`], this.projectRoot).catch(() => {});
56
+ await this.claudeService.spawnCommand('git', ['branch', '-D', `feature/${featureId}`], projectRoot).catch((err: any) => { this.log('DEVELOP', 'cleanup failed (non-fatal):', err.message); });
40
57
  }
41
58
 
42
- await this.claudeService.spawnCommand('git', ['worktree', 'add', worktreePath, '-b', `feature/${featureId}`], this.projectRoot);
59
+ await this.claudeService.spawnCommand('git', ['worktree', 'add', worktreePath, '-b', `feature/${featureId}`], projectRoot);
43
60
  this.log('DEVELOP', `Worktree created successfully`);
44
61
  return worktreePath;
45
62
  };
46
63
 
47
- removeWorktree = async (worktreePath) => {
48
- await this.claudeService.spawnCommand('git', ['worktree', 'remove', '--force', worktreePath], this.projectRoot).catch(err => {
49
- this.log('PIPELINE', `Worktree cleanup failed (non-fatal): ${err.message}`);
64
+ removeWorktree = async (worktreePath: string, projectId: string) => {
65
+ const projectRoot = this.getProjectRoot(projectId);
66
+ await this.claudeService.spawnCommand('git', ['worktree', 'remove', '--force', worktreePath], projectRoot).catch(err => {
67
+ this.log('PIPELINE', `Worktree cleanup failed (non-fatal):`, err.stack || err.message);
50
68
  });
51
69
  };
52
70
 
53
- deleteBranch = async (featureId, force = false) => {
71
+ deleteBranch = async (featureId: string, projectId: string, force = false) => {
72
+ const projectRoot = this.getProjectRoot(projectId);
54
73
  const flag = force ? '-D' : '-d';
55
- await this.claudeService.spawnCommand('git', ['branch', flag, `feature/${featureId}`], this.projectRoot).catch(err => {
56
- this.log('PIPELINE', `Branch cleanup failed (non-fatal): ${err.message}`);
74
+ await this.claudeService.spawnCommand('git', ['branch', flag, `feature/${featureId}`], projectRoot).catch(err => {
75
+ this.log('PIPELINE', `Branch cleanup failed (non-fatal):`, err.stack || err.message);
57
76
  });
58
77
  };
59
78
 
60
- commitChanges = async (featureId, worktreePath) => {
79
+ commitChanges = async (featureId: string, worktreePath: string) => {
61
80
  const statusResult = await this.claudeService.spawnCommand('git', ['status', '--porcelain'], worktreePath);
62
81
  if (statusResult.trim()) {
63
82
  this.log('PIPELINE', `Committing developer changes for ${featureId}...`);
@@ -69,41 +88,44 @@ export class GitWorkflow {
69
88
  }
70
89
  };
71
90
 
72
- rebaseBranch = async (featureId, worktreePath) => {
91
+ rebaseBranch = async (featureId: string, worktreePath: string) => {
73
92
  this.log('PIPELINE', `Rebasing feature/${featureId} onto main...`);
74
93
  try {
75
94
  await this.claudeService.spawnCommand('git', ['rebase', 'main'], worktreePath);
76
95
  this.log('PIPELINE', `Rebase succeeded for ${featureId}`);
77
96
  } catch (rebaseErr) {
78
97
  this.log('PIPELINE', `Rebase conflict for ${featureId} — aborting and attempting merge instead`);
79
- await this.claudeService.spawnCommand('git', ['rebase', '--abort'], worktreePath).catch(() => {});
98
+ await this.claudeService.spawnCommand('git', ['rebase', '--abort'], worktreePath).catch((err: any) => { this.log('PIPELINE', 'cleanup failed (non-fatal):', err.message); });
80
99
  try {
81
100
  await this.claudeService.spawnCommand('git', ['merge', 'main', '-m', `merge main into feature/${featureId}`], worktreePath);
82
101
  this.log('PIPELINE', `Merge main into feature/${featureId} succeeded`);
83
102
  } catch (mergeErr) {
84
- this.log('PIPELINE', `WARN: Could not integrate main into ${featureId}: ${mergeErr.message}`);
103
+ this.log('PIPELINE', `WARN: Could not integrate main into ${featureId}:`, mergeErr.stack || mergeErr.message);
85
104
  }
86
105
  }
87
106
  };
88
107
 
89
- mergeBranch = async (featureId) => {
108
+ mergeBranch = async (featureId: string, projectId: string) => {
109
+ const projectRoot = this.getProjectRoot(projectId);
90
110
  this.log('PIPELINE', `Merging feature/${featureId} into parent branch...`);
91
- await this.claudeService.spawnCommand('git', ['merge', `feature/${featureId}`], this.projectRoot);
111
+ await this.claudeService.spawnCommand('git', ['merge', `feature/${featureId}`], projectRoot);
92
112
  this.log('PIPELINE', `Merge succeeded`);
93
113
  };
94
114
 
95
- fixMergeConflicts = async (featureId) => {
115
+ fixMergeConflicts = async (featureId: string, projectId: string) => {
116
+ const projectRoot = this.getProjectRoot(projectId);
96
117
  await this.claudeService.spawnClaude(
97
118
  `There are merge conflicts after merging feature/${featureId}. Resolve all conflicts and commit.`,
98
- this.projectRoot,
119
+ projectRoot,
99
120
  `merge-fix:${featureId}`,
100
121
  );
101
- const mergedBranches = await this.claudeService.spawnCommand('git', ['branch', '--merged', 'HEAD', '--list', `feature/${featureId}`], this.projectRoot);
122
+ const mergedBranches = await this.claudeService.spawnCommand('git', ['branch', '--merged', 'HEAD', '--list', `feature/${featureId}`], projectRoot);
102
123
  return mergedBranches.trim().length > 0;
103
124
  };
104
125
 
105
- stash = async () => {
106
- const stashResult = await this.claudeService.spawnCommand('git', ['stash', '--include-untracked'], this.projectRoot);
126
+ stash = async (projectId: string) => {
127
+ const projectRoot = this.getProjectRoot(projectId);
128
+ const stashResult = await this.claudeService.spawnCommand('git', ['stash', '--include-untracked'], projectRoot);
107
129
  const stashed = !stashResult.includes('No local changes to save');
108
130
  if (stashed) {
109
131
  this.log('PIPELINE', `Stashed local changes before merge`);
@@ -111,33 +133,37 @@ export class GitWorkflow {
111
133
  return stashed;
112
134
  };
113
135
 
114
- unstash = async () => {
115
- await this.claudeService.spawnCommand('git', ['stash', 'pop'], this.projectRoot);
136
+ unstash = async (projectId: string) => {
137
+ const projectRoot = this.getProjectRoot(projectId);
138
+ await this.claudeService.spawnCommand('git', ['stash', 'pop'], projectRoot);
116
139
  this.log('PIPELINE', `Restored stashed local changes`);
117
140
  };
118
141
 
119
142
  /** Pull the default branch before creating a worktree to ensure up-to-date base. */
120
- pullDefaultBranch = async (branch?: string) => {
143
+ pullDefaultBranch = async (projectId: string, branch?: string) => {
144
+ const projectRoot = this.getProjectRoot(projectId);
121
145
  const targetBranch = branch || 'main';
122
146
  this.log('PIPELINE', `Pulling ${targetBranch} to ensure up-to-date base...`);
123
147
  try {
124
- await this.claudeService.spawnCommand('git', ['pull', 'origin', targetBranch], this.projectRoot);
148
+ await this.claudeService.spawnCommand('git', ['pull', 'origin', targetBranch], projectRoot);
125
149
  this.log('PIPELINE', `Pull succeeded for ${targetBranch}`);
126
150
  } catch (err: any) {
127
- this.log('PIPELINE', `Pull failed (non-fatal): ${err.message}`);
151
+ this.log('PIPELINE', `Pull failed (non-fatal):`, err.stack || err.message);
128
152
  }
129
153
  };
130
154
 
131
155
  /** Push the current branch to the remote after a successful merge. */
132
- pushToRemote = async (branch?: string) => {
156
+ pushToRemote = async (projectId: string, branch?: string) => {
157
+ const projectRoot = this.getProjectRoot(projectId);
133
158
  const targetBranch = branch || 'main';
134
159
  this.log('PIPELINE', `Pushing ${targetBranch} to remote...`);
135
- await this.claudeService.spawnCommand('git', ['push', 'origin', targetBranch], this.projectRoot);
160
+ await this.claudeService.spawnCommand('git', ['push', 'origin', targetBranch], projectRoot);
136
161
  this.log('PIPELINE', `Push succeeded for ${targetBranch}`);
137
162
  };
138
163
 
139
164
  /** Set the remote URL (used for authenticated push/pull with tokens). */
140
- setRemoteUrl = async (url: string, remote = 'origin') => {
141
- await this.claudeService.spawnCommand('git', ['remote', 'set-url', remote, url], this.projectRoot);
165
+ setRemoteUrl = async (projectId: string, url: string, remote = 'origin') => {
166
+ const projectRoot = this.getProjectRoot(projectId);
167
+ await this.claudeService.spawnCommand('git', ['remote', 'set-url', remote, url], projectRoot);
142
168
  };
143
169
  }
@@ -0,0 +1,173 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { ProgrammableNodeExecutor, DEFAULT_LIMITS } from './programmable_node_executor.js';
4
+
5
+ const mockLog = (_tag: string, _msg: string) => {};
6
+
7
+ describe('ProgrammableNodeExecutor', () => {
8
+ describe('execute', () => {
9
+ it('runs a simple synchronous return', async () => {
10
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
11
+ const result = await executor.execute(
12
+ 'async function run(context) { return { greeting: "hello" }; }',
13
+ {},
14
+ );
15
+ assert.equal(result.success, true);
16
+ assert.deepEqual(result.result, { greeting: 'hello' });
17
+ });
18
+
19
+ it('passes context to the run function', async () => {
20
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
21
+ const result = await executor.execute(
22
+ 'async function run(context) { return { id: context.featureId }; }',
23
+ { featureId: 'feat_123' },
24
+ );
25
+ assert.equal(result.success, true);
26
+ assert.deepEqual(result.result, { id: 'feat_123' });
27
+ });
28
+
29
+ it('provides access to nodeOutputs from previous nodes', async () => {
30
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
31
+ const result = await executor.execute(
32
+ `async function run(context) {
33
+ const prev = context.nodeOutputs["Validate Data"];
34
+ return { received: prev.status };
35
+ }`,
36
+ { nodeOutputs: { 'Validate Data': { status: 'ok' } } },
37
+ );
38
+ assert.equal(result.success, true);
39
+ assert.deepEqual(result.result, { received: 'ok' });
40
+ });
41
+
42
+ it('returns error result when run() throws', async () => {
43
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
44
+ const result = await executor.execute(
45
+ 'async function run(context) { throw new Error("validation failed"); }',
46
+ {},
47
+ );
48
+ assert.equal(result.success, false);
49
+ assert.ok(result.error?.includes('validation failed'));
50
+ });
51
+
52
+ it('returns null for undefined return value', async () => {
53
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
54
+ const result = await executor.execute(
55
+ 'async function run(context) { /* no return */ }',
56
+ {},
57
+ );
58
+ assert.equal(result.success, true);
59
+ assert.equal(result.result, null);
60
+ });
61
+
62
+ it('handles numeric return values', async () => {
63
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
64
+ const result = await executor.execute(
65
+ 'async function run(context) { return 42; }',
66
+ {},
67
+ );
68
+ assert.equal(result.success, true);
69
+ assert.equal(result.result, 42);
70
+ });
71
+
72
+ it('handles string return values', async () => {
73
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
74
+ const result = await executor.execute(
75
+ 'async function run(context) { return "done"; }',
76
+ {},
77
+ );
78
+ assert.equal(result.success, true);
79
+ assert.equal(result.result, 'done');
80
+ });
81
+
82
+ it('enforces timeout limit', async () => {
83
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
84
+ const result = await executor.execute(
85
+ 'async function run(context) { while(true) {} }',
86
+ {},
87
+ { timeout: 100 },
88
+ );
89
+ assert.equal(result.success, false);
90
+ assert.ok(result.error);
91
+ });
92
+
93
+ it('prevents access to Node.js APIs', async () => {
94
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
95
+ const result = await executor.execute(
96
+ `async function run(context) {
97
+ try { const fs = require('fs'); return { leaked: true }; }
98
+ catch (e) { return { blocked: true, error: e.message }; }
99
+ }`,
100
+ {},
101
+ );
102
+ assert.equal(result.success, true);
103
+ assert.equal((result.result as any).blocked, true);
104
+ });
105
+ });
106
+
107
+ describe('validateUrl', () => {
108
+ it('allows public URLs', () => {
109
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
110
+ assert.doesNotThrow(() => executor.validateUrl('https://api.example.com/webhook'));
111
+ assert.doesNotThrow(() => executor.validateUrl('https://8.8.8.8/dns'));
112
+ });
113
+
114
+ it('blocks localhost', () => {
115
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
116
+ assert.throws(() => executor.validateUrl('http://localhost:3000'), /SSRF/);
117
+ assert.throws(() => executor.validateUrl('http://LOCALHOST:8080'), /SSRF/);
118
+ });
119
+
120
+ it('blocks 127.0.0.0/8', () => {
121
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
122
+ assert.throws(() => executor.validateUrl('http://127.0.0.1'), /SSRF/);
123
+ assert.throws(() => executor.validateUrl('http://127.0.0.2:9000'), /SSRF/);
124
+ });
125
+
126
+ it('blocks 10.0.0.0/8', () => {
127
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
128
+ assert.throws(() => executor.validateUrl('http://10.0.0.1'), /SSRF/);
129
+ assert.throws(() => executor.validateUrl('http://10.255.255.255'), /SSRF/);
130
+ });
131
+
132
+ it('blocks 172.16.0.0/12', () => {
133
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
134
+ assert.throws(() => executor.validateUrl('http://172.16.0.1'), /SSRF/);
135
+ assert.throws(() => executor.validateUrl('http://172.31.255.255'), /SSRF/);
136
+ });
137
+
138
+ it('blocks 192.168.0.0/16', () => {
139
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
140
+ assert.throws(() => executor.validateUrl('http://192.168.1.1'), /SSRF/);
141
+ assert.throws(() => executor.validateUrl('http://192.168.0.100:8080'), /SSRF/);
142
+ });
143
+
144
+ it('blocks 169.254.0.0/16 (link-local)', () => {
145
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
146
+ assert.throws(() => executor.validateUrl('http://169.254.169.254/metadata'), /SSRF/);
147
+ });
148
+
149
+ it('blocks IPv6 loopback', () => {
150
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
151
+ assert.throws(() => executor.validateUrl('http://[::1]:3000'), /SSRF/);
152
+ });
153
+
154
+ it('blocks fd00::/8 (IPv6 unique local)', () => {
155
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
156
+ assert.throws(() => executor.validateUrl('http://[fd12::1]'), /SSRF/);
157
+ });
158
+
159
+ it('rejects invalid URLs', () => {
160
+ const executor = new ProgrammableNodeExecutor({ log: mockLog });
161
+ assert.throws(() => executor.validateUrl('not a url'), /Invalid URL/);
162
+ });
163
+ });
164
+
165
+ describe('DEFAULT_LIMITS', () => {
166
+ it('has correct system-wide defaults', () => {
167
+ assert.equal(DEFAULT_LIMITS.timeout, 30_000);
168
+ assert.equal(DEFAULT_LIMITS.memory, 128);
169
+ assert.equal(DEFAULT_LIMITS.maxRequests, 20);
170
+ assert.equal(DEFAULT_LIMITS.maxResponseSize, 5 * 1024 * 1024);
171
+ });
172
+ });
173
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * ProgrammableNodeExecutor — executes user-written JavaScript code inside
3
+ * a secure isolated-vm V8 isolate with a host-bridged fetch API.
4
+ *
5
+ * Responsibilities:
6
+ * 1. Create an isolated V8 context with configurable memory/CPU limits
7
+ * 2. Inject execution context (including nodeOutputs) as read-only data
8
+ * 3. Bridge a standard fetch() API from the host with SSRF protection
9
+ * 4. Execute user's async run(context) function and return the result
10
+ * 5. Enforce execution limits: timeout, memory, max HTTP requests, max response size
11
+ */
12
+
13
+ import ivm from 'isolated-vm';
14
+
15
+ // ── Types ──────────────────────────────────────────────────────────────
16
+
17
+ export interface ExecutionLimits {
18
+ /** CPU timeout in milliseconds (default: 30000) */
19
+ timeout: number;
20
+ /** V8 isolate memory limit in MB (default: 128) */
21
+ memory: number;
22
+ /** Maximum number of HTTP requests allowed per execution (default: 20) */
23
+ maxRequests: number;
24
+ /** Maximum HTTP response body size in bytes (default: 5MB) */
25
+ maxResponseSize: number;
26
+ }
27
+
28
+ export interface ExecutionResult {
29
+ success: boolean;
30
+ result?: unknown;
31
+ error?: string;
32
+ }
33
+
34
+ interface ProgrammableNodeExecutorDeps {
35
+ log: (tag: string, message: string) => void;
36
+ }
37
+
38
+ // ── Constants ──────────────────────────────────────────────────────────
39
+
40
+ export const DEFAULT_LIMITS: ExecutionLimits = {
41
+ timeout: 30_000,
42
+ memory: 128,
43
+ maxRequests: 20,
44
+ maxResponseSize: 5 * 1024 * 1024,
45
+ };
46
+
47
+ /**
48
+ * Regex patterns for blocked IP ranges (SSRF protection).
49
+ * Blocks: localhost, 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
50
+ * 192.168.0.0/16, 169.254.0.0/16, fd00::/8, link-local IPv6.
51
+ */
52
+ const BLOCKED_HOSTNAME_PATTERNS: RegExp[] = [
53
+ /^localhost$/i,
54
+ /^127\./,
55
+ /^10\./,
56
+ /^172\.(1[6-9]|2\d|3[01])\./,
57
+ /^192\.168\./,
58
+ /^169\.254\./,
59
+ /^0\./,
60
+ /^::1$/,
61
+ /^fd[0-9a-f]{2}:/i,
62
+ /^fe80:/i,
63
+ ];
64
+
65
+ // ── Executor ───────────────────────────────────────────────────────────
66
+
67
+ export class ProgrammableNodeExecutor {
68
+ private log: ProgrammableNodeExecutorDeps['log'];
69
+
70
+ constructor({ log }: ProgrammableNodeExecutorDeps) {
71
+ this.log = log;
72
+ }
73
+
74
+ /**
75
+ * Execute user code inside an isolated V8 context.
76
+ *
77
+ * @param code - The user's code containing an `async function run(context) { ... }` definition.
78
+ * @param context - The execution context object passed to run(). Includes nodeOutputs.
79
+ * @param limits - Optional per-node execution limit overrides.
80
+ * @returns ExecutionResult with success/result or error message.
81
+ */
82
+ execute = async (
83
+ code: string,
84
+ context: Record<string, unknown>,
85
+ limits: Partial<ExecutionLimits> = {},
86
+ ): Promise<ExecutionResult> => {
87
+ const effectiveLimits: ExecutionLimits = { ...DEFAULT_LIMITS, ...limits };
88
+ const isolate = new ivm.Isolate({ memoryLimit: effectiveLimits.memory });
89
+
90
+ try {
91
+ const ivmContext = await isolate.createContext();
92
+ const jail = ivmContext.global;
93
+
94
+ // Make globalThis available inside the isolate
95
+ await jail.set('global', jail.derefInto());
96
+
97
+ // Inject the execution context as a deep copy
98
+ await jail.set(
99
+ '__context__',
100
+ new ivm.ExternalCopy(context).copyInto(),
101
+ );
102
+
103
+ // Set up the host-side fetch bridge
104
+ let requestCount = 0;
105
+ const fetchRef = new ivm.Reference(
106
+ async (url: string, optionsJson: string): Promise<ivm.Copy<unknown>> => {
107
+ requestCount++;
108
+ if (requestCount > effectiveLimits.maxRequests) {
109
+ throw new Error(
110
+ `Exceeded maximum HTTP requests (${effectiveLimits.maxRequests})`,
111
+ );
112
+ }
113
+
114
+ this.validateUrl(url);
115
+
116
+ const options = optionsJson ? JSON.parse(optionsJson) : {};
117
+ const response = await fetch(url, options);
118
+ const body = await response.text();
119
+
120
+ if (body.length > effectiveLimits.maxResponseSize) {
121
+ throw new Error(
122
+ `Response size (${body.length} bytes) exceeds limit (${effectiveLimits.maxResponseSize} bytes)`,
123
+ );
124
+ }
125
+
126
+ return new ivm.ExternalCopy({
127
+ ok: response.ok,
128
+ status: response.status,
129
+ statusText: response.statusText,
130
+ headers: Object.fromEntries(response.headers.entries()),
131
+ body,
132
+ }).copyInto();
133
+ },
134
+ );
135
+ await jail.set('__fetchBridge__', fetchRef);
136
+
137
+ // Install the fetch() wrapper inside the isolate.
138
+ // The wrapper serializes options to JSON for the host bridge,
139
+ // and returns a Response-like object with text() and json() methods.
140
+ await ivmContext.eval(`
141
+ globalThis.fetch = async (url, options) => {
142
+ const optionsJson = options ? JSON.stringify({
143
+ method: options.method,
144
+ headers: options.headers,
145
+ body: options.body,
146
+ }) : '';
147
+ const resp = await __fetchBridge__.apply(
148
+ undefined,
149
+ [String(url), optionsJson],
150
+ { result: { promise: true } }
151
+ );
152
+ return {
153
+ ok: resp.ok,
154
+ status: resp.status,
155
+ statusText: resp.statusText,
156
+ headers: resp.headers,
157
+ text: async () => resp.body,
158
+ json: async () => JSON.parse(resp.body),
159
+ };
160
+ };
161
+ `);
162
+
163
+ // Wrap the user code: define run(), invoke it, and serialize the result.
164
+ // The outer IIFE returns a JSON string because isolated-vm can only
165
+ // transfer primitives and ExternalCopy across the boundary.
166
+ const wrappedCode = `
167
+ (async () => {
168
+ ${code}
169
+ const __result__ = await run(__context__);
170
+ return JSON.stringify(__result__ === undefined ? null : __result__);
171
+ })()
172
+ `;
173
+
174
+ const module = await isolate.compileScript(wrappedCode);
175
+ const resultJson = await module.run(ivmContext, {
176
+ promise: true,
177
+ timeout: effectiveLimits.timeout,
178
+ });
179
+
180
+ const result = resultJson ? JSON.parse(resultJson as string) : null;
181
+ return { success: true, result };
182
+ } catch (err: unknown) {
183
+ const message = err instanceof Error ? err.message : String(err);
184
+ this.log('PROGRAMMABLE', `Execution error: ${message}`);
185
+ return { success: false, error: message };
186
+ } finally {
187
+ isolate.dispose();
188
+ }
189
+ };
190
+
191
+ /**
192
+ * Validate that a URL does not target a private/internal IP range.
193
+ * Throws if the hostname matches any blocked pattern.
194
+ */
195
+ validateUrl = (url: string): void => {
196
+ let parsed: URL;
197
+ try {
198
+ parsed = new URL(url);
199
+ } catch {
200
+ throw new Error(`Invalid URL: ${url}`);
201
+ }
202
+
203
+ // Strip brackets from IPv6 hostnames (Node.js URL parser keeps them)
204
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
205
+ for (const pattern of BLOCKED_HOSTNAME_PATTERNS) {
206
+ if (pattern.test(hostname)) {
207
+ throw new Error(
208
+ `SSRF protection: requests to ${hostname} are blocked`,
209
+ );
210
+ }
211
+ }
212
+ };
213
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ /**
5
+ * Re-implement the assertNotDone guard logic from validator.ts for testing.
6
+ * This mirrors the same decision: given a kanban row result, throw if
7
+ * the column is 'done', pass through otherwise.
8
+ */
9
+ const checkNotDone = (
10
+ nodeId: string,
11
+ kanbanRow: { columnName: string } | null,
12
+ ): void => {
13
+ if (kanbanRow && kanbanRow.columnName === 'done') {
14
+ throw new Error(
15
+ `Forbidden: ${nodeId} is in the Done column. Create a new feature instead of modifying completed work.`
16
+ );
17
+ }
18
+ };
19
+
20
+ describe('assertNotDone guard logic', () => {
21
+ it('throws when node is in the done column', () => {
22
+ assert.throws(
23
+ () => checkNotDone('feat_001', { columnName: 'done' }),
24
+ {
25
+ message: 'Forbidden: feat_001 is in the Done column. Create a new feature instead of modifying completed work.',
26
+ },
27
+ );
28
+ });
29
+
30
+ it('does not throw when node is in todo column', () => {
31
+ assert.doesNotThrow(() => checkNotDone('feat_002', { columnName: 'todo' }));
32
+ });
33
+
34
+ it('does not throw when node is in in_progress column', () => {
35
+ assert.doesNotThrow(() => checkNotDone('feat_003', { columnName: 'in_progress' }));
36
+ });
37
+
38
+ it('does not throw when node is in qa column', () => {
39
+ assert.doesNotThrow(() => checkNotDone('feat_004', { columnName: 'qa' }));
40
+ });
41
+
42
+ it('does not throw when node is in backlog column', () => {
43
+ assert.doesNotThrow(() => checkNotDone('feat_005', { columnName: 'backlog' }));
44
+ });
45
+
46
+ it('does not throw when node has no kanban entry (null)', () => {
47
+ assert.doesNotThrow(() => checkNotDone('dec_001', null));
48
+ });
49
+
50
+ it('includes the node ID in the error message', () => {
51
+ assert.throws(
52
+ () => checkNotDone('epic_abc123', { columnName: 'done' }),
53
+ {
54
+ message: /epic_abc123/,
55
+ },
56
+ );
57
+ });
58
+
59
+ it('error message matches the exact required format', () => {
60
+ try {
61
+ checkNotDone('feat_xyz', { columnName: 'done' });
62
+ assert.fail('Expected an error to be thrown');
63
+ } catch (err) {
64
+ assert.equal(
65
+ err.message,
66
+ 'Forbidden: feat_xyz is in the Done column. Create a new feature instead of modifying completed work.',
67
+ );
68
+ }
69
+ });
70
+ });