groove-dev 0.16.0 → 0.16.1

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.
@@ -4,7 +4,8 @@
4
4
  import express from 'express';
5
5
  import { resolve, dirname } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
- import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
7
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync, renameSync, rmSync, createReadStream } from 'fs';
8
+ import { lookup as mimeLookup } from './mimetypes.js';
8
9
  import { listProviders } from './providers/index.js';
9
10
  import { validateAgentConfig } from './validate.js';
10
11
 
@@ -648,6 +649,126 @@ export function createApi(app, daemon) {
648
649
  }
649
650
  });
650
651
 
652
+ // Create a new file
653
+ app.post('/api/files/create', (req, res) => {
654
+ const { path: relPath, content = '' } = req.body;
655
+ const result = validateFilePath(relPath, daemon.projectDir);
656
+ if (result.error) return res.status(400).json({ error: result.error });
657
+
658
+ if (existsSync(result.fullPath)) {
659
+ return res.status(409).json({ error: 'File already exists' });
660
+ }
661
+
662
+ try {
663
+ // Ensure parent directory exists
664
+ const parentDir = resolve(result.fullPath, '..');
665
+ if (!parentDir.startsWith(daemon.projectDir)) {
666
+ return res.status(400).json({ error: 'Path outside project' });
667
+ }
668
+ mkdirSync(parentDir, { recursive: true });
669
+ writeFileSync(result.fullPath, content, 'utf8');
670
+ daemon.audit.log('file.create', { path: relPath });
671
+ res.status(201).json({ ok: true, path: relPath });
672
+ } catch (err) {
673
+ res.status(500).json({ error: err.message });
674
+ }
675
+ });
676
+
677
+ // Create a new directory
678
+ app.post('/api/files/mkdir', (req, res) => {
679
+ const { path: relPath } = req.body;
680
+ const result = validateFilePath(relPath, daemon.projectDir);
681
+ if (result.error) return res.status(400).json({ error: result.error });
682
+
683
+ if (existsSync(result.fullPath)) {
684
+ return res.status(409).json({ error: 'Directory already exists' });
685
+ }
686
+
687
+ try {
688
+ mkdirSync(result.fullPath, { recursive: true });
689
+ daemon.audit.log('file.mkdir', { path: relPath });
690
+ res.status(201).json({ ok: true, path: relPath });
691
+ } catch (err) {
692
+ res.status(500).json({ error: err.message });
693
+ }
694
+ });
695
+
696
+ // Delete a file or directory
697
+ app.delete('/api/files/delete', (req, res) => {
698
+ const relPath = req.query.path || req.body?.path;
699
+ const result = validateFilePath(relPath, daemon.projectDir);
700
+ if (result.error) return res.status(400).json({ error: result.error });
701
+
702
+ if (!existsSync(result.fullPath)) {
703
+ return res.status(404).json({ error: 'Not found' });
704
+ }
705
+
706
+ try {
707
+ const stat = statSync(result.fullPath);
708
+ if (stat.isDirectory()) {
709
+ rmSync(result.fullPath, { recursive: true });
710
+ } else {
711
+ unlinkSync(result.fullPath);
712
+ }
713
+ daemon.audit.log('file.delete', { path: relPath });
714
+ res.json({ ok: true });
715
+ } catch (err) {
716
+ res.status(500).json({ error: err.message });
717
+ }
718
+ });
719
+
720
+ // Rename / move a file or directory
721
+ app.post('/api/files/rename', (req, res) => {
722
+ const { oldPath, newPath } = req.body;
723
+ const oldResult = validateFilePath(oldPath, daemon.projectDir);
724
+ if (oldResult.error) return res.status(400).json({ error: oldResult.error });
725
+ const newResult = validateFilePath(newPath, daemon.projectDir);
726
+ if (newResult.error) return res.status(400).json({ error: newResult.error });
727
+
728
+ if (!existsSync(oldResult.fullPath)) {
729
+ return res.status(404).json({ error: 'Source not found' });
730
+ }
731
+ if (existsSync(newResult.fullPath)) {
732
+ return res.status(409).json({ error: 'Destination already exists' });
733
+ }
734
+
735
+ try {
736
+ // Ensure parent of new path exists
737
+ const parentDir = resolve(newResult.fullPath, '..');
738
+ mkdirSync(parentDir, { recursive: true });
739
+ renameSync(oldResult.fullPath, newResult.fullPath);
740
+ daemon.audit.log('file.rename', { oldPath, newPath });
741
+ res.json({ ok: true, oldPath, newPath });
742
+ } catch (err) {
743
+ res.status(500).json({ error: err.message });
744
+ }
745
+ });
746
+
747
+ // Serve raw file (images, video, etc.)
748
+ app.get('/api/files/raw', (req, res) => {
749
+ const result = validateFilePath(req.query.path, daemon.projectDir);
750
+ if (result.error) return res.status(400).json({ error: result.error });
751
+
752
+ if (!existsSync(result.fullPath)) {
753
+ return res.status(404).json({ error: 'File not found' });
754
+ }
755
+
756
+ try {
757
+ const stat = statSync(result.fullPath);
758
+ if (stat.size > 50 * 1024 * 1024) {
759
+ return res.status(400).json({ error: 'File too large (>50MB)' });
760
+ }
761
+ const filename = req.query.path.split('/').pop();
762
+ const contentType = mimeLookup(filename);
763
+ res.setHeader('Content-Type', contentType);
764
+ res.setHeader('Content-Length', stat.size);
765
+ res.setHeader('Cache-Control', 'no-cache');
766
+ createReadStream(result.fullPath).pipe(res);
767
+ } catch (err) {
768
+ res.status(500).json({ error: err.message });
769
+ }
770
+ });
771
+
651
772
  // --- Codebase Indexer ---
652
773
 
653
774
  app.get('/api/indexer', (req, res) => {
@@ -0,0 +1,43 @@
1
+ // GROOVE — Lightweight MIME type lookup (no deps)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ const TYPES = {
5
+ // Images
6
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
7
+ svg: 'image/svg+xml', webp: 'image/webp', ico: 'image/x-icon', bmp: 'image/bmp',
8
+ avif: 'image/avif',
9
+ // Video
10
+ mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', avi: 'video/x-msvideo',
11
+ mkv: 'video/x-matroska', ogv: 'video/ogg',
12
+ // Audio
13
+ mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', flac: 'audio/flac',
14
+ // Text/code
15
+ js: 'text/javascript', mjs: 'text/javascript', jsx: 'text/javascript',
16
+ ts: 'text/typescript', tsx: 'text/typescript',
17
+ css: 'text/css', html: 'text/html', json: 'application/json',
18
+ md: 'text/markdown', txt: 'text/plain', xml: 'application/xml',
19
+ yaml: 'text/yaml', yml: 'text/yaml', toml: 'text/plain',
20
+ py: 'text/x-python', rs: 'text/x-rust', go: 'text/x-go',
21
+ sh: 'text/x-sh', sql: 'text/x-sql',
22
+ // Other
23
+ pdf: 'application/pdf', zip: 'application/zip', wasm: 'application/wasm',
24
+ };
25
+
26
+ export function lookup(filename) {
27
+ const ext = filename.split('.').pop()?.toLowerCase();
28
+ return TYPES[ext] || 'application/octet-stream';
29
+ }
30
+
31
+ export function isImage(filename) {
32
+ const ext = filename.split('.').pop()?.toLowerCase();
33
+ return ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'].includes(ext);
34
+ }
35
+
36
+ export function isVideo(filename) {
37
+ const ext = filename.split('.').pop()?.toLowerCase();
38
+ return ['mp4', 'webm', 'mov', 'avi', 'mkv', 'ogv'].includes(ext);
39
+ }
40
+
41
+ export function isMedia(filename) {
42
+ return isImage(filename) || isVideo(filename);
43
+ }