@assistkick/create 1.2.0 → 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/package.json +2 -1
- package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +231 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +4 -4
- package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +49 -2
- package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +82 -0
- package/templates/assistkick-product-system/packages/backend/src/server.ts +19 -6
- 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 +69 -2
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +71 -0
- 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 +88 -17
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +114 -39
- package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +28 -14
- package/templates/assistkick-product-system/packages/frontend/src/App.tsx +1 -1
- package/templates/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/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +208 -95
- package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +17 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +238 -105
- package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +15 -13
- package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +1 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +4 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/dashboard.tsx +22 -4
- package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +486 -38
- 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 +14 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +5 -0
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +54 -1
- package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +25 -0
- package/templates/assistkick-product-system/packages/shared/lib/pipeline-state-store.ts +4 -0
- package/templates/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/assistkick-product-system/packages/shared/tools/db_explorer.ts +275 -0
- package/templates/assistkick-product-system/packages/shared/tools/get_kanban.ts +2 -1
- package/templates/assistkick-product-system/packages/shared/tools/move_card.ts +3 -2
- package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -2
- package/templates/assistkick-product-system/tests/kanban.test.ts +1 -1
- package/templates/assistkick-product-system/tests/pipeline_stats_all_cards.test.ts +1 -1
- package/templates/assistkick-product-system/tests/web_terminal.test.ts +189 -150
- package/templates/skills/assistkick-bootstrap/SKILL.md +33 -25
- package/templates/skills/assistkick-code-reviewer/SKILL.md +23 -15
- package/templates/skills/assistkick-db-explorer/SKILL.md +86 -0
- package/templates/skills/assistkick-debugger/SKILL.md +30 -22
- package/templates/skills/assistkick-developer/SKILL.md +37 -29
- package/templates/skills/assistkick-interview/SKILL.md +34 -26
|
@@ -12,9 +12,13 @@ import { PipelineStateStore } from '@assistkick/shared/lib/pipeline-state-store.
|
|
|
12
12
|
import { PromptBuilder } from '@assistkick/shared/lib/prompt_builder.js';
|
|
13
13
|
import { GitWorkflow } from '@assistkick/shared/lib/git_workflow.js';
|
|
14
14
|
import { getDb } from '@assistkick/shared/lib/db.js';
|
|
15
|
-
import { getKanbanEntry, saveKanbanEntry } from '@assistkick/shared/lib/kanban.js';
|
|
16
|
-
import { getNode } from '@assistkick/shared/lib/graph.js';
|
|
15
|
+
import { loadKanban, getKanbanEntry, saveKanbanEntry } from '@assistkick/shared/lib/kanban.js';
|
|
16
|
+
import { readGraph, getNode } from '@assistkick/shared/lib/graph.js';
|
|
17
17
|
import { WorkSummaryParser } from '@assistkick/shared/lib/work_summary_parser.js';
|
|
18
|
+
import { PipelineOrchestrator } from '@assistkick/shared/lib/pipeline_orchestrator.js';
|
|
19
|
+
import { GitHubAppService } from './github_app_service.js';
|
|
20
|
+
import { ProjectWorkspaceService } from './project_workspace_service.js';
|
|
21
|
+
import { ProjectService } from './project_service.js';
|
|
18
22
|
|
|
19
23
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
24
|
// Navigate from packages/backend/src/services/ up to assistkick-product-system/
|
|
@@ -41,6 +45,9 @@ export const log = (tag: string, ...args: any[]) => {
|
|
|
41
45
|
export let claudeService: any;
|
|
42
46
|
export let pipeline: any;
|
|
43
47
|
export let pipelineStateStore: any;
|
|
48
|
+
export let orchestrator: PipelineOrchestrator;
|
|
49
|
+
export let githubAppService: GitHubAppService;
|
|
50
|
+
export let workspaceService: ProjectWorkspaceService;
|
|
44
51
|
|
|
45
52
|
export const paths = {
|
|
46
53
|
projectRoot: PROJECT_ROOT,
|
|
@@ -61,6 +68,58 @@ export const initServices = (verbose: boolean) => {
|
|
|
61
68
|
const promptBuilder = new PromptBuilder({ paths, log });
|
|
62
69
|
const gitWorkflow = new GitWorkflow({ claudeService, projectRoot: PROJECT_ROOT, worktreesDir: WORKTREES_DIR, log });
|
|
63
70
|
const workSummaryParser = new WorkSummaryParser();
|
|
71
|
+
|
|
72
|
+
// New services for project git repo integration
|
|
73
|
+
githubAppService = new GitHubAppService({ log });
|
|
74
|
+
workspaceService = new ProjectWorkspaceService({ claudeService, projectRoot: PROJECT_ROOT, log });
|
|
75
|
+
const projectService = new ProjectService({ getDb, log });
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a project-scoped workspace for the pipeline.
|
|
79
|
+
* Returns a GitWorkflow pointing to the project workspace + an afterMerge hook
|
|
80
|
+
* that pushes to the remote. Returns null if no workspace is configured.
|
|
81
|
+
*/
|
|
82
|
+
const resolveProjectWorkspace = async (projectId: string) => {
|
|
83
|
+
const project = await projectService.getById(projectId);
|
|
84
|
+
if (!project) return null;
|
|
85
|
+
|
|
86
|
+
// Check if project has a workspace
|
|
87
|
+
if (!workspaceService.hasWorkspace(projectId)) {
|
|
88
|
+
// No workspace — use default behavior
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const wsPath = workspaceService.getWorkspacePath(projectId);
|
|
93
|
+
const wsWorktreesDir = workspaceService.getWorktreesDir(projectId);
|
|
94
|
+
|
|
95
|
+
const projectGitWorkflow = new GitWorkflow({
|
|
96
|
+
claudeService,
|
|
97
|
+
projectRoot: wsPath,
|
|
98
|
+
worktreesDir: wsWorktreesDir,
|
|
99
|
+
log,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Build afterMerge hook for pushing to remote
|
|
103
|
+
let afterMerge = null;
|
|
104
|
+
if (project.repoUrl && project.githubInstallationId && project.githubRepoFullName) {
|
|
105
|
+
afterMerge = async () => {
|
|
106
|
+
// Get a fresh token for push
|
|
107
|
+
const token = await githubAppService.getInstallationToken(project.githubInstallationId!);
|
|
108
|
+
const authUrl = `https://x-access-token:${token}@github.com/${project.githubRepoFullName}.git`;
|
|
109
|
+
await projectGitWorkflow.setRemoteUrl(authUrl);
|
|
110
|
+
try {
|
|
111
|
+
const branch = project.baseBranch || 'main';
|
|
112
|
+
await projectGitWorkflow.pushToRemote(branch);
|
|
113
|
+
} finally {
|
|
114
|
+
// Reset to non-authenticated URL
|
|
115
|
+
await projectGitWorkflow.setRemoteUrl(`https://github.com/${project.githubRepoFullName}.git`).catch(() => {});
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { gitWorkflow: projectGitWorkflow, afterMerge };
|
|
121
|
+
};
|
|
122
|
+
|
|
64
123
|
pipeline = new Pipeline({
|
|
65
124
|
promptBuilder,
|
|
66
125
|
gitWorkflow,
|
|
@@ -71,6 +130,14 @@ export const initServices = (verbose: boolean) => {
|
|
|
71
130
|
stateStore: pipelineStateStore,
|
|
72
131
|
workSummaryParser,
|
|
73
132
|
getNode,
|
|
133
|
+
resolveProjectWorkspace,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
orchestrator = new PipelineOrchestrator({
|
|
137
|
+
pipeline,
|
|
138
|
+
loadKanban,
|
|
139
|
+
readGraph,
|
|
140
|
+
log,
|
|
74
141
|
});
|
|
75
142
|
|
|
76
143
|
// Mark any active pipeline states as interrupted (server restart recovery)
|
package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts
CHANGED
|
@@ -17,6 +17,10 @@ export interface ProjectRecord {
|
|
|
17
17
|
name: string;
|
|
18
18
|
isDefault: number;
|
|
19
19
|
archivedAt: string | null;
|
|
20
|
+
repoUrl: string | null;
|
|
21
|
+
githubInstallationId: string | null;
|
|
22
|
+
githubRepoFullName: string | null;
|
|
23
|
+
baseBranch: string | null;
|
|
20
24
|
createdAt: string;
|
|
21
25
|
updatedAt: string;
|
|
22
26
|
}
|
|
@@ -64,6 +68,10 @@ export class ProjectService {
|
|
|
64
68
|
name,
|
|
65
69
|
isDefault: 0,
|
|
66
70
|
archivedAt: null,
|
|
71
|
+
repoUrl: null,
|
|
72
|
+
githubInstallationId: null,
|
|
73
|
+
githubRepoFullName: null,
|
|
74
|
+
baseBranch: null,
|
|
67
75
|
createdAt: now,
|
|
68
76
|
updatedAt: now,
|
|
69
77
|
};
|
|
@@ -139,6 +147,69 @@ export class ProjectService {
|
|
|
139
147
|
return { ...existing, archivedAt: null, updatedAt: now };
|
|
140
148
|
};
|
|
141
149
|
|
|
150
|
+
/** Connect a git repository to a project (stores repo metadata). */
|
|
151
|
+
connectRepo = async (id: string, opts: {
|
|
152
|
+
repoUrl: string;
|
|
153
|
+
githubInstallationId?: string;
|
|
154
|
+
githubRepoFullName?: string;
|
|
155
|
+
baseBranch?: string;
|
|
156
|
+
}): Promise<ProjectRecord> => {
|
|
157
|
+
const db = this.getDb();
|
|
158
|
+
const existing = await this.getById(id);
|
|
159
|
+
if (!existing) throw new Error('Project not found');
|
|
160
|
+
|
|
161
|
+
const now = new Date().toISOString();
|
|
162
|
+
await db
|
|
163
|
+
.update(projects)
|
|
164
|
+
.set({
|
|
165
|
+
repoUrl: opts.repoUrl,
|
|
166
|
+
githubInstallationId: opts.githubInstallationId || null,
|
|
167
|
+
githubRepoFullName: opts.githubRepoFullName || null,
|
|
168
|
+
baseBranch: opts.baseBranch || null,
|
|
169
|
+
updatedAt: now,
|
|
170
|
+
})
|
|
171
|
+
.where(eq(projects.id, id));
|
|
172
|
+
|
|
173
|
+
this.log('PROJECTS', `Connected repo to project ${id}: ${opts.repoUrl}`);
|
|
174
|
+
return {
|
|
175
|
+
...existing,
|
|
176
|
+
repoUrl: opts.repoUrl,
|
|
177
|
+
githubInstallationId: opts.githubInstallationId || null,
|
|
178
|
+
githubRepoFullName: opts.githubRepoFullName || null,
|
|
179
|
+
baseBranch: opts.baseBranch || null,
|
|
180
|
+
updatedAt: now,
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** Disconnect a git repository from a project. */
|
|
185
|
+
disconnectRepo = async (id: string): Promise<ProjectRecord> => {
|
|
186
|
+
const db = this.getDb();
|
|
187
|
+
const existing = await this.getById(id);
|
|
188
|
+
if (!existing) throw new Error('Project not found');
|
|
189
|
+
|
|
190
|
+
const now = new Date().toISOString();
|
|
191
|
+
await db
|
|
192
|
+
.update(projects)
|
|
193
|
+
.set({
|
|
194
|
+
repoUrl: null,
|
|
195
|
+
githubInstallationId: null,
|
|
196
|
+
githubRepoFullName: null,
|
|
197
|
+
baseBranch: null,
|
|
198
|
+
updatedAt: now,
|
|
199
|
+
})
|
|
200
|
+
.where(eq(projects.id, id));
|
|
201
|
+
|
|
202
|
+
this.log('PROJECTS', `Disconnected repo from project ${id}`);
|
|
203
|
+
return {
|
|
204
|
+
...existing,
|
|
205
|
+
repoUrl: null,
|
|
206
|
+
githubInstallationId: null,
|
|
207
|
+
githubRepoFullName: null,
|
|
208
|
+
baseBranch: null,
|
|
209
|
+
updatedAt: now,
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
|
|
142
213
|
ensureDefaultAndAssignOrphans = async (): Promise<void> => {
|
|
143
214
|
const db = this.getDb();
|
|
144
215
|
|
|
@@ -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
|
+
}
|
|
@@ -20,6 +20,8 @@ describe('PtySessionManager', () => {
|
|
|
20
20
|
let logMock: ReturnType<typeof mock.fn>;
|
|
21
21
|
let mockPty: ReturnType<typeof createMockPty>;
|
|
22
22
|
const PROJECT_ROOT = '/test/project/root';
|
|
23
|
+
const PROJECT_ID = 'proj_test';
|
|
24
|
+
const PROJECT_NAME = 'Test Project';
|
|
23
25
|
|
|
24
26
|
beforeEach(() => {
|
|
25
27
|
mockPty = createMockPty();
|
|
@@ -29,12 +31,10 @@ describe('PtySessionManager', () => {
|
|
|
29
31
|
});
|
|
30
32
|
|
|
31
33
|
describe('spawnCommand uses projectRoot as cwd', () => {
|
|
32
|
-
it('passes projectRoot as cwd when
|
|
33
|
-
manager.createSession(
|
|
34
|
-
|
|
35
|
-
// Simulate typing "claude" + Enter
|
|
36
|
-
manager.writeToSession('user1', 'claude\r');
|
|
34
|
+
it('passes projectRoot as cwd when auto-launching claude', () => {
|
|
35
|
+
const session = manager.createSession(PROJECT_ID, PROJECT_NAME, 80, 24);
|
|
37
36
|
|
|
37
|
+
// Auto-launch is triggered on session creation
|
|
38
38
|
assert.equal(spawnMock.mock.calls.length, 1);
|
|
39
39
|
const spawnOptions = spawnMock.mock.calls[0].arguments[2] as Record<string, unknown>;
|
|
40
40
|
assert.equal(spawnOptions.cwd, PROJECT_ROOT);
|
|
@@ -65,24 +65,95 @@ describe('PtySessionManager', () => {
|
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
describe('session lifecycle', () => {
|
|
68
|
-
it('creates a new session', () => {
|
|
69
|
-
const session = manager.createSession(
|
|
70
|
-
assert.equal(session.
|
|
71
|
-
assert.equal(session.
|
|
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));
|
|
72
75
|
});
|
|
73
76
|
|
|
74
|
-
it('
|
|
75
|
-
const s1 = manager.createSession(
|
|
76
|
-
const s2 = manager.createSession(
|
|
77
|
-
assert.
|
|
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);
|
|
78
81
|
});
|
|
79
82
|
|
|
80
83
|
it('destroys session and kills pty', () => {
|
|
81
|
-
manager.createSession(
|
|
82
|
-
|
|
83
|
-
manager.destroySession(
|
|
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);
|
|
84
87
|
assert.equal(mockPty.kill.mock.calls.length, 1);
|
|
85
|
-
assert.equal(manager.getSession(
|
|
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');
|
|
86
157
|
});
|
|
87
158
|
});
|
|
88
159
|
});
|