Add hooks and CLAUDE.md resource types with install/uninstall scripts

Introduces two new resource types (hooks, claude-md) with full CRUD,
   visual hook config editor, section-delimited CLAUDE.md installs,
   uninstall endpoints, and shell injection hardening in sync scripts.
This commit is contained in:
Alejandro Martinez
2026-02-16 11:51:33 +01:00
parent c1a9442868
commit b86c9f3e3a
33 changed files with 2345 additions and 204 deletions

View File

@@ -7,6 +7,26 @@ export function isPowerShell(request: Request): boolean {
return /PowerShell/i.test(ua);
}
// --- Shell safety helpers ---
/** Escape a string for safe use inside double-quoted bash echo */
function escBash(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
}
/** Escape a string for safe use inside double-quoted PowerShell Write-Host */
function escPS(s: string): string {
return s.replace(/`/g, '``').replace(/"/g, '`"').replace(/\$/g, '`$');
}
/** Validate that a value is safe to use as a path segment in generated scripts (slug or relativePath) */
function assertSafePath(s: string): string {
if (/[^a-zA-Z0-9_./-]/.test(s) || s.includes('..') || s.startsWith('/')) {
throw new Error(`Unsafe path segment rejected: ${s}`);
}
return s;
}
// --- Push scripts (type-aware) ---
export async function buildPushScript(baseUrl: string, skillsDir: string): Promise<string> {
@@ -277,32 +297,37 @@ export async function buildSyncScriptForType(baseUrl: string, targetDir: string,
lines.push('');
for (const r of resources) {
const safeSlug = assertSafePath(r.slug);
const safeName = escBash(r.name);
if (r.format === 'folder') {
// Fetch full resource to get file list
const full = await getResource(type, r.slug);
if (!full) continue;
lines.push(`mkdir -p "$TARGET_DIR/${r.slug}"`);
lines.push(`curl -fsSL "${baseUrl}/${type}/${r.slug}" -o "$TARGET_DIR/${r.slug}/${config.mainFileName}"`);
lines.push(`mkdir -p "$TARGET_DIR/${safeSlug}"`);
lines.push(`curl -fsSL "${baseUrl}/${type}/${safeSlug}" -o "$TARGET_DIR/${safeSlug}/${config.mainFileName}"`);
for (const f of full.files) {
const dir = f.relativePath.split('/').slice(0, -1).join('/');
const safePath = assertSafePath(f.relativePath);
const dir = safePath.split('/').slice(0, -1).join('/');
if (dir) {
lines.push(`mkdir -p "$TARGET_DIR/${r.slug}/${dir}"`);
lines.push(`mkdir -p "$TARGET_DIR/${safeSlug}/${dir}"`);
}
lines.push(`curl -fsSL "${baseUrl}/api/resources/${type}/${r.slug}/files/${f.relativePath}" -o "$TARGET_DIR/${r.slug}/${f.relativePath}"`);
lines.push(`curl -fsSL "${baseUrl}/api/resources/${type}/${safeSlug}/files/${safePath}" -o "$TARGET_DIR/${safeSlug}/${safePath}"`);
}
const scriptFiles = full.files.filter(f => f.relativePath.startsWith('scripts/'));
for (const f of scriptFiles) {
lines.push(`chmod +x "$TARGET_DIR/${r.slug}/${f.relativePath}"`);
const safePath = assertSafePath(f.relativePath);
lines.push(`chmod +x "$TARGET_DIR/${safeSlug}/${safePath}"`);
}
lines.push(`echo " ✓ ${r.name} (folder, ${full.files.length + 1} files)"`);
lines.push(`echo " ✓ ${safeName} (folder, ${full.files.length + 1} files)"`);
} else {
const resourceUrl = `${baseUrl}/${type}/${r.slug}`;
lines.push(`curl -fsSL "${resourceUrl}" -o "$TARGET_DIR/${r.slug}.md"`);
lines.push(`echo " ✓ ${r.name}"`);
const resourceUrl = `${baseUrl}/${type}/${safeSlug}`;
lines.push(`curl -fsSL "${resourceUrl}" -o "$TARGET_DIR/${safeSlug}.md"`);
lines.push(`echo " ✓ ${safeName}"`);
}
}
@@ -339,27 +364,31 @@ export async function buildSyncScriptPSForType(baseUrl: string, targetDir: strin
lines.push('');
for (const r of resources) {
const safeSlug = assertSafePath(r.slug);
const safeName = escPS(r.name);
if (r.format === 'folder') {
const full = await getResource(type, r.slug);
if (!full) continue;
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $TargetDir "${r.slug}") | Out-Null`);
lines.push(`Invoke-WebRequest -Uri "${baseUrl}/${type}/${r.slug}" -OutFile (Join-Path $TargetDir "${r.slug}\\${config.mainFileName}")`);
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $TargetDir "${safeSlug}") | Out-Null`);
lines.push(`Invoke-WebRequest -Uri "${baseUrl}/${type}/${safeSlug}" -OutFile (Join-Path $TargetDir "${safeSlug}\\${config.mainFileName}")`);
for (const f of full.files) {
const dir = f.relativePath.split('/').slice(0, -1).join('\\');
const safePath = assertSafePath(f.relativePath);
const dir = safePath.split('/').slice(0, -1).join('\\');
if (dir) {
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $TargetDir "${r.slug}\\${dir}") | Out-Null`);
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $TargetDir "${safeSlug}\\${dir}") | Out-Null`);
}
const winPath = f.relativePath.replace(/\//g, '\\');
lines.push(`Invoke-WebRequest -Uri "${baseUrl}/api/resources/${type}/${r.slug}/files/${f.relativePath}" -OutFile (Join-Path $TargetDir "${r.slug}\\${winPath}")`);
const winPath = safePath.replace(/\//g, '\\');
lines.push(`Invoke-WebRequest -Uri "${baseUrl}/api/resources/${type}/${safeSlug}/files/${safePath}" -OutFile (Join-Path $TargetDir "${safeSlug}\\${winPath}")`);
}
lines.push(`Write-Host " ✓ ${r.name} (folder, ${full.files.length + 1} files)"`);
lines.push(`Write-Host " ✓ ${safeName} (folder, ${full.files.length + 1} files)"`);
} else {
const resourceUrl = `${baseUrl}/${type}/${r.slug}`;
lines.push(`Invoke-WebRequest -Uri "${resourceUrl}" -OutFile (Join-Path $TargetDir "${r.slug}.md")`);
lines.push(`Write-Host " ✓ ${r.name}"`);
const resourceUrl = `${baseUrl}/${type}/${safeSlug}`;
lines.push(`Invoke-WebRequest -Uri "${resourceUrl}" -OutFile (Join-Path $TargetDir "${safeSlug}.md")`);
lines.push(`Write-Host " ✓ ${safeName}"`);
}
}