feat: migrate from JSON files to PocketBase public API (#510)

* feat: migrate from JSON files to PocketBase public API

- Remove all community JSON files from json/ folder (folder kept for user scripts)
- Add pocketbase npm package
- Add pbService.ts: PocketBase singleton client (unauthenticated)
- Add pbScripts.ts: PocketBase queries mirroring the website's API
- Update localScripts.ts: use PocketBase as primary source, local JSON as fallback
- Rewrite scripts.ts router: all data fetching via PocketBase
- Update scriptDownloader.js: derive script paths by convention (type+slug)
  instead of relying on explicit JSON paths
- Update autoSyncService.js: fetch scripts from PocketBase instead of JSON files
- Update env.js: add PB_URL, remove JSON_FOLDER requirement
- Update .env.example: document new PB_URL variable

* fix: correct PocketBase URL to db.community-scripts.org

* fix: downgrade @vitejs/plugin-react to ^5 (compatible with vitest@4/vite@6), add .npmrc

* fix: resolve all npm audit vulnerabilities via overrides

- @hono/node-server: >=1.19.10 (authorization bypass fix)
- lodash: ^4.17.23 (prototype pollution fix)
All vulns were transitive deps of prisma CLI internals (@prisma/dev)

* fix: remove .js extensions from TS imports for webpack compatibility

* fix: remove 'source' from ScriptCard literal, fix pbScripts import extension

* fix: restore repository_url as optional in ScriptCard, remove dead repo filters

* fix: resolve all remaining build errors

- resyncScripts: add error field to return type
- ScriptsGrid.tsx: remove 'source' from ScriptCard literal
- autoSyncService.js: add JSDoc types for implicit any params
- githubJsonService.ts: use process.env.JSON_FOLDER directly
- localScripts.ts: fix PB->Script mapping (interface_port, default_credentials,
  date_created, logo as null); add website field to getScriptCards()

* fix: use dbUrl fallback for Prisma adapter and align default DB path with prisma.config.ts

- db.ts / db.js: use dbUrl variable (with fallback to pve-scripts.db) instead
  of process.env.DATABASE_URL! directly, prevents crash when DATABASE_URL is
  not set in environment
- autoSyncService.js: suppress ENOENT error log in loadSettings when .env file
  does not exist (return defaults silently instead of printing error)

* fix: align .env.example DATABASE_URL db filename with prisma.config.ts default
This commit is contained in:
CanbiZ (MickLesk)
2026-03-17 15:44:32 +01:00
committed by GitHub
parent f396e387f9
commit d8e92e0445
422 changed files with 1783 additions and 19641 deletions

View File

@@ -2,16 +2,104 @@
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { scriptManager } from "~/server/lib/scripts";
import { githubJsonService } from "~/server/services/githubJsonService";
import { localScriptsService } from "~/server/services/localScripts";
import { scriptDownloaderService } from "~/server/services/scriptDownloader.js";
import { AutoSyncService } from "~/server/services/autoSyncService";
import { repositoryService } from "~/server/services/repositoryService";
import { getStorageService } from "~/server/services/storageService";
import { getDatabase } from "~/server/database-prisma";
import type { ScriptCard } from "~/types/script";
import {
getScriptCards,
getScriptBySlug as pbGetScriptBySlug,
getAllScripts as pbGetAllScripts,
getMetadata as pbGetMetadata,
type PBScript,
type PBScriptCard,
} from "~/server/services/pbScripts";
import type { Script, ScriptCard } from "~/types/script";
import type { Server } from "~/types/server";
// ---------------------------------------------------------------------------
// Mapper: PocketBase record → internal Script type (used by scriptDownloader)
// ---------------------------------------------------------------------------
function pbToScript(pb: PBScript): Script {
return {
name: pb.name,
slug: pb.slug,
categories: pb.categories.map((c) => c.name),
date_created: pb.script_created,
type: pb.type,
updateable: pb.updateable,
privileged: pb.privileged,
interface_port: pb.port,
documentation: pb.documentation,
website: pb.website,
logo: pb.logo,
config_path: pb.config_path,
description: pb.description,
install_methods: pb.install_methods_json.map((m) => ({
type: m.type,
resources: m.resources,
config_path: m.config_path,
})),
default_credentials: {
username: pb.default_user,
password: pb.default_passwd,
},
notes: pb.notes_json,
is_dev: pb.is_dev,
is_disabled: pb.is_disabled,
is_deleted: pb.is_deleted,
has_arm: pb.has_arm,
version: pb.version,
};
}
function pbCardToScriptCard(pb: PBScriptCard): ScriptCard {
return {
name: pb.name,
slug: pb.slug,
description: pb.description,
logo: pb.logo,
type: pb.type,
updateable: pb.updateable,
website: pb.website,
categoryNames: pb.categories.map((c) => c.name),
date_created: pb.script_created,
interface_port: pb.port,
is_dev: pb.is_dev,
is_disabled: pb.is_disabled,
is_deleted: pb.is_deleted,
has_arm: pb.has_arm,
// Derive install basenames from type + slug (same convention as the website)
install_basenames: deriveInstallBasenames(pb.type, pb.slug),
};
}
/**
* Derive the expected install file basenames from script type + slug.
* Mirrors ProxmoxVE-Frontend/lib/install-command.ts conventions.
*/
function deriveInstallBasenames(type: string, slug: string): string[] {
const t = (type || "ct").toLowerCase().trim();
const basenames: string[] = [];
if (t === "ct" || t === "lxc") {
basenames.push(slug); // ct/{slug}.sh
basenames.push(`alpine-${slug}`); // ct/alpine-{slug}.sh (optional)
} else if (t === "pve") {
basenames.push(slug); // tools/pve/{slug}.sh
} else if (t === "addon") {
basenames.push(slug); // tools/addon/{slug}.sh
} else if (t === "vm") {
basenames.push(slug); // vm/{slug}.sh
} else if (t === "turnkey") {
basenames.push(slug); // turnkey/{slug}.sh
} else {
basenames.push(slug);
}
return basenames;
}
export const scriptsRouter = createTRPCRouter({
// Get all available scripts
getScripts: publicProcedure
@@ -83,13 +171,13 @@ export const scriptsRouter = createTRPCRouter({
return scriptManager.getScriptsDirectoryInfo();
}),
// Local script routes (using scripts/json directory)
// Get all script cards from local directory
// Local script routes (using PocketBase)
// Get all script cards for the UI listing
getScriptCards: publicProcedure
.query(async () => {
try {
const cards = await localScriptsService.getScriptCards();
return { success: true, cards };
const cards = await getScriptCards();
return { success: true, cards: cards.map(pbCardToScriptCard) };
} catch (error) {
console.error('Error in getScriptCards:', error);
return {
@@ -100,12 +188,12 @@ export const scriptsRouter = createTRPCRouter({
}
}),
// Get all scripts from GitHub (1 API call + raw downloads)
// Get all scripts from PocketBase
getAllScripts: publicProcedure
.query(async () => {
try {
const scripts = await localScriptsService.getAllScripts();
return { success: true, scripts };
const pbScripts = await pbGetAllScripts();
return { success: true, scripts: pbScripts.map(pbToScript) };
} catch (error) {
return {
success: false,
@@ -115,32 +203,16 @@ export const scriptsRouter = createTRPCRouter({
}
}),
// Get script by slug from GitHub (1 API call + raw downloads)
// Get script by slug from PocketBase
getScriptBySlug: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ input }) => {
try {
console.log('getScriptBySlug called with slug:', input.slug);
console.log('githubJsonService methods:', Object.getOwnPropertyNames(Object.getPrototypeOf(githubJsonService)));
console.log('githubJsonService.getScriptBySlug type:', typeof githubJsonService.getScriptBySlug);
if (typeof githubJsonService.getScriptBySlug !== 'function') {
return {
success: false,
error: 'getScriptBySlug method is not available on githubJsonService',
script: null
};
const pb = await pbGetScriptBySlug(input.slug);
if (!pb) {
return { success: false, error: 'Script not found', script: null };
}
const script = await githubJsonService.getScriptBySlug(input.slug);
if (!script) {
return {
success: false,
error: 'Script not found',
script: null
};
}
return { success: true, script };
return { success: true, script: pbToScript(pb) };
} catch (error) {
console.error('Error in getScriptBySlug:', error);
return {
@@ -151,11 +223,11 @@ export const scriptsRouter = createTRPCRouter({
}
}),
// Get metadata (categories and other metadata)
// Get metadata (categories and script types) from PocketBase
getMetadata: publicProcedure
.query(async () => {
try {
const metadata = await localScriptsService.getMetadata();
const metadata = await pbGetMetadata();
return { success: true, metadata };
} catch (error) {
console.error('Error in getMetadata:', error);
@@ -167,81 +239,18 @@ export const scriptsRouter = createTRPCRouter({
}
}),
// Get script cards with category information
// Get script cards with category information from PocketBase
getScriptCardsWithCategories: publicProcedure
.query(async () => {
try {
const [cards, metadata, enabledRepos] = await Promise.all([
localScriptsService.getScriptCards(),
localScriptsService.getMetadata(),
repositoryService.getEnabledRepositories()
]);
// PocketBase already returns category names expanded on each card
const cards = await getScriptCards();
const scriptCards = cards.map(pbCardToScriptCard);
// Get all scripts to access their categories
const scripts = await localScriptsService.getAllScripts();
// Create a set of enabled repository URLs for fast lookup
const enabledRepoUrls = new Set(enabledRepos.map((repo: { url: string }) => repo.url));
// Create category ID to name mapping
const categoryMap: Record<number, string> = {};
if (metadata?.categories) {
metadata.categories.forEach((cat: any) => {
categoryMap[cat.id] = cat.name;
});
}
// Also return the category list for the sidebar filter
const metadata = await pbGetMetadata();
// Enhance cards with category information and additional script data
const cardsWithCategories = cards.map((card: ScriptCard) => {
const script = scripts.find(s => s.slug === card.slug);
const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? [];
// Extract OS and version from first install method
const firstInstallMethod = script?.install_methods?.[0];
const os = firstInstallMethod?.resources?.os;
const version = firstInstallMethod?.resources?.version;
// Extract install basenames for robust local matching (e.g., execute.sh -> execute)
const install_basenames = (script?.install_methods ?? [])
.map(m => m?.script)
.filter((p): p is string => typeof p === 'string')
.map(p => {
const parts = p.split('/');
const file = parts[parts.length - 1] ?? '';
return file.replace(/\.(sh|bash|py|js|ts)$/i, '');
});
return {
...card,
categories: script?.categories ?? [],
categoryNames: categoryNames,
// Add date_created from script
date_created: script?.date_created,
// Add OS and version from install methods
os: os,
version: version,
// Add interface port
interface_port: script?.interface_port,
install_basenames,
// Add repository_url from script
repository_url: script?.repository_url ?? card.repository_url,
} as ScriptCard;
});
// Filter cards to only include scripts from enabled repositories
// For backward compatibility, include scripts without repository_url
const filteredCards = cardsWithCategories.filter((card: ScriptCard) => {
const repoUrl = card.repository_url;
// If script has no repository_url, include it for backward compatibility
if (!repoUrl) {
return true;
}
// Only include scripts from enabled repositories
return enabledRepoUrls.has(repoUrl);
});
return { success: true, cards: filteredCards, metadata };
return { success: true, cards: scriptCards, metadata };
} catch (error) {
console.error('Error in getScriptCardsWithCategories:', error);
return {
@@ -253,45 +262,27 @@ export const scriptsRouter = createTRPCRouter({
}
}),
// Resync scripts from GitHub (1 API call + raw downloads)
// PocketBase is always up to date this is a no-op kept for API compatibility
resyncScripts: publicProcedure
.mutation(async () => {
try {
// Sync JSON files using 1 API call + raw downloads
const result = await githubJsonService.syncJsonFiles();
return {
success: result.success,
message: result.message,
count: result.count
};
} catch (error) {
console.error('Error in resyncScripts:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to resync scripts. Make sure REPO_URL is set.',
count: 0
};
}
return {
success: true,
message: 'Script catalog is served directly from PocketBase and is always up to date.',
count: 0,
error: undefined as string | undefined,
};
}),
// Load script files from GitHub
// Load script files from the community repository
loadScript: publicProcedure
.input(z.object({ slug: z.string() }))
.mutation(async ({ input }) => {
try {
// Get the script details
const script = await localScriptsService.getScriptBySlug(input.slug);
if (!script) {
return {
success: false,
error: 'Script not found',
files: []
};
const pb = await pbGetScriptBySlug(input.slug);
if (!pb) {
return { success: false, error: 'Script not found', files: [] };
}
// Load the script files
const result = await scriptDownloaderService.loadScript(script);
const result = await scriptDownloaderService.loadScript(pbToScript(pb));
return result;
} catch (error) {
console.error('Error in loadScript:', error);
@@ -303,7 +294,7 @@ export const scriptsRouter = createTRPCRouter({
}
}),
// Load multiple scripts from GitHub
// Load multiple scripts from the community repository
loadMultipleScripts: publicProcedure
.input(z.object({ slugs: z.array(z.string()) }))
.mutation(async ({ input }) => {
@@ -313,15 +304,12 @@ export const scriptsRouter = createTRPCRouter({
for (const slug of input.slugs) {
try {
// Get the script details
const script = await localScriptsService.getScriptBySlug(slug);
if (!script) {
const pb = await pbGetScriptBySlug(slug);
if (!pb) {
failed.push({ slug, error: 'Script not found' });
continue;
}
// Load the script files
const result = await scriptDownloaderService.loadScript(script);
const result = await scriptDownloaderService.loadScript(pbToScript(pb));
if (result.success) {
successful.push({ slug, files: result.files });
} else {
@@ -360,22 +348,12 @@ export const scriptsRouter = createTRPCRouter({
.input(z.object({ slug: z.string() }))
.query(async ({ input }) => {
try {
const script = await localScriptsService.getScriptBySlug(input.slug);
if (!script) {
return {
success: false,
error: 'Script not found',
ctExists: false,
installExists: false,
files: []
};
const pb = await pbGetScriptBySlug(input.slug);
if (!pb) {
return { success: false, error: 'Script not found', ctExists: false, installExists: false, files: [] };
}
const result = await scriptDownloaderService.checkScriptExists(script);
return {
success: true,
...result
};
const result = await scriptDownloaderService.checkScriptExists(pbToScript(pb));
return { success: true, ...result };
} catch (error) {
console.error('Error in checkScriptFiles:', error);
return {
@@ -393,18 +371,11 @@ export const scriptsRouter = createTRPCRouter({
.input(z.object({ slug: z.string() }))
.mutation(async ({ input }) => {
try {
// Get the script details
const script = await localScriptsService.getScriptBySlug(input.slug);
if (!script) {
return {
success: false,
error: 'Script not found',
deletedFiles: []
};
const pb = await pbGetScriptBySlug(input.slug);
if (!pb) {
return { success: false, error: 'Script not found', deletedFiles: [] };
}
// Delete the script files
const result = await scriptDownloaderService.deleteScript(script);
const result = await scriptDownloaderService.deleteScript(pbToScript(pb));
return result;
} catch (error) {
console.error('Error in deleteScript:', error);
@@ -421,21 +392,12 @@ export const scriptsRouter = createTRPCRouter({
.input(z.object({ slug: z.string() }))
.query(async ({ input }) => {
try {
const script = await localScriptsService.getScriptBySlug(input.slug);
if (!script) {
return {
success: false,
error: 'Script not found',
hasDifferences: false,
differences: []
};
const pb = await pbGetScriptBySlug(input.slug);
if (!pb) {
return { success: false, error: 'Script not found', hasDifferences: false, differences: [] };
}
const result = await scriptDownloaderService.compareScriptContent(script);
return {
success: true,
...result
};
const result = await scriptDownloaderService.compareScriptContent(pbToScript(pb));
return { success: true, ...result };
} catch (error) {
console.error('Error in compareScriptContent:', error);
return {
@@ -452,20 +414,12 @@ export const scriptsRouter = createTRPCRouter({
.input(z.object({ slug: z.string(), filePath: z.string() }))
.query(async ({ input }) => {
try {
const script = await localScriptsService.getScriptBySlug(input.slug);
if (!script) {
return {
success: false,
error: 'Script not found',
diff: null
};
const pb = await pbGetScriptBySlug(input.slug);
if (!pb) {
return { success: false, error: 'Script not found', diff: null };
}
const result = await scriptDownloaderService.getScriptDiff(script, input.filePath);
return {
success: true,
...result
};
const result = await scriptDownloaderService.getScriptDiff(pbToScript(pb), input.filePath);
return { success: true, ...result };
} catch (error) {
console.error('Error in getScriptDiff:', error);
return {