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.
590 lines
23 KiB
TypeScript
590 lines
23 KiB
TypeScript
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<string> {
|
|
return buildPushScriptForType(baseUrl, skillsDir, 'skills');
|
|
}
|
|
|
|
export async function buildPushScriptForType(baseUrl: string, resourceDir: string, type: ResourceType): Promise<string> {
|
|
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<string> {
|
|
return buildSyncScriptForType(baseUrl, skillsDir, 'skills');
|
|
}
|
|
|
|
export async function buildSyncScriptForType(baseUrl: string, targetDir: string, type: ResourceType): Promise<string> {
|
|
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<string> {
|
|
return buildSyncScriptPSForType(baseUrl, skillsDir, 'skills');
|
|
}
|
|
|
|
export async function buildSyncScriptPSForType(baseUrl: string, targetDir: string, type: ResourceType): Promise<string> {
|
|
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<string> {
|
|
return buildPushScriptPSForType(baseUrl, skillsDir, 'skills');
|
|
}
|
|
|
|
export async function buildPushScriptPSForType(baseUrl: string, resourceDir: string, type: ResourceType): Promise<string> {
|
|
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');
|
|
}
|