@bamptee/aia-code 2.0.0 → 2.0.2

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.
@@ -0,0 +1,197 @@
1
+ import { execSync } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import fs from 'fs-extra';
5
+
6
+ // F1: Validate branch name to prevent command injection
7
+ function validateBranchName(branch) {
8
+ // Allow only alphanumeric, dash, underscore, slash
9
+ if (!/^[a-zA-Z0-9_\-/]+$/.test(branch)) {
10
+ throw new Error(`Invalid branch name: "${branch}". Only alphanumeric characters, dashes, underscores, and slashes are allowed.`);
11
+ }
12
+ return branch;
13
+ }
14
+
15
+ // Find wt binary - check common locations
16
+ let wtBinary = null;
17
+
18
+ function findWtBinary() {
19
+ if (wtBinary !== null) return wtBinary;
20
+
21
+ const home = os.homedir();
22
+ const candidates = [
23
+ 'wt', // in PATH
24
+ path.join(home, '.cargo', 'bin', 'wt'),
25
+ '/usr/local/bin/wt',
26
+ '/opt/homebrew/bin/wt',
27
+ ];
28
+
29
+ for (const candidate of candidates) {
30
+ try {
31
+ execSync(`${candidate} --version`, { stdio: 'ignore' });
32
+ wtBinary = candidate;
33
+ return wtBinary;
34
+ } catch {
35
+ // Try next
36
+ }
37
+ }
38
+
39
+ wtBinary = false;
40
+ return wtBinary;
41
+ }
42
+
43
+ function getWtCommand() {
44
+ const wt = findWtBinary();
45
+ if (!wt) throw new Error('Worktrunk (wt) CLI not found');
46
+ return wt;
47
+ }
48
+
49
+ /**
50
+ * Check if worktrunk (wt) CLI is installed
51
+ */
52
+ export function isWtInstalled() {
53
+ return findWtBinary() !== false;
54
+ }
55
+
56
+ /**
57
+ * List all worktrees in a repository
58
+ * @param {string} cwd - Repository root directory
59
+ * @returns {Array} List of worktree objects
60
+ */
61
+ export function listWorktrees(cwd) {
62
+ try {
63
+ const wt = getWtCommand();
64
+ const output = execSync(`${wt} list --format=json`, { cwd, encoding: 'utf-8' });
65
+ return JSON.parse(output);
66
+ } catch {
67
+ return [];
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Check if a git branch exists
73
+ * @param {string} branch - Branch name
74
+ * @param {string} cwd - Repository root directory
75
+ * @returns {boolean}
76
+ */
77
+ function branchExists(branch, cwd) {
78
+ try {
79
+ execSync(`git show-ref --verify --quiet refs/heads/${branch}`, { cwd, stdio: 'ignore' });
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Create a worktree for a branch
88
+ * @param {string} branch - Branch name (e.g., 'feature/my-feature')
89
+ * @param {string} cwd - Repository root directory
90
+ */
91
+ export function createWorktree(branch, cwd) {
92
+ validateBranchName(branch);
93
+ const wt = getWtCommand();
94
+
95
+ // --no-cd: Don't try to change directory (we're in Node.js, not a shell)
96
+ // --yes: Skip approval prompts
97
+ // If branch already exists, switch to it (creates worktree)
98
+ // If branch doesn't exist, create it with -c
99
+ if (branchExists(branch, cwd)) {
100
+ execSync(`${wt} switch --no-cd --yes ${branch}`, { cwd, stdio: 'inherit' });
101
+ } else {
102
+ execSync(`${wt} switch --no-cd --yes -c ${branch}`, { cwd, stdio: 'inherit' });
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Get the path of a worktree for a branch
108
+ * @param {string} branch - Branch name
109
+ * @param {string} cwd - Repository root directory
110
+ * @returns {string|null} Worktree path or null if not found
111
+ */
112
+ export function getWorktreePath(branch, cwd) {
113
+ const list = listWorktrees(cwd);
114
+ const wt = list.find(w => w.branch === branch || w.branch === `refs/heads/${branch}`);
115
+ return wt?.path || null;
116
+ }
117
+
118
+ /**
119
+ * Check if a worktree exists for a branch
120
+ * @param {string} branch - Branch name
121
+ * @param {string} cwd - Repository root directory
122
+ * @returns {boolean}
123
+ */
124
+ export function hasWorktree(branch, cwd) {
125
+ return getWorktreePath(branch, cwd) !== null;
126
+ }
127
+
128
+ /**
129
+ * Remove a worktree
130
+ * @param {string} branch - Branch name
131
+ * @param {string} cwd - Repository root directory
132
+ */
133
+ export function removeWorktree(branch, cwd) {
134
+ validateBranchName(branch);
135
+ const wt = getWtCommand();
136
+ execSync(`${wt} remove ${branch}`, { cwd, stdio: 'inherit' });
137
+ }
138
+
139
+ /**
140
+ * Get the feature branch name from a feature name
141
+ * @param {string} featureName - Feature name
142
+ * @returns {string} Branch name (e.g., 'feature/my-feature')
143
+ */
144
+ export function getFeatureBranch(featureName) {
145
+ return `feature/${featureName}`;
146
+ }
147
+
148
+ /**
149
+ * Start docker-compose services in a worktree
150
+ * @param {string} wtPath - Worktree directory path
151
+ */
152
+ export function startServices(wtPath) {
153
+ const composePath = path.join(wtPath, 'docker-compose.wt.yml');
154
+ if (fs.existsSync(composePath)) {
155
+ execSync('docker-compose -f docker-compose.wt.yml up -d', { cwd: wtPath, stdio: 'inherit' });
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Stop docker-compose services in a worktree
161
+ * @param {string} wtPath - Worktree directory path
162
+ */
163
+ export function stopServices(wtPath) {
164
+ const composePath = path.join(wtPath, 'docker-compose.wt.yml');
165
+ if (fs.existsSync(composePath)) {
166
+ execSync('docker-compose -f docker-compose.wt.yml down', { cwd: wtPath, stdio: 'inherit' });
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Check if docker-compose.wt.yml exists in a worktree
172
+ * @param {string} wtPath - Worktree directory path
173
+ * @returns {boolean}
174
+ */
175
+ export function hasDockerServices(wtPath) {
176
+ if (!wtPath) return false;
177
+ const composePath = path.join(wtPath, 'docker-compose.wt.yml');
178
+ return fs.existsSync(composePath);
179
+ }
180
+
181
+ /**
182
+ * Get docker services status
183
+ * @param {string} wtPath - Worktree directory path
184
+ * @returns {Array} List of service objects with name and status
185
+ */
186
+ export function getServicesStatus(wtPath) {
187
+ if (!hasDockerServices(wtPath)) return [];
188
+ try {
189
+ const output = execSync('docker-compose -f docker-compose.wt.yml ps --format json', {
190
+ cwd: wtPath,
191
+ encoding: 'utf-8',
192
+ });
193
+ return JSON.parse(output);
194
+ } catch {
195
+ return [];
196
+ }
197
+ }
@@ -2,10 +2,11 @@ import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import yaml from 'yaml';
4
4
  import { AIA_DIR } from '../../constants.js';
5
+ import { loadGlobalConfig, saveGlobalConfig, getGlobalConfigPath } from '../../services/config.js';
5
6
  import { json, error } from '../router.js';
6
7
 
7
8
  export function registerConfigRoutes(router) {
8
- // Get config
9
+ // Get project config
9
10
  router.get('/api/config', async (req, res, { root }) => {
10
11
  const configPath = path.join(root, AIA_DIR, 'config.yaml');
11
12
  if (!(await fs.pathExists(configPath))) {
@@ -15,7 +16,7 @@ export function registerConfigRoutes(router) {
15
16
  json(res, { content, parsed: yaml.parse(content) });
16
17
  });
17
18
 
18
- // Save config
19
+ // Save project config
19
20
  router.put('/api/config', async (req, res, { root, parseBody }) => {
20
21
  const body = await parseBody();
21
22
  const configPath = path.join(root, AIA_DIR, 'config.yaml');
@@ -23,6 +24,58 @@ export function registerConfigRoutes(router) {
23
24
  json(res, { ok: true });
24
25
  });
25
26
 
27
+ // Get global user config
28
+ router.get('/api/user-config', async (req, res) => {
29
+ try {
30
+ const config = await loadGlobalConfig();
31
+ const configPath = getGlobalConfigPath();
32
+ json(res, { parsed: config, path: configPath });
33
+ } catch (e) {
34
+ error(res, e.message, 500);
35
+ }
36
+ });
37
+
38
+ // Update global user preferences
39
+ router.patch('/api/user-config', async (req, res, { parseBody }) => {
40
+ const body = await parseBody();
41
+
42
+ try {
43
+ const config = await loadGlobalConfig();
44
+
45
+ // Update only user preference fields
46
+ if (body.user_name !== undefined) config.user_name = body.user_name;
47
+ if (body.communication_language !== undefined) config.communication_language = body.communication_language;
48
+
49
+ await saveGlobalConfig(config);
50
+
51
+ json(res, { ok: true, config });
52
+ } catch (e) {
53
+ error(res, e.message, 500);
54
+ }
55
+ });
56
+
57
+ // Update project preferences (partial update)
58
+ router.patch('/api/config/project', async (req, res, { root, parseBody }) => {
59
+ const body = await parseBody();
60
+ const configPath = path.join(root, AIA_DIR, 'config.yaml');
61
+
62
+ if (!(await fs.pathExists(configPath))) {
63
+ return error(res, 'config.yaml not found', 404);
64
+ }
65
+
66
+ const content = await fs.readFile(configPath, 'utf-8');
67
+ const config = yaml.parse(content);
68
+
69
+ // Update project preference fields
70
+ if (body.projectName !== undefined) config.projectName = body.projectName;
71
+ if (body.document_output_language !== undefined) config.document_output_language = body.document_output_language;
72
+
73
+ const newContent = yaml.stringify(config);
74
+ await fs.writeFile(configPath, newContent, 'utf-8');
75
+
76
+ json(res, { ok: true, config });
77
+ });
78
+
26
79
  // List context files
27
80
  router.get('/api/context', async (req, res, { root }) => {
28
81
  const dir = path.join(root, AIA_DIR, 'context');
@@ -7,13 +7,19 @@ export function registerConstantsRoutes(router) {
7
7
  json(res, { FEATURE_STEPS, STEP_STATUS, QUICK_STEPS });
8
8
  });
9
9
 
10
- // List available models per step from config
10
+ // List all unique models from config
11
11
  router.get('/api/models', async (req, res, { root }) => {
12
12
  try {
13
13
  const config = await loadConfig(root);
14
- json(res, config.models || {});
14
+ const all = new Set();
15
+ for (const models of Object.values(config.models || {})) {
16
+ for (const entry of models) {
17
+ all.add(entry.model);
18
+ }
19
+ }
20
+ json(res, [...all]);
15
21
  } catch (err) {
16
- json(res, {});
22
+ json(res, []);
17
23
  }
18
24
  });
19
25
  }
@@ -1,11 +1,19 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
+ import Busboy from 'busboy';
3
4
  import { AIA_DIR } from '../../constants.js';
4
- import { loadStatus, updateStepStatus, resetStep } from '../../services/status.js';
5
+ import { loadStatus, updateStepStatus, resetStep, updateFlowType } from '../../services/status.js';
5
6
  import { createFeature, validateFeatureName } from '../../services/feature.js';
6
7
  import { runStep } from '../../services/runner.js';
7
8
  import { runQuick } from '../../services/quick.js';
9
+ import { suggestFlowType } from '../../services/flow-analyzer.js';
10
+ import { getGuidance } from '../../services/suggestions.js';
11
+ import { callModel } from '../../services/model-call.js';
12
+ import { loadConfig } from '../../models.js';
8
13
  import { json, error } from '../router.js';
14
+ import { isWtInstalled, hasWorktree, getFeatureBranch } from '../../services/worktrunk.js';
15
+
16
+ const MAX_DESCRIPTION_LENGTH = 50000; // 50KB
9
17
 
10
18
  function sseHeaders(res) {
11
19
  res.writeHead(200, {
@@ -20,6 +28,27 @@ function sseSend(res, event, data) {
20
28
  res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
21
29
  }
22
30
 
31
+ // F6: Extracted validation helpers to avoid code duplication
32
+ function validateHistory(rawHistory) {
33
+ return Array.isArray(rawHistory)
34
+ ? rawHistory
35
+ .filter(msg => msg && typeof msg === 'object' && typeof msg.content === 'string')
36
+ .map(msg => ({
37
+ role: msg.role === 'agent' ? 'agent' : 'user',
38
+ content: String(msg.content).slice(0, 50000),
39
+ }))
40
+ .slice(-20)
41
+ : [];
42
+ }
43
+
44
+ function validateAttachments(rawAttachments) {
45
+ return Array.isArray(rawAttachments)
46
+ ? rawAttachments
47
+ .filter(a => a && typeof a === 'object' && typeof a.path === 'string')
48
+ .map(a => ({ filename: a.filename || 'unknown', path: a.path }))
49
+ : [];
50
+ }
51
+
23
52
  export function registerFeatureRoutes(router) {
24
53
  // List all features
25
54
  router.get('/api/features', async (req, res, { root }) => {
@@ -29,13 +58,15 @@ export function registerFeatureRoutes(router) {
29
58
  }
30
59
  const entries = await fs.readdir(featuresDir, { withFileTypes: true });
31
60
  const features = [];
61
+ const wtInstalled = isWtInstalled();
32
62
  for (const entry of entries) {
33
63
  if (entry.isDirectory()) {
34
64
  try {
35
65
  const status = await loadStatus(entry.name, root);
36
- features.push({ name: entry.name, ...status });
66
+ const hasWt = wtInstalled && hasWorktree(getFeatureBranch(entry.name), root);
67
+ features.push({ name: entry.name, ...status, hasWorktree: hasWt });
37
68
  } catch {
38
- features.push({ name: entry.name, error: true });
69
+ features.push({ name: entry.name, error: true, hasWorktree: false });
39
70
  }
40
71
  }
41
72
  }
@@ -95,10 +126,15 @@ export function registerFeatureRoutes(router) {
95
126
  };
96
127
 
97
128
  try {
129
+ const validatedHistory = validateHistory(body.history || []);
130
+ const validatedAttachments = validateAttachments(body.attachments || []);
131
+
98
132
  const output = await runStep(params.step, params.name, {
99
133
  description: body.description,
134
+ history: validatedHistory,
135
+ attachments: validatedAttachments,
100
136
  model: body.model || undefined,
101
- verbose: true,
137
+ verbose: body.verbose !== undefined ? body.verbose : true,
102
138
  apply: body.apply || false,
103
139
  root,
104
140
  onData,
@@ -171,10 +207,15 @@ export function registerFeatureRoutes(router) {
171
207
  try { sseSend(res, 'log', { type, text }); } catch {}
172
208
  };
173
209
 
174
- const output = await runStep(params.step, params.name, {
210
+ const validatedHistory = validateHistory(body.history || []);
211
+ const validatedAttachments = validateAttachments(body.attachments || []);
212
+
213
+ await runStep(params.step, params.name, {
175
214
  instructions: body.instructions,
215
+ history: validatedHistory,
216
+ attachments: validatedAttachments,
176
217
  model: body.model || undefined,
177
- verbose: true,
218
+ verbose: body.verbose !== undefined ? body.verbose : true,
178
219
  apply: body.apply || false,
179
220
  root,
180
221
  onData,
@@ -195,4 +236,258 @@ export function registerFeatureRoutes(router) {
195
236
  error(res, err.message, 400);
196
237
  }
197
238
  });
239
+
240
+ // Initialize feature with description, enrich with agent, and get flow suggestion
241
+ router.post('/api/features/:name/init', async (req, res, { params, root, parseBody }) => {
242
+ const body = await parseBody();
243
+ const { description } = body;
244
+
245
+ if (!description) {
246
+ return error(res, 'Description is required', 400);
247
+ }
248
+
249
+ if (description.length > MAX_DESCRIPTION_LENGTH) {
250
+ return error(res, `Description too long (${description.length} chars, max ${MAX_DESCRIPTION_LENGTH})`, 400);
251
+ }
252
+
253
+ sseHeaders(res);
254
+ sseSend(res, 'status', { status: 'enriching', message: 'Structuring your description...' });
255
+
256
+ const onData = ({ type, text }) => {
257
+ try { sseSend(res, 'log', { type, text }); } catch {}
258
+ };
259
+
260
+ try {
261
+ // Load config to get user preferences
262
+ const config = await loadConfig(root);
263
+
264
+ // Build enrichment prompt
265
+ const enrichPrompt = `You are helping structure a feature description for a development project.
266
+
267
+ USER INPUT:
268
+ ${description}
269
+
270
+ TASK:
271
+ Transform this input into a well-structured feature specification document in Markdown format.
272
+
273
+ OUTPUT FORMAT:
274
+ # ${params.name}
275
+
276
+ ## Summary
277
+ (1-2 sentence summary of what this feature does)
278
+
279
+ ## Problem Statement
280
+ (What problem does this solve? Why is it needed?)
281
+
282
+ ## Requirements
283
+ (List the key functional requirements as bullet points)
284
+
285
+ ## Constraints
286
+ (Any technical constraints, limitations, or considerations)
287
+
288
+ ## Success Criteria
289
+ (How do we know this feature is complete and working?)
290
+
291
+ IMPORTANT:
292
+ - Keep it concise but complete
293
+ - Don't add requirements that weren't mentioned or implied
294
+ - If the input is vague, make reasonable assumptions and note them
295
+ - Output ONLY the markdown document, no explanations
296
+ - Write the ENTIRE document in ${config.document_output_language || 'English'} regardless of the input language`;
297
+
298
+ // Call model to enrich (use brief model config or default)
299
+ const model = config.models?.brief?.[0]?.model || 'claude-default';
300
+
301
+ sseSend(res, 'status', { status: 'generating', message: 'AI is structuring the feature...' });
302
+
303
+ const enrichedContent = await callModel(model, enrichPrompt, {
304
+ verbose: false,
305
+ apply: false,
306
+ onData
307
+ });
308
+
309
+ // Save enriched content to init.md
310
+ const initPath = path.join(root, AIA_DIR, 'features', params.name, 'init.md');
311
+ await fs.writeFile(initPath, enrichedContent.trim(), 'utf-8');
312
+
313
+ // Analyze and suggest flow type based on enriched content
314
+ const suggestion = suggestFlowType(enrichedContent);
315
+
316
+ sseSend(res, 'done', { ok: true, suggestion, content: enrichedContent.trim() });
317
+ } catch (err) {
318
+ sseSend(res, 'error', { message: err.message });
319
+ }
320
+ res.end();
321
+ });
322
+
323
+ // Get guidance for a step
324
+ router.get('/api/features/:name/guidance/:step', async (req, res, { params, root }) => {
325
+ const config = await loadConfig(root);
326
+ const language = config.communication_language || 'English';
327
+ const guidance = getGuidance(params.step, language);
328
+ if (!guidance) {
329
+ return error(res, `No guidance for step "${params.step}"`, 404);
330
+ }
331
+ json(res, guidance);
332
+ });
333
+
334
+ // Upload attachments (multipart/form-data)
335
+ router.post('/api/features/:name/attachments', async (req, res, { params, root }) => {
336
+ const attachDir = path.join(root, AIA_DIR, 'features', params.name, 'attachments');
337
+ await fs.ensureDir(attachDir);
338
+
339
+ const busboy = Busboy({
340
+ headers: req.headers,
341
+ limits: { fileSize: 10 * 1024 * 1024 }, // 10MB per file
342
+ });
343
+
344
+ const files = [];
345
+ const writtenFiles = []; // F12: Track all written files for cleanup
346
+ let totalSize = 0;
347
+ const maxTotalSize = 50 * 1024 * 1024; // 50MB total
348
+ let hasError = false;
349
+
350
+ // F12: Cleanup function to remove all written files on error
351
+ const cleanupFiles = async () => {
352
+ for (const filepath of writtenFiles) {
353
+ await fs.unlink(filepath).catch(() => {});
354
+ }
355
+ };
356
+
357
+ busboy.on('file', (fieldname, file, info) => {
358
+ const { filename, mimeType } = info;
359
+
360
+ // Validate mime type
361
+ const allowedTypes = ['image/', 'application/pdf', 'text/'];
362
+ const isAllowed = allowedTypes.some(t => mimeType.startsWith(t));
363
+ if (!isAllowed) {
364
+ file.resume(); // Drain the stream
365
+ return;
366
+ }
367
+
368
+ const safeFilename = `${Date.now()}-${filename.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
369
+ const filepath = path.join(attachDir, safeFilename);
370
+ const writeStream = fs.createWriteStream(filepath);
371
+ writtenFiles.push(filepath); // F12: Track for cleanup
372
+
373
+ // F7: Handle writeStream errors
374
+ writeStream.on('error', async (err) => {
375
+ hasError = true;
376
+ file.resume(); // Stop reading
377
+ await cleanupFiles();
378
+ if (!res.headersSent) {
379
+ error(res, `Write error: ${err.message}`, 500);
380
+ }
381
+ });
382
+
383
+ let fileSize = 0;
384
+ file.on('data', (data) => {
385
+ fileSize += data.length;
386
+ totalSize += data.length;
387
+
388
+ // F3: Check total size during upload, not after
389
+ if (totalSize > maxTotalSize) {
390
+ hasError = true;
391
+ file.resume(); // Stop reading this file
392
+ }
393
+ });
394
+
395
+ file.pipe(writeStream);
396
+
397
+ file.on('end', () => {
398
+ if (!hasError && fileSize > 0) {
399
+ files.push({
400
+ filename: safeFilename,
401
+ originalName: filename,
402
+ path: filepath,
403
+ mimeType,
404
+ size: fileSize,
405
+ });
406
+ }
407
+ });
408
+
409
+ file.on('limit', () => {
410
+ fs.unlink(filepath).catch(() => {});
411
+ // Remove from writtenFiles since we're handling it here
412
+ const idx = writtenFiles.indexOf(filepath);
413
+ if (idx > -1) writtenFiles.splice(idx, 1);
414
+ });
415
+ });
416
+
417
+ busboy.on('finish', async () => {
418
+ if (hasError) {
419
+ // F12: Clean up all files if we hit an error
420
+ await cleanupFiles();
421
+ if (!res.headersSent) {
422
+ error(res, `Total size exceeds ${maxTotalSize / 1024 / 1024}MB limit`, 413);
423
+ }
424
+ return;
425
+ }
426
+ json(res, { ok: true, files });
427
+ });
428
+
429
+ busboy.on('error', async (err) => {
430
+ await cleanupFiles();
431
+ if (!res.headersSent) {
432
+ error(res, err.message, 500);
433
+ }
434
+ });
435
+
436
+ req.pipe(busboy);
437
+ });
438
+
439
+ // List attachments
440
+ router.get('/api/features/:name/attachments', async (req, res, { params, root }) => {
441
+ const attachDir = path.join(root, AIA_DIR, 'features', params.name, 'attachments');
442
+ if (!(await fs.pathExists(attachDir))) {
443
+ return json(res, []);
444
+ }
445
+
446
+ const entries = await fs.readdir(attachDir);
447
+ const files = await Promise.all(
448
+ entries.map(async (filename) => {
449
+ const filepath = path.join(attachDir, filename);
450
+ const stat = await fs.stat(filepath);
451
+ return {
452
+ filename,
453
+ path: filepath,
454
+ size: stat.size,
455
+ };
456
+ })
457
+ );
458
+ json(res, files);
459
+ });
460
+
461
+ // Delete an attachment
462
+ router.delete('/api/features/:name/attachments/:filename', async (req, res, { params, root }) => {
463
+ // F1: Prevent path traversal attacks
464
+ const filename = path.basename(params.filename);
465
+ if (filename !== params.filename || filename.includes('..')) {
466
+ return error(res, 'Invalid filename', 400);
467
+ }
468
+
469
+ const filepath = path.join(root, AIA_DIR, 'features', params.name, 'attachments', filename);
470
+ if (!(await fs.pathExists(filepath))) {
471
+ return error(res, 'Attachment not found', 404);
472
+ }
473
+ await fs.remove(filepath);
474
+ json(res, { ok: true });
475
+ });
476
+
477
+ // Update flow type (quick/full)
478
+ router.patch('/api/features/:name/flow', async (req, res, { params, root, parseBody }) => {
479
+ const body = await parseBody();
480
+ const { flow } = body;
481
+
482
+ if (!flow || !['quick', 'full'].includes(flow)) {
483
+ return error(res, 'Invalid flow type. Must be "quick" or "full"', 400);
484
+ }
485
+
486
+ try {
487
+ await updateFlowType(params.name, flow, root);
488
+ json(res, { ok: true, flow });
489
+ } catch (err) {
490
+ error(res, err.message, 400);
491
+ }
492
+ });
198
493
  }
@@ -2,10 +2,12 @@ import { registerFeatureRoutes } from './features.js';
2
2
  import { registerConfigRoutes } from './config.js';
3
3
  import { registerLogRoutes } from './logs.js';
4
4
  import { registerConstantsRoutes } from './constants.js';
5
+ import { registerWorktrunkRoutes } from './worktrunk.js';
5
6
 
6
7
  export function registerApiRoutes(router, root) {
7
8
  registerFeatureRoutes(router);
8
9
  registerConfigRoutes(router);
9
10
  registerLogRoutes(router);
10
11
  registerConstantsRoutes(router);
12
+ registerWorktrunkRoutes(router);
11
13
  }