Skip to content

Commit 2b1a3b4

Browse files
committed
feat(dashboard): add code editor for raw JSON settings editing
- Add GET/PUT /api/file endpoints for generic file access with security validation - Add GET /api/files endpoint to list editable JSON files in ~/.ccs/ - Create CodeEditor component with JSON syntax highlighting (prism-react-renderer) - Add "Raw JSON" tab to SettingsDialog for direct JSON editing - Support conflict detection, atomic writes, and automatic backups - Lazy load editor to minimize initial bundle impact (~31KB gzipped) Closes #73
1 parent f83051b commit 2b1a3b4

File tree

5 files changed

+452
-12
lines changed

5 files changed

+452
-12
lines changed

src/web-server/routes.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,3 +779,190 @@ apiRoutes.get('/secrets/:profile/exists', (req: Request, res: Response) => {
779779
keys: Object.keys(secrets), // Only key names, not values
780780
});
781781
});
782+
783+
// ==================== Generic File API (Issue #73) ====================
784+
785+
/**
786+
* Security: Validate file path is within allowed directories
787+
* - ~/.ccs/ directory: read/write allowed
788+
* - ~/.claude/settings.json: read-only
789+
*/
790+
function validateFilePath(filePath: string): { valid: boolean; readonly: boolean; error?: string } {
791+
const expandedPath = expandPath(filePath);
792+
const normalizedPath = path.normalize(expandedPath);
793+
const ccsDir = getCcsDir();
794+
const claudeSettingsPath = expandPath('~/.claude/settings.json');
795+
796+
// Check if path is within ~/.ccs/
797+
if (normalizedPath.startsWith(ccsDir)) {
798+
// Block access to sensitive subdirectories
799+
const relativePath = normalizedPath.slice(ccsDir.length);
800+
if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) {
801+
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
802+
}
803+
return { valid: true, readonly: false };
804+
}
805+
806+
// Allow read-only access to ~/.claude/settings.json
807+
if (normalizedPath === claudeSettingsPath) {
808+
return { valid: true, readonly: true };
809+
}
810+
811+
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
812+
}
813+
814+
/**
815+
* GET /api/file - Read a file with path validation
816+
* Query params: path (required)
817+
* Returns: { content: string, mtime: number, readonly: boolean, path: string }
818+
*/
819+
apiRoutes.get('/file', (req: Request, res: Response): void => {
820+
const filePath = req.query.path as string;
821+
822+
if (!filePath) {
823+
res.status(400).json({ error: 'Missing required query parameter: path' });
824+
return;
825+
}
826+
827+
const validation = validateFilePath(filePath);
828+
if (!validation.valid) {
829+
res.status(403).json({ error: validation.error });
830+
return;
831+
}
832+
833+
const expandedPath = expandPath(filePath);
834+
835+
if (!fs.existsSync(expandedPath)) {
836+
res.status(404).json({ error: 'File not found' });
837+
return;
838+
}
839+
840+
try {
841+
const stat = fs.statSync(expandedPath);
842+
const content = fs.readFileSync(expandedPath, 'utf8');
843+
844+
res.json({
845+
content,
846+
mtime: stat.mtime.getTime(),
847+
readonly: validation.readonly,
848+
path: expandedPath,
849+
});
850+
} catch (error) {
851+
res.status(500).json({ error: (error as Error).message });
852+
}
853+
});
854+
855+
/**
856+
* PUT /api/file - Write a file with conflict detection and backup
857+
* Query params: path (required)
858+
* Body: { content: string, expectedMtime?: number }
859+
* Returns: { success: true, mtime: number, backupPath?: string }
860+
*/
861+
apiRoutes.put('/file', (req: Request, res: Response): void => {
862+
const filePath = req.query.path as string;
863+
const { content, expectedMtime } = req.body;
864+
865+
if (!filePath) {
866+
res.status(400).json({ error: 'Missing required query parameter: path' });
867+
return;
868+
}
869+
870+
if (typeof content !== 'string') {
871+
res.status(400).json({ error: 'Missing required field: content' });
872+
return;
873+
}
874+
875+
const validation = validateFilePath(filePath);
876+
if (!validation.valid) {
877+
res.status(403).json({ error: validation.error });
878+
return;
879+
}
880+
881+
if (validation.readonly) {
882+
res.status(403).json({ error: 'File is read-only' });
883+
return;
884+
}
885+
886+
const expandedPath = expandPath(filePath);
887+
const ccsDir = getCcsDir();
888+
889+
// Conflict detection (if file exists and expectedMtime provided)
890+
if (fs.existsSync(expandedPath) && expectedMtime !== undefined) {
891+
const stat = fs.statSync(expandedPath);
892+
if (stat.mtime.getTime() !== expectedMtime) {
893+
res.status(409).json({
894+
error: 'File modified externally',
895+
currentMtime: stat.mtime.getTime(),
896+
});
897+
return;
898+
}
899+
}
900+
901+
try {
902+
// Create backup if file exists
903+
let backupPath: string | undefined;
904+
if (fs.existsSync(expandedPath)) {
905+
const backupDir = path.join(ccsDir, 'backups');
906+
if (!fs.existsSync(backupDir)) {
907+
fs.mkdirSync(backupDir, { recursive: true });
908+
}
909+
const filename = path.basename(expandedPath);
910+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
911+
backupPath = path.join(backupDir, `${filename}.${timestamp}.bak`);
912+
fs.copyFileSync(expandedPath, backupPath);
913+
}
914+
915+
// Ensure parent directory exists
916+
const parentDir = path.dirname(expandedPath);
917+
if (!fs.existsSync(parentDir)) {
918+
fs.mkdirSync(parentDir, { recursive: true });
919+
}
920+
921+
// Write atomically
922+
const tempPath = expandedPath + '.tmp';
923+
fs.writeFileSync(tempPath, content);
924+
fs.renameSync(tempPath, expandedPath);
925+
926+
const newStat = fs.statSync(expandedPath);
927+
res.json({
928+
success: true,
929+
mtime: newStat.mtime.getTime(),
930+
backupPath,
931+
});
932+
} catch (error) {
933+
res.status(500).json({ error: (error as Error).message });
934+
}
935+
});
936+
937+
/**
938+
* GET /api/files - List editable files in ~/.ccs/
939+
* Returns: { files: Array<{ name: string, path: string, mtime: number }> }
940+
*/
941+
apiRoutes.get('/files', (_req: Request, res: Response): void => {
942+
const ccsDir = getCcsDir();
943+
944+
if (!fs.existsSync(ccsDir)) {
945+
res.json({ files: [] });
946+
return;
947+
}
948+
949+
try {
950+
const entries = fs.readdirSync(ccsDir, { withFileTypes: true });
951+
const files = entries
952+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
953+
.map((entry) => {
954+
const filePath = path.join(ccsDir, entry.name);
955+
const stat = fs.statSync(filePath);
956+
return {
957+
name: entry.name,
958+
path: `~/.ccs/${entry.name}`,
959+
mtime: stat.mtime.getTime(),
960+
};
961+
})
962+
.sort((a, b) => a.name.localeCompare(b.name));
963+
964+
res.json({ files });
965+
} catch (error) {
966+
res.status(500).json({ error: (error as Error).message });
967+
}
968+
});

ui/bun.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@
2323
"clsx": "^2.1.1",
2424
"date-fns": "^4.1.0",
2525
"lucide-react": "^0.556.0",
26+
"prism-react-renderer": "^2.4.1",
2627
"react": "^19.2.0",
2728
"react-day-picker": "^9.12.0",
2829
"react-dom": "^19.2.0",
2930
"react-hook-form": "^7.68.0",
3031
"react-router-dom": "^7.10.1",
32+
"react-simple-code-editor": "^0.14.1",
3133
"recharts": "^2.12.0",
3234
"sonner": "^2.0.7",
3335
"tailwind-merge": "^3.4.0",
@@ -387,6 +389,8 @@
387389

388390
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
389391

392+
"@types/prismjs": ["@types/[email protected]", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
393+
390394
"@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
391395

392396
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@@ -677,6 +681,8 @@
677681

678682
"prettier": ["[email protected]", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
679683

684+
"prism-react-renderer": ["[email protected]", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="],
685+
680686
"prop-types": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
681687

682688
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -701,6 +707,8 @@
701707

702708
"react-router-dom": ["[email protected]", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="],
703709

710+
"react-simple-code-editor": ["[email protected]", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow=="],
711+
704712
"react-smooth": ["[email protected]", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
705713

706714
"react-style-singleton": ["[email protected]", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],

ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@
3434
"clsx": "^2.1.1",
3535
"date-fns": "^4.1.0",
3636
"lucide-react": "^0.556.0",
37+
"prism-react-renderer": "^2.4.1",
3738
"react": "^19.2.0",
3839
"react-day-picker": "^9.12.0",
3940
"react-dom": "^19.2.0",
4041
"react-hook-form": "^7.68.0",
4142
"react-router-dom": "^7.10.1",
43+
"react-simple-code-editor": "^0.14.1",
4244
"recharts": "^2.12.0",
4345
"sonner": "^2.0.7",
4446
"tailwind-merge": "^3.4.0",

0 commit comments

Comments
 (0)