@assistkick/create 1.7.0 → 1.9.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/bin/create.js +0 -0
  2. package/package.json +9 -7
  3. package/templates/assistkick-product-system/.env.example +1 -0
  4. package/templates/assistkick-product-system/local.db +0 -0
  5. package/templates/assistkick-product-system/package.json +4 -2
  6. package/templates/assistkick-product-system/packages/backend/package.json +2 -0
  7. package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +165 -0
  8. package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +358 -0
  9. package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +356 -0
  10. package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +96 -1
  11. package/templates/assistkick-product-system/packages/backend/src/routes/graph.ts +1 -0
  12. package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +61 -6
  13. package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +200 -84
  14. package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +6 -3
  15. package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +53 -17
  16. package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +218 -0
  17. package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +119 -0
  18. package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +158 -0
  19. package/templates/assistkick-product-system/packages/backend/src/server.ts +60 -9
  20. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +489 -0
  21. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +416 -0
  22. package/templates/assistkick-product-system/packages/backend/src/services/bundle_service.test.ts +189 -0
  23. package/templates/assistkick-product-system/packages/backend/src/services/bundle_service.ts +182 -0
  24. package/templates/assistkick-product-system/packages/backend/src/services/init.ts +43 -77
  25. package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +16 -0
  26. package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +73 -2
  27. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +4 -4
  28. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +87 -11
  29. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +210 -69
  30. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +210 -215
  31. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +162 -0
  32. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +148 -0
  33. package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +11 -5
  34. package/templates/assistkick-product-system/packages/backend/src/services/tts_service.test.ts +64 -0
  35. package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +134 -0
  36. package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.test.ts +256 -0
  37. package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +258 -0
  38. package/templates/assistkick-product-system/packages/backend/src/services/workflow_group_service.ts +106 -0
  39. package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.test.ts +275 -0
  40. package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.ts +245 -0
  41. package/templates/assistkick-product-system/packages/frontend/package-lock.json +3455 -0
  42. package/templates/assistkick-product-system/packages/frontend/package.json +6 -0
  43. package/templates/assistkick-product-system/packages/frontend/src/App.tsx +8 -0
  44. package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +458 -18
  45. package/templates/assistkick-product-system/packages/frontend/src/api/client_files.test.ts +172 -0
  46. package/templates/assistkick-product-system/packages/frontend/src/api/client_video.test.ts +238 -0
  47. package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +307 -0
  48. package/templates/assistkick-product-system/packages/frontend/src/components/CoherenceView.tsx +82 -66
  49. package/templates/assistkick-product-system/packages/frontend/src/components/CompositionPlaceholder.tsx +97 -0
  50. package/templates/assistkick-product-system/packages/frontend/src/components/DesignSystemView.tsx +20 -0
  51. package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +57 -0
  52. package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +313 -0
  53. package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeContextMenu.tsx +61 -0
  54. package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +73 -0
  55. package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +404 -0
  56. package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +187 -56
  57. package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +71 -73
  58. package/templates/assistkick-product-system/packages/frontend/src/components/GraphSettings.tsx +8 -8
  59. package/templates/assistkick-product-system/packages/frontend/src/components/GraphView.tsx +1 -1
  60. package/templates/assistkick-product-system/packages/frontend/src/components/InviteUserDialog.tsx +15 -11
  61. package/templates/assistkick-product-system/packages/frontend/src/components/IterationCommentModal.tsx +80 -0
  62. package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +263 -167
  63. package/templates/assistkick-product-system/packages/frontend/src/components/LoginPage.tsx +14 -14
  64. package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +54 -33
  65. package/templates/assistkick-product-system/packages/frontend/src/components/QaIssueSheet.tsx +32 -49
  66. package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +43 -48
  67. package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +121 -52
  68. package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +20 -14
  69. package/templates/assistkick-product-system/packages/frontend/src/components/UsersView.tsx +52 -52
  70. package/templates/assistkick-product-system/packages/frontend/src/components/VideoGallery.tsx +313 -0
  71. package/templates/assistkick-product-system/packages/frontend/src/components/VideographyView.tsx +250 -0
  72. package/templates/assistkick-product-system/packages/frontend/src/components/WorkflowsView.tsx +474 -0
  73. package/templates/assistkick-product-system/packages/frontend/src/components/ds/AccentBorderList.tsx +53 -0
  74. package/templates/assistkick-product-system/packages/frontend/src/components/ds/Button.tsx +87 -0
  75. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ButtonGroup.tsx +29 -0
  76. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ButtonShowcase.tsx +221 -0
  77. package/templates/assistkick-product-system/packages/frontend/src/components/ds/CardGlass.tsx +141 -0
  78. package/templates/assistkick-product-system/packages/frontend/src/components/ds/CompletionRing.tsx +30 -0
  79. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ContentCard.tsx +34 -0
  80. package/templates/assistkick-product-system/packages/frontend/src/components/ds/IconButton.tsx +74 -0
  81. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCard.tsx +103 -87
  82. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCardShowcase.tsx +9 -188
  83. package/templates/assistkick-product-system/packages/frontend/src/components/ds/Kbd.tsx +11 -0
  84. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KindBadge.tsx +21 -0
  85. package/templates/assistkick-product-system/packages/frontend/src/components/ds/NavBarSidekick.tsx +81 -37
  86. package/templates/assistkick-product-system/packages/frontend/src/components/ds/SidePanelShowcase.tsx +370 -0
  87. package/templates/assistkick-product-system/packages/frontend/src/components/ds/SideSheet.tsx +64 -0
  88. package/templates/assistkick-product-system/packages/frontend/src/components/ds/StatusDot.tsx +18 -0
  89. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/CheckCardPositionNode.tsx +36 -0
  90. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/CheckCycleCountNode.tsx +60 -0
  91. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/EndNode.tsx +42 -0
  92. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GenerateTTSNode.tsx +52 -0
  93. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +189 -0
  94. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +123 -0
  95. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RebuildBundleNode.tsx +20 -0
  96. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RenderVideoNode.tsx +72 -0
  97. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RunAgentNode.tsx +51 -0
  98. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/SetCardMetadataNode.tsx +53 -0
  99. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/StartNode.tsx +18 -0
  100. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/TransitionCardNode.tsx +59 -0
  101. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +341 -0
  102. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +643 -0
  103. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/autoLayout.ts +103 -0
  104. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/edgeColors.ts +35 -0
  105. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +246 -0
  106. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.test.ts +119 -0
  107. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +136 -0
  108. package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +13 -11
  109. package/templates/assistkick-product-system/packages/frontend/src/hooks/useAutoSave.ts +75 -0
  110. package/templates/assistkick-product-system/packages/frontend/src/hooks/useToast.tsx +16 -3
  111. package/templates/assistkick-product-system/packages/frontend/src/pages/accept_invitation_page.tsx +30 -27
  112. package/templates/assistkick-product-system/packages/frontend/src/pages/forgot_password_page.tsx +18 -15
  113. package/templates/assistkick-product-system/packages/frontend/src/pages/register_page.tsx +21 -18
  114. package/templates/assistkick-product-system/packages/frontend/src/pages/reset_password_page.tsx +28 -25
  115. package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +6 -0
  116. package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +1 -1
  117. package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +2 -2
  118. package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +13 -0
  119. package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +2 -2
  120. package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +13 -0
  121. package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +6 -0
  122. package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +6 -3
  123. package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +4 -4
  124. package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +275 -3535
  125. package/templates/assistkick-product-system/packages/frontend/src/utils/auto_save_service.test.ts +167 -0
  126. package/templates/assistkick-product-system/packages/frontend/src/utils/auto_save_service.ts +101 -0
  127. package/templates/assistkick-product-system/packages/frontend/src/utils/composition_matcher.test.ts +42 -0
  128. package/templates/assistkick-product-system/packages/frontend/src/utils/composition_matcher.ts +17 -0
  129. package/templates/assistkick-product-system/packages/frontend/src/utils/file_utils.test.ts +145 -0
  130. package/templates/assistkick-product-system/packages/frontend/src/utils/file_utils.ts +42 -0
  131. package/templates/assistkick-product-system/packages/frontend/src/utils/task_status.test.ts +4 -10
  132. package/templates/assistkick-product-system/packages/frontend/src/utils/task_status.ts +19 -1
  133. package/templates/assistkick-product-system/packages/frontend/vite.config.ts +5 -0
  134. package/templates/assistkick-product-system/packages/shared/db/local.db +0 -0
  135. package/templates/assistkick-product-system/packages/shared/db/migrations/0004_tidy_matthew_murdock.sql +9 -0
  136. package/templates/assistkick-product-system/packages/shared/db/migrations/0005_mysterious_falcon.sql +692 -0
  137. package/templates/assistkick-product-system/packages/shared/db/migrations/0006_next_venom.sql +9 -0
  138. package/templates/assistkick-product-system/packages/shared/db/migrations/0007_deep_barracuda.sql +39 -0
  139. package/templates/assistkick-product-system/packages/shared/db/migrations/0008_puzzling_hannibal_king.sql +1 -0
  140. package/templates/assistkick-product-system/packages/shared/db/migrations/0009_amused_beast.sql +8 -0
  141. package/templates/assistkick-product-system/packages/shared/db/migrations/0010_spotty_moira_mactaggert.sql +9 -0
  142. package/templates/assistkick-product-system/packages/shared/db/migrations/0011_goofy_snowbird.sql +3 -0
  143. package/templates/assistkick-product-system/packages/shared/db/migrations/0011_supreme_doctor_octopus.sql +3 -0
  144. package/templates/assistkick-product-system/packages/shared/db/migrations/0013_reflective_prowler.sql +15 -0
  145. package/templates/assistkick-product-system/packages/shared/db/migrations/0014_nifty_punisher.sql +15 -0
  146. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0004_snapshot.json +921 -0
  147. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0005_snapshot.json +1042 -0
  148. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0006_snapshot.json +1101 -0
  149. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0007_snapshot.json +1336 -0
  150. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0008_snapshot.json +1275 -0
  151. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0009_snapshot.json +1327 -0
  152. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0010_snapshot.json +1393 -0
  153. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0011_snapshot.json +1436 -0
  154. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0013_snapshot.json +1538 -0
  155. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0014_snapshot.json +1545 -0
  156. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +77 -0
  157. package/templates/assistkick-product-system/packages/shared/db/schema.ts +114 -0
  158. package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +32 -7
  159. package/templates/assistkick-product-system/packages/shared/lib/constants.ts +9 -0
  160. package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +12 -4
  161. package/templates/assistkick-product-system/packages/shared/lib/graph.ts +5 -0
  162. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +1999 -0
  163. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +1437 -0
  164. package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +211 -0
  165. package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +43 -0
  166. package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +13 -2
  167. package/templates/assistkick-product-system/packages/shared/tools/get_kanban.ts +1 -1
  168. package/templates/assistkick-product-system/packages/shared/tools/migrate_epics.test.ts +226 -0
  169. package/templates/assistkick-product-system/packages/shared/tools/migrate_epics.ts +251 -0
  170. package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -2
  171. package/templates/assistkick-product-system/packages/shared/utils/hello_workflow.test.ts +10 -0
  172. package/templates/assistkick-product-system/packages/shared/utils/hello_workflow.ts +6 -0
  173. package/templates/assistkick-product-system/packages/video/Root.tsx +85 -0
  174. package/templates/assistkick-product-system/packages/video/components/email_scene.tsx +231 -0
  175. package/templates/assistkick-product-system/packages/video/components/outro_scene.tsx +153 -0
  176. package/templates/assistkick-product-system/packages/video/components/part_divider.tsx +90 -0
  177. package/templates/assistkick-product-system/packages/video/components/scene.tsx +226 -0
  178. package/templates/assistkick-product-system/packages/video/components/theme.ts +22 -0
  179. package/templates/assistkick-product-system/packages/video/components/title_scene.tsx +169 -0
  180. package/templates/assistkick-product-system/packages/video/components/video_split_layout.tsx +84 -0
  181. package/templates/assistkick-product-system/packages/video/compositions/.gitkeep +0 -0
  182. package/templates/assistkick-product-system/packages/video/index.ts +4 -0
  183. package/templates/assistkick-product-system/packages/video/package.json +28 -0
  184. package/templates/assistkick-product-system/packages/video/remotion.config.ts +11 -0
  185. package/templates/assistkick-product-system/packages/video/scripts/process_script.test.ts +326 -0
  186. package/templates/assistkick-product-system/packages/video/scripts/process_script.ts +630 -0
  187. package/templates/assistkick-product-system/packages/video/style.css +1 -0
  188. package/templates/assistkick-product-system/packages/video/tsconfig.json +18 -0
  189. package/templates/assistkick-product-system/tests/graph_legend.test.ts +2 -1
  190. package/templates/assistkick-product-system/tests/video_render_service.test.ts +181 -0
  191. package/templates/assistkick-product-system/tests/web_terminal.test.ts +219 -455
  192. package/templates/assistkick-product-system/tests/workflow_integration.test.ts +341 -0
  193. package/templates/skills/assistkick-developer/SKILL.md +3 -0
  194. package/templates/skills/assistkick-developer/references/react_development_guidelines.md +225 -0
  195. package/templates/skills/product-system/graph.json +1890 -0
  196. package/templates/skills/product-system/kanban.json +304 -0
  197. package/templates/skills/product-system/nodes/comp_001.md +56 -0
  198. package/templates/skills/product-system/nodes/comp_002.md +57 -0
  199. package/templates/skills/product-system/nodes/data_001.md +51 -0
  200. package/templates/skills/product-system/nodes/data_002.md +40 -0
  201. package/templates/skills/product-system/nodes/data_004.md +38 -0
  202. package/templates/skills/product-system/nodes/dec_001.md +34 -0
  203. package/templates/skills/product-system/nodes/dec_016.md +32 -0
  204. package/templates/skills/product-system/nodes/feat_008.md +30 -0
  205. package/templates/skills/video-composition-agent/SKILL.md +232 -0
  206. package/templates/skills/video-script-writer/SKILL.md +136 -0
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * ProjectWorkspaceService — manages per-project workspace directories.
3
- * Each project gets a workspace at <projectRoot>/workspaces/<projectId>/.
3
+ * Each project gets a workspace at <workspacesDir>/<projectId>/.
4
+ * workspacesDir is injected via DI — in production it resolves to a persistent
5
+ * volume (/data/workspaces), in development to <projectRoot>/workspaces.
4
6
  * If a remote git repo is connected, it is cloned there.
5
7
  * If not, git init creates a local repo so worktrees and branching always work.
6
8
  */
@@ -8,27 +10,31 @@
8
10
  import { join } from 'node:path';
9
11
  import { existsSync } from 'node:fs';
10
12
  import { mkdir } from 'node:fs/promises';
13
+ import type { SshKeyService } from './ssh_key_service.js';
11
14
 
12
15
  interface ProjectWorkspaceServiceDeps {
13
- claudeService: { spawnCommand: (cmd: string, args: string[], cwd: string) => Promise<string> };
14
- projectRoot: string;
16
+ claudeService: { spawnCommand: (cmd: string, args: string[], cwd: string, env?: Record<string, string>) => Promise<string> };
17
+ workspacesDir: string;
15
18
  log: (tag: string, ...args: any[]) => void;
19
+ sshKeyService?: SshKeyService;
16
20
  }
17
21
 
18
22
  export class ProjectWorkspaceService {
19
23
  private readonly claudeService: ProjectWorkspaceServiceDeps['claudeService'];
20
- private readonly projectRoot: string;
24
+ private readonly workspacesDir: string;
21
25
  private readonly log: ProjectWorkspaceServiceDeps['log'];
26
+ private readonly sshKeyService?: SshKeyService;
22
27
 
23
- constructor({ claudeService, projectRoot, log }: ProjectWorkspaceServiceDeps) {
28
+ constructor({ claudeService, workspacesDir, log, sshKeyService }: ProjectWorkspaceServiceDeps) {
24
29
  this.claudeService = claudeService;
25
- this.projectRoot = projectRoot;
30
+ this.workspacesDir = workspacesDir;
26
31
  this.log = log;
32
+ this.sshKeyService = sshKeyService;
27
33
  }
28
34
 
29
35
  /** Get the workspace directory path for a project. */
30
36
  getWorkspacePath = (projectId: string): string => {
31
- return join(this.projectRoot, 'workspaces', projectId);
37
+ return join(this.workspacesDir, projectId);
32
38
  };
33
39
 
34
40
  /** Get the worktrees directory inside a project workspace. */
@@ -71,17 +77,16 @@ export class ProjectWorkspaceService {
71
77
  /** Clone a remote repository into the project workspace. */
72
78
  cloneRepo = async (projectId: string, cloneUrl: string): Promise<string> => {
73
79
  const wsPath = this.getWorkspacePath(projectId);
74
- const parentDir = join(this.projectRoot, 'workspaces');
75
- await this.ensureDir(parentDir);
80
+ await this.ensureDir(this.workspacesDir);
76
81
 
77
82
  // If workspace already exists, remove it first
78
83
  if (existsSync(wsPath)) {
79
84
  this.log('WORKSPACE', `Removing existing workspace before clone: ${wsPath}`);
80
- await this.claudeService.spawnCommand('rm', ['-rf', wsPath], parentDir);
85
+ await this.claudeService.spawnCommand('rm', ['-rf', wsPath], this.workspacesDir);
81
86
  }
82
87
 
83
88
  this.log('WORKSPACE', `Cloning ${cloneUrl} into ${wsPath}`);
84
- await this.claudeService.spawnCommand('git', ['clone', cloneUrl, wsPath], parentDir);
89
+ await this.claudeService.spawnCommand('git', ['clone', cloneUrl, wsPath], this.workspacesDir);
85
90
  this.log('WORKSPACE', `Clone completed for project ${projectId}`);
86
91
  return wsPath;
87
92
  };
@@ -162,6 +167,77 @@ export class ProjectWorkspaceService {
162
167
  }
163
168
  };
164
169
 
170
+ /**
171
+ * Run a git command with GIT_SSH_COMMAND set for SSH key auth.
172
+ * Handles writing temp key, setting env, running the command, and cleanup.
173
+ */
174
+ private withSshKey = async <T>(encryptedPrivateKey: string, fn: () => Promise<T>): Promise<T> => {
175
+ if (!this.sshKeyService) throw new Error('SshKeyService not available');
176
+
177
+ const privateKeyPem = this.sshKeyService.decrypt(encryptedPrivateKey);
178
+ const keyFilePath = await this.sshKeyService.writeTempKeyFile(privateKeyPem);
179
+ const gitSshCommand = this.sshKeyService.buildGitSshCommand(keyFilePath);
180
+ const previousValue = process.env.GIT_SSH_COMMAND;
181
+
182
+ process.env.GIT_SSH_COMMAND = gitSshCommand;
183
+ try {
184
+ return await fn();
185
+ } finally {
186
+ if (previousValue !== undefined) {
187
+ process.env.GIT_SSH_COMMAND = previousValue;
188
+ } else {
189
+ delete process.env.GIT_SSH_COMMAND;
190
+ }
191
+ await this.sshKeyService.removeTempKeyFile(keyFilePath);
192
+ }
193
+ };
194
+
195
+ /** Clone a remote repository using SSH key authentication. */
196
+ cloneRepoSsh = async (projectId: string, sshCloneUrl: string, encryptedPrivateKey: string): Promise<string> => {
197
+ const wsPath = this.getWorkspacePath(projectId);
198
+ await this.ensureDir(this.workspacesDir);
199
+
200
+ if (existsSync(wsPath)) {
201
+ this.log('WORKSPACE', `Removing existing workspace before SSH clone: ${wsPath}`);
202
+ await this.claudeService.spawnCommand('rm', ['-rf', wsPath], this.workspacesDir);
203
+ }
204
+
205
+ this.log('WORKSPACE', `SSH cloning into ${wsPath}`);
206
+ await this.withSshKey(encryptedPrivateKey, () =>
207
+ this.claudeService.spawnCommand('git', ['clone', sshCloneUrl, wsPath], this.workspacesDir),
208
+ );
209
+ this.log('WORKSPACE', `SSH clone completed for project ${projectId}`);
210
+ return wsPath;
211
+ };
212
+
213
+ /** Pull latest changes using SSH key authentication. */
214
+ pullLatestSsh = async (projectId: string, encryptedPrivateKey: string): Promise<void> => {
215
+ const wsPath = this.getWorkspacePath(projectId);
216
+
217
+ try {
218
+ const branch = await this.getDefaultBranch(projectId);
219
+ this.log('WORKSPACE', `SSH pulling latest on ${branch} for project ${projectId}`);
220
+ await this.withSshKey(encryptedPrivateKey, () =>
221
+ this.claudeService.spawnCommand('git', ['pull', 'origin', branch], wsPath),
222
+ );
223
+ this.log('WORKSPACE', `SSH pull completed for project ${projectId}`);
224
+ } catch (err: any) {
225
+ this.log('WORKSPACE', `SSH pull failed (non-fatal): ${err.message}`);
226
+ }
227
+ };
228
+
229
+ /** Push the default branch to the remote using SSH key authentication. */
230
+ pushToRemoteSsh = async (projectId: string, encryptedPrivateKey: string): Promise<void> => {
231
+ const wsPath = this.getWorkspacePath(projectId);
232
+ const branch = await this.getDefaultBranch(projectId);
233
+
234
+ this.log('WORKSPACE', `SSH pushing ${branch} to remote for project ${projectId}`);
235
+ await this.withSshKey(encryptedPrivateKey, () =>
236
+ this.claudeService.spawnCommand('git', ['push', 'origin', branch], wsPath),
237
+ );
238
+ this.log('WORKSPACE', `SSH push completed for project ${projectId}`);
239
+ };
240
+
165
241
  /** Get workspace git status info. */
166
242
  getStatus = async (projectId: string): Promise<{
167
243
  hasWorkspace: boolean;
@@ -1,4 +1,4 @@
1
- import { describe, it, mock, beforeEach } from 'node:test';
1
+ import { describe, it, mock, beforeEach, afterEach } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { PtySessionManager } from './pty_session_manager.ts';
4
4
 
@@ -14,11 +14,50 @@ const createMockPty = () => {
14
14
  };
15
15
  };
16
16
 
17
+ /** In-memory mock DB that mimics Drizzle's API surface for terminal_sessions. */
18
+ const createMockDb = () => {
19
+ const rows: any[] = [];
20
+
21
+ const where = (pred: any) => ({
22
+ // select().from().where() returns matching rows
23
+ then: (resolve: Function) => resolve(rows),
24
+ });
25
+
26
+ return {
27
+ _rows: rows,
28
+ select: () => ({
29
+ from: () => ({
30
+ where: (_pred: any) => Promise.resolve(rows.filter(() => true)),
31
+ then: (resolve: Function) => resolve(rows),
32
+ }),
33
+ }),
34
+ insert: () => ({
35
+ values: (val: any) => {
36
+ rows.push(val);
37
+ return Promise.resolve();
38
+ },
39
+ }),
40
+ update: () => ({
41
+ set: () => ({
42
+ where: () => Promise.resolve(),
43
+ }),
44
+ }),
45
+ delete: () => ({
46
+ where: () => {
47
+ // Remove all rows (simplified for tests)
48
+ rows.length = 0;
49
+ return Promise.resolve();
50
+ },
51
+ }),
52
+ };
53
+ };
54
+
17
55
  describe('PtySessionManager', () => {
18
56
  let manager: PtySessionManager;
19
57
  let spawnMock: ReturnType<typeof mock.fn>;
20
58
  let logMock: ReturnType<typeof mock.fn>;
21
59
  let mockPty: ReturnType<typeof createMockPty>;
60
+ let mockDb: ReturnType<typeof createMockDb>;
22
61
  const PROJECT_ROOT = '/test/project/root';
23
62
  const PROJECT_ID = 'proj_test';
24
63
  const PROJECT_NAME = 'Test Project';
@@ -27,125 +66,94 @@ describe('PtySessionManager', () => {
27
66
  mockPty = createMockPty();
28
67
  spawnMock = mock.fn(() => mockPty);
29
68
  logMock = mock.fn();
30
- manager = new PtySessionManager({ spawn: spawnMock as any, log: logMock, projectRoot: PROJECT_ROOT });
69
+ mockDb = createMockDb();
70
+ manager = new PtySessionManager({
71
+ spawn: spawnMock as any,
72
+ log: logMock,
73
+ projectRoot: PROJECT_ROOT,
74
+ getDb: () => mockDb,
75
+ });
31
76
  });
32
77
 
33
78
  describe('spawnCommand uses projectRoot as cwd', () => {
34
- it('passes projectRoot as cwd when auto-launching claude', () => {
35
- const session = manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
79
+ it('passes projectRoot as cwd when auto-launching claude', async () => {
80
+ await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
36
81
 
37
- // Auto-launch is triggered on session creation
38
82
  assert.equal(spawnMock.mock.calls.length, 1);
39
83
  const spawnOptions = spawnMock.mock.calls[0].arguments[2] as Record<string, unknown>;
40
84
  assert.equal(spawnOptions.cwd, PROJECT_ROOT);
41
85
  });
42
86
  });
43
87
 
44
- describe('validateCommand', () => {
45
- it('allows "claude" command', () => {
46
- const result = manager.validateCommand('claude');
47
- assert.equal(result.valid, true);
48
- });
49
-
50
- it('allows "claude" with arguments', () => {
51
- const result = manager.validateCommand('claude --help');
52
- assert.equal(result.valid, true);
53
- });
54
-
55
- it('rejects disallowed commands', () => {
56
- const result = manager.validateCommand('rm -rf /');
57
- assert.equal(result.valid, false);
58
- assert.ok(result.error?.includes('not allowed'));
59
- });
60
-
61
- it('rejects empty command', () => {
62
- const result = manager.validateCommand('');
63
- assert.equal(result.valid, false);
64
- });
65
- });
66
-
67
88
  describe('session lifecycle', () => {
68
- it('creates a new named session with project binding', () => {
69
- const session = manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
89
+ it('creates a new named session with project binding', async () => {
90
+ const session = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
70
91
  assert.equal(session.projectId, PROJECT_ID);
71
92
  assert.equal(session.projectName, PROJECT_NAME);
72
- assert.equal(session.state, 'running'); // auto-launch sets state to running
93
+ assert.equal(session.state, 'running');
73
94
  assert.ok(session.id.startsWith('term_'));
74
95
  assert.ok(session.name.startsWith(PROJECT_NAME));
75
96
  });
76
97
 
77
- it('creates separate sessions for the same project', () => {
78
- const s1 = manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
79
- const s2 = manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
98
+ it('creates separate sessions for the same project', async () => {
99
+ const s1 = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
100
+ const s2 = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
80
101
  assert.notEqual(s1.id, s2.id);
81
102
  });
82
103
 
83
- it('destroys session and kills pty', () => {
84
- const session = manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
85
- // session.pty is already set by auto-launch
86
- manager.destroySession(session.id);
104
+ it('destroys session and kills pty', async () => {
105
+ const session = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
106
+ await manager.destroySession(session.id);
87
107
  assert.equal(mockPty.kill.mock.calls.length, 1);
88
- assert.equal(manager.getSession(session.id), undefined);
89
- });
90
-
91
- it('lists all active sessions', () => {
92
- const s1 = manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
93
- const s2 = manager.createSession('proj_other', 'Other', 80, 24);
94
- const list = manager.listSessions();
95
- assert.equal(list.length, 2);
96
- assert.ok(list.some(s => s.id === s1.id));
97
- assert.ok(list.some(s => s.id === s2.id));
98
108
  });
99
109
 
100
- it('session removed from list after destroy', () => {
101
- const session = manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
102
- manager.destroySession(session.id);
103
- const list = manager.listSessions();
104
- assert.equal(list.length, 0);
110
+ it('session state is running immediately after creation', async () => {
111
+ const session = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
112
+ assert.equal(session.state, 'running');
105
113
  });
106
114
  });
107
115
 
108
116
  describe('auto-launch claude on session creation', () => {
109
- it('auto-launches claude with --dangerously-skip-permissions and --append-system-prompt on session creation', () => {
110
- const session = manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
117
+ it('auto-launches claude with --dangerously-skip-permissions, --append-system-prompt, and --session-id', async () => {
118
+ const session = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
111
119
 
112
120
  assert.equal(spawnMock.mock.calls.length, 1);
113
121
  const [cmd, args] = spawnMock.mock.calls[0].arguments as [string, string[]];
114
122
  assert.equal(cmd, 'claude');
115
123
  assert.ok(args.includes('--dangerously-skip-permissions'));
116
124
  assert.ok(args.includes('--append-system-prompt'));
125
+ assert.ok(args.includes('--session-id'));
126
+ assert.ok(args.includes(session.claudeSessionId));
117
127
  assert.ok(args.includes(`We are working on project-id ${PROJECT_ID}`));
118
128
  });
119
129
 
120
- it('uses --append-system-prompt (not --system-prompt) to preserve default prompt', () => {
121
- manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
130
+ it('uses --append-system-prompt (not --system-prompt) to preserve default prompt', async () => {
131
+ await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
122
132
 
123
133
  const [, args] = spawnMock.mock.calls[0].arguments as [string, string[]];
124
134
  assert.ok(args.includes('--append-system-prompt'));
125
- assert.ok(!args.includes('--system-prompt') || args.indexOf('--system-prompt') === args.indexOf('--append-system-prompt'));
126
135
  });
127
136
 
128
- it('includes the session projectId in the system prompt', () => {
137
+ it('includes the session projectId in the system prompt', async () => {
129
138
  const customProjectId = 'proj_custom_123';
130
- manager.createSession(customProjectId, PROJECT_NAME, 80, 24);
139
+ await manager.createSession(customProjectId, PROJECT_NAME, 80, 24);
131
140
 
132
141
  const [, args] = spawnMock.mock.calls[0].arguments as [string, string[]];
133
142
  const promptIndex = args.indexOf('--append-system-prompt');
134
143
  assert.ok(promptIndex !== -1);
135
144
  assert.ok(args[promptIndex + 1].includes(customProjectId));
136
145
  });
146
+ });
137
147
 
138
- it('does not re-launch claude when reconnecting to an existing session', () => {
139
- const session = manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
140
- // Reconnect is simulated by getSession no new spawn
141
- const existing = manager.getSession(session.id);
142
- assert.ok(existing);
148
+ describe('ensureRunning resumes suspended sessions', () => {
149
+ it('returns true and does not re-spawn for already live session', async () => {
150
+ const session = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
143
151
  assert.equal(spawnMock.mock.calls.length, 1);
144
- });
145
152
 
146
- it('session state is running immediately after creation', () => {
147
- const session = manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
148
- assert.equal(session.state, 'running');
153
+ const result = await manager.ensureRunning(session.id, 80, 24);
154
+ assert.equal(result, true);
155
+ // No extra spawn
156
+ assert.equal(spawnMock.mock.calls.length, 1);
149
157
  });
150
158
  });
151
159
 
@@ -156,4 +164,137 @@ describe('PtySessionManager', () => {
156
164
  assert.equal(name, 'My Project - 06/03/26 - 14:30');
157
165
  });
158
166
  });
167
+
168
+ describe('orphan session cleanup', () => {
169
+ afterEach(() => {
170
+ mock.timers.reset();
171
+ });
172
+
173
+ it('starts idle timer when last listener disconnects', async () => {
174
+ mock.timers.enable({ apis: ['setTimeout'] });
175
+ const session = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
176
+ const listener = mock.fn();
177
+
178
+ manager.addListener(session.id, listener);
179
+ manager.removeListener(session.id, listener);
180
+
181
+ assert.equal(manager.hasDisconnectTimer(session.id), true);
182
+ });
183
+
184
+ it('does not start timer when other listeners remain', async () => {
185
+ mock.timers.enable({ apis: ['setTimeout'] });
186
+ const session = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
187
+ const listener1 = mock.fn();
188
+ const listener2 = mock.fn();
189
+
190
+ manager.addListener(session.id, listener1);
191
+ manager.addListener(session.id, listener2);
192
+ manager.removeListener(session.id, listener1);
193
+
194
+ assert.equal(manager.hasDisconnectTimer(session.id), false);
195
+ });
196
+
197
+ it('cancels timer when client reconnects before timeout', async () => {
198
+ mock.timers.enable({ apis: ['setTimeout'] });
199
+ const session = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
200
+ const listener1 = mock.fn();
201
+ const listener2 = mock.fn();
202
+
203
+ manager.addListener(session.id, listener1);
204
+ manager.removeListener(session.id, listener1);
205
+ assert.equal(manager.hasDisconnectTimer(session.id), true);
206
+
207
+ // Reconnect before timer fires
208
+ manager.addListener(session.id, listener2);
209
+ assert.equal(manager.hasDisconnectTimer(session.id), false);
210
+
211
+ // PTY should still be live
212
+ assert.equal(manager.isLive(session.id), true);
213
+ });
214
+
215
+ it('kills PTY when idle timer fires with zero listeners', async () => {
216
+ mock.timers.enable({ apis: ['setTimeout'] });
217
+ const session = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
218
+ const listener = mock.fn();
219
+
220
+ manager.addListener(session.id, listener);
221
+ manager.removeListener(session.id, listener);
222
+
223
+ // Advance time past the 30-minute timeout
224
+ mock.timers.tick(1_800_000);
225
+
226
+ // PTY should be killed (removed from live map), but DB record persists
227
+ assert.equal(manager.isLive(session.id), false);
228
+ assert.equal(manager.hasDisconnectTimer(session.id), false);
229
+ });
230
+
231
+ it('does not kill PTY if listener reconnects before timer fires', async () => {
232
+ mock.timers.enable({ apis: ['setTimeout'] });
233
+ const session = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
234
+ const listener1 = mock.fn();
235
+ const listener2 = mock.fn();
236
+
237
+ manager.addListener(session.id, listener1);
238
+ manager.removeListener(session.id, listener1);
239
+
240
+ // Advance time partially
241
+ mock.timers.tick(900_000); // 15 minutes
242
+
243
+ // Reconnect
244
+ manager.addListener(session.id, listener2);
245
+
246
+ // Advance past original timeout
247
+ mock.timers.tick(900_001);
248
+
249
+ // PTY should still be live
250
+ assert.equal(manager.isLive(session.id), true);
251
+ });
252
+
253
+ it('clears pending timer when session is manually destroyed', async () => {
254
+ mock.timers.enable({ apis: ['setTimeout'] });
255
+ const session = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
256
+ const listener = mock.fn();
257
+
258
+ manager.addListener(session.id, listener);
259
+ manager.removeListener(session.id, listener);
260
+ assert.equal(manager.hasDisconnectTimer(session.id), true);
261
+
262
+ await manager.destroySession(session.id);
263
+ assert.equal(manager.hasDisconnectTimer(session.id), false);
264
+ assert.equal(manager.getDisconnectTimerCount(), 0);
265
+ });
266
+
267
+ it('clears all pending timers on destroyAllPty', async () => {
268
+ mock.timers.enable({ apis: ['setTimeout'] });
269
+ const s1 = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
270
+ const s2 = await manager.createSession('proj_other', 'Other', 80, 24);
271
+ const l1 = mock.fn();
272
+ const l2 = mock.fn();
273
+
274
+ manager.addListener(s1.id, l1);
275
+ manager.addListener(s2.id, l2);
276
+ manager.removeListener(s1.id, l1);
277
+ manager.removeListener(s2.id, l2);
278
+
279
+ assert.equal(manager.getDisconnectTimerCount(), 2);
280
+
281
+ manager.destroyAllPty();
282
+ assert.equal(manager.getDisconnectTimerCount(), 0);
283
+ });
284
+
285
+ it('only starts one timer per session even with multiple removeListener calls', async () => {
286
+ mock.timers.enable({ apis: ['setTimeout'] });
287
+ const session = await manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
288
+ const listener = mock.fn();
289
+
290
+ manager.addListener(session.id, listener);
291
+ manager.removeListener(session.id, listener);
292
+
293
+ // Try removing again (no-op since listener already removed)
294
+ manager.removeListener(session.id, listener);
295
+
296
+ // Should still have exactly one timer
297
+ assert.equal(manager.hasDisconnectTimer(session.id), true);
298
+ });
299
+ });
159
300
  });