feat(pcd): add database linking functionality

This commit is contained in:
Sam Chau
2025-11-04 06:58:54 +10:30
parent a7d9685ed9
commit 37ae5164e6
35 changed files with 2790 additions and 63 deletions

View File

@@ -26,6 +26,7 @@ class Config {
await Deno.mkdir(this.paths.logs, { recursive: true });
await Deno.mkdir(this.paths.data, { recursive: true });
await Deno.mkdir(this.paths.backups, { recursive: true });
await Deno.mkdir(this.paths.databases, { recursive: true });
}
/**
@@ -54,6 +55,9 @@ class Config {
get database(): string {
return `${config.basePath}/data/profilarr.db`;
},
get databases(): string {
return `${config.basePath}/data/databases`;
},
get backups(): string {
return `${config.basePath}/backups`;
}

View File

@@ -0,0 +1,299 @@
/**
* Git utility functions for managing database repositories
*/
export interface GitStatus {
currentBranch: string;
isDirty: boolean;
untracked: string[];
modified: string[];
staged: string[];
}
export interface UpdateInfo {
hasUpdates: boolean;
commitsBehind: number;
commitsAhead: number;
latestRemoteCommit: string;
currentLocalCommit: string;
}
/**
* Execute a git command with sandboxed environment (no system credentials)
*/
async function execGit(args: string[], cwd?: string): Promise<string> {
const command = new Deno.Command('git', {
args,
cwd,
stdout: 'piped',
stderr: 'piped',
env: {
// Disable all credential helpers and interactive prompts
GIT_TERMINAL_PROMPT: '0', // Fail instead of prompting (git 2.3+)
GIT_ASKPASS: 'echo', // Return empty on credential requests
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', // Disable SSH password prompts
// Clear credential helpers via environment config
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'credential.helper',
GIT_CONFIG_VALUE_0: ''
}
});
const { code, stdout, stderr } = await command.output();
if (code !== 0) {
const errorMessage = new TextDecoder().decode(stderr);
throw new Error(`Git command failed: ${errorMessage}`);
}
return new TextDecoder().decode(stdout).trim();
}
/**
* Validate that a repository URL is accessible and detect if it's private using GitHub API
* Returns true if the repository is private, false if public
*/
async function validateRepository(repositoryUrl: string, personalAccessToken?: string): Promise<boolean> {
// Validate GitHub URL format and extract owner/repo
const githubPattern = /^https:\/\/github\.com\/([\w-]+)\/([\w-]+)\/?$/;
const normalizedUrl = repositoryUrl.replace(/\.git$/, '');
const match = normalizedUrl.match(githubPattern);
if (!match) {
throw new Error('Repository URL must be a valid GitHub repository (https://github.com/username/repo)');
}
const [, owner, repo] = match;
const apiUrl = `https://api.github.com/repos/${owner}/${repo}`;
// First try without authentication to check if it's public
try {
const response = await globalThis.fetch(apiUrl, {
headers: {
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'Profilarr'
}
});
if (response.ok) {
const data = await response.json();
// Repository is accessible without auth
return data.private === true;
}
// 404 or 403 means repo doesn't exist or is private
if (response.status === 404 || response.status === 403) {
// If we have a PAT, try with authentication
if (personalAccessToken) {
const authResponse = await globalThis.fetch(apiUrl, {
headers: {
'Accept': 'application/vnd.github+json',
'Authorization': `Bearer ${personalAccessToken}`,
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'Profilarr'
}
});
if (authResponse.ok) {
const data = await authResponse.json();
return data.private === true;
}
if (authResponse.status === 404) {
throw new Error('Repository not found. Please check the URL.');
}
if (authResponse.status === 401 || authResponse.status === 403) {
throw new Error('Unable to access repository. Please check your Personal Access Token has the correct permissions (repo scope required).');
}
throw new Error(`GitHub API error: ${authResponse.status} ${authResponse.statusText}`);
}
throw new Error('Repository not found or is private. Please provide a Personal Access Token if this is a private repository.');
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
} catch (error) {
if (error instanceof Error && (
error.message.includes('Repository not found') ||
error.message.includes('Unable to access') ||
error.message.includes('GitHub API error')
)) {
throw error;
}
throw new Error(`Failed to validate repository: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Clone a git repository
* Returns true if the repository is private, false if public
*/
export async function clone(
repositoryUrl: string,
targetPath: string,
branch?: string,
personalAccessToken?: string
): Promise<boolean> {
// Validate repository exists and detect if it's private
const isPrivate = await validateRepository(repositoryUrl, personalAccessToken);
const args = ['clone'];
if (branch) {
args.push('--branch', branch);
}
// Inject personal access token into URL if provided (for private repos or push access)
let authUrl = repositoryUrl;
if (personalAccessToken) {
// Format: https://TOKEN@github.com/username/repo
authUrl = repositoryUrl.replace('https://github.com', `https://${personalAccessToken}@github.com`);
}
args.push(authUrl, targetPath);
await execGit(args);
return isPrivate;
}
/**
* Pull latest changes from remote
*/
export async function pull(repoPath: string): Promise<void> {
await execGit(['pull'], repoPath);
}
/**
* Fetch from remote without merging
*/
export async function fetchRemote(repoPath: string): Promise<void> {
await execGit(['fetch'], repoPath);
}
/**
* Get current branch name
*/
export async function getCurrentBranch(repoPath: string): Promise<string> {
return await execGit(['branch', '--show-current'], repoPath);
}
/**
* Checkout a branch
*/
export async function checkout(repoPath: string, branch: string): Promise<void> {
await execGit(['checkout', branch], repoPath);
}
/**
* Get repository status
*/
export async function getStatus(repoPath: string): Promise<GitStatus> {
const currentBranch = await getCurrentBranch(repoPath);
// Get short status
const statusOutput = await execGit(['status', '--short'], repoPath);
const untracked: string[] = [];
const modified: string[] = [];
const staged: string[] = [];
for (const line of statusOutput.split('\n')) {
if (!line.trim()) continue;
const status = line.substring(0, 2);
const file = line.substring(3);
if (status.startsWith('??')) {
untracked.push(file);
} else if (status[1] === 'M' || status[1] === 'D') {
modified.push(file);
} else if (status[0] === 'M' || status[0] === 'A' || status[0] === 'D') {
staged.push(file);
}
}
const isDirty = untracked.length > 0 || modified.length > 0 || staged.length > 0;
return {
currentBranch,
isDirty,
untracked,
modified,
staged
};
}
/**
* Check for updates from remote
*/
export async function checkForUpdates(repoPath: string): Promise<UpdateInfo> {
// Fetch latest from remote
await fetchRemote(repoPath);
const currentBranch = await getCurrentBranch(repoPath);
const remoteBranch = `origin/${currentBranch}`;
// Get current commit
const currentLocalCommit = await execGit(['rev-parse', 'HEAD'], repoPath);
// Get remote commit
let latestRemoteCommit: string;
try {
latestRemoteCommit = await execGit(['rev-parse', remoteBranch], repoPath);
} catch {
// Remote branch doesn't exist or hasn't been fetched
return {
hasUpdates: false,
commitsBehind: 0,
commitsAhead: 0,
latestRemoteCommit: currentLocalCommit,
currentLocalCommit
};
}
// Count commits behind
let commitsBehind = 0;
try {
const behindOutput = await execGit(
['rev-list', '--count', `HEAD..${remoteBranch}`],
repoPath
);
commitsBehind = parseInt(behindOutput) || 0;
} catch {
commitsBehind = 0;
}
// Count commits ahead
let commitsAhead = 0;
try {
const aheadOutput = await execGit(
['rev-list', '--count', `${remoteBranch}..HEAD`],
repoPath
);
commitsAhead = parseInt(aheadOutput) || 0;
} catch {
commitsAhead = 0;
}
return {
hasUpdates: commitsBehind > 0,
commitsBehind,
commitsAhead,
latestRemoteCommit,
currentLocalCommit
};
}
/**
* Reset repository to match remote (discards local changes)
*/
export async function resetToRemote(repoPath: string): Promise<void> {
const currentBranch = await getCurrentBranch(repoPath);
const remoteBranch = `origin/${currentBranch}`;
await execGit(['reset', '--hard', remoteBranch], repoPath);
}

View File

@@ -54,21 +54,24 @@ class Logger {
* Check if logging is enabled
*/
private isEnabled(): boolean {
return this.config.enabled;
const currentSettings = logSettings.get();
return currentSettings.enabled === 1;
}
/**
* Check if file logging is enabled
*/
private isFileLoggingEnabled(): boolean {
return this.config.fileLogging;
const currentSettings = logSettings.get();
return currentSettings.file_logging === 1;
}
/**
* Check if console logging is enabled
*/
private isConsoleLoggingEnabled(): boolean {
return this.config.consoleLogging;
const currentSettings = logSettings.get();
return currentSettings.console_logging === 1;
}
/**
@@ -79,8 +82,12 @@ class Logger {
return false;
}
// Get fresh settings from database instead of using cached config
const currentSettings = logSettings.get();
const currentMinLevel = currentSettings.min_level;
const levels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
const minIndex = levels.indexOf(this.config.minLevel);
const minIndex = levels.indexOf(currentMinLevel);
const levelIndex = levels.indexOf(level);
return levelIndex >= minIndex;