- Delete stub scriptDownloader.js that contained placeholder implementation - Implement real JavaScript script downloader with GitHub fetch functionality - Fix incremental JSON sync to only process newly synced files - Add proper error handling and file structure management - Support all script types (ct/, tools/, vm/, vw/) with directory preservation - Download install scripts for CT scripts - Re-enable auto-sync service to use real implementation Scripts now download real content from GitHub instead of placeholders.
409 lines
12 KiB
TypeScript
409 lines
12 KiB
TypeScript
import { readdir, stat, readFile } from 'fs/promises';
|
|
import { join, resolve, extname } from 'path';
|
|
import { env } from '~/env.js';
|
|
import { spawn, type ChildProcess } from 'child_process';
|
|
import { localScriptsService } from '~/server/services/localScripts';
|
|
|
|
export interface ScriptInfo {
|
|
name: string;
|
|
path: string;
|
|
extension: string;
|
|
size: number;
|
|
lastModified: Date;
|
|
executable: boolean;
|
|
logo?: string;
|
|
slug?: string;
|
|
}
|
|
|
|
export class ScriptManager {
|
|
private scriptsDir: string | null = null;
|
|
private allowedExtensions: string[] | null = null;
|
|
private allowedPaths: string[] | null = null;
|
|
private maxExecutionTime: number | null = null;
|
|
|
|
constructor() {
|
|
// Initialize lazily to avoid accessing env vars during module load
|
|
}
|
|
|
|
/**
|
|
* Safely handle file modification time, providing fallback for invalid dates
|
|
* @param mtime - The file modification time from fs.stat
|
|
* @returns Date - Valid date or current date as fallback
|
|
*/
|
|
private safeMtime(mtime: Date): Date {
|
|
try {
|
|
// Check if the date is valid
|
|
if (!mtime || isNaN(mtime.getTime())) {
|
|
console.warn('Invalid mtime detected, using current time as fallback');
|
|
return new Date();
|
|
}
|
|
return mtime;
|
|
} catch (error) {
|
|
console.warn('Error processing mtime:', error);
|
|
return new Date();
|
|
}
|
|
}
|
|
|
|
private initializeConfig() {
|
|
if (this.scriptsDir === null) {
|
|
// Handle both absolute and relative paths for testing
|
|
this.scriptsDir = env.SCRIPTS_DIRECTORY.startsWith('/')
|
|
? env.SCRIPTS_DIRECTORY
|
|
: join(process.cwd(), env.SCRIPTS_DIRECTORY);
|
|
this.allowedExtensions = env.ALLOWED_SCRIPT_EXTENSIONS.split(',').map(ext => ext.trim());
|
|
this.allowedPaths = env.ALLOWED_SCRIPT_PATHS.split(',').map(path => path.trim());
|
|
this.maxExecutionTime = parseInt(env.MAX_SCRIPT_EXECUTION_TIME, 10);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all available scripts in the scripts directory
|
|
*/
|
|
async getScripts(): Promise<ScriptInfo[]> {
|
|
this.initializeConfig();
|
|
try {
|
|
const files = await readdir(this.scriptsDir!);
|
|
const scripts: ScriptInfo[] = [];
|
|
|
|
for (const file of files) {
|
|
const filePath = join(this.scriptsDir!, file);
|
|
const stats = await stat(filePath);
|
|
|
|
if (stats.isFile()) {
|
|
const extension = extname(file);
|
|
|
|
// Check if file extension is allowed
|
|
if (this.allowedExtensions!.includes(extension)) {
|
|
// Check if file is executable
|
|
const executable = await this.isExecutable(filePath);
|
|
|
|
scripts.push({
|
|
name: file,
|
|
path: filePath,
|
|
extension,
|
|
size: stats.size,
|
|
lastModified: this.safeMtime(stats.mtime),
|
|
executable
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return scripts.sort((a, b) => a.name.localeCompare(b.name));
|
|
} catch (error) {
|
|
console.error('Error reading scripts directory:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all available scripts in the ct subdirectory
|
|
*/
|
|
async getCtScripts(): Promise<ScriptInfo[]> {
|
|
this.initializeConfig();
|
|
try {
|
|
const ctDir = join(this.scriptsDir!, 'ct');
|
|
|
|
// Check if ct directory exists
|
|
try {
|
|
await stat(ctDir);
|
|
} catch {
|
|
console.warn(`CT scripts directory not found: ${ctDir}`);
|
|
return [];
|
|
}
|
|
|
|
const files = await readdir(ctDir);
|
|
const scripts: ScriptInfo[] = [];
|
|
|
|
for (const file of files) {
|
|
const filePath = join(ctDir, file);
|
|
const stats = await stat(filePath);
|
|
|
|
if (stats.isFile()) {
|
|
const extension = extname(file);
|
|
|
|
// Check if file extension is allowed
|
|
if (this.allowedExtensions!.includes(extension)) {
|
|
// Check if file is executable
|
|
const executable = await this.isExecutable(filePath);
|
|
|
|
// Extract slug from filename (remove .sh extension)
|
|
const slug = file.replace(/\.sh$/, '');
|
|
|
|
// Try to get logo from JSON data
|
|
let logo: string | undefined;
|
|
try {
|
|
const scriptData = await localScriptsService.getScriptBySlug(slug);
|
|
logo = scriptData?.logo ?? undefined;
|
|
} catch {
|
|
// JSON file might not exist, that's okay
|
|
}
|
|
|
|
scripts.push({
|
|
name: file,
|
|
path: filePath,
|
|
extension,
|
|
size: stats.size,
|
|
lastModified: this.safeMtime(stats.mtime),
|
|
executable,
|
|
logo,
|
|
slug
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return scripts.sort((a, b) => a.name.localeCompare(b.name));
|
|
} catch (error) {
|
|
console.error('Error reading ct scripts directory:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all downloaded scripts from all directories (ct, tools, vm, vw)
|
|
*/
|
|
async getAllDownloadedScripts(): Promise<ScriptInfo[]> {
|
|
this.initializeConfig();
|
|
const allScripts: ScriptInfo[] = [];
|
|
|
|
// Define all script directories to scan
|
|
const scriptDirs = ['ct', 'tools', 'vm', 'vw'];
|
|
|
|
for (const dirName of scriptDirs) {
|
|
try {
|
|
const dirPath = join(this.scriptsDir!, dirName);
|
|
|
|
// Check if directory exists
|
|
try {
|
|
await stat(dirPath);
|
|
} catch {
|
|
// Directory doesn't exist, skip it
|
|
continue;
|
|
}
|
|
|
|
const scripts = await this.getScriptsFromDirectory(dirPath);
|
|
allScripts.push(...scripts);
|
|
} catch (error) {
|
|
console.error(`Error reading ${dirName} scripts directory:`, error);
|
|
// Continue with other directories even if one fails
|
|
}
|
|
}
|
|
|
|
return allScripts.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
/**
|
|
* Get scripts from a specific directory (recursively)
|
|
*/
|
|
private async getScriptsFromDirectory(dirPath: string): Promise<ScriptInfo[]> {
|
|
const scripts: ScriptInfo[] = [];
|
|
|
|
const scanDirectory = async (currentPath: string, relativePath = ''): Promise<void> => {
|
|
const files = await readdir(currentPath);
|
|
|
|
for (const file of files) {
|
|
const filePath = join(currentPath, file);
|
|
const stats = await stat(filePath);
|
|
|
|
if (stats.isFile()) {
|
|
const extension = extname(file);
|
|
|
|
// Check if file extension is allowed
|
|
if (this.allowedExtensions!.includes(extension)) {
|
|
// Check if file is executable
|
|
const executable = await this.isExecutable(filePath);
|
|
|
|
// Extract slug from filename (remove .sh extension)
|
|
const slug = file.replace(/\.sh$/, '');
|
|
|
|
// Try to get logo from JSON data
|
|
let logo: string | undefined;
|
|
try {
|
|
const scriptData = await localScriptsService.getScriptBySlug(slug);
|
|
logo = scriptData?.logo ?? undefined;
|
|
} catch {
|
|
// JSON file might not exist, that's okay
|
|
}
|
|
|
|
scripts.push({
|
|
name: file,
|
|
path: filePath,
|
|
extension,
|
|
size: stats.size,
|
|
lastModified: this.safeMtime(stats.mtime),
|
|
executable,
|
|
logo,
|
|
slug
|
|
});
|
|
}
|
|
} else if (stats.isDirectory()) {
|
|
// Recursively scan subdirectories
|
|
const subRelativePath = relativePath ? join(relativePath, file) : file;
|
|
await scanDirectory(filePath, subRelativePath);
|
|
}
|
|
}
|
|
};
|
|
|
|
await scanDirectory(dirPath);
|
|
return scripts;
|
|
}
|
|
|
|
/**
|
|
* Check if a file is executable
|
|
*/
|
|
private async isExecutable(filePath: string): Promise<boolean> {
|
|
try {
|
|
const stats = await stat(filePath);
|
|
// Check if file has execute permission for owner, group, or others
|
|
const mode = stats.mode;
|
|
const isExecutable = !!(mode & parseInt('111', 8));
|
|
return isExecutable;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate if a script path is allowed to be executed
|
|
*/
|
|
validateScriptPath(scriptPath: string): { valid: boolean; message?: string } {
|
|
this.initializeConfig();
|
|
const resolvedPath = resolve(scriptPath);
|
|
const scriptsDirResolved = resolve(this.scriptsDir!);
|
|
|
|
// Check if the script is within the allowed directory
|
|
if (!resolvedPath.startsWith(scriptsDirResolved)) {
|
|
return {
|
|
valid: false,
|
|
message: 'Script path is not within the allowed scripts directory'
|
|
};
|
|
}
|
|
|
|
// Check if the script path matches any allowed path pattern
|
|
const relativePath = resolvedPath.replace(scriptsDirResolved, '').replace(/\\/g, '/');
|
|
const normalizedRelativePath = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
|
|
|
|
const isAllowed = this.allowedPaths!.some(allowedPath => {
|
|
const normalizedAllowedPath = allowedPath.startsWith('/') ? allowedPath : '/' + allowedPath;
|
|
// For root path '/', allow files directly in the scripts directory (no subdirectories)
|
|
if (normalizedAllowedPath === '/') {
|
|
return normalizedRelativePath === '/' || (normalizedRelativePath.startsWith('/') && !normalizedRelativePath.substring(1).includes('/'));
|
|
}
|
|
// For other paths like '/ct/', check if the path starts with it
|
|
return normalizedRelativePath.startsWith(normalizedAllowedPath);
|
|
});
|
|
|
|
if (!isAllowed) {
|
|
return {
|
|
valid: false,
|
|
message: 'Script path is not in the allowed paths list'
|
|
};
|
|
}
|
|
|
|
// Check file extension
|
|
const extension = extname(scriptPath);
|
|
if (!this.allowedExtensions!.includes(extension)) {
|
|
return {
|
|
valid: false,
|
|
message: `File extension '${extension}' is not allowed. Allowed extensions: ${this.allowedExtensions!.join(', ')}`
|
|
};
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Execute a script and return a child process
|
|
*/
|
|
async executeScript(scriptPath: string): Promise<ChildProcess> {
|
|
const validation = this.validateScriptPath(scriptPath);
|
|
if (!validation.valid) {
|
|
throw new Error(validation.message);
|
|
}
|
|
|
|
// Determine the command to run based on file extension
|
|
const extension = extname(scriptPath);
|
|
let command: string;
|
|
let args: string[] = [];
|
|
|
|
switch (extension) {
|
|
case '.sh':
|
|
case '.bash':
|
|
command = 'bash';
|
|
args = [scriptPath];
|
|
break;
|
|
case '.py':
|
|
command = 'python';
|
|
args = [scriptPath];
|
|
break;
|
|
case '.js':
|
|
command = 'node';
|
|
args = [scriptPath];
|
|
break;
|
|
case '.ts':
|
|
command = 'npx';
|
|
args = ['ts-node', scriptPath];
|
|
break;
|
|
default:
|
|
// Try to execute directly (for files with shebang)
|
|
command = scriptPath;
|
|
args = [];
|
|
}
|
|
|
|
// Spawn the process
|
|
const childProcess = spawn(command, args, {
|
|
cwd: this.scriptsDir!,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
shell: true
|
|
});
|
|
|
|
// Set up timeout
|
|
const timeout = setTimeout(() => {
|
|
if (!childProcess.killed) {
|
|
childProcess.kill('SIGTERM');
|
|
}
|
|
}, this.maxExecutionTime!);
|
|
|
|
// Clean up timeout when process exits
|
|
childProcess.on('exit', () => {
|
|
clearTimeout(timeout);
|
|
});
|
|
|
|
return childProcess;
|
|
}
|
|
|
|
/**
|
|
* Get script content for display
|
|
*/
|
|
async getScriptContent(scriptPath: string): Promise<string> {
|
|
const validation = this.validateScriptPath(scriptPath);
|
|
if (!validation.valid) {
|
|
throw new Error(validation.message);
|
|
}
|
|
|
|
return await readFile(scriptPath, 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* Get scripts directory information
|
|
*/
|
|
getScriptsDirectoryInfo(): {
|
|
path: string;
|
|
allowedExtensions: string[];
|
|
allowedPaths: string[];
|
|
maxExecutionTime: number;
|
|
} {
|
|
this.initializeConfig();
|
|
return {
|
|
path: this.scriptsDir!,
|
|
allowedExtensions: this.allowedExtensions!,
|
|
allowedPaths: this.allowedPaths!,
|
|
maxExecutionTime: this.maxExecutionTime!
|
|
};
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const scriptManager = new ScriptManager();
|