This commit is contained in:
Jarek Krochmalski committed 2026-01-11 09:01:42 +01:00
1 parent c15355e159
commit 851e56bc57
23 files changed
+8923

No files matched your search

@@ -0,0 +1,79 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getStackSource } from '$lib/server/db';
import { findStackDir } from '$lib/server/stacks';
import { existsSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
/**
* POST /api/stacks/[name]/check-path-change
*
* Check if the proposed compose path differs from current and if old directory has files.
* Returns information about what would need to be moved if location changes.
*/
export const POST: RequestHandler = async ({ params, request, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !(await auth.can('stacks', 'edit'))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const { name } = params;
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
try {
const body = await request.json();
const { newComposePath } = body;
// Get current source info
const source = await getStackSource(name, envIdNum);
// Determine current compose path and directory
let currentComposePath: string | null = null;
let currentDir: string | null = null;
if (source?.composePath) {
currentComposePath = source.composePath;
currentDir = dirname(source.composePath);
} else {
// Stack uses default directory structure
const stackDir = await findStackDir(name, envIdNum);
if (stackDir) {
const defaultComposePath = join(stackDir, 'docker-compose.yml');
if (existsSync(defaultComposePath)) {
currentComposePath = defaultComposePath;
currentDir = stackDir;
}
}
}
// Determine new directory
const newDir = newComposePath ? dirname(newComposePath) : null;
// Check if directories are different and old directory exists with files
let hasChanges = false;
let fileCount = 0;
if (currentDir && newDir && currentDir !== newDir && existsSync(currentDir)) {
try {
const files = readdirSync(currentDir);
fileCount = files.length;
hasChanges = fileCount > 0;
} catch {
// Ignore read errors
}
}
return json({
hasChanges,
oldDir: currentDir,
newDir,
fileCount,
currentComposePath
});
} catch (error: any) {
console.error(`Error checking path change for stack ${name}:`, error);
return json({ error: error.message || 'Failed to check path changes' }, { status: 500 });
}
};
@@ -0,0 +1,131 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getStackSource, updateStackSource } from '$lib/server/db';
import { existsSync, readdirSync, renameSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
/**
* POST /api/stacks/[name]/relocate
*
* Move all stack files from old directory to new location.
* Updates the database with new paths and returns refreshed content.
*/
export const POST: RequestHandler = async ({ params, request, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !(await auth.can('stacks', 'edit'))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const { name } = params;
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
try {
const body = await request.json();
const { oldDir, newComposePath, newEnvPath } = body;
if (!oldDir || !newComposePath) {
return json({ error: 'oldDir and newComposePath are required' }, { status: 400 });
}
const newDir = dirname(newComposePath);
// Verify old directory exists
if (!existsSync(oldDir)) {
return json({ error: 'Source directory does not exist' }, { status: 400 });
}
// Create new directory if it doesn't exist
if (!existsSync(newDir)) {
mkdirSync(newDir, { recursive: true });
}
// Move all files from old directory to new directory
const files = readdirSync(oldDir);
const movedFiles: string[] = [];
const errors: string[] = [];
for (const file of files) {
const oldFilePath = join(oldDir, file);
const newFilePath = join(newDir, file);
try {
// Use rename for atomic move (same filesystem) or copy+delete for cross-filesystem
renameSync(oldFilePath, newFilePath);
movedFiles.push(file);
} catch (renameErr: any) {
if (renameErr.code === 'EXDEV') {
// Cross-filesystem move - copy then delete
try {
const data = readFileSync(oldFilePath);
writeFileSync(newFilePath, data);
unlinkSync(oldFilePath);
movedFiles.push(file);
} catch (copyErr: any) {
errors.push(`Failed to copy ${file}: ${copyErr.message}`);
}
} else {
errors.push(`Failed to move ${file}: ${renameErr.message}`);
}
}
}
// Remove old directory if it's now empty
try {
const remaining = readdirSync(oldDir);
if (remaining.length === 0) {
rmSync(oldDir, { recursive: true, force: true });
}
} catch {
// Ignore errors when checking/removing old directory
}
// Update database with new paths
await updateStackSource(name, envIdNum ?? null, {
composePath: newComposePath,
envPath: newEnvPath || null
});
// Read content from new location
let composeContent = '';
let rawEnvContent = '';
const envVars: { key: string; value: string; isSecret: boolean }[] = [];
// Read compose file
if (existsSync(newComposePath)) {
composeContent = readFileSync(newComposePath, 'utf-8');
}
// Read env file if it exists
const envFilePath = newEnvPath || join(newDir, '.env');
if (existsSync(envFilePath)) {
rawEnvContent = readFileSync(envFilePath, 'utf-8');
// Parse env vars from raw content
const lines = rawEnvContent.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex);
const value = trimmed.substring(eqIndex + 1);
envVars.push({ key, value, isSecret: false });
}
}
}
return json({
success: true,
movedFiles,
errors: errors.length > 0 ? errors : undefined,
composeContent,
rawEnvContent,
envVars
});
} catch (error: any) {
console.error(`Error relocating stack ${name}:`, error);
return json({ error: error.message || 'Failed to relocate stack' }, { status: 500 });
}
};
+41
View File
@@ -0,0 +1,41 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { adoptSelectedStacks, type DiscoveredStack } from '$lib/server/stack-scanner';
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('stacks', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const stacks = body.stacks as DiscoveredStack[];
const environmentId = body.environmentId as number | undefined;
if (!stacks || !Array.isArray(stacks) || stacks.length === 0) {
return json({ error: 'No stacks provided' }, { status: 400 });
}
if (!environmentId || typeof environmentId !== 'number') {
return json({ error: 'Environment ID is required' }, { status: 400 });
}
// Validate each stack has required fields
for (const stack of stacks) {
if (!stack.name || !stack.composePath) {
return json({ error: 'Invalid stack data: missing name or composePath' }, { status: 400 });
}
}
const result = await adoptSelectedStacks(stacks, environmentId);
return json({
adopted: result.adopted,
failed: result.failed
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return json({ error: message }, { status: 500 });
}
};
@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';
import { getStacksDir } from '$lib/server/stacks';
import type { RequestHandler } from './$types';
/**
* GET /api/stacks/base-path
*
* Returns the default Dockhand stacks directory path.
* This is where stacks are stored by default ($DATA_DIR/stacks/).
*/
export const GET: RequestHandler = async () => {
const basePath = getStacksDir();
return json({ basePath });
};
@@ -0,0 +1,54 @@
import { json } from '@sveltejs/kit';
import { join } from 'path';
import { getStackDir } from '$lib/server/stacks';
import { getEnvironment } from '$lib/server/db';
import type { RequestHandler } from './$types';
/**
* Get the default path for a new stack
* Used by the UI to show where files will be created
*
* Query params:
* - name: Stack name (required)
* - env: Environment ID (optional)
* - location: Custom base location path (optional)
*
* If location is provided, path will be: {location}/{envName}/{stackName}/
* Otherwise uses Dockhand's default: $DATA_DIR/stacks/{envName}/{stackName}/
*/
export const GET: RequestHandler = async ({ url }) => {
const stackName = url.searchParams.get('name');
const envId = url.searchParams.get('env');
const location = url.searchParams.get('location');
const envIdNum = envId ? parseInt(envId) : undefined;
if (!stackName) {
return json({ error: 'Stack name is required' }, { status: 400 });
}
let stackDir: string;
if (location) {
// Custom location: {location}/{envName}/{stackName}/
if (envIdNum) {
const env = await getEnvironment(envIdNum);
if (env) {
stackDir = join(location, env.name, stackName);
} else {
stackDir = join(location, stackName);
}
} else {
stackDir = join(location, stackName);
}
} else {
// Dockhand default location
stackDir = await getStackDir(stackName, envIdNum);
}
return json({
stackDir,
composePath: `${stackDir}/docker-compose.yml`,
envPath: `${stackDir}/.env`,
source: location ? 'custom' : 'default'
});
};
@@ -0,0 +1,37 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { getStackPathHints } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
/**
* GET /api/stacks/path-hints?name=stackName&env=envId
* Returns path hints extracted from Docker container labels for a stack.
*/
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const stackName = url.searchParams.get('name');
const envId = url.searchParams.get('env');
if (!stackName) {
return json({ error: 'Stack name is required' }, { status: 400 });
}
try {
const hints = await getStackPathHints(stackName, envId ? parseInt(envId) : undefined);
return json({
stackName,
workingDir: hints.workingDir,
configFiles: hints.configFiles
});
} catch (error) {
console.error('Failed to get stack path hints:', error);
return json(
{ error: error instanceof Error ? error.message : 'Failed to get path hints' },
{ status: 500 }
);
}
};
+35
View File
@@ -0,0 +1,35 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { scanExternalPaths, scanPaths, detectRunningStacks } from '$lib/server/stack-scanner';
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('stacks', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json().catch(() => ({}));
const { path } = body;
let result;
if (path) {
// Scan a specific path provided by the user
result = await scanPaths([path]);
} else {
// Scan all configured external paths (legacy behavior)
result = await scanExternalPaths();
}
// Detect which stacks are already running on any environment
const discoveredWithRunning = await detectRunningStacks(result.discovered);
return json({
...result,
discovered: discoveredWithRunning
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return json({ error: message }, { status: 500 });
}
};
@@ -0,0 +1,28 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { validatePath } from '$lib/server/stack-scanner';
import { getExternalStackPaths } from '$lib/server/db';
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('settings', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const { path } = await request.json();
if (!path || typeof path !== 'string') {
return json({ valid: false, error: 'Path is required' });
}
// Get existing paths to check for overlaps
const existingPaths = await getExternalStackPaths();
const result = validatePath(path, existingPaths);
return json(result);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return json({ valid: false, error: message });
}
};
+80
View File
@@ -0,0 +1,80 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { readdirSync, statSync, existsSync } from 'node:fs';
import { join, basename } from 'node:path';
import { authorize } from '$lib/server/authorize';
export interface FileEntry {
name: string;
path: string;
type: 'file' | 'directory' | 'symlink';
size: number;
mtime: string;
mode: string;
}
/**
* GET /api/system/files
* Browse Dockhand's local filesystem (for mount browsing)
*
* Query params:
* - path: Directory path to list
*/
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('stacks', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const path = url.searchParams.get('path') || '/';
try {
if (!existsSync(path)) {
return json({ error: `Path not found: ${path}` }, { status: 404 });
}
const stat = statSync(path);
if (!stat.isDirectory()) {
return json({ error: `Not a directory: ${path}` }, { status: 400 });
}
const entries: FileEntry[] = [];
const dirEntries = readdirSync(path, { withFileTypes: true });
for (const entry of dirEntries) {
try {
const fullPath = join(path, entry.name);
const entryStat = statSync(fullPath);
entries.push({
name: entry.name,
path: fullPath,
type: entry.isDirectory() ? 'directory' : entry.isSymbolicLink() ? 'symlink' : 'file',
size: entryStat.size,
mtime: entryStat.mtime.toISOString(),
mode: (entryStat.mode & 0o777).toString(8).padStart(3, '0')
});
} catch {
// Skip entries we can't stat (permission issues, etc.)
}
}
// Sort: directories first, then alphabetically
entries.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1;
if (a.type !== 'directory' && b.type === 'directory') return 1;
return a.name.localeCompare(b.name);
});
return json({
path,
parent: path === '/' ? null : join(path, '..'),
entries
});
} catch (error) {
console.error('Error listing directory:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return json({ error: `Failed to list directory: ${message}` }, { status: 500 });
}
};
@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { readFileSync, existsSync, statSync } from 'node:fs';
import { authorize } from '$lib/server/authorize';
/**
* GET /api/system/files/content
* Read file content from Dockhand's local filesystem
*
* Query params:
* - path: File path to read
*/
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('stacks', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const path = url.searchParams.get('path');
if (!path) {
return json({ error: 'Path is required' }, { status: 400 });
}
try {
if (!existsSync(path)) {
return json({ error: `File not found: ${path}` }, { status: 404 });
}
const stat = statSync(path);
if (stat.isDirectory()) {
return json({ error: `Cannot read directory as file: ${path}` }, { status: 400 });
}
// Limit file size to 10MB
const maxSize = 10 * 1024 * 1024;
if (stat.size > maxSize) {
return json({ error: `File too large (max ${maxSize / 1024 / 1024}MB)` }, { status: 400 });
}
const content = readFileSync(path, 'utf-8');
return json({
path,
content,
size: stat.size,
mtime: stat.mtime.toISOString()
});
} catch (error) {
console.error('Error reading file:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return json({ error: `Failed to read file: ${message}` }, { status: 500 });
}
};