@evolve.labs/devflow 0.8.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/.claude/commands/agents/architect.md +1162 -0
- package/.claude/commands/agents/architect.meta.yaml +124 -0
- package/.claude/commands/agents/builder.md +1432 -0
- package/.claude/commands/agents/builder.meta.yaml +117 -0
- package/.claude/commands/agents/chronicler.md +633 -0
- package/.claude/commands/agents/chronicler.meta.yaml +217 -0
- package/.claude/commands/agents/guardian.md +456 -0
- package/.claude/commands/agents/guardian.meta.yaml +127 -0
- package/.claude/commands/agents/strategist.md +483 -0
- package/.claude/commands/agents/strategist.meta.yaml +158 -0
- package/.claude/commands/agents/system-designer.md +1137 -0
- package/.claude/commands/agents/system-designer.meta.yaml +156 -0
- package/.claude/commands/devflow-help.md +93 -0
- package/.claude/commands/devflow-status.md +60 -0
- package/.claude/commands/quick/create-adr.md +82 -0
- package/.claude/commands/quick/new-feature.md +57 -0
- package/.claude/commands/quick/security-check.md +54 -0
- package/.claude/commands/quick/system-design.md +58 -0
- package/.claude_project +52 -0
- package/.devflow/agents/architect.meta.yaml +122 -0
- package/.devflow/agents/builder.meta.yaml +116 -0
- package/.devflow/agents/chronicler.meta.yaml +222 -0
- package/.devflow/agents/guardian.meta.yaml +127 -0
- package/.devflow/agents/strategist.meta.yaml +158 -0
- package/.devflow/agents/system-designer.meta.yaml +265 -0
- package/.devflow/project.yaml +242 -0
- package/.gitignore-template +84 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/bin/devflow.js +54 -0
- package/lib/autopilot.js +235 -0
- package/lib/autopilotConstants.js +213 -0
- package/lib/constants.js +95 -0
- package/lib/init.js +200 -0
- package/lib/update.js +181 -0
- package/lib/utils.js +157 -0
- package/lib/web.js +119 -0
- package/package.json +57 -0
- package/web/CHANGELOG.md +192 -0
- package/web/README.md +156 -0
- package/web/app/api/autopilot/execute/route.ts +102 -0
- package/web/app/api/autopilot/terminal-execute/route.ts +124 -0
- package/web/app/api/files/route.ts +280 -0
- package/web/app/api/files/tree/route.ts +160 -0
- package/web/app/api/git/route.ts +201 -0
- package/web/app/api/health/route.ts +94 -0
- package/web/app/api/project/open/route.ts +134 -0
- package/web/app/api/search/route.ts +247 -0
- package/web/app/api/specs/route.ts +405 -0
- package/web/app/api/terminal/route.ts +222 -0
- package/web/app/globals.css +160 -0
- package/web/app/ide/layout.tsx +43 -0
- package/web/app/ide/page.tsx +216 -0
- package/web/app/layout.tsx +34 -0
- package/web/app/page.tsx +303 -0
- package/web/components/agents/AgentIcons.tsx +281 -0
- package/web/components/autopilot/AutopilotConfigModal.tsx +245 -0
- package/web/components/autopilot/AutopilotPanel.tsx +299 -0
- package/web/components/dashboard/DashboardPanel.tsx +393 -0
- package/web/components/editor/Breadcrumbs.tsx +134 -0
- package/web/components/editor/EditorPanel.tsx +120 -0
- package/web/components/editor/EditorTabs.tsx +229 -0
- package/web/components/editor/MarkdownPreview.tsx +154 -0
- package/web/components/editor/MermaidDiagram.tsx +113 -0
- package/web/components/editor/MonacoEditor.tsx +177 -0
- package/web/components/editor/TabContextMenu.tsx +207 -0
- package/web/components/git/GitPanel.tsx +534 -0
- package/web/components/layout/Shell.tsx +15 -0
- package/web/components/layout/StatusBar.tsx +100 -0
- package/web/components/modals/CommandPalette.tsx +393 -0
- package/web/components/modals/GlobalSearch.tsx +348 -0
- package/web/components/modals/QuickOpen.tsx +241 -0
- package/web/components/modals/RecentFiles.tsx +208 -0
- package/web/components/projects/ProjectSelector.tsx +147 -0
- package/web/components/settings/SettingItem.tsx +150 -0
- package/web/components/settings/SettingsPanel.tsx +323 -0
- package/web/components/specs/SpecsPanel.tsx +1091 -0
- package/web/components/terminal/TerminalPanel.tsx +683 -0
- package/web/components/ui/ContextMenu.tsx +182 -0
- package/web/components/ui/LoadingSpinner.tsx +66 -0
- package/web/components/ui/ResizeHandle.tsx +110 -0
- package/web/components/ui/Skeleton.tsx +108 -0
- package/web/components/ui/SkipLinks.tsx +37 -0
- package/web/components/ui/Toaster.tsx +57 -0
- package/web/hooks/useFocusTrap.ts +141 -0
- package/web/hooks/useKeyboardShortcuts.ts +169 -0
- package/web/hooks/useListNavigation.ts +237 -0
- package/web/lib/autopilotConstants.ts +213 -0
- package/web/lib/constants/agents.ts +67 -0
- package/web/lib/git.ts +339 -0
- package/web/lib/ptyManager.ts +191 -0
- package/web/lib/specsParser.ts +299 -0
- package/web/lib/stores/autopilotStore.ts +288 -0
- package/web/lib/stores/fileStore.ts +550 -0
- package/web/lib/stores/gitStore.ts +386 -0
- package/web/lib/stores/projectStore.ts +196 -0
- package/web/lib/stores/settingsStore.ts +126 -0
- package/web/lib/stores/specsStore.ts +297 -0
- package/web/lib/stores/uiStore.ts +175 -0
- package/web/lib/types/index.ts +177 -0
- package/web/lib/utils.ts +98 -0
- package/web/next.config.js +50 -0
- package/web/package.json +54 -0
- package/web/postcss.config.js +6 -0
- package/web/tailwind.config.ts +68 -0
- package/web/tsconfig.json +41 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
parseUserStory,
|
|
6
|
+
parseADR,
|
|
7
|
+
getSpecType,
|
|
8
|
+
parseFrontmatter,
|
|
9
|
+
extractTasks,
|
|
10
|
+
} from '@/lib/specsParser';
|
|
11
|
+
import type { Spec, Requirement, DesignDecision, Task } from '@/lib/types';
|
|
12
|
+
|
|
13
|
+
interface SpecsResponse {
|
|
14
|
+
specs: Spec[];
|
|
15
|
+
requirements: Requirement[];
|
|
16
|
+
decisions: DesignDecision[];
|
|
17
|
+
tasks: Task[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* GET /api/specs - List all specs from a project
|
|
22
|
+
*/
|
|
23
|
+
export async function GET(req: NextRequest) {
|
|
24
|
+
try {
|
|
25
|
+
const searchParams = req.nextUrl.searchParams;
|
|
26
|
+
const projectPath = searchParams.get('projectPath');
|
|
27
|
+
|
|
28
|
+
if (!projectPath) {
|
|
29
|
+
return NextResponse.json(
|
|
30
|
+
{ error: 'projectPath is required' },
|
|
31
|
+
{ status: 400 }
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Security: Validate path
|
|
36
|
+
if (projectPath.includes('..')) {
|
|
37
|
+
return NextResponse.json(
|
|
38
|
+
{ error: 'Invalid path' },
|
|
39
|
+
{ status: 400 }
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const response: SpecsResponse = {
|
|
44
|
+
specs: [],
|
|
45
|
+
requirements: [],
|
|
46
|
+
decisions: [],
|
|
47
|
+
tasks: [],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Directories to scan for specs
|
|
51
|
+
const specDirs = [
|
|
52
|
+
'docs/planning/stories',
|
|
53
|
+
'docs/planning/specs',
|
|
54
|
+
'docs/planning',
|
|
55
|
+
'docs/decisions',
|
|
56
|
+
'.devflow/specs',
|
|
57
|
+
'.devflow/stories',
|
|
58
|
+
'specs',
|
|
59
|
+
'stories',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
for (const dir of specDirs) {
|
|
63
|
+
const fullPath = path.join(projectPath, dir);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const stat = await fs.stat(fullPath);
|
|
67
|
+
if (!stat.isDirectory()) continue;
|
|
68
|
+
|
|
69
|
+
const files = await fs.readdir(fullPath);
|
|
70
|
+
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
if (!file.endsWith('.md')) continue;
|
|
73
|
+
|
|
74
|
+
const filePath = path.join(fullPath, file);
|
|
75
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
76
|
+
const specType = getSpecType(filePath);
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
if (specType === 'story' || specType === 'spec') {
|
|
80
|
+
const { spec, requirement, tasks } = parseUserStory(content, filePath);
|
|
81
|
+
response.specs.push(spec);
|
|
82
|
+
response.requirements.push(requirement);
|
|
83
|
+
response.tasks.push(...tasks);
|
|
84
|
+
} else if (specType === 'adr') {
|
|
85
|
+
const { spec, decision } = parseADR(content, filePath);
|
|
86
|
+
response.specs.push(spec);
|
|
87
|
+
response.decisions.push(decision);
|
|
88
|
+
} else {
|
|
89
|
+
// Generic spec parsing
|
|
90
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
91
|
+
const titleMatch = body.match(/^#\s+(.+)$/m);
|
|
92
|
+
const id = file.replace('.md', '');
|
|
93
|
+
|
|
94
|
+
const spec: Spec = {
|
|
95
|
+
id,
|
|
96
|
+
name: frontmatter.title || titleMatch?.[1] || id,
|
|
97
|
+
description: body.slice(0, 200).replace(/^#.+\n/, '').trim(),
|
|
98
|
+
phase: 'requirements',
|
|
99
|
+
status: 'draft',
|
|
100
|
+
createdAt: new Date(),
|
|
101
|
+
updatedAt: new Date(),
|
|
102
|
+
filePath,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
response.specs.push(spec);
|
|
106
|
+
response.tasks.push(...extractTasks(body, id, filePath));
|
|
107
|
+
}
|
|
108
|
+
} catch (parseError) {
|
|
109
|
+
console.error(`Error parsing ${filePath}:`, parseError);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// Directory doesn't exist, skip
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Deduplicate by ID
|
|
118
|
+
response.specs = [...new Map(response.specs.map(s => [s.id, s])).values()];
|
|
119
|
+
response.requirements = [...new Map(response.requirements.map(r => [r.id, r])).values()];
|
|
120
|
+
response.decisions = [...new Map(response.decisions.map(d => [d.id, d])).values()];
|
|
121
|
+
response.tasks = [...new Map(response.tasks.map(t => [t.id, t])).values()];
|
|
122
|
+
|
|
123
|
+
return NextResponse.json(response);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error('Error loading specs:', error);
|
|
126
|
+
return NextResponse.json(
|
|
127
|
+
{ error: 'Failed to load specs' },
|
|
128
|
+
{ status: 500 }
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* POST /api/specs - Create a new spec
|
|
135
|
+
*/
|
|
136
|
+
export async function POST(req: NextRequest) {
|
|
137
|
+
try {
|
|
138
|
+
const body = await req.json();
|
|
139
|
+
const { projectPath, type, title, description, priority, phase } = body;
|
|
140
|
+
|
|
141
|
+
if (!projectPath || !type || !title) {
|
|
142
|
+
return NextResponse.json(
|
|
143
|
+
{ error: 'projectPath, type, and title are required' },
|
|
144
|
+
{ status: 400 }
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Security: Validate path
|
|
149
|
+
if (projectPath.includes('..')) {
|
|
150
|
+
return NextResponse.json(
|
|
151
|
+
{ error: 'Invalid path' },
|
|
152
|
+
{ status: 400 }
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Determine directory based on type
|
|
157
|
+
let dir: string;
|
|
158
|
+
let prefix: string;
|
|
159
|
+
let template: string;
|
|
160
|
+
|
|
161
|
+
switch (type) {
|
|
162
|
+
case 'story':
|
|
163
|
+
dir = 'docs/planning/stories';
|
|
164
|
+
prefix = 'US';
|
|
165
|
+
template = generateStoryTemplate(title, description, priority);
|
|
166
|
+
break;
|
|
167
|
+
case 'adr':
|
|
168
|
+
dir = 'docs/decisions';
|
|
169
|
+
prefix = 'ADR';
|
|
170
|
+
template = generateADRTemplate(title, description);
|
|
171
|
+
break;
|
|
172
|
+
case 'spec':
|
|
173
|
+
default:
|
|
174
|
+
dir = 'docs/planning/specs';
|
|
175
|
+
prefix = 'SPEC';
|
|
176
|
+
template = generateSpecTemplate(title, description, phase);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Create directory if it doesn't exist
|
|
181
|
+
const fullDir = path.join(projectPath, dir);
|
|
182
|
+
await fs.mkdir(fullDir, { recursive: true });
|
|
183
|
+
|
|
184
|
+
// Find next ID
|
|
185
|
+
const files = await fs.readdir(fullDir).catch(() => []);
|
|
186
|
+
const existingIds = files
|
|
187
|
+
.filter(f => f.endsWith('.md'))
|
|
188
|
+
.map(f => {
|
|
189
|
+
const match = f.match(new RegExp(`${prefix}-(\\d+)`));
|
|
190
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
191
|
+
});
|
|
192
|
+
const nextId = Math.max(0, ...existingIds) + 1;
|
|
193
|
+
|
|
194
|
+
// Generate filename
|
|
195
|
+
const slug = title
|
|
196
|
+
.toLowerCase()
|
|
197
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
198
|
+
.replace(/^-|-$/g, '')
|
|
199
|
+
.slice(0, 50);
|
|
200
|
+
const filename = `${prefix}-${String(nextId).padStart(3, '0')}-${slug}.md`;
|
|
201
|
+
const filePath = path.join(fullDir, filename);
|
|
202
|
+
|
|
203
|
+
// Write file
|
|
204
|
+
await fs.writeFile(filePath, template, 'utf-8');
|
|
205
|
+
|
|
206
|
+
return NextResponse.json({
|
|
207
|
+
success: true,
|
|
208
|
+
filePath,
|
|
209
|
+
id: `${prefix}-${String(nextId).padStart(3, '0')}`,
|
|
210
|
+
});
|
|
211
|
+
} catch (error) {
|
|
212
|
+
console.error('Error creating spec:', error);
|
|
213
|
+
return NextResponse.json(
|
|
214
|
+
{ error: 'Failed to create spec' },
|
|
215
|
+
{ status: 500 }
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Template generators
|
|
221
|
+
function generateStoryTemplate(title: string, description?: string, priority?: string): string {
|
|
222
|
+
const date = new Date().toISOString().split('T')[0];
|
|
223
|
+
return `---
|
|
224
|
+
title: "${title}"
|
|
225
|
+
status: draft
|
|
226
|
+
priority: ${priority || 'should'}
|
|
227
|
+
created: ${date}
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
# ${title}
|
|
231
|
+
|
|
232
|
+
${description || 'Description of the user story.'}
|
|
233
|
+
|
|
234
|
+
## User Story
|
|
235
|
+
|
|
236
|
+
As a [type of user],
|
|
237
|
+
I want [goal/desire],
|
|
238
|
+
So that [benefit].
|
|
239
|
+
|
|
240
|
+
## Acceptance Criteria
|
|
241
|
+
|
|
242
|
+
- [ ] Criteria 1
|
|
243
|
+
- [ ] Criteria 2
|
|
244
|
+
- [ ] Criteria 3
|
|
245
|
+
|
|
246
|
+
## Tasks
|
|
247
|
+
|
|
248
|
+
- [ ] Task 1
|
|
249
|
+
- [ ] Task 2
|
|
250
|
+
- [ ] Task 3
|
|
251
|
+
|
|
252
|
+
## Notes
|
|
253
|
+
|
|
254
|
+
_Additional notes and context._
|
|
255
|
+
`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function generateADRTemplate(title: string, description?: string): string {
|
|
259
|
+
const date = new Date().toISOString().split('T')[0];
|
|
260
|
+
return `---
|
|
261
|
+
title: "${title}"
|
|
262
|
+
status: proposed
|
|
263
|
+
created: ${date}
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
# ${title}
|
|
267
|
+
|
|
268
|
+
## Status
|
|
269
|
+
|
|
270
|
+
Proposed
|
|
271
|
+
|
|
272
|
+
## Context
|
|
273
|
+
|
|
274
|
+
${description || 'Describe the context and problem statement.'}
|
|
275
|
+
|
|
276
|
+
## Decision
|
|
277
|
+
|
|
278
|
+
Describe the decision made.
|
|
279
|
+
|
|
280
|
+
## Consequences
|
|
281
|
+
|
|
282
|
+
- Positive consequence 1
|
|
283
|
+
- Positive consequence 2
|
|
284
|
+
- Negative consequence 1
|
|
285
|
+
|
|
286
|
+
## Alternatives
|
|
287
|
+
|
|
288
|
+
- Alternative 1: Description
|
|
289
|
+
- Alternative 2: Description
|
|
290
|
+
`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function generateSpecTemplate(title: string, description?: string, phase?: string): string {
|
|
294
|
+
const date = new Date().toISOString().split('T')[0];
|
|
295
|
+
return `---
|
|
296
|
+
title: "${title}"
|
|
297
|
+
status: draft
|
|
298
|
+
phase: ${phase || 'requirements'}
|
|
299
|
+
created: ${date}
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
# ${title}
|
|
303
|
+
|
|
304
|
+
${description || 'Description of the specification.'}
|
|
305
|
+
|
|
306
|
+
## Overview
|
|
307
|
+
|
|
308
|
+
Provide an overview of this specification.
|
|
309
|
+
|
|
310
|
+
## Requirements
|
|
311
|
+
|
|
312
|
+
- [ ] Requirement 1
|
|
313
|
+
- [ ] Requirement 2
|
|
314
|
+
|
|
315
|
+
## Implementation Notes
|
|
316
|
+
|
|
317
|
+
_Notes for implementation._
|
|
318
|
+
`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* PATCH /api/specs - Update task status in a spec file
|
|
323
|
+
*/
|
|
324
|
+
export async function PATCH(req: NextRequest) {
|
|
325
|
+
try {
|
|
326
|
+
const body = await req.json();
|
|
327
|
+
const { filePath, taskTitle, completed } = body;
|
|
328
|
+
|
|
329
|
+
if (!filePath || taskTitle === undefined || completed === undefined) {
|
|
330
|
+
return NextResponse.json(
|
|
331
|
+
{ error: 'filePath, taskTitle, and completed are required' },
|
|
332
|
+
{ status: 400 }
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Security: Validate path
|
|
337
|
+
if (filePath.includes('..')) {
|
|
338
|
+
return NextResponse.json(
|
|
339
|
+
{ error: 'Invalid path' },
|
|
340
|
+
{ status: 400 }
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Read the file
|
|
345
|
+
let content: string;
|
|
346
|
+
try {
|
|
347
|
+
content = await fs.readFile(filePath, 'utf-8');
|
|
348
|
+
} catch {
|
|
349
|
+
return NextResponse.json(
|
|
350
|
+
{ error: 'File not found' },
|
|
351
|
+
{ status: 404 }
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Find and update the task checkbox
|
|
356
|
+
// Task format: - [ ] Task title or - [x] Task title
|
|
357
|
+
const escapedTitle = taskTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
358
|
+
const taskRegex = new RegExp(
|
|
359
|
+
`^(\\s*[-*]\\s*)\\[([ xX])\\](\\s*(?:\\[[^\\]]+\\]\\s*)?)${escapedTitle}`,
|
|
360
|
+
'gm'
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
let found = false;
|
|
364
|
+
const newContent = content.replace(taskRegex, (match, prefix, _checkbox, priorityAndSpace) => {
|
|
365
|
+
found = true;
|
|
366
|
+
const newCheckbox = completed ? 'x' : ' ';
|
|
367
|
+
return `${prefix}[${newCheckbox}]${priorityAndSpace}${taskTitle}`;
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (!found) {
|
|
371
|
+
// Try a more lenient match (just the title anywhere in a checkbox line)
|
|
372
|
+
const lenientRegex = new RegExp(
|
|
373
|
+
`^(\\s*[-*]\\s*)\\[([ xX])\\](\\s*.*)${escapedTitle}(.*)$`,
|
|
374
|
+
'gm'
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const newContentLenient = content.replace(lenientRegex, (match, prefix, _checkbox, beforeTitle, afterTitle) => {
|
|
378
|
+
found = true;
|
|
379
|
+
const newCheckbox = completed ? 'x' : ' ';
|
|
380
|
+
return `${prefix}[${newCheckbox}]${beforeTitle}${taskTitle}${afterTitle}`;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
if (found) {
|
|
384
|
+
await fs.writeFile(filePath, newContentLenient, 'utf-8');
|
|
385
|
+
return NextResponse.json({ success: true, updated: true });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return NextResponse.json(
|
|
389
|
+
{ error: 'Task not found in file' },
|
|
390
|
+
{ status: 404 }
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Write the updated content
|
|
395
|
+
await fs.writeFile(filePath, newContent, 'utf-8');
|
|
396
|
+
|
|
397
|
+
return NextResponse.json({ success: true, updated: true });
|
|
398
|
+
} catch (error) {
|
|
399
|
+
console.error('Error updating task:', error);
|
|
400
|
+
return NextResponse.json(
|
|
401
|
+
{ error: 'Failed to update task' },
|
|
402
|
+
{ status: 500 }
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { ptyManager } from '@/lib/ptyManager';
|
|
3
|
+
|
|
4
|
+
// POST - Create session or write data
|
|
5
|
+
export async function POST(request: NextRequest) {
|
|
6
|
+
try {
|
|
7
|
+
const body = await request.json();
|
|
8
|
+
const { action, sessionId, data, cwd, cols, rows } = body;
|
|
9
|
+
|
|
10
|
+
switch (action) {
|
|
11
|
+
case 'create': {
|
|
12
|
+
if (!sessionId || !cwd) {
|
|
13
|
+
return NextResponse.json(
|
|
14
|
+
{ error: 'sessionId and cwd are required' },
|
|
15
|
+
{ status: 400 }
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Check if session already exists
|
|
20
|
+
const existing = ptyManager.getSession(sessionId);
|
|
21
|
+
if (existing) {
|
|
22
|
+
return NextResponse.json({
|
|
23
|
+
success: true,
|
|
24
|
+
sessionId,
|
|
25
|
+
message: 'Session already exists'
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
ptyManager.createSession(sessionId, cwd, cols || 80, rows || 24);
|
|
30
|
+
return NextResponse.json({
|
|
31
|
+
success: true,
|
|
32
|
+
sessionId,
|
|
33
|
+
message: 'Session created'
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
case 'write': {
|
|
38
|
+
if (!sessionId || data === undefined) {
|
|
39
|
+
return NextResponse.json(
|
|
40
|
+
{ error: 'sessionId and data are required' },
|
|
41
|
+
{ status: 400 }
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const success = ptyManager.write(sessionId, data);
|
|
46
|
+
if (!success) {
|
|
47
|
+
return NextResponse.json(
|
|
48
|
+
{ error: 'Session not found' },
|
|
49
|
+
{ status: 404 }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return NextResponse.json({ success: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
case 'resize': {
|
|
57
|
+
if (!sessionId || !cols || !rows) {
|
|
58
|
+
return NextResponse.json(
|
|
59
|
+
{ error: 'sessionId, cols, and rows are required' },
|
|
60
|
+
{ status: 400 }
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const success = ptyManager.resize(sessionId, cols, rows);
|
|
65
|
+
if (!success) {
|
|
66
|
+
return NextResponse.json(
|
|
67
|
+
{ error: 'Session not found' },
|
|
68
|
+
{ status: 404 }
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return NextResponse.json({ success: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
case 'destroy': {
|
|
76
|
+
if (!sessionId) {
|
|
77
|
+
return NextResponse.json(
|
|
78
|
+
{ error: 'sessionId is required' },
|
|
79
|
+
{ status: 400 }
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ptyManager.destroySession(sessionId);
|
|
84
|
+
return NextResponse.json({ success: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
default:
|
|
88
|
+
return NextResponse.json(
|
|
89
|
+
{ error: 'Invalid action' },
|
|
90
|
+
{ status: 400 }
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error('Terminal API error:', error);
|
|
95
|
+
return NextResponse.json(
|
|
96
|
+
{ error: 'Internal server error' },
|
|
97
|
+
{ status: 500 }
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// GET - Stream output via SSE
|
|
103
|
+
export async function GET(request: NextRequest) {
|
|
104
|
+
const { searchParams } = new URL(request.url);
|
|
105
|
+
const sessionId = searchParams.get('sessionId');
|
|
106
|
+
|
|
107
|
+
if (!sessionId) {
|
|
108
|
+
return NextResponse.json(
|
|
109
|
+
{ error: 'sessionId is required' },
|
|
110
|
+
{ status: 400 }
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const session = ptyManager.getSession(sessionId);
|
|
115
|
+
if (!session) {
|
|
116
|
+
return NextResponse.json(
|
|
117
|
+
{ error: 'Session not found' },
|
|
118
|
+
{ status: 404 }
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Create SSE stream
|
|
123
|
+
const encoder = new TextEncoder();
|
|
124
|
+
const stream = new ReadableStream({
|
|
125
|
+
start(controller) {
|
|
126
|
+
// Send any buffered output first
|
|
127
|
+
const buffer = ptyManager.getOutputBuffer(sessionId);
|
|
128
|
+
if (buffer.length > 0) {
|
|
129
|
+
const initialData = buffer.join('');
|
|
130
|
+
controller.enqueue(
|
|
131
|
+
encoder.encode(`data: ${JSON.stringify({ type: 'data', data: initialData })}\n\n`)
|
|
132
|
+
);
|
|
133
|
+
ptyManager.clearOutputBuffer(sessionId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Listen for new data
|
|
137
|
+
const dataHandler = ({ sessionId: sid, data }: { sessionId: string; data: string }) => {
|
|
138
|
+
if (sid === sessionId) {
|
|
139
|
+
try {
|
|
140
|
+
controller.enqueue(
|
|
141
|
+
encoder.encode(`data: ${JSON.stringify({ type: 'data', data })}\n\n`)
|
|
142
|
+
);
|
|
143
|
+
} catch {
|
|
144
|
+
// Stream closed
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Listen for exit
|
|
150
|
+
const exitHandler = ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number }) => {
|
|
151
|
+
if (sid === sessionId) {
|
|
152
|
+
try {
|
|
153
|
+
controller.enqueue(
|
|
154
|
+
encoder.encode(`data: ${JSON.stringify({ type: 'exit', exitCode })}\n\n`)
|
|
155
|
+
);
|
|
156
|
+
controller.close();
|
|
157
|
+
} catch {
|
|
158
|
+
// Stream already closed
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Listen for autopilot phase completion
|
|
164
|
+
const autopilotDoneHandler = ({ sessionId: sid, exitCode }: { sessionId: string; output: string; exitCode: number }) => {
|
|
165
|
+
if (sid === sessionId) {
|
|
166
|
+
try {
|
|
167
|
+
controller.enqueue(
|
|
168
|
+
encoder.encode(`data: ${JSON.stringify({ type: 'autopilot-phase-done', exitCode })}\n\n`)
|
|
169
|
+
);
|
|
170
|
+
} catch {
|
|
171
|
+
// Stream closed
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
ptyManager.on('data', dataHandler);
|
|
177
|
+
ptyManager.on('exit', exitHandler);
|
|
178
|
+
ptyManager.on('autopilot-phase-done', autopilotDoneHandler);
|
|
179
|
+
|
|
180
|
+
// Heartbeat to keep connection alive
|
|
181
|
+
const heartbeat = setInterval(() => {
|
|
182
|
+
try {
|
|
183
|
+
controller.enqueue(encoder.encode(': heartbeat\n\n'));
|
|
184
|
+
} catch {
|
|
185
|
+
clearInterval(heartbeat);
|
|
186
|
+
}
|
|
187
|
+
}, 30000);
|
|
188
|
+
|
|
189
|
+
// Cleanup on close
|
|
190
|
+
request.signal.addEventListener('abort', () => {
|
|
191
|
+
ptyManager.off('data', dataHandler);
|
|
192
|
+
ptyManager.off('exit', exitHandler);
|
|
193
|
+
ptyManager.off('autopilot-phase-done', autopilotDoneHandler);
|
|
194
|
+
clearInterval(heartbeat);
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return new Response(stream, {
|
|
200
|
+
headers: {
|
|
201
|
+
'Content-Type': 'text/event-stream',
|
|
202
|
+
'Cache-Control': 'no-cache',
|
|
203
|
+
'Connection': 'keep-alive',
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// DELETE - Destroy session
|
|
209
|
+
export async function DELETE(request: NextRequest) {
|
|
210
|
+
const { searchParams } = new URL(request.url);
|
|
211
|
+
const sessionId = searchParams.get('sessionId');
|
|
212
|
+
|
|
213
|
+
if (!sessionId) {
|
|
214
|
+
return NextResponse.json(
|
|
215
|
+
{ error: 'sessionId is required' },
|
|
216
|
+
{ status: 400 }
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
ptyManager.destroySession(sessionId);
|
|
221
|
+
return NextResponse.json({ success: true });
|
|
222
|
+
}
|