Fix CI/CD problems

This commit is contained in:
Michel Roegl-Brunner
2025-09-15 15:46:05 +02:00
parent c97af5a122
commit a5975a9b56
5 changed files with 157 additions and 81 deletions

View File

@@ -2,6 +2,44 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import Home from '../page'
// Mock tRPC
vi.mock('~/trpc/react', () => ({
api: {
scripts: {
getRepoStatus: {
useQuery: vi.fn(() => ({
data: { isRepo: true, isBehind: false, branch: 'main', lastCommit: 'abc123' },
refetch: vi.fn(),
})),
},
getScriptCards: {
useQuery: vi.fn(() => ({
data: { success: true, cards: [] },
isLoading: false,
error: null,
})),
},
getCtScripts: {
useQuery: vi.fn(() => ({
data: { scripts: [] },
isLoading: false,
error: null,
})),
},
getScriptBySlug: {
useQuery: vi.fn(() => ({
data: null,
})),
},
fullUpdateRepo: {
useMutation: vi.fn(() => ({
mutate: vi.fn(),
})),
},
},
},
}))
// Mock child components
vi.mock('../_components/ScriptsGrid', () => ({
ScriptsGrid: ({ onInstallScript }: { onInstallScript?: (path: string, name: string) => void }) => (

View File

@@ -9,14 +9,17 @@ const execAsync = promisify(exec);
export class GitManager {
private git: SimpleGit;
private repoPath: string;
private scriptsDir: string;
private scriptsDir: string | null = null;
constructor() {
this.repoPath = process.cwd();
this.scriptsDir = join(this.repoPath, env.SCRIPTS_DIRECTORY);
this.git = simpleGit(this.repoPath);
}
private initializeConfig() {
this.scriptsDir ??= join(this.repoPath, env.SCRIPTS_DIRECTORY);
}
/**
* Check if the repository is behind the remote
*/

View File

@@ -16,38 +16,45 @@ export interface ScriptInfo {
}
export class ScriptManager {
private scriptsDir: string;
private allowedExtensions: string[];
private allowedPaths: string[];
private maxExecutionTime: number;
private scriptsDir: string | null = null;
private allowedExtensions: string[] | null = null;
private allowedPaths: string[] | null = null;
private maxExecutionTime: number | null = null;
constructor() {
// 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);
// Initialize lazily to avoid accessing env vars during module load
}
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 files = await readdir(this.scriptsDir!);
const scripts: ScriptInfo[] = [];
for (const file of files) {
const filePath = join(this.scriptsDir, file);
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)) {
if (this.allowedExtensions!.includes(extension)) {
// Check if file is executable
const executable = await this.isExecutable(filePath);
@@ -74,8 +81,9 @@ export class ScriptManager {
* Get all available scripts in the ct subdirectory
*/
async getCtScripts(): Promise<ScriptInfo[]> {
this.initializeConfig();
try {
const ctDir = join(this.scriptsDir, 'ct');
const ctDir = join(this.scriptsDir!, 'ct');
const files = await readdir(ctDir);
const scripts: ScriptInfo[] = [];
@@ -87,7 +95,7 @@ export class ScriptManager {
const extension = extname(file);
// Check if file extension is allowed
if (this.allowedExtensions.includes(extension)) {
if (this.allowedExtensions!.includes(extension)) {
// Check if file is executable
const executable = await this.isExecutable(filePath);
@@ -143,8 +151,9 @@ export class ScriptManager {
* 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);
const scriptsDirResolved = resolve(this.scriptsDir!);
// Check if the script is within the allowed directory
if (!resolvedPath.startsWith(scriptsDirResolved)) {
@@ -158,7 +167,7 @@ export class ScriptManager {
const relativePath = resolvedPath.replace(scriptsDirResolved, '').replace(/\\/g, '/');
const normalizedRelativePath = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
const isAllowed = this.allowedPaths.some(allowedPath => {
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 === '/') {
@@ -177,10 +186,10 @@ export class ScriptManager {
// Check file extension
const extension = extname(scriptPath);
if (!this.allowedExtensions.includes(extension)) {
if (!this.allowedExtensions!.includes(extension)) {
return {
valid: false,
message: `File extension '${extension}' is not allowed. Allowed extensions: ${this.allowedExtensions.join(', ')}`
message: `File extension '${extension}' is not allowed. Allowed extensions: ${this.allowedExtensions!.join(', ')}`
};
}
@@ -227,7 +236,7 @@ export class ScriptManager {
// Spawn the process
const childProcess = spawn(command, args, {
cwd: this.scriptsDir,
cwd: this.scriptsDir!,
stdio: ['pipe', 'pipe', 'pipe'],
shell: true
});
@@ -237,7 +246,7 @@ export class ScriptManager {
if (!childProcess.killed) {
childProcess.kill('SIGTERM');
}
}, this.maxExecutionTime);
}, this.maxExecutionTime!);
// Clean up timeout when process exits
childProcess.on('exit', () => {
@@ -268,11 +277,12 @@ export class ScriptManager {
allowedPaths: string[];
maxExecutionTime: number;
} {
this.initializeConfig();
return {
path: this.scriptsDir,
allowedExtensions: this.allowedExtensions,
allowedPaths: this.allowedPaths,
maxExecutionTime: this.maxExecutionTime
path: this.scriptsDir!,
allowedExtensions: this.allowedExtensions!,
allowedPaths: this.allowedPaths!,
maxExecutionTime: this.maxExecutionTime!
};
}
}

View File

@@ -4,37 +4,44 @@ import { env } from '~/env.js';
import type { Script, ScriptCard, GitHubFile } from '~/types/script';
export class GitHubJsonService {
private baseUrl: string;
private repoUrl: string;
private branch: string;
private jsonFolder: string;
private localJsonDirectory: string;
private baseUrl: string | null = null;
private repoUrl: string | null = null;
private branch: string | null = null;
private jsonFolder: string | null = null;
private localJsonDirectory: string | null = null;
private scriptCache: Map<string, Script> = new Map();
constructor() {
this.repoUrl = env.REPO_URL ?? "";
this.branch = env.REPO_BRANCH;
this.jsonFolder = env.JSON_FOLDER;
this.localJsonDirectory = join(process.cwd(), 'scripts', 'json');
// Only validate GitHub URL if it's provided
if (this.repoUrl) {
// Extract owner and repo from the URL
const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl);
if (!urlMatch) {
throw new Error(`Invalid GitHub repository URL: ${this.repoUrl}`);
}
// Initialize lazily to avoid accessing env vars during module load
}
private initializeConfig() {
if (this.repoUrl === null) {
this.repoUrl = env.REPO_URL ?? "";
this.branch = env.REPO_BRANCH;
this.jsonFolder = env.JSON_FOLDER;
this.localJsonDirectory = join(process.cwd(), 'scripts', 'json');
const [, owner, repo] = urlMatch;
this.baseUrl = `https://api.github.com/repos/${owner}/${repo}`;
} else {
// Set a dummy base URL if no REPO_URL is provided
this.baseUrl = "";
// Only validate GitHub URL if it's provided
if (this.repoUrl) {
// Extract owner and repo from the URL
const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl);
if (!urlMatch) {
throw new Error(`Invalid GitHub repository URL: ${this.repoUrl}`);
}
const [, owner, repo] = urlMatch;
this.baseUrl = `https://api.github.com/repos/${owner}/${repo}`;
} else {
// Set a dummy base URL if no REPO_URL is provided
this.baseUrl = "";
}
}
}
private async fetchFromGitHub<T>(endpoint: string): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
this.initializeConfig();
const response = await fetch(`${this.baseUrl!}${endpoint}`, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
@@ -49,7 +56,8 @@ export class GitHubJsonService {
}
private async downloadJsonFile(filePath: string): Promise<Script> {
const rawUrl = `https://raw.githubusercontent.com/${this.extractRepoPath()}/${this.branch}/${filePath}`;
this.initializeConfig();
const rawUrl = `https://raw.githubusercontent.com/${this.extractRepoPath()}/${this.branch!}/${filePath}`;
const response = await fetch(rawUrl);
if (!response.ok) {
@@ -61,7 +69,8 @@ export class GitHubJsonService {
}
private extractRepoPath(): string {
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl);
this.initializeConfig();
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl!);
if (!match) {
throw new Error('Invalid GitHub repository URL');
}
@@ -69,13 +78,14 @@ export class GitHubJsonService {
}
async getJsonFiles(): Promise<GitHubFile[]> {
this.initializeConfig();
if (!this.repoUrl) {
throw new Error('REPO_URL environment variable is not set. Cannot fetch from GitHub.');
}
try {
const files = await this.fetchFromGitHub<GitHubFile[]>(
`/contents/${this.jsonFolder}?ref=${this.branch}`
`/contents/${this.jsonFolder!}?ref=${this.branch!}`
);
// Filter for JSON files only
@@ -139,7 +149,8 @@ export class GitHubJsonService {
// If not found locally, try to download just this specific script
try {
const script = await this.downloadJsonFile(`${this.jsonFolder}/${slug}.json`);
this.initializeConfig();
const script = await this.downloadJsonFile(`${this.jsonFolder!}/${slug}.json`);
return script;
} catch {
console.log(`Script ${slug} not found in repository`);
@@ -161,7 +172,8 @@ export class GitHubJsonService {
const { readFile } = await import('fs/promises');
const { join } = await import('path');
const filePath = join(this.localJsonDirectory, `${slug}.json`);
this.initializeConfig();
const filePath = join(this.localJsonDirectory!, `${slug}.json`);
const content = await readFile(filePath, 'utf-8');
const script = JSON.parse(content) as Script;
@@ -198,14 +210,15 @@ export class GitHubJsonService {
}
private async saveScriptsLocally(scripts: Script[]): Promise<void> {
this.initializeConfig();
try {
// Ensure the directory exists
await mkdir(this.localJsonDirectory, { recursive: true });
await mkdir(this.localJsonDirectory!, { recursive: true });
// Save each script as a JSON file
for (const script of scripts) {
const filename = `${script.slug}.json`;
const filePath = join(this.localJsonDirectory, filename);
const filePath = join(this.localJsonDirectory!, filename);
const content = JSON.stringify(script, null, 2);
await writeFile(filePath, content, 'utf-8');
}

View File

@@ -4,12 +4,18 @@ import { env } from '~/env.js';
import type { Script } from '~/types/script';
export class ScriptDownloaderService {
private scriptsDirectory: string;
private repoUrl: string;
private scriptsDirectory: string | null = null;
private repoUrl: string | null = null;
constructor() {
this.scriptsDirectory = join(process.cwd(), 'scripts');
this.repoUrl = env.REPO_URL ?? '';
// Initialize lazily to avoid accessing env vars during module load
}
private initializeConfig() {
if (this.scriptsDirectory === null) {
this.scriptsDirectory = join(process.cwd(), 'scripts');
this.repoUrl = env.REPO_URL ?? '';
}
}
private async ensureDirectoryExists(dirPath: string): Promise<void> {
@@ -21,6 +27,7 @@ export class ScriptDownloaderService {
}
private async downloadFileFromGitHub(filePath: string): Promise<string> {
this.initializeConfig();
if (!this.repoUrl) {
throw new Error('REPO_URL environment variable is not set');
}
@@ -36,7 +43,8 @@ export class ScriptDownloaderService {
}
private extractRepoPath(): string {
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl);
this.initializeConfig();
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl!);
if (!match) {
throw new Error('Invalid GitHub repository URL');
}
@@ -53,14 +61,15 @@ export class ScriptDownloaderService {
}
async loadScript(script: Script): Promise<{ success: boolean; message: string; files: string[] }> {
this.initializeConfig();
try {
const files: string[] = [];
// Ensure directories exist
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'ct'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'install'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'tools'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'vm'));
await this.ensureDirectoryExists(join(this.scriptsDirectory!, 'ct'));
await this.ensureDirectoryExists(join(this.scriptsDirectory!, 'install'));
await this.ensureDirectoryExists(join(this.scriptsDirectory!, 'tools'));
await this.ensureDirectoryExists(join(this.scriptsDirectory!, 'vm'));
if (script.install_methods?.length) {
for (const method of script.install_methods) {
@@ -80,28 +89,28 @@ export class ScriptDownloaderService {
targetDir = 'ct';
// Modify the content for CT scripts
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory, targetDir, fileName);
filePath = join(this.scriptsDirectory!, targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8');
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
// Don't modify content for tools scripts
filePath = join(this.scriptsDirectory, targetDir, fileName);
filePath = join(this.scriptsDirectory!, targetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
// Don't modify content for VM scripts
filePath = join(this.scriptsDirectory, targetDir, fileName);
filePath = join(this.scriptsDirectory!, targetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
// Don't modify content for VW scripts
filePath = join(this.scriptsDirectory, targetDir, fileName);
filePath = join(this.scriptsDirectory!, targetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else {
// Handle other script types (fallback to ct directory)
targetDir = 'ct';
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory, targetDir, fileName);
filePath = join(this.scriptsDirectory!, targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8');
}
@@ -117,7 +126,7 @@ export class ScriptDownloaderService {
const installScriptName = `${script.slug}-install.sh`;
try {
const installContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
const localInstallPath = join(this.scriptsDirectory!, 'install', installScriptName);
await writeFile(localInstallPath, installContent, 'utf-8');
files.push(`install/${installScriptName}`);
} catch {
@@ -141,6 +150,7 @@ export class ScriptDownloaderService {
}
async checkScriptExists(script: Script): Promise<{ ctExists: boolean; installExists: boolean; files: string[] }> {
this.initializeConfig();
const files: string[] = [];
let ctExists = false;
let installExists = false;
@@ -159,7 +169,7 @@ export class ScriptDownloaderService {
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
localPath = join(this.scriptsDirectory, targetDir, fileName);
localPath = join(this.scriptsDirectory!, targetDir, fileName);
try {
await readFile(localPath, 'utf-8');
ctExists = true;
@@ -169,7 +179,7 @@ export class ScriptDownloaderService {
}
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
localPath = join(this.scriptsDirectory, targetDir, fileName);
localPath = join(this.scriptsDirectory!, targetDir, fileName);
try {
await readFile(localPath, 'utf-8');
ctExists = true; // Use ctExists for tools scripts too for UI consistency
@@ -179,7 +189,7 @@ export class ScriptDownloaderService {
}
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
localPath = join(this.scriptsDirectory, targetDir, fileName);
localPath = join(this.scriptsDirectory!, targetDir, fileName);
try {
await readFile(localPath, 'utf-8');
ctExists = true; // Use ctExists for VM scripts too for UI consistency
@@ -189,7 +199,7 @@ export class ScriptDownloaderService {
}
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
localPath = join(this.scriptsDirectory, targetDir, fileName);
localPath = join(this.scriptsDirectory!, targetDir, fileName);
try {
await readFile(localPath, 'utf-8');
ctExists = true; // Use ctExists for VW scripts too for UI consistency
@@ -207,7 +217,7 @@ export class ScriptDownloaderService {
const hasCtScript = script.install_methods?.some(method => method.script?.startsWith('ct/'));
if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`;
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
const localInstallPath = join(this.scriptsDirectory!, 'install', installScriptName);
try {
await readFile(localInstallPath, 'utf-8');
installExists = true;
@@ -225,6 +235,7 @@ export class ScriptDownloaderService {
}
async compareScriptContent(script: Script): Promise<{ hasDifferences: boolean; differences: string[] }> {
this.initializeConfig();
const differences: string[] = [];
let hasDifferences = false;
@@ -310,7 +321,7 @@ export class ScriptDownloaderService {
private async compareSingleFile(remotePath: string, filePath: string): Promise<{ hasDifferences: boolean; filePath: string }> {
try {
const localPath = join(this.scriptsDirectory, filePath);
const localPath = join(this.scriptsDirectory!, filePath);
// Read local content
const localContent = await readFile(localPath, 'utf-8');
@@ -337,6 +348,7 @@ export class ScriptDownloaderService {
}
async getScriptDiff(script: Script, filePath: string): Promise<{ diff: string | null; localContent: string | null; remoteContent: string | null }> {
this.initializeConfig();
try {
let localContent: string | null = null;
let remoteContent: string | null = null;
@@ -345,7 +357,7 @@ export class ScriptDownloaderService {
// Handle CT script
const fileName = filePath.split('/').pop();
if (fileName) {
const localPath = join(this.scriptsDirectory, 'ct', fileName);
const localPath = join(this.scriptsDirectory!, 'ct', fileName);
try {
localContent = await readFile(localPath, 'utf-8');
} catch {
@@ -365,7 +377,7 @@ export class ScriptDownloaderService {
}
} else if (filePath.startsWith('install/')) {
// Handle install script
const localPath = join(this.scriptsDirectory, filePath);
const localPath = join(this.scriptsDirectory!, filePath);
try {
localContent = await readFile(localPath, 'utf-8');
} catch {