Skip to content

Commit a56c625

Browse files
feat: private/custom git repos - GitHub, GitLab, Bitbucket, custom
- Add repository URL validation for GitHub, GitLab, Bitbucket, and custom hosts - Add git provider layer (listDirectory, downloadRawFile) for all providers - Wire githubJsonService and scriptDownloader to use provider; sync/download from any supported source - Update GeneralSettingsModal placeholder and help text; .env.example and env schema for GITLAB_TOKEN, BITBUCKET_APP_PASSWORD Closes #406
1 parent 54b2187 commit a56c625

17 files changed

Lines changed: 408 additions & 220 deletions

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ ALLOWED_SCRIPT_PATHS="scripts/"
1818
WEBSOCKET_PORT="3001"
1919

2020
# User settings
21+
# Optional tokens for private repos: GITHUB_TOKEN (GitHub), GITLAB_TOKEN (GitLab),
22+
# BITBUCKET_APP_PASSWORD or BITBUCKET_TOKEN (Bitbucket). REPO_URL and added repos
23+
# can be GitHub, GitLab, Bitbucket, or custom Git servers.
2124
GITHUB_TOKEN=
25+
GITLAB_TOKEN=
26+
BITBUCKET_APP_PASSWORD=
2227
SAVE_FILTER=false
2328
FILTERS=
2429
AUTH_USERNAME=

src/app/_components/GeneralSettingsModal.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1617,7 +1617,7 @@ export function GeneralSettingsModal({
16171617
<Input
16181618
id="new-repo-url"
16191619
type="url"
1620-
placeholder="https://github.com/owner/repo"
1620+
placeholder="https://github.com/owner/repo or https://git.example.com/owner/repo"
16211621
value={newRepoUrl}
16221622
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
16231623
setNewRepoUrl(e.target.value)
@@ -1626,8 +1626,9 @@ export function GeneralSettingsModal({
16261626
className="w-full"
16271627
/>
16281628
<p className="text-muted-foreground mt-1 text-xs">
1629-
Enter a GitHub repository URL (e.g.,
1630-
https://github.com/owner/repo)
1629+
Supported: GitHub, GitLab, Bitbucket, or custom Git
1630+
servers (e.g. https://github.com/owner/repo,
1631+
https://gitlab.com/owner/repo)
16311632
</p>
16321633
</div>
16331634
<div className="border-border flex items-center justify-between gap-3 rounded-lg border p-3">

src/env.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@ export const env = createEnv({
2323
ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"),
2424
// WebSocket Configuration
2525
WEBSOCKET_PORT: z.string().default("3001"),
26-
// GitHub Configuration
26+
// Git provider tokens (optional, for private repos)
2727
GITHUB_TOKEN: z.string().optional(),
28+
GITLAB_TOKEN: z.string().optional(),
29+
BITBUCKET_APP_PASSWORD: z.string().optional(),
30+
BITBUCKET_TOKEN: z.string().optional(),
2831
// Authentication Configuration
2932
AUTH_USERNAME: z.string().optional(),
3033
AUTH_PASSWORD_HASH: z.string().optional(),
@@ -62,8 +65,10 @@ export const env = createEnv({
6265
ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS,
6366
// WebSocket Configuration
6467
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT,
65-
// GitHub Configuration
6668
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
69+
GITLAB_TOKEN: process.env.GITLAB_TOKEN,
70+
BITBUCKET_APP_PASSWORD: process.env.BITBUCKET_APP_PASSWORD,
71+
BITBUCKET_TOKEN: process.env.BITBUCKET_TOKEN,
6772
// Authentication Configuration
6873
AUTH_USERNAME: process.env.AUTH_USERNAME,
6974
AUTH_PASSWORD_HASH: process.env.AUTH_PASSWORD_HASH,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { DirEntry, GitProvider } from './types';
2+
import { parseRepoUrl } from '../repositoryUrlValidation';
3+
4+
export class BitbucketProvider implements GitProvider {
5+
async listDirectory(repoUrl: string, path: string, branch: string): Promise<DirEntry[]> {
6+
const { owner, repo } = parseRepoUrl(repoUrl);
7+
const listUrl = `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${encodeURIComponent(branch)}/${path}`;
8+
const headers: Record<string, string> = {
9+
'User-Agent': 'PVEScripts-Local/1.0',
10+
};
11+
const token = process.env.BITBUCKET_APP_PASSWORD ?? process.env.BITBUCKET_TOKEN;
12+
if (token) {
13+
const auth = Buffer.from(`:${token}`).toString('base64');
14+
headers.Authorization = `Basic ${auth}`;
15+
}
16+
17+
const response = await fetch(listUrl, { headers });
18+
if (!response.ok) {
19+
throw new Error(`Bitbucket API error: ${response.status} ${response.statusText}`);
20+
}
21+
22+
const body = (await response.json()) as { values?: { path: string; type: string }[] };
23+
const data = body.values ?? (Array.isArray(body) ? body : []);
24+
if (!Array.isArray(data)) {
25+
throw new Error('Bitbucket API returned unexpected response');
26+
}
27+
return data.map((item: { path: string; type: string }) => {
28+
const name = item.path.split('/').pop() ?? item.path;
29+
return {
30+
name,
31+
path: item.path,
32+
type: item.type === 'commit_directory' ? ('dir' as const) : ('file' as const),
33+
};
34+
});
35+
}
36+
37+
async downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise<string> {
38+
const { owner, repo } = parseRepoUrl(repoUrl);
39+
const rawUrl = `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${encodeURIComponent(branch)}/${filePath}`;
40+
const headers: Record<string, string> = {
41+
'User-Agent': 'PVEScripts-Local/1.0',
42+
};
43+
const token = process.env.BITBUCKET_APP_PASSWORD ?? process.env.BITBUCKET_TOKEN;
44+
if (token) {
45+
const auth = Buffer.from(`:${token}`).toString('base64');
46+
headers.Authorization = `Basic ${auth}`;
47+
}
48+
49+
const response = await fetch(rawUrl, { headers });
50+
if (!response.ok) {
51+
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
52+
}
53+
return response.text();
54+
}
55+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { DirEntry, GitProvider } from "./types";
2+
import { parseRepoUrl } from "../repositoryUrlValidation";
3+
4+
export class CustomProvider implements GitProvider {
5+
async listDirectory(repoUrl: string, path: string, branch: string): Promise<DirEntry[]> {
6+
const { origin, owner, repo } = parseRepoUrl(repoUrl);
7+
const apiUrl = `${origin}/api/v1/repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(branch)}`;
8+
const headers: Record<string, string> = { "User-Agent": "PVEScripts-Local/1.0" };
9+
const token = process.env.GITEA_TOKEN ?? process.env.GIT_TOKEN;
10+
if (token) headers.Authorization = `token ${token}`;
11+
12+
const response = await fetch(apiUrl, { headers });
13+
if (!response.ok) {
14+
throw new Error(`Custom Git server: list directory failed (${response.status}).`);
15+
}
16+
const data = (await response.json()) as { type: string; name: string; path: string }[];
17+
if (!Array.isArray(data)) {
18+
const single = data as unknown as { type?: string; name?: string; path?: string };
19+
if (single?.name) {
20+
return [{ name: single.name, path: single.path ?? path, type: single.type === "dir" ? "dir" : "file" }];
21+
}
22+
throw new Error("Custom Git server returned unexpected response");
23+
}
24+
return data.map((item) => ({
25+
name: item.name,
26+
path: item.path,
27+
type: item.type === "dir" ? ("dir" as const) : ("file" as const),
28+
}));
29+
}
30+
31+
async downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise<string> {
32+
const { origin, owner, repo } = parseRepoUrl(repoUrl);
33+
const rawUrl = `${origin}/${owner}/${repo}/raw/${encodeURIComponent(branch)}/${filePath}`;
34+
const headers: Record<string, string> = { "User-Agent": "PVEScripts-Local/1.0" };
35+
const token = process.env.GITEA_TOKEN ?? process.env.GIT_TOKEN;
36+
if (token) headers.Authorization = `token ${token}`;
37+
38+
const response = await fetch(rawUrl, { headers });
39+
if (!response.ok) {
40+
throw new Error(`Failed to download ${filePath} from custom Git server (${response.status}).`);
41+
}
42+
return response.text();
43+
}
44+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { DirEntry, GitProvider } from './types';
2+
import { parseRepoUrl } from '../repositoryUrlValidation';
3+
4+
export class GitHubProvider implements GitProvider {
5+
async listDirectory(repoUrl: string, path: string, branch: string): Promise<DirEntry[]> {
6+
const { owner, repo } = parseRepoUrl(repoUrl);
7+
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(branch)}`;
8+
const headers: Record<string, string> = {
9+
Accept: 'application/vnd.github.v3+json',
10+
'User-Agent': 'PVEScripts-Local/1.0',
11+
};
12+
const token = process.env.GITHUB_TOKEN;
13+
if (token) headers.Authorization = `token ${token}`;
14+
15+
const response = await fetch(apiUrl, { headers });
16+
if (!response.ok) {
17+
if (response.status === 403) {
18+
const err = new Error(
19+
`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN. Status: ${response.status} ${response.statusText}`
20+
);
21+
(err as Error & { name: string }).name = 'RateLimitError';
22+
throw err;
23+
}
24+
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
25+
}
26+
27+
const data = (await response.json()) as { type: string; name: string; path: string }[];
28+
if (!Array.isArray(data)) {
29+
throw new Error('GitHub API returned unexpected response');
30+
}
31+
return data.map((item) => ({
32+
name: item.name,
33+
path: item.path,
34+
type: item.type === 'dir' ? ('dir' as const) : ('file' as const),
35+
}));
36+
}
37+
38+
async downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise<string> {
39+
const { owner, repo } = parseRepoUrl(repoUrl);
40+
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(branch)}/${filePath}`;
41+
const headers: Record<string, string> = {
42+
'User-Agent': 'PVEScripts-Local/1.0',
43+
};
44+
const token = process.env.GITHUB_TOKEN;
45+
if (token) headers.Authorization = `token ${token}`;
46+
47+
const response = await fetch(rawUrl, { headers });
48+
if (!response.ok) {
49+
if (response.status === 403) {
50+
const err = new Error(
51+
`GitHub rate limit exceeded while downloading ${filePath}. Consider setting GITHUB_TOKEN.`
52+
);
53+
(err as Error & { name: string }).name = 'RateLimitError';
54+
throw err;
55+
}
56+
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
57+
}
58+
return response.text();
59+
}
60+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { DirEntry, GitProvider } from './types';
2+
import { parseRepoUrl } from '../repositoryUrlValidation';
3+
4+
export class GitLabProvider implements GitProvider {
5+
private getBaseUrl(repoUrl: string): string {
6+
const { origin } = parseRepoUrl(repoUrl);
7+
return origin;
8+
}
9+
10+
private getProjectId(repoUrl: string): string {
11+
const { owner, repo } = parseRepoUrl(repoUrl);
12+
return encodeURIComponent(`${owner}/${repo}`);
13+
}
14+
15+
async listDirectory(repoUrl: string, path: string, branch: string): Promise<DirEntry[]> {
16+
const baseUrl = this.getBaseUrl(repoUrl);
17+
const projectId = this.getProjectId(repoUrl);
18+
const apiUrl = `${baseUrl}/api/v4/projects/${projectId}/repository/tree?path=${encodeURIComponent(path)}&ref=${encodeURIComponent(branch)}&per_page=100`;
19+
const headers: Record<string, string> = {
20+
'User-Agent': 'PVEScripts-Local/1.0',
21+
};
22+
const token = process.env.GITLAB_TOKEN;
23+
if (token) headers['PRIVATE-TOKEN'] = token;
24+
25+
const response = await fetch(apiUrl, { headers });
26+
if (!response.ok) {
27+
throw new Error(`GitLab API error: ${response.status} ${response.statusText}`);
28+
}
29+
30+
const data = (await response.json()) as { type: string; name: string; path: string }[];
31+
if (!Array.isArray(data)) {
32+
throw new Error('GitLab API returned unexpected response');
33+
}
34+
return data.map((item) => ({
35+
name: item.name,
36+
path: item.path,
37+
type: item.type === 'tree' ? ('dir' as const) : ('file' as const),
38+
}));
39+
}
40+
41+
async downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise<string> {
42+
const baseUrl = this.getBaseUrl(repoUrl);
43+
const projectId = this.getProjectId(repoUrl);
44+
const encodedPath = encodeURIComponent(filePath);
45+
const rawUrl = `${baseUrl}/api/v4/projects/${projectId}/repository/files/${encodedPath}/raw?ref=${encodeURIComponent(branch)}`;
46+
const headers: Record<string, string> = {
47+
'User-Agent': 'PVEScripts-Local/1.0',
48+
};
49+
const token = process.env.GITLAB_TOKEN;
50+
if (token) headers['PRIVATE-TOKEN'] = token;
51+
52+
const response = await fetch(rawUrl, { headers });
53+
if (!response.ok) {
54+
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
55+
}
56+
return response.text();
57+
}
58+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { listDirectory, downloadRawFile, getRepoProvider } from "./index.ts";
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { DirEntry, GitProvider } from "./types";
2+
import { getRepoProvider } from "../repositoryUrlValidation";
3+
import { GitHubProvider } from "./github";
4+
import { GitLabProvider } from "./gitlab";
5+
import { BitbucketProvider } from "./bitbucket";
6+
import { CustomProvider } from "./custom";
7+
8+
const providers: Record<string, GitProvider> = {
9+
github: new GitHubProvider(),
10+
gitlab: new GitLabProvider(),
11+
bitbucket: new BitbucketProvider(),
12+
custom: new CustomProvider(),
13+
};
14+
15+
export type { DirEntry, GitProvider };
16+
export { getRepoProvider };
17+
18+
export function getGitProvider(repoUrl: string): GitProvider {
19+
return providers[getRepoProvider(repoUrl)]!;
20+
}
21+
22+
export async function listDirectory(repoUrl: string, path: string, branch: string): Promise<DirEntry[]> {
23+
return getGitProvider(repoUrl).listDirectory(repoUrl, path, branch);
24+
}
25+
26+
export async function downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise<string> {
27+
return getGitProvider(repoUrl).downloadRawFile(repoUrl, filePath, branch);
28+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Git provider interface for listing and downloading repository files.
3+
*/
4+
5+
export type DirEntry = {
6+
name: string;
7+
path: string;
8+
type: 'file' | 'dir';
9+
};
10+
11+
export interface GitProvider {
12+
listDirectory(repoUrl: string, path: string, branch: string): Promise<DirEntry[]>;
13+
downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise<string>;
14+
}

0 commit comments

Comments
 (0)