import { listResources, getResource } from './resources'; import { listSkills } from './skills'; import { type ResourceType, REGISTRY, RESOURCE_TYPES, getTypeConfig } from './registry'; export function isPowerShell(request: Request): boolean { const ua = request.headers.get('user-agent') || ''; 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 { return buildPushScriptForType(baseUrl, skillsDir, 'skills'); } export async function buildPushScriptForType(baseUrl: string, resourceDir: string, type: ResourceType): Promise { const config = getTypeConfig(type); const lines = [ '#!/usr/bin/env bash', 'set -euo pipefail', '', `RESOURCE_DIR="${resourceDir}"`, `BASE_URL="${baseUrl}"`, `RESOURCE_TYPE="${type}"`, `MAIN_FILE_NAME="${config.mainFileName}"`, 'FILTER="${1:-}"', 'TOKEN_FILE="$HOME/.claude/grimoired-token"', '', '# Get git author if available', 'AUTHOR_NAME=$(git config user.name 2>/dev/null || echo "")', 'AUTHOR_EMAIL=$(git config user.email 2>/dev/null || echo "")', '', '# Load or register token', 'TOKEN=""', 'if [ -f "$TOKEN_FILE" ]; then', ' TOKEN=$(cat "$TOKEN_FILE")', 'elif [ -n "$AUTHOR_EMAIL" ]; then', ' echo "No token found. Registering with $AUTHOR_EMAIL..."', ' REGISTER_RESPONSE=$(curl -sS -X POST \\', ' -H "Content-Type: application/json" \\', ' -d "{\\"email\\": \\"$AUTHOR_EMAIL\\", \\"name\\": \\"$AUTHOR_NAME\\"}" \\', ' -w "\\n%{http_code}" \\', ' "$BASE_URL/api/auth/register")', ' REGISTER_BODY=$(echo "$REGISTER_RESPONSE" | sed \'$d\')', ' REGISTER_STATUS=$(echo "$REGISTER_RESPONSE" | tail -1)', ' if [ "$REGISTER_STATUS" = "201" ]; then', ' TOKEN=$(echo "$REGISTER_BODY" | jq -r .token)', ' mkdir -p "$(dirname "$TOKEN_FILE")"', ' echo "$TOKEN" > "$TOKEN_FILE"', ' chmod 600 "$TOKEN_FILE"', ' echo " Token saved to $TOKEN_FILE"', ' elif [ "$REGISTER_STATUS" = "409" ]; then', ' echo " Email already registered. Place your token in $TOKEN_FILE"', ' echo " Continuing without token (unprotected resources only)..."', ' else', ' echo " Registration failed ($REGISTER_STATUS): $REGISTER_BODY"', ' echo " Continuing without token (unprotected resources only)..."', ' fi', 'fi', '', 'if [ ! -d "$RESOURCE_DIR" ]; then', ' echo "No directory found at $RESOURCE_DIR"', ' exit 1', 'fi', '', 'AUTH_HEADER=""', 'if [ -n "$TOKEN" ]; then', ' AUTH_HEADER="Authorization: Bearer $TOKEN"', 'fi', '', 'push_file_resource() {', ' local file="$1"', ' local slug=$(basename "$file" .md)', ' local content=$(cat "$file")', '', ' # Inject author into frontmatter if available and not already present', ' if [ -n "$AUTHOR_EMAIL" ] && ! echo "$content" | grep -q "^author-email:"; then', ' content=$(echo "$content" | awk -v name="$AUTHOR_NAME" -v email="$AUTHOR_EMAIL" \'NR==1 && /^---$/{print; if (name) print "author: " name; print "author-email: " email; next} {print}\')', ' fi', '', ' # Try PUT (update), fallback to POST (create)', ' local response', ' if [ -n "$TOKEN" ]; then', ' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\', ' -H "Content-Type: application/json" \\', ' -H "Authorization: Bearer $TOKEN" \\', ' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\', ' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug")', ' else', ' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\', ' -H "Content-Type: application/json" \\', ' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\', ' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug")', ' fi', '', ' if [ "$response" = "403" ]; then', ' echo " ✗ $slug (permission denied — token missing or invalid)"', ' return 1', ' fi', '', ' if [ "$response" = "404" ]; then', ' if [ -n "$TOKEN" ]; then', ' local post_status=$(curl -sS -o /dev/null -w "%{http_code}" -X POST \\', ' -H "Content-Type: application/json" \\', ' -H "Authorization: Bearer $TOKEN" \\', ' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\', ' "$BASE_URL/api/resources/$RESOURCE_TYPE")', ' else', ' local post_status=$(curl -sS -o /dev/null -w "%{http_code}" -X POST \\', ' -H "Content-Type: application/json" \\', ' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\', ' "$BASE_URL/api/resources/$RESOURCE_TYPE")', ' fi', ' if [ "$post_status" = "403" ]; then', ' echo " ✗ $slug (permission denied — token missing or invalid)"', ' return 1', ' fi', ' fi', '', ' echo " ✓ $slug"', '}', '', 'push_folder_resource() {', ' local dir="$1"', ' local slug=$(basename "$dir")', ' local main_file="$dir/$MAIN_FILE_NAME"', '', ' if [ ! -f "$main_file" ]; then', ' echo " ✗ $slug (no $MAIN_FILE_NAME found)"', ' return 1', ' fi', '', ' local content=$(cat "$main_file")', '', ' # Inject author', ' if [ -n "$AUTHOR_EMAIL" ] && ! echo "$content" | grep -q "^author-email:"; then', ' content=$(echo "$content" | awk -v name="$AUTHOR_NAME" -v email="$AUTHOR_EMAIL" \'NR==1 && /^---$/{print; if (name) print "author: " name; print "author-email: " email; next} {print}\')', ' fi', '', ' # Create as folder format, try PUT first then POST', ' local response', ' if [ -n "$TOKEN" ]; then', ' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\', ' -H "Content-Type: application/json" \\', ' -H "Authorization: Bearer $TOKEN" \\', ' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\', ' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug")', ' else', ' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\', ' -H "Content-Type: application/json" \\', ' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\', ' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug")', ' fi', '', ' if [ "$response" = "403" ]; then', ' echo " ✗ $slug (permission denied)"', ' return 1', ' fi', '', ' if [ "$response" = "404" ]; then', ' if [ -n "$TOKEN" ]; then', ' curl -sS -o /dev/null -w "%{http_code}" -X POST \\', ' -H "Content-Type: application/json" \\', ' -H "Authorization: Bearer $TOKEN" \\', ' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .), \\"format\\": \\"folder\\"}" \\', ' "$BASE_URL/api/resources/$RESOURCE_TYPE" > /dev/null', ' else', ' curl -sS -o /dev/null -w "%{http_code}" -X POST \\', ' -H "Content-Type: application/json" \\', ' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .), \\"format\\": \\"folder\\"}" \\', ' "$BASE_URL/api/resources/$RESOURCE_TYPE" > /dev/null', ' fi', ' fi', '', ' # Upload sub-files', ' local file_count=0', ' for subdir in scripts references assets; do', ' if [ -d "$dir/$subdir" ]; then', ' find "$dir/$subdir" -type f | while read -r subfile; do', ' local rel_path="${subfile#$dir/}"', ' local auth_flag=""', ' if [ -n "$TOKEN" ]; then', ' auth_flag="-H \\"Authorization: Bearer $TOKEN\\""', ' fi', ' if [ -n "$TOKEN" ]; then', ' curl -sS -X PUT \\', ' -H "Authorization: Bearer $TOKEN" \\', ' --data-binary "@$subfile" \\', ' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug/files/$rel_path" > /dev/null', ' else', ' curl -sS -X PUT \\', ' --data-binary "@$subfile" \\', ' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug/files/$rel_path" > /dev/null', ' fi', ' done', ' fi', ' done', '', ' echo " ✓ $slug (folder)"', '}', '', 'count=0', 'failed=0', 'if [ -n "$FILTER" ]; then', ' # Check if filter matches a directory (folder resource)', ' dir_path="$RESOURCE_DIR/${FILTER%.md}"', ' dir_path="${dir_path%.md}"', ' file_path="$RESOURCE_DIR/${FILTER%.md}.md"', ' if [ -d "$dir_path" ] && [ -f "$dir_path/$MAIN_FILE_NAME" ]; then', ' if push_folder_resource "$dir_path"; then count=1; else failed=1; fi', ' elif [ -f "$file_path" ]; then', ' if push_file_resource "$file_path"; then count=1; else failed=1; fi', ' else', ' echo "Resource not found: $FILTER"', ' exit 1', ' fi', 'else', ' # Push all .md files (simple format)', ' for file in "$RESOURCE_DIR"/*.md; do', ' [ -f "$file" ] || continue', ' slug_name=$(basename "$file" .md)', ' # Skip if folder version exists', ' if [ -d "$RESOURCE_DIR/$slug_name" ] && [ -f "$RESOURCE_DIR/$slug_name/$MAIN_FILE_NAME" ]; then', ' continue', ' fi', ' if push_file_resource "$file"; then', ' count=$((count + 1))', ' else', ' failed=$((failed + 1))', ' fi', ' done', ' # Push all directories (folder format)', ' for dir in "$RESOURCE_DIR"/*/; do', ' [ -d "$dir" ] || continue', ' [ -f "$dir/$MAIN_FILE_NAME" ] || continue', ' if push_folder_resource "$dir"; then', ' count=$((count + 1))', ' else', ' failed=$((failed + 1))', ' fi', ' done', 'fi', '', `echo "Pushed $count resource(s) to $BASE_URL"`, 'if [ "$failed" -gt 0 ]; then', ' echo "$failed resource(s) failed (permission denied)"', 'fi', '', ]; return lines.join('\n'); } // --- Sync scripts (type-aware) --- export async function buildSyncScript(baseUrl: string, skillsDir: string): Promise { return buildSyncScriptForType(baseUrl, skillsDir, 'skills'); } export async function buildSyncScriptForType(baseUrl: string, targetDir: string, type: ResourceType): Promise { const resources = await listResources(type); const config = getTypeConfig(type); const lines = [ '#!/usr/bin/env bash', 'set -euo pipefail', '', `TARGET_DIR="${targetDir}"`, 'mkdir -p "$TARGET_DIR"', '', ]; if (resources.length === 0) { lines.push(`echo "No ${type} available to sync."`); } else { lines.push(`echo "Syncing ${resources.length} ${type} from ${baseUrl}..."`); 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/${safeSlug}"`); lines.push(`curl -fsSL "${baseUrl}/${type}/${safeSlug}" -o "$TARGET_DIR/${safeSlug}/${config.mainFileName}"`); for (const f of full.files) { const safePath = assertSafePath(f.relativePath); const dir = safePath.split('/').slice(0, -1).join('/'); if (dir) { lines.push(`mkdir -p "$TARGET_DIR/${safeSlug}/${dir}"`); } 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) { const safePath = assertSafePath(f.relativePath); lines.push(`chmod +x "$TARGET_DIR/${safeSlug}/${safePath}"`); } lines.push(`echo " ✓ ${safeName} (folder, ${full.files.length + 1} files)"`); } else { const resourceUrl = `${baseUrl}/${type}/${safeSlug}`; lines.push(`curl -fsSL "${resourceUrl}" -o "$TARGET_DIR/${safeSlug}.md"`); lines.push(`echo " ✓ ${safeName}"`); } } lines.push(''); lines.push('echo "Done! Synced to $TARGET_DIR"'); } lines.push(''); return lines.join('\n'); } // --- PowerShell variants --- export async function buildSyncScriptPS(baseUrl: string, skillsDir: string): Promise { return buildSyncScriptPSForType(baseUrl, skillsDir, 'skills'); } export async function buildSyncScriptPSForType(baseUrl: string, targetDir: string, type: ResourceType): Promise { const resources = await listResources(type); const config = getTypeConfig(type); const lines = [ '$ErrorActionPreference = "Stop"', '', `$TargetDir = "${targetDir}"`, 'New-Item -ItemType Directory -Force -Path $TargetDir | Out-Null', '', ]; if (resources.length === 0) { lines.push(`Write-Host "No ${type} available to sync."`); } else { lines.push(`Write-Host "Syncing ${resources.length} ${type} from ${baseUrl}..."`); 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 "${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 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 "${safeSlug}\\${dir}") | Out-Null`); } 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 " ✓ ${safeName} (folder, ${full.files.length + 1} files)"`); } else { const resourceUrl = `${baseUrl}/${type}/${safeSlug}`; lines.push(`Invoke-WebRequest -Uri "${resourceUrl}" -OutFile (Join-Path $TargetDir "${safeSlug}.md")`); lines.push(`Write-Host " ✓ ${safeName}"`); } } lines.push(''); lines.push('Write-Host "Done! Synced to $TargetDir"'); } lines.push(''); return lines.join('\n'); } export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Promise { return buildPushScriptPSForType(baseUrl, skillsDir, 'skills'); } export async function buildPushScriptPSForType(baseUrl: string, resourceDir: string, type: ResourceType): Promise { const config = getTypeConfig(type); const lines = [ '$ErrorActionPreference = "Stop"', '', `$ResourceDir = "${resourceDir}"`, `$BaseUrl = "${baseUrl}"`, `$ResourceType = "${type}"`, `$MainFileName = "${config.mainFileName}"`, '$Filter = if ($args.Count -gt 0) { $args[0] } else { "" }', '$TokenFile = Join-Path $HOME ".claude\\grimoired-token"', '', '# Get git author if available', '$AuthorName = try { git config user.name 2>$null } catch { "" }', '$AuthorEmail = try { git config user.email 2>$null } catch { "" }', '', '# Load or register token', '$Token = ""', 'if (Test-Path $TokenFile) {', ' $Token = (Get-Content $TokenFile -Raw).Trim()', '} elseif ($AuthorEmail) {', ' Write-Host "No token found. Registering with $AuthorEmail..."', ' try {', ' $body = @{ email = $AuthorEmail; name = $AuthorName } | ConvertTo-Json', ' $resp = Invoke-WebRequest -Uri "$BaseUrl/api/auth/register" -Method POST -ContentType "application/json" -Body $body', ' if ($resp.StatusCode -eq 201) {', ' $Token = ($resp.Content | ConvertFrom-Json).token', ' New-Item -ItemType Directory -Force -Path (Split-Path $TokenFile) | Out-Null', ' Set-Content -Path $TokenFile -Value $Token', ' Write-Host " Token saved to $TokenFile"', ' }', ' } catch {', ' $code = $_.Exception.Response.StatusCode.value__', ' if ($code -eq 409) {', ' Write-Host " Email already registered. Place your token in $TokenFile"', ' } else {', ' Write-Host " Registration failed: $_"', ' }', ' Write-Host " Continuing without token (unprotected resources only)..."', ' }', '}', '', 'if (-not (Test-Path $ResourceDir)) {', ' Write-Host "No directory found at $ResourceDir"', ' exit 1', '}', '', '$headers = @{ "Content-Type" = "application/json" }', 'if ($Token) { $headers["Authorization"] = "Bearer $Token" }', '', 'function Push-FileResource($file) {', ' $slug = [IO.Path]::GetFileNameWithoutExtension($file)', ' $content = Get-Content $file -Raw', '', ' # Inject author if available and not present', ' if ($AuthorEmail -and $content -notmatch "(?m)^author-email:") {', ' $inject = ""', ' if ($AuthorName) { $inject += "author: $AuthorName`n" }', ' $inject += "author-email: $AuthorEmail"', ' $content = $content -replace "^---$", "---`n$inject" -replace "^---`n", "---`n"', ' }', '', ' $body = @{ content = $content } | ConvertTo-Json', ' try {', ' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType/$slug" -Method PUT -Headers $headers -Body $body | Out-Null', ' Write-Host " ✓ $slug"', ' return $true', ' } catch {', ' $code = $_.Exception.Response.StatusCode.value__', ' if ($code -eq 403) {', ' Write-Host " ✗ $slug (permission denied)"', ' return $false', ' }', ' if ($code -eq 404) {', ' $postBody = @{ slug = $slug; content = $content } | ConvertTo-Json', ' try {', ' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType" -Method POST -Headers $headers -Body $postBody | Out-Null', ' Write-Host " ✓ $slug"', ' return $true', ' } catch {', ' $postCode = $_.Exception.Response.StatusCode.value__', ' if ($postCode -eq 403) {', ' Write-Host " ✗ $slug (permission denied)"', ' return $false', ' }', ' }', ' }', ' }', ' Write-Host " ✓ $slug"', ' return $true', '}', '', 'function Push-FolderResource($dir) {', ' $slug = Split-Path $dir -Leaf', ' $mainFile = Join-Path $dir $MainFileName', ' if (-not (Test-Path $mainFile)) {', ' Write-Host " ✗ $slug (no $MainFileName found)"', ' return $false', ' }', ' $content = Get-Content $mainFile -Raw', '', ' if ($AuthorEmail -and $content -notmatch "(?m)^author-email:") {', ' $inject = ""', ' if ($AuthorName) { $inject += "author: $AuthorName`n" }', ' $inject += "author-email: $AuthorEmail"', ' $content = $content -replace "^---$", "---`n$inject" -replace "^---`n", "---`n"', ' }', '', ' $body = @{ content = $content } | ConvertTo-Json', ' try {', ' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType/$slug" -Method PUT -Headers $headers -Body $body | Out-Null', ' } catch {', ' $code = $_.Exception.Response.StatusCode.value__', ' if ($code -eq 403) {', ' Write-Host " ✗ $slug (permission denied)"', ' return $false', ' }', ' if ($code -eq 404) {', ' $postBody = @{ slug = $slug; content = $content; format = "folder" } | ConvertTo-Json', ' try {', ' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType" -Method POST -Headers $headers -Body $postBody | Out-Null', ' } catch {', ' $postCode = $_.Exception.Response.StatusCode.value__', ' if ($postCode -eq 403) {', ' Write-Host " ✗ $slug (permission denied)"', ' return $false', ' }', ' }', ' }', ' }', '', ' # Upload sub-files', ' foreach ($subdir in @("scripts", "references", "assets")) {', ' $subdirPath = Join-Path $dir $subdir', ' if (Test-Path $subdirPath) {', ' Get-ChildItem -Path $subdirPath -Recurse -File | ForEach-Object {', ' $relPath = $_.FullName.Substring($dir.Length + 1).Replace("\\", "/")', ' $fileHeaders = @{}', ' if ($Token) { $fileHeaders["Authorization"] = "Bearer $Token" }', ' $fileBytes = [IO.File]::ReadAllBytes($_.FullName)', ' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType/$slug/files/$relPath" -Method PUT -Headers $fileHeaders -Body $fileBytes | Out-Null', ' }', ' }', ' }', '', ' Write-Host " ✓ $slug (folder)"', ' return $true', '}', '', '$count = 0; $failed = 0', 'if ($Filter) {', ' $dirPath = Join-Path $ResourceDir ($Filter -replace "\\.md$","")', ' $filePath = Join-Path $ResourceDir "$($Filter -replace \'\\.md$\',\'\').md"', ' if ((Test-Path $dirPath -PathType Container) -and (Test-Path (Join-Path $dirPath $MainFileName))) {', ' if (Push-FolderResource $dirPath) { $count++ } else { $failed++ }', ' } elseif (Test-Path $filePath) {', ' if (Push-FileResource $filePath) { $count++ } else { $failed++ }', ' } else {', ' Write-Host "Resource not found: $Filter"; exit 1', ' }', '} else {', ' # Push .md files (skip if folder version exists)', ' Get-ChildItem -Path $ResourceDir -Filter "*.md" | ForEach-Object {', ' $slugName = $_.BaseName', ' $folderPath = Join-Path $ResourceDir $slugName', ' if ((Test-Path $folderPath -PathType Container) -and (Test-Path (Join-Path $folderPath $MainFileName))) { return }', ' if (Push-FileResource $_.FullName) { $count++ } else { $failed++ }', ' }', ' # Push directories', ' Get-ChildItem -Path $ResourceDir -Directory | ForEach-Object {', ' if (Test-Path (Join-Path $_.FullName $MainFileName)) {', ' if (Push-FolderResource $_.FullName) { $count++ } else { $failed++ }', ' }', ' }', '}', '', 'Write-Host "Pushed $count resource(s) to $BaseUrl"', 'if ($failed -gt 0) { Write-Host "$failed resource(s) failed (permission denied)" }', '', ]; return lines.join('\n'); }