1.0.7
This commit is contained in:
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 });
|
||||
}
|
||||
};
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user