@assistkick/create 1.0.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/scaffolder.d.ts +6 -1
- package/dist/src/scaffolder.js +20 -9
- package/dist/src/scaffolder.js.map +1 -1
- package/package.json +3 -2
- package/templates/{product-system → assistkick-product-system}/CLAUDE.md +4 -4
- package/templates/{product-system → assistkick-product-system}/package.json +5 -5
- package/templates/{product-system → assistkick-product-system}/packages/backend/package.json +2 -2
- package/templates/{product-system → assistkick-product-system}/packages/backend/src/routes/auth.ts +1 -1
- package/templates/{product-system → assistkick-product-system}/packages/backend/src/routes/coherence.ts +1 -1
- package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +231 -0
- package/templates/{product-system → assistkick-product-system}/packages/backend/src/routes/graph.ts +3 -3
- package/templates/{product-system → assistkick-product-system}/packages/backend/src/routes/kanban.ts +6 -6
- package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +88 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +82 -0
- package/templates/{product-system → assistkick-product-system}/packages/backend/src/server.ts +23 -10
- package/templates/{product-system → assistkick-product-system}/packages/backend/src/services/coherence-review.ts +4 -4
- package/templates/assistkick-product-system/packages/backend/src/services/github_app_service.ts +146 -0
- package/templates/assistkick-product-system/packages/backend/src/services/init.ts +147 -0
- package/templates/{product-system → assistkick-product-system}/packages/backend/src/services/invitation_service.ts +1 -1
- package/templates/{product-system → assistkick-product-system}/packages/backend/src/services/password_reset_service.ts +1 -1
- package/templates/{product-system → assistkick-product-system}/packages/backend/src/services/project_service.ts +72 -1
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +87 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +194 -0
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +159 -0
- package/templates/{product-system → assistkick-product-system}/packages/backend/src/services/pty_session_manager.ts +114 -39
- package/templates/{product-system → assistkick-product-system}/packages/backend/src/services/terminal_ws_handler.ts +28 -14
- package/templates/{product-system → assistkick-product-system}/packages/backend/src/services/user_management_service.ts +1 -1
- package/templates/{product-system → assistkick-product-system}/packages/frontend/package.json +1 -1
- package/templates/{product-system → assistkick-product-system}/packages/frontend/src/App.tsx +1 -1
- package/templates/{product-system → assistkick-product-system}/packages/frontend/src/api/client.ts +151 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +352 -0
- package/templates/{product-system → assistkick-product-system}/packages/frontend/src/components/KanbanView.tsx +208 -95
- package/templates/{product-system → assistkick-product-system}/packages/frontend/src/components/ProjectSelector.tsx +17 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +333 -0
- package/templates/{product-system → assistkick-product-system}/packages/frontend/src/components/Toolbar.tsx +15 -13
- package/templates/{product-system → assistkick-product-system}/packages/frontend/src/constants/graph.ts +1 -0
- package/templates/{product-system → assistkick-product-system}/packages/frontend/src/hooks/useProjects.ts +4 -0
- package/templates/{product-system → assistkick-product-system}/packages/frontend/src/routes/dashboard.tsx +22 -4
- package/templates/{product-system → assistkick-product-system}/packages/frontend/src/styles/index.css +486 -38
- package/templates/assistkick-product-system/packages/frontend/vite.config.ts +31 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0001_vengeful_wallop.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0002_greedy_excalibur.sql +4 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0001_snapshot.json +826 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0002_snapshot.json +854 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +27 -0
- package/templates/{product-system → assistkick-product-system}/packages/shared/db/schema.ts +5 -0
- package/templates/{product-system → assistkick-product-system}/packages/shared/lib/claude-service.ts +54 -1
- package/templates/{product-system → assistkick-product-system}/packages/shared/lib/db.ts +1 -1
- package/templates/{product-system → assistkick-product-system}/packages/shared/lib/git_workflow.ts +25 -0
- package/templates/{product-system → assistkick-product-system}/packages/shared/lib/pipeline-state-store.ts +4 -0
- package/templates/{product-system → assistkick-product-system}/packages/shared/lib/pipeline.ts +329 -89
- package/templates/assistkick-product-system/packages/shared/lib/pipeline_orchestrator.ts +186 -0
- package/templates/{product-system → assistkick-product-system}/packages/shared/lib/prompt_builder.ts +2 -2
- package/templates/{product-system → assistkick-product-system}/packages/shared/package.json +1 -1
- package/templates/assistkick-product-system/packages/shared/tools/db_explorer.ts +275 -0
- package/templates/{product-system → assistkick-product-system}/packages/shared/tools/get_kanban.ts +2 -1
- package/templates/{product-system → assistkick-product-system}/packages/shared/tools/move_card.ts +3 -2
- package/templates/{product-system → assistkick-product-system}/packages/shared/tools/update_node.ts +2 -2
- package/templates/{product-system → assistkick-product-system}/tests/db_sqlite_fallback.test.ts +1 -1
- package/templates/{product-system → assistkick-product-system}/tests/kanban.test.ts +1 -1
- package/templates/{product-system → assistkick-product-system}/tests/pipeline_stats_all_cards.test.ts +1 -1
- package/templates/{product-system → assistkick-product-system}/tests/web_terminal.test.ts +189 -150
- package/templates/skills/{product-bootstrap → assistkick-bootstrap}/SKILL.md +36 -28
- package/templates/skills/{product-code-reviewer → assistkick-code-reviewer}/SKILL.md +26 -18
- package/templates/skills/assistkick-db-explorer/SKILL.md +86 -0
- package/templates/skills/{product-debugger → assistkick-debugger}/SKILL.md +35 -27
- package/templates/skills/{product-developer → assistkick-developer}/SKILL.md +40 -32
- package/templates/skills/{product-interview → assistkick-interview}/SKILL.md +37 -29
- package/templates/product-system/packages/backend/src/routes/pipeline.ts +0 -41
- package/templates/product-system/packages/backend/src/services/init.ts +0 -80
- package/templates/product-system/packages/backend/src/services/pty_session_manager.test.ts +0 -88
- package/templates/product-system/packages/frontend/src/components/TerminalView.tsx +0 -200
- package/templates/product-system/packages/frontend/vite.config.ts +0 -20
- package/templates/product-system/packages/shared/db/migrations/meta/_journal.json +0 -13
- /package/templates/{product-system → assistkick-product-system}/.env.example +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/backend/src/middleware/auth_middleware.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/backend/src/middleware/auth_middleware.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/backend/src/routes/projects.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/backend/src/routes/users.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/backend/src/services/auth_service.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/backend/src/services/auth_service.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/backend/src/services/email_service.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/backend/src/services/invitation_service.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/backend/src/services/password_reset_service.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/backend/src/services/project_service.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/backend/src/services/user_management_service.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/backend/tsconfig.json +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/index.html +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/package-lock.json +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/public/favicon.svg +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/api/client_projects.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/api/client_refresh.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/components/CoherenceView.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/components/GraphLegend.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/components/GraphSettings.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/components/GraphView.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/components/InviteUserDialog.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/components/LoginPage.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/components/QaIssueSheet.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/components/SidePanel.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/components/UsersView.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/hooks/useAuth.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/hooks/useGraph.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/hooks/useKanban.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/hooks/useTheme.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/hooks/useToast.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/hooks/use_projects_logic.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/main.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/pages/accept_invitation_page.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/pages/forgot_password_page.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/pages/register_page.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/pages/reset_password_page.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/routes/ProtectedRoute.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/routes/accept_invitation.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/routes/forgot_password.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/routes/login.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/routes/register.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/routes/reset_password.tsx +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/utils/auth_validation.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/utils/auth_validation.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/utils/login_validation.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/utils/login_validation.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/utils/logout.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/utils/node_sizing.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/utils/node_sizing.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/utils/task_status.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/src/utils/task_status.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/frontend/tsconfig.json +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/.env.example +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/README.md +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/db/migrate.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/db/migrations/0000_dashing_gorgon.sql +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/db/migrations/meta/0000_snapshot.json +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/drizzle.config.js +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/lib/coherence.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/lib/completeness.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/lib/constants.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/lib/graph.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/lib/kanban.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/lib/markdown.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/lib/relevance_search.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/lib/session.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/lib/validator.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/lib/work_summary_parser.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/scripts/assign-project.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/tools/add_edge.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/tools/add_node.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/tools/end_session.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/tools/get_gaps.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/tools/get_node.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/tools/get_status.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/tools/migrate_to_turso.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/tools/rebuild_index.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/tools/remove_edge.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/tools/remove_node.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/tools/resolve_question.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/tools/search_nodes.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/tools/start_session.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/packages/shared/tsconfig.json +0 -0
- /package/templates/{product-system → assistkick-product-system}/pnpm-workspace.yaml +0 -0
- /package/templates/{product-system → assistkick-product-system}/smoke_test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/coherence_review.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/edge_type_color_coding.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/emit-tool-use-events.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/feature_kind.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/gap_indicators.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/graceful_init.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/graph_legend.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/graph_settings_sheet.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/hide_defined_filter.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/neighborhood_focus.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/node_search.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/node_sizing.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/node_type_toggle_filters.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/node_type_visual_encoding.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/pipeline-state-store.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/pipeline-unit.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/pipeline.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/play_all.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/qa_issue_sheet.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/relevance_search.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/search_reorder.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/serve_ui.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/serve_ui_drizzle.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/session_context_recall.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/side_panel.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/spec_completeness_label.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/url_routing_test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/user_login.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/user_registration.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/work_summary.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tests/zoom_pan.test.ts +0 -0
- /package/templates/{product-system → assistkick-product-system}/tsconfig.json +0 -0
- /package/templates/skills/{product-debugger → assistkick-debugger}/references/agent-browser.md +0 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, mock, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { ProjectWorkspaceService } from './project_workspace_service.ts';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const PROJECT_ROOT = '/tmp/test-project-root';
|
|
7
|
+
|
|
8
|
+
const createMockClaudeService = () => ({
|
|
9
|
+
spawnCommand: mock.fn(async () => ''),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const createService = (claudeService: any) => {
|
|
13
|
+
return new ProjectWorkspaceService({
|
|
14
|
+
claudeService,
|
|
15
|
+
projectRoot: PROJECT_ROOT,
|
|
16
|
+
log: mock.fn(),
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe('ProjectWorkspaceService', () => {
|
|
21
|
+
let claudeService: ReturnType<typeof createMockClaudeService>;
|
|
22
|
+
let service: ProjectWorkspaceService;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
claudeService = createMockClaudeService();
|
|
26
|
+
service = createService(claudeService);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('getWorkspacePath', () => {
|
|
30
|
+
it('returns correct workspace path for a project', () => {
|
|
31
|
+
const path = service.getWorkspacePath('proj_abc123');
|
|
32
|
+
assert.equal(path, join(PROJECT_ROOT, 'workspaces', 'proj_abc123'));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('getWorktreesDir', () => {
|
|
37
|
+
it('returns correct worktrees dir inside workspace', () => {
|
|
38
|
+
const path = service.getWorktreesDir('proj_abc123');
|
|
39
|
+
assert.equal(path, join(PROJECT_ROOT, 'workspaces', 'proj_abc123', '.worktrees'));
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('getDefaultBranch', () => {
|
|
44
|
+
it('parses symbolic-ref output correctly', async () => {
|
|
45
|
+
claudeService.spawnCommand = mock.fn(async (_cmd: string, args: string[]) => {
|
|
46
|
+
if (args.includes('symbolic-ref')) return 'origin/main\n';
|
|
47
|
+
return '';
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const branch = await service.getDefaultBranch('proj_abc123');
|
|
51
|
+
assert.equal(branch, 'main');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('falls back to branch --show-current', async () => {
|
|
55
|
+
let callCount = 0;
|
|
56
|
+
claudeService.spawnCommand = mock.fn(async (_cmd: string, args: string[]) => {
|
|
57
|
+
callCount++;
|
|
58
|
+
if (args.includes('symbolic-ref')) throw new Error('no remote');
|
|
59
|
+
if (args.includes('--show-current')) return 'develop\n';
|
|
60
|
+
return '';
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const branch = await service.getDefaultBranch('proj_abc123');
|
|
64
|
+
assert.equal(branch, 'develop');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('falls back to "main" when all methods fail', async () => {
|
|
68
|
+
claudeService.spawnCommand = mock.fn(async () => {
|
|
69
|
+
throw new Error('git failed');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const branch = await service.getDefaultBranch('proj_abc123');
|
|
73
|
+
assert.equal(branch, 'main');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('getStatus', () => {
|
|
78
|
+
it('returns hasWorkspace false when no .git directory', async () => {
|
|
79
|
+
// existsSync will return false for non-existent paths
|
|
80
|
+
const status = await service.getStatus('proj_nonexistent');
|
|
81
|
+
assert.equal(status.hasWorkspace, false);
|
|
82
|
+
assert.equal(status.hasRemote, false);
|
|
83
|
+
assert.equal(status.currentBranch, null);
|
|
84
|
+
assert.equal(status.remoteUrl, null);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProjectWorkspaceService — manages per-project workspace directories.
|
|
3
|
+
* Each project gets a workspace at <projectRoot>/workspaces/<projectId>/.
|
|
4
|
+
* If a remote git repo is connected, it is cloned there.
|
|
5
|
+
* If not, git init creates a local repo so worktrees and branching always work.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { mkdir } from 'node:fs/promises';
|
|
11
|
+
|
|
12
|
+
interface ProjectWorkspaceServiceDeps {
|
|
13
|
+
claudeService: { spawnCommand: (cmd: string, args: string[], cwd: string) => Promise<string> };
|
|
14
|
+
projectRoot: string;
|
|
15
|
+
log: (tag: string, ...args: any[]) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ProjectWorkspaceService {
|
|
19
|
+
private readonly claudeService: ProjectWorkspaceServiceDeps['claudeService'];
|
|
20
|
+
private readonly projectRoot: string;
|
|
21
|
+
private readonly log: ProjectWorkspaceServiceDeps['log'];
|
|
22
|
+
|
|
23
|
+
constructor({ claudeService, projectRoot, log }: ProjectWorkspaceServiceDeps) {
|
|
24
|
+
this.claudeService = claudeService;
|
|
25
|
+
this.projectRoot = projectRoot;
|
|
26
|
+
this.log = log;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Get the workspace directory path for a project. */
|
|
30
|
+
getWorkspacePath = (projectId: string): string => {
|
|
31
|
+
return join(this.projectRoot, 'workspaces', projectId);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Get the worktrees directory inside a project workspace. */
|
|
35
|
+
getWorktreesDir = (projectId: string): string => {
|
|
36
|
+
return join(this.getWorkspacePath(projectId), '.worktrees');
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Check if a workspace exists and has a git repo. */
|
|
40
|
+
hasWorkspace = (projectId: string): boolean => {
|
|
41
|
+
const wsPath = this.getWorkspacePath(projectId);
|
|
42
|
+
return existsSync(join(wsPath, '.git'));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Ensure the workspace directory exists. Creates it if missing. */
|
|
46
|
+
private ensureDir = async (dir: string): Promise<void> => {
|
|
47
|
+
if (!existsSync(dir)) {
|
|
48
|
+
await mkdir(dir, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Initialize a new empty local git repository for a project. */
|
|
53
|
+
initWorkspace = async (projectId: string): Promise<string> => {
|
|
54
|
+
const wsPath = this.getWorkspacePath(projectId);
|
|
55
|
+
await this.ensureDir(wsPath);
|
|
56
|
+
|
|
57
|
+
if (existsSync(join(wsPath, '.git'))) {
|
|
58
|
+
this.log('WORKSPACE', `Workspace already has a git repo: ${wsPath}`);
|
|
59
|
+
return wsPath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.log('WORKSPACE', `Initializing git repo at ${wsPath}`);
|
|
63
|
+
await this.claudeService.spawnCommand('git', ['init'], wsPath);
|
|
64
|
+
|
|
65
|
+
// Create an initial commit so branches and worktrees work
|
|
66
|
+
await this.claudeService.spawnCommand('git', ['commit', '--allow-empty', '-m', 'Initial commit'], wsPath);
|
|
67
|
+
this.log('WORKSPACE', `Git repo initialized at ${wsPath}`);
|
|
68
|
+
return wsPath;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Clone a remote repository into the project workspace. */
|
|
72
|
+
cloneRepo = async (projectId: string, cloneUrl: string): Promise<string> => {
|
|
73
|
+
const wsPath = this.getWorkspacePath(projectId);
|
|
74
|
+
const parentDir = join(this.projectRoot, 'workspaces');
|
|
75
|
+
await this.ensureDir(parentDir);
|
|
76
|
+
|
|
77
|
+
// If workspace already exists, remove it first
|
|
78
|
+
if (existsSync(wsPath)) {
|
|
79
|
+
this.log('WORKSPACE', `Removing existing workspace before clone: ${wsPath}`);
|
|
80
|
+
await this.claudeService.spawnCommand('rm', ['-rf', wsPath], parentDir);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.log('WORKSPACE', `Cloning ${cloneUrl} into ${wsPath}`);
|
|
84
|
+
await this.claudeService.spawnCommand('git', ['clone', cloneUrl, wsPath], parentDir);
|
|
85
|
+
this.log('WORKSPACE', `Clone completed for project ${projectId}`);
|
|
86
|
+
return wsPath;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Get the default branch name of the workspace repo. */
|
|
90
|
+
getDefaultBranch = async (projectId: string): Promise<string> => {
|
|
91
|
+
const wsPath = this.getWorkspacePath(projectId);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// Try symbolic-ref for repos with a remote
|
|
95
|
+
const result = await this.claudeService.spawnCommand(
|
|
96
|
+
'git', ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'], wsPath,
|
|
97
|
+
);
|
|
98
|
+
// Returns e.g. "origin/main" — strip the "origin/" prefix
|
|
99
|
+
const branch = result.trim().replace(/^origin\//, '');
|
|
100
|
+
if (branch) return branch;
|
|
101
|
+
} catch {
|
|
102
|
+
// Fallback: try to get current branch name
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const result = await this.claudeService.spawnCommand('git', ['branch', '--show-current'], wsPath);
|
|
107
|
+
const branch = result.trim();
|
|
108
|
+
if (branch) return branch;
|
|
109
|
+
} catch {
|
|
110
|
+
// Final fallback
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return 'main';
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Pull latest changes from the remote on the default branch. */
|
|
117
|
+
pullLatest = async (projectId: string, token?: string, repoFullName?: string): Promise<void> => {
|
|
118
|
+
const wsPath = this.getWorkspacePath(projectId);
|
|
119
|
+
|
|
120
|
+
if (token && repoFullName) {
|
|
121
|
+
// Set up temporary credential for this pull
|
|
122
|
+
const authUrl = `https://x-access-token:${token}@github.com/${repoFullName}.git`;
|
|
123
|
+
await this.claudeService.spawnCommand('git', ['remote', 'set-url', 'origin', authUrl], wsPath);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const branch = await this.getDefaultBranch(projectId);
|
|
128
|
+
this.log('WORKSPACE', `Pulling latest on ${branch} for project ${projectId}`);
|
|
129
|
+
await this.claudeService.spawnCommand('git', ['pull', 'origin', branch], wsPath);
|
|
130
|
+
this.log('WORKSPACE', `Pull completed for project ${projectId}`);
|
|
131
|
+
} catch (err: any) {
|
|
132
|
+
// Pull may fail if there's no remote or no upstream — that's OK for local-only repos
|
|
133
|
+
this.log('WORKSPACE', `Pull failed (non-fatal): ${err.message}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (token && repoFullName) {
|
|
137
|
+
// Reset remote URL to non-authenticated form to avoid storing token
|
|
138
|
+
await this.claudeService.spawnCommand(
|
|
139
|
+
'git', ['remote', 'set-url', 'origin', `https://github.com/${repoFullName}.git`], wsPath,
|
|
140
|
+
).catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Push the default branch to the remote. */
|
|
145
|
+
pushToRemote = async (projectId: string, token: string, repoFullName: string): Promise<void> => {
|
|
146
|
+
const wsPath = this.getWorkspacePath(projectId);
|
|
147
|
+
const branch = await this.getDefaultBranch(projectId);
|
|
148
|
+
|
|
149
|
+
// Set authenticated remote URL temporarily
|
|
150
|
+
const authUrl = `https://x-access-token:${token}@github.com/${repoFullName}.git`;
|
|
151
|
+
await this.claudeService.spawnCommand('git', ['remote', 'set-url', 'origin', authUrl], wsPath);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
this.log('WORKSPACE', `Pushing ${branch} to remote for project ${projectId}`);
|
|
155
|
+
await this.claudeService.spawnCommand('git', ['push', 'origin', branch], wsPath);
|
|
156
|
+
this.log('WORKSPACE', `Push completed for project ${projectId}`);
|
|
157
|
+
} finally {
|
|
158
|
+
// Reset to non-authenticated URL
|
|
159
|
+
await this.claudeService.spawnCommand(
|
|
160
|
+
'git', ['remote', 'set-url', 'origin', `https://github.com/${repoFullName}.git`], wsPath,
|
|
161
|
+
).catch(() => {});
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Get workspace git status info. */
|
|
166
|
+
getStatus = async (projectId: string): Promise<{
|
|
167
|
+
hasWorkspace: boolean;
|
|
168
|
+
hasRemote: boolean;
|
|
169
|
+
currentBranch: string | null;
|
|
170
|
+
remoteUrl: string | null;
|
|
171
|
+
}> => {
|
|
172
|
+
const wsPath = this.getWorkspacePath(projectId);
|
|
173
|
+
|
|
174
|
+
if (!existsSync(join(wsPath, '.git'))) {
|
|
175
|
+
return { hasWorkspace: false, hasRemote: false, currentBranch: null, remoteUrl: null };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let currentBranch: string | null = null;
|
|
179
|
+
try {
|
|
180
|
+
const result = await this.claudeService.spawnCommand('git', ['branch', '--show-current'], wsPath);
|
|
181
|
+
currentBranch = result.trim() || null;
|
|
182
|
+
} catch {}
|
|
183
|
+
|
|
184
|
+
let remoteUrl: string | null = null;
|
|
185
|
+
let hasRemote = false;
|
|
186
|
+
try {
|
|
187
|
+
const result = await this.claudeService.spawnCommand('git', ['remote', 'get-url', 'origin'], wsPath);
|
|
188
|
+
remoteUrl = result.trim() || null;
|
|
189
|
+
hasRemote = !!remoteUrl;
|
|
190
|
+
} catch {}
|
|
191
|
+
|
|
192
|
+
return { hasWorkspace: true, hasRemote, currentBranch, remoteUrl };
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, mock, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { PtySessionManager } from './pty_session_manager.ts';
|
|
4
|
+
|
|
5
|
+
const createMockPty = () => {
|
|
6
|
+
const listeners: Record<string, Function> = {};
|
|
7
|
+
return {
|
|
8
|
+
onData: mock.fn((cb: Function) => { listeners.data = cb; }),
|
|
9
|
+
onExit: mock.fn((cb: Function) => { listeners.exit = cb; }),
|
|
10
|
+
write: mock.fn(),
|
|
11
|
+
resize: mock.fn(),
|
|
12
|
+
kill: mock.fn(),
|
|
13
|
+
_emit: (event: string, data: unknown) => listeners[event]?.(data),
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe('PtySessionManager', () => {
|
|
18
|
+
let manager: PtySessionManager;
|
|
19
|
+
let spawnMock: ReturnType<typeof mock.fn>;
|
|
20
|
+
let logMock: ReturnType<typeof mock.fn>;
|
|
21
|
+
let mockPty: ReturnType<typeof createMockPty>;
|
|
22
|
+
const PROJECT_ROOT = '/test/project/root';
|
|
23
|
+
const PROJECT_ID = 'proj_test';
|
|
24
|
+
const PROJECT_NAME = 'Test Project';
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
mockPty = createMockPty();
|
|
28
|
+
spawnMock = mock.fn(() => mockPty);
|
|
29
|
+
logMock = mock.fn();
|
|
30
|
+
manager = new PtySessionManager({ spawn: spawnMock as any, log: logMock, projectRoot: PROJECT_ROOT });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
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);
|
|
36
|
+
|
|
37
|
+
// Auto-launch is triggered on session creation
|
|
38
|
+
assert.equal(spawnMock.mock.calls.length, 1);
|
|
39
|
+
const spawnOptions = spawnMock.mock.calls[0].arguments[2] as Record<string, unknown>;
|
|
40
|
+
assert.equal(spawnOptions.cwd, PROJECT_ROOT);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
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
|
+
describe('session lifecycle', () => {
|
|
68
|
+
it('creates a new named session with project binding', () => {
|
|
69
|
+
const session = manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
|
|
70
|
+
assert.equal(session.projectId, PROJECT_ID);
|
|
71
|
+
assert.equal(session.projectName, PROJECT_NAME);
|
|
72
|
+
assert.equal(session.state, 'running'); // auto-launch sets state to running
|
|
73
|
+
assert.ok(session.id.startsWith('term_'));
|
|
74
|
+
assert.ok(session.name.startsWith(PROJECT_NAME));
|
|
75
|
+
});
|
|
76
|
+
|
|
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);
|
|
80
|
+
assert.notEqual(s1.id, s2.id);
|
|
81
|
+
});
|
|
82
|
+
|
|
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);
|
|
87
|
+
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
|
+
});
|
|
99
|
+
|
|
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);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
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);
|
|
111
|
+
|
|
112
|
+
assert.equal(spawnMock.mock.calls.length, 1);
|
|
113
|
+
const [cmd, args] = spawnMock.mock.calls[0].arguments as [string, string[]];
|
|
114
|
+
assert.equal(cmd, 'claude');
|
|
115
|
+
assert.ok(args.includes('--dangerously-skip-permissions'));
|
|
116
|
+
assert.ok(args.includes('--append-system-prompt'));
|
|
117
|
+
assert.ok(args.includes(`We are working on project-id ${PROJECT_ID}`));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('uses --append-system-prompt (not --system-prompt) to preserve default prompt', () => {
|
|
121
|
+
manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
|
|
122
|
+
|
|
123
|
+
const [, args] = spawnMock.mock.calls[0].arguments as [string, string[]];
|
|
124
|
+
assert.ok(args.includes('--append-system-prompt'));
|
|
125
|
+
assert.ok(!args.includes('--system-prompt') || args.indexOf('--system-prompt') === args.indexOf('--append-system-prompt'));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('includes the session projectId in the system prompt', () => {
|
|
129
|
+
const customProjectId = 'proj_custom_123';
|
|
130
|
+
manager.createSession(customProjectId, PROJECT_NAME, 80, 24);
|
|
131
|
+
|
|
132
|
+
const [, args] = spawnMock.mock.calls[0].arguments as [string, string[]];
|
|
133
|
+
const promptIndex = args.indexOf('--append-system-prompt');
|
|
134
|
+
assert.ok(promptIndex !== -1);
|
|
135
|
+
assert.ok(args[promptIndex + 1].includes(customProjectId));
|
|
136
|
+
});
|
|
137
|
+
|
|
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);
|
|
143
|
+
assert.equal(spawnMock.mock.calls.length, 1);
|
|
144
|
+
});
|
|
145
|
+
|
|
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');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('formatSessionName', () => {
|
|
153
|
+
it('formats session name as ProjectName - DD/MM/YY - HH:MM', () => {
|
|
154
|
+
const date = new Date(2026, 2, 6, 14, 30); // 2026-03-06 14:30
|
|
155
|
+
const name = manager.formatSessionName('My Project', date);
|
|
156
|
+
assert.equal(name, 'My Project - 06/03/26 - 14:30');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|