@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assistkick/create",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Scaffold assistkick-product-system into any project",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"build": "tsc",
|
|
15
15
|
"prepare_templates": "bash scripts/prepare_templates.sh",
|
|
16
16
|
"prepublishOnly": "bash scripts/prepare_templates.sh && pnpm build",
|
|
17
|
+
"publish": "npm publish --access public",
|
|
17
18
|
"test": "tsx --test tests/**/*.test.ts"
|
|
18
19
|
},
|
|
19
20
|
"devDependencies": {
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git repository routes — connect/manage git repos for projects.
|
|
3
|
+
* POST /api/projects/:id/git/connect — connect a git repo (clone URL + GitHub App)
|
|
4
|
+
* POST /api/projects/:id/git/init — initialize a new local git repo
|
|
5
|
+
* POST /api/projects/:id/git/disconnect — disconnect a git repo
|
|
6
|
+
* GET /api/projects/:id/git/status — get git repo status
|
|
7
|
+
* POST /api/projects/:id/git/test — test GitHub App connection
|
|
8
|
+
* GET /api/projects/:id/git/installations — list GitHub App installations
|
|
9
|
+
* GET /api/projects/:id/git/repos — list repos for an installation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Router } from 'express';
|
|
13
|
+
import type { ProjectService } from '../services/project_service.js';
|
|
14
|
+
import type { GitHubAppService } from '../services/github_app_service.js';
|
|
15
|
+
import type { ProjectWorkspaceService } from '../services/project_workspace_service.js';
|
|
16
|
+
|
|
17
|
+
interface GitRoutesDeps {
|
|
18
|
+
projectService: ProjectService;
|
|
19
|
+
githubAppService: GitHubAppService;
|
|
20
|
+
workspaceService: ProjectWorkspaceService;
|
|
21
|
+
log: (tag: string, ...args: any[]) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const createGitRoutes = ({ projectService, githubAppService, workspaceService, log }: GitRoutesDeps): Router => {
|
|
25
|
+
const router: Router = Router({ mergeParams: true });
|
|
26
|
+
|
|
27
|
+
// POST /api/projects/:id/git/connect — connect a git repo
|
|
28
|
+
router.post('/connect', async (req, res) => {
|
|
29
|
+
const { id } = req.params;
|
|
30
|
+
const { repoUrl, githubInstallationId, githubRepoFullName, baseBranch } = req.body;
|
|
31
|
+
log('GIT', `POST /api/projects/${id}/git/connect repoUrl="${repoUrl}"`);
|
|
32
|
+
|
|
33
|
+
if (!repoUrl && !githubRepoFullName) {
|
|
34
|
+
res.status(400).json({ error: 'Either repoUrl or githubRepoFullName is required' });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const project = await projectService.getById(id);
|
|
40
|
+
if (!project) {
|
|
41
|
+
res.status(404).json({ error: 'Project not found' });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Determine clone URL
|
|
46
|
+
let cloneUrl = repoUrl;
|
|
47
|
+
if (!cloneUrl && githubRepoFullName) {
|
|
48
|
+
if (githubInstallationId && githubAppService.isConfigured()) {
|
|
49
|
+
cloneUrl = await githubAppService.buildAuthenticatedCloneUrl(githubInstallationId, githubRepoFullName);
|
|
50
|
+
} else {
|
|
51
|
+
cloneUrl = `https://github.com/${githubRepoFullName}.git`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Clone the repo into the workspace
|
|
56
|
+
await workspaceService.cloneRepo(id, cloneUrl);
|
|
57
|
+
|
|
58
|
+
// Detect default branch
|
|
59
|
+
const detectedBranch = await workspaceService.getDefaultBranch(id);
|
|
60
|
+
const effectiveBranch = baseBranch || detectedBranch;
|
|
61
|
+
|
|
62
|
+
// Save repo metadata to project
|
|
63
|
+
const updated = await projectService.connectRepo(id, {
|
|
64
|
+
repoUrl: githubRepoFullName ? `https://github.com/${githubRepoFullName}.git` : repoUrl,
|
|
65
|
+
githubInstallationId,
|
|
66
|
+
githubRepoFullName,
|
|
67
|
+
baseBranch: effectiveBranch,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
res.json({ project: updated });
|
|
71
|
+
} catch (err: any) {
|
|
72
|
+
log('GIT', `Connect repo failed: ${err.message}`);
|
|
73
|
+
res.status(500).json({ error: `Failed to connect repo: ${err.message}` });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// POST /api/projects/:id/git/init — initialize a new local git repo
|
|
78
|
+
router.post('/init', async (req, res) => {
|
|
79
|
+
const { id } = req.params;
|
|
80
|
+
log('GIT', `POST /api/projects/${id}/git/init`);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const project = await projectService.getById(id);
|
|
84
|
+
if (!project) {
|
|
85
|
+
res.status(404).json({ error: 'Project not found' });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await workspaceService.initWorkspace(id);
|
|
90
|
+
const branch = await workspaceService.getDefaultBranch(id);
|
|
91
|
+
|
|
92
|
+
// Update project with base branch (no remote URL since it's local only)
|
|
93
|
+
const updated = await projectService.connectRepo(id, {
|
|
94
|
+
repoUrl: '',
|
|
95
|
+
baseBranch: branch,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
res.json({ project: updated });
|
|
99
|
+
} catch (err: any) {
|
|
100
|
+
log('GIT', `Init repo failed: ${err.message}`);
|
|
101
|
+
res.status(500).json({ error: `Failed to initialize repo: ${err.message}` });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// POST /api/projects/:id/git/disconnect — disconnect a git repo
|
|
106
|
+
router.post('/disconnect', async (req, res) => {
|
|
107
|
+
const { id } = req.params;
|
|
108
|
+
log('GIT', `POST /api/projects/${id}/git/disconnect`);
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const updated = await projectService.disconnectRepo(id);
|
|
112
|
+
res.json({ project: updated });
|
|
113
|
+
} catch (err: any) {
|
|
114
|
+
log('GIT', `Disconnect repo failed: ${err.message}`);
|
|
115
|
+
if (err.message === 'Project not found') {
|
|
116
|
+
res.status(404).json({ error: err.message });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
res.status(500).json({ error: `Failed to disconnect repo: ${err.message}` });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// GET /api/projects/:id/git/status — get git repo status
|
|
124
|
+
router.get('/status', async (req, res) => {
|
|
125
|
+
const { id } = req.params;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const project = await projectService.getById(id);
|
|
129
|
+
if (!project) {
|
|
130
|
+
res.status(404).json({ error: 'Project not found' });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const gitStatus = await workspaceService.getStatus(id);
|
|
135
|
+
res.json({
|
|
136
|
+
...gitStatus,
|
|
137
|
+
repoUrl: project.repoUrl,
|
|
138
|
+
githubInstallationId: project.githubInstallationId,
|
|
139
|
+
githubRepoFullName: project.githubRepoFullName,
|
|
140
|
+
baseBranch: project.baseBranch,
|
|
141
|
+
githubAppConfigured: githubAppService.isConfigured(),
|
|
142
|
+
});
|
|
143
|
+
} catch (err: any) {
|
|
144
|
+
log('GIT', `Get git status failed: ${err.message}`);
|
|
145
|
+
res.status(500).json({ error: `Failed to get git status: ${err.message}` });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// POST /api/projects/:id/git/test — test GitHub App connection
|
|
150
|
+
router.post('/test', async (req, res) => {
|
|
151
|
+
const { id } = req.params;
|
|
152
|
+
log('GIT', `POST /api/projects/${id}/git/test`);
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
if (!githubAppService.isConfigured()) {
|
|
156
|
+
res.json({
|
|
157
|
+
configured: false,
|
|
158
|
+
error: 'GitHub App credentials not configured (GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY)',
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const installations = await githubAppService.listInstallations();
|
|
164
|
+
res.json({
|
|
165
|
+
configured: true,
|
|
166
|
+
installations: installations.map(i => ({
|
|
167
|
+
id: i.id,
|
|
168
|
+
account: i.account.login,
|
|
169
|
+
accountType: i.account.type,
|
|
170
|
+
})),
|
|
171
|
+
});
|
|
172
|
+
} catch (err: any) {
|
|
173
|
+
log('GIT', `Test GitHub App connection failed: ${err.message}`);
|
|
174
|
+
res.json({
|
|
175
|
+
configured: true,
|
|
176
|
+
error: `Connection test failed: ${err.message}`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// GET /api/projects/:id/git/installations — list GitHub App installations
|
|
182
|
+
router.get('/installations', async (_req, res) => {
|
|
183
|
+
try {
|
|
184
|
+
if (!githubAppService.isConfigured()) {
|
|
185
|
+
res.json({ installations: [], configured: false });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const installations = await githubAppService.listInstallations();
|
|
190
|
+
res.json({
|
|
191
|
+
configured: true,
|
|
192
|
+
installations: installations.map(i => ({
|
|
193
|
+
id: i.id,
|
|
194
|
+
account: i.account.login,
|
|
195
|
+
accountType: i.account.type,
|
|
196
|
+
})),
|
|
197
|
+
});
|
|
198
|
+
} catch (err: any) {
|
|
199
|
+
log('GIT', `List installations failed: ${err.message}`);
|
|
200
|
+
res.status(500).json({ error: `Failed to list installations: ${err.message}` });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// GET /api/projects/:id/git/repos?installation_id=xxx — list repos for an installation
|
|
205
|
+
router.get('/repos', async (req, res) => {
|
|
206
|
+
const installationId = req.query.installation_id as string;
|
|
207
|
+
|
|
208
|
+
if (!installationId) {
|
|
209
|
+
res.status(400).json({ error: 'installation_id query parameter is required' });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const repos = await githubAppService.listInstallationRepos(installationId);
|
|
215
|
+
res.json({
|
|
216
|
+
repos: repos.map(r => ({
|
|
217
|
+
id: r.id,
|
|
218
|
+
fullName: r.full_name,
|
|
219
|
+
private: r.private,
|
|
220
|
+
defaultBranch: r.default_branch,
|
|
221
|
+
cloneUrl: r.clone_url,
|
|
222
|
+
})),
|
|
223
|
+
});
|
|
224
|
+
} catch (err: any) {
|
|
225
|
+
log('GIT', `List repos failed: ${err.message}`);
|
|
226
|
+
res.status(500).json({ error: `Failed to list repos: ${err.message}` });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return router;
|
|
231
|
+
};
|
|
@@ -10,7 +10,7 @@ import { log, pipeline } from '../services/init.js';
|
|
|
10
10
|
|
|
11
11
|
const router: Router = Router();
|
|
12
12
|
|
|
13
|
-
const VALID_COLUMNS = ['todo', 'in_progress', 'in_review', 'qa', 'done'];
|
|
13
|
+
const VALID_COLUMNS = ['backlog', 'todo', 'in_progress', 'in_review', 'qa', 'done'];
|
|
14
14
|
|
|
15
15
|
// GET /api/kanban
|
|
16
16
|
router.get('/', async (req, res) => {
|
|
@@ -19,20 +19,20 @@ router.get('/', async (req, res) => {
|
|
|
19
19
|
try {
|
|
20
20
|
const kanban = await loadKanban(projectId);
|
|
21
21
|
|
|
22
|
-
// Auto-add missing feature nodes to the
|
|
22
|
+
// Auto-add missing feature nodes to the Backlog column
|
|
23
23
|
const graph = await readGraph(projectId);
|
|
24
24
|
const featureNodes = graph.nodes.filter((n: any) => n.type === 'feature');
|
|
25
25
|
for (const node of featureNodes) {
|
|
26
26
|
if (!kanban[node.id]) {
|
|
27
27
|
const newEntry = {
|
|
28
|
-
column: '
|
|
28
|
+
column: 'backlog',
|
|
29
29
|
rejection_count: 0,
|
|
30
30
|
notes: [],
|
|
31
31
|
moved_at: node.created_at || new Date().toISOString(),
|
|
32
32
|
};
|
|
33
33
|
await saveKanbanEntry(node.id, newEntry, projectId);
|
|
34
34
|
kanban[node.id] = newEntry;
|
|
35
|
-
log('KANBAN', `Auto-added ${node.id} to
|
|
35
|
+
log('KANBAN', `Auto-added ${node.id} to backlog`);
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pipeline API routes — start/status/unblock dev pipeline.
|
|
2
|
+
* Pipeline API routes — start/status/unblock dev pipeline + orchestrator.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { Router } from 'express';
|
|
6
|
-
import { log, pipeline } from '../services/init.js';
|
|
6
|
+
import { log, pipeline, orchestrator } from '../services/init.js';
|
|
7
7
|
|
|
8
8
|
const router: Router = Router();
|
|
9
9
|
|
|
@@ -26,6 +26,18 @@ router.get('/:id/pipeline', async (req, res) => {
|
|
|
26
26
|
res.json(result);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
// POST /api/kanban/:id/resume
|
|
30
|
+
router.post('/:id/resume', async (req, res) => {
|
|
31
|
+
const featureId = req.params.id;
|
|
32
|
+
try {
|
|
33
|
+
const result = await pipeline.resume(featureId);
|
|
34
|
+
res.status(result.status).json(result.error ? { error: result.error } : { resumed: result.resumed, feature_id: result.feature_id });
|
|
35
|
+
} catch (err: any) {
|
|
36
|
+
log('RESUME', `UNEXPECTED ERROR: ${err.message}`);
|
|
37
|
+
res.status(500).json({ error: err.message });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
29
41
|
// POST /api/kanban/:id/unblock
|
|
30
42
|
router.post('/:id/unblock', async (req, res) => {
|
|
31
43
|
const featureId = req.params.id;
|
|
@@ -38,4 +50,39 @@ router.post('/:id/unblock', async (req, res) => {
|
|
|
38
50
|
}
|
|
39
51
|
});
|
|
40
52
|
|
|
53
|
+
// --- Orchestrator (Play All) endpoints ---
|
|
54
|
+
|
|
55
|
+
// POST /api/pipeline/play-all
|
|
56
|
+
router.post('/play-all', async (req, res) => {
|
|
57
|
+
const projectId = req.query.project_id as string | undefined;
|
|
58
|
+
try {
|
|
59
|
+
const result = orchestrator.startPlayAll(projectId);
|
|
60
|
+
const resolved = await result;
|
|
61
|
+
res.status(resolved.status).json(
|
|
62
|
+
resolved.error ? { error: resolved.error } : { started: resolved.started }
|
|
63
|
+
);
|
|
64
|
+
} catch (err: any) {
|
|
65
|
+
log('ORCHESTRATOR', `UNEXPECTED ERROR: ${err.message}`);
|
|
66
|
+
res.status(500).json({ error: err.message });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// POST /api/pipeline/stop-all
|
|
71
|
+
router.post('/stop-all', (_req, res) => {
|
|
72
|
+
try {
|
|
73
|
+
const result = orchestrator.stopPlayAll();
|
|
74
|
+
res.status(result.status).json(
|
|
75
|
+
result.error ? { error: result.error } : { stopped: result.stopped }
|
|
76
|
+
);
|
|
77
|
+
} catch (err: any) {
|
|
78
|
+
log('ORCHESTRATOR', `UNEXPECTED ERROR: ${err.message}`);
|
|
79
|
+
res.status(500).json({ error: err.message });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// GET /api/pipeline/orchestrator-status
|
|
84
|
+
router.get('/orchestrator-status', (_req, res) => {
|
|
85
|
+
res.json(orchestrator.getStatus());
|
|
86
|
+
});
|
|
87
|
+
|
|
41
88
|
export default router;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal session routes — REST API for managing named PTY sessions.
|
|
3
|
+
* GET /api/terminal/sessions — list all active sessions
|
|
4
|
+
* POST /api/terminal/sessions — create a new named session
|
|
5
|
+
* DELETE /api/terminal/sessions/:id — kill and remove a session
|
|
6
|
+
*
|
|
7
|
+
* Admin-only: all routes require admin role.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Router } from 'express';
|
|
11
|
+
import type { Request, Response } from 'express';
|
|
12
|
+
import type { PtySessionManager } from '../services/pty_session_manager.js';
|
|
13
|
+
|
|
14
|
+
interface TerminalRoutesDeps {
|
|
15
|
+
ptyManager: PtySessionManager;
|
|
16
|
+
log: (tag: string, ...args: unknown[]) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const requireAdmin = (req: Request, res: Response, next: () => void): void => {
|
|
20
|
+
const user = (req as any).user;
|
|
21
|
+
if (!user || user.role !== 'admin') {
|
|
22
|
+
res.status(403).json({ error: 'Admin privileges required' });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
next();
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const createTerminalRoutes = ({ ptyManager, log }: TerminalRoutesDeps): Router => {
|
|
29
|
+
const router: Router = Router();
|
|
30
|
+
|
|
31
|
+
router.use(requireAdmin as any);
|
|
32
|
+
|
|
33
|
+
// GET /api/terminal/sessions
|
|
34
|
+
router.get('/sessions', (_req, res) => {
|
|
35
|
+
const sessions = ptyManager.listSessions();
|
|
36
|
+
res.json({ sessions });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// POST /api/terminal/sessions
|
|
40
|
+
router.post('/sessions', (req, res) => {
|
|
41
|
+
const { projectId, projectName } = req.body;
|
|
42
|
+
|
|
43
|
+
if (!projectId || typeof projectId !== 'string') {
|
|
44
|
+
res.status(400).json({ error: 'projectId is required' });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!projectName || typeof projectName !== 'string') {
|
|
48
|
+
res.status(400).json({ error: 'projectName is required' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const session = ptyManager.createSession(projectId.trim(), projectName.trim(), 80, 24);
|
|
53
|
+
log('TERMINAL', `Created session "${session.name}" for project ${projectId}`);
|
|
54
|
+
res.status(201).json({
|
|
55
|
+
session: {
|
|
56
|
+
id: session.id,
|
|
57
|
+
name: session.name,
|
|
58
|
+
projectId: session.projectId,
|
|
59
|
+
projectName: session.projectName,
|
|
60
|
+
state: session.state,
|
|
61
|
+
createdAt: session.createdAt.toISOString(),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// DELETE /api/terminal/sessions/:id
|
|
67
|
+
router.delete('/sessions/:id', (req, res) => {
|
|
68
|
+
const { id } = req.params;
|
|
69
|
+
const session = ptyManager.getSession(id);
|
|
70
|
+
|
|
71
|
+
if (!session) {
|
|
72
|
+
res.status(404).json({ error: 'Session not found' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ptyManager.destroySession(id);
|
|
77
|
+
log('TERMINAL', `Killed session ${id}`);
|
|
78
|
+
res.json({ ok: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return router;
|
|
82
|
+
};
|
|
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
16
16
|
import { existsSync } from 'node:fs';
|
|
17
17
|
import { WebSocketServer } from 'ws';
|
|
18
18
|
import * as pty from 'node-pty';
|
|
19
|
-
import { initServices, log } from './services/init.js';
|
|
19
|
+
import { initServices, log, githubAppService, workspaceService } from './services/init.js';
|
|
20
20
|
import { AuthService } from './services/auth_service.js';
|
|
21
21
|
import { EmailService } from './services/email_service.js';
|
|
22
22
|
import { PasswordResetService } from './services/password_reset_service.js';
|
|
@@ -29,11 +29,13 @@ import { AuthMiddleware } from './middleware/auth_middleware.js';
|
|
|
29
29
|
import { createAuthRoutes } from './routes/auth.js';
|
|
30
30
|
import { createUserRoutes } from './routes/users.js';
|
|
31
31
|
import { createProjectRoutes } from './routes/projects.js';
|
|
32
|
+
import { createTerminalRoutes } from './routes/terminal.js';
|
|
32
33
|
import { getDb } from '@assistkick/shared/lib/db.js';
|
|
33
34
|
import graphRoutes from './routes/graph.js';
|
|
34
35
|
import kanbanRoutes from './routes/kanban.js';
|
|
35
36
|
import pipelineRoutes from './routes/pipeline.js';
|
|
36
37
|
import coherenceRoutes from './routes/coherence.js';
|
|
38
|
+
import { createGitRoutes } from './routes/git.js';
|
|
37
39
|
|
|
38
40
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
39
41
|
const DEFAULT_PORT = parseInt(process.env.PORT || '3000', 10);
|
|
@@ -68,8 +70,8 @@ app.use((req, res, next) => {
|
|
|
68
70
|
const start = Date.now();
|
|
69
71
|
res.on('finish', () => {
|
|
70
72
|
const duration = Date.now() - start;
|
|
71
|
-
// Skip noisy pipeline polls
|
|
72
|
-
if (req.originalUrl.endsWith('/pipeline')) return;
|
|
73
|
+
// Skip noisy pipeline and orchestrator status polls
|
|
74
|
+
if (req.originalUrl.endsWith('/pipeline') || req.originalUrl.endsWith('/orchestrator-status')) return;
|
|
73
75
|
log('HTTP', `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
|
|
74
76
|
});
|
|
75
77
|
next();
|
|
@@ -101,15 +103,29 @@ const projectService = new ProjectService({ getDb, log });
|
|
|
101
103
|
const projectRoutes = createProjectRoutes({ projectService, log });
|
|
102
104
|
app.use('/api/projects', authMiddleware.requireAuth, projectRoutes);
|
|
103
105
|
|
|
106
|
+
// Git repository routes (nested under /api/projects/:id/git)
|
|
107
|
+
const gitRoutes = createGitRoutes({ projectService, githubAppService, workspaceService, log });
|
|
108
|
+
app.use('/api/projects/:id/git', authMiddleware.requireAuth, gitRoutes);
|
|
109
|
+
|
|
104
110
|
// Ensure default project exists and assign orphan nodes on startup
|
|
105
111
|
projectService.ensureDefaultAndAssignOrphans().catch((err: any) => {
|
|
106
112
|
log('STARTUP', `Failed to ensure default project: ${err.message}`);
|
|
107
113
|
});
|
|
108
114
|
|
|
115
|
+
// PTY session manager — initialized early so terminal REST routes can reference it
|
|
116
|
+
// Resolve project root: from packages/backend/src → assistkick-product-system → repo root
|
|
117
|
+
const PROJECT_ROOT = join(__dirname, '..', '..', '..', '..');
|
|
118
|
+
const ptyManager = new PtySessionManager({ spawn: pty.spawn, log, projectRoot: PROJECT_ROOT });
|
|
119
|
+
|
|
120
|
+
// Terminal session management REST routes (admin-only, auth required)
|
|
121
|
+
const terminalRoutes = createTerminalRoutes({ ptyManager, log });
|
|
122
|
+
app.use('/api/terminal', authMiddleware.requireAuth, terminalRoutes);
|
|
123
|
+
|
|
109
124
|
// Protected API routes — require authentication
|
|
110
125
|
app.use('/api', authMiddleware.requireAuth, graphRoutes);
|
|
111
126
|
app.use('/api/kanban', authMiddleware.requireAuth, kanbanRoutes);
|
|
112
127
|
app.use('/api/kanban', authMiddleware.requireAuth, pipelineRoutes);
|
|
128
|
+
app.use('/api/pipeline', authMiddleware.requireAuth, pipelineRoutes);
|
|
113
129
|
app.use('/api/coherence', authMiddleware.requireAuth, coherenceRoutes);
|
|
114
130
|
|
|
115
131
|
// Redirect favicon.ico to SVG favicon served from static files
|
|
@@ -130,9 +146,6 @@ const server = createServer(app);
|
|
|
130
146
|
|
|
131
147
|
// Set up WebSocket for terminal
|
|
132
148
|
const wss = new WebSocketServer({ noServer: true });
|
|
133
|
-
// Resolve project root: from packages/backend/src → assistkick-product-system → repo root
|
|
134
|
-
const PROJECT_ROOT = join(__dirname, '..', '..', '..', '..');
|
|
135
|
-
const ptyManager = new PtySessionManager({ spawn: pty.spawn, log, projectRoot: PROJECT_ROOT });
|
|
136
149
|
const terminalHandler = new TerminalWsHandler({ wss, authService, ptyManager, log });
|
|
137
150
|
|
|
138
151
|
server.on('upgrade', (req, socket, head) => {
|
package/templates/assistkick-product-system/packages/backend/src/services/github_app_service.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHubAppService — generates installation access tokens for GitHub App authentication.
|
|
3
|
+
* Uses env vars GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY.
|
|
4
|
+
* Tokens are short-lived (1 hour) and scoped to a specific installation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { SignJWT, importPKCS8 } from 'jose';
|
|
8
|
+
|
|
9
|
+
interface GitHubAppServiceDeps {
|
|
10
|
+
log: (tag: string, ...args: any[]) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Installation {
|
|
14
|
+
id: number;
|
|
15
|
+
account: { login: string; type: string };
|
|
16
|
+
app_id: number;
|
|
17
|
+
target_type: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface InstallationRepo {
|
|
21
|
+
id: number;
|
|
22
|
+
full_name: string;
|
|
23
|
+
private: boolean;
|
|
24
|
+
default_branch: string;
|
|
25
|
+
clone_url: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class GitHubAppService {
|
|
29
|
+
private readonly log: (tag: string, ...args: any[]) => void;
|
|
30
|
+
|
|
31
|
+
constructor({ log }: GitHubAppServiceDeps) {
|
|
32
|
+
this.log = log;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private getAppId = (): string => {
|
|
36
|
+
const appId = process.env.GITHUB_APP_ID;
|
|
37
|
+
if (!appId) throw new Error('GITHUB_APP_ID environment variable is not set');
|
|
38
|
+
return appId;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
private getPrivateKey = (): string => {
|
|
42
|
+
const key = process.env.GITHUB_APP_PRIVATE_KEY;
|
|
43
|
+
if (!key) throw new Error('GITHUB_APP_PRIVATE_KEY environment variable is not set');
|
|
44
|
+
// Support both raw PEM and base64-encoded PEM (for env vars that can't contain newlines)
|
|
45
|
+
if (key.startsWith('-----BEGIN')) return key;
|
|
46
|
+
return Buffer.from(key, 'base64').toString('utf-8');
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Check whether GitHub App credentials are configured. */
|
|
50
|
+
isConfigured = (): boolean => {
|
|
51
|
+
return !!(process.env.GITHUB_APP_ID && process.env.GITHUB_APP_PRIVATE_KEY);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Generate a JWT signed with the App's private key (valid for 10 minutes). */
|
|
55
|
+
private generateJwt = async (): Promise<string> => {
|
|
56
|
+
const appId = this.getAppId();
|
|
57
|
+
const privateKeyPem = this.getPrivateKey();
|
|
58
|
+
const privateKey = await importPKCS8(privateKeyPem, 'RS256');
|
|
59
|
+
|
|
60
|
+
const now = Math.floor(Date.now() / 1000);
|
|
61
|
+
const jwt = await new SignJWT({})
|
|
62
|
+
.setProtectedHeader({ alg: 'RS256' })
|
|
63
|
+
.setIssuer(appId)
|
|
64
|
+
.setIssuedAt(now - 60) // clock skew tolerance
|
|
65
|
+
.setExpirationTime(now + 600) // 10 minutes
|
|
66
|
+
.sign(privateKey);
|
|
67
|
+
|
|
68
|
+
return jwt;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Get a short-lived installation access token for git operations. */
|
|
72
|
+
getInstallationToken = async (installationId: string): Promise<string> => {
|
|
73
|
+
const jwt = await this.generateJwt();
|
|
74
|
+
this.log('GITHUB', `Requesting installation token for installation ${installationId}`);
|
|
75
|
+
|
|
76
|
+
const resp = await fetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: {
|
|
79
|
+
'Authorization': `Bearer ${jwt}`,
|
|
80
|
+
'Accept': 'application/vnd.github+json',
|
|
81
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!resp.ok) {
|
|
86
|
+
const body = await resp.text();
|
|
87
|
+
throw new Error(`GitHub API error (${resp.status}): ${body}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const data = await resp.json();
|
|
91
|
+
this.log('GITHUB', `Installation token obtained, expires at ${data.expires_at}`);
|
|
92
|
+
return data.token;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** List all installations of this GitHub App. */
|
|
96
|
+
listInstallations = async (): Promise<Installation[]> => {
|
|
97
|
+
const jwt = await this.generateJwt();
|
|
98
|
+
|
|
99
|
+
const resp = await fetch('https://api.github.com/app/installations', {
|
|
100
|
+
headers: {
|
|
101
|
+
'Authorization': `Bearer ${jwt}`,
|
|
102
|
+
'Accept': 'application/vnd.github+json',
|
|
103
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (!resp.ok) {
|
|
108
|
+
const body = await resp.text();
|
|
109
|
+
throw new Error(`GitHub API error (${resp.status}): ${body}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return resp.json();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** List repositories accessible to a specific installation. */
|
|
116
|
+
listInstallationRepos = async (installationId: string): Promise<InstallationRepo[]> => {
|
|
117
|
+
const token = await this.getInstallationToken(installationId);
|
|
118
|
+
|
|
119
|
+
const resp = await fetch('https://api.github.com/installation/repositories?per_page=100', {
|
|
120
|
+
headers: {
|
|
121
|
+
'Authorization': `token ${token}`,
|
|
122
|
+
'Accept': 'application/vnd.github+json',
|
|
123
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!resp.ok) {
|
|
128
|
+
const body = await resp.text();
|
|
129
|
+
throw new Error(`GitHub API error (${resp.status}): ${body}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const data = await resp.json();
|
|
133
|
+
return data.repositories;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Build an authenticated clone URL using an installation token. */
|
|
137
|
+
buildAuthenticatedCloneUrl = async (installationId: string, repoFullName: string): Promise<string> => {
|
|
138
|
+
const token = await this.getInstallationToken(installationId);
|
|
139
|
+
return `https://x-access-token:${token}@github.com/${repoFullName}.git`;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** Configure git credential helper for a workspace to use installation tokens. */
|
|
143
|
+
buildGitCredentialUrl = (token: string, repoFullName: string): string => {
|
|
144
|
+
return `https://x-access-token:${token}@github.com/${repoFullName}.git`;
|
|
145
|
+
};
|
|
146
|
+
}
|