import { readdirSync, readFileSync, statSync, type Dirent } from "node:fs"; import { join } from "node:path"; import type { Catalog, CatalogEntry, ServiceMetadata } from "./types.ts"; import type { Config } from "./config.ts"; import { parseAndValidate } from "./metadata.ts"; const UA = "homelab-installer/1.0"; function rawUrl(cfg: Config, path: string): string { if (cfg.forgejo) { return `${cfg.rawBase}/${cfg.owner}/${cfg.repo}/raw/branch/${cfg.branch}/${path}`; } return `${cfg.rawBase}/${cfg.owner}/${cfg.repo}/${cfg.branch}/${path}`; } function apiUrl(cfg: Config, path: string): string { return `${cfg.apiBase}/repos/${cfg.owner}/${cfg.repo}/${path}`; } function authHeaders(cfg: Config, scheme: "token" | "bearer"): Record { return cfg.token ? { Authorization: `${scheme} ${cfg.token}` } : {}; } async function fetchText( url: string, cfg: Config, scheme: "token" | "bearer" = "bearer", ): Promise { const res = await fetch(url, { headers: { "User-Agent": UA, Accept: "application/vnd.github+json", ...authHeaders(cfg, scheme), }, }); if (!res.ok) { throw new Error(`HTTP ${res.status} for ${url}`); } return await res.text(); } async function fetchJson(url: string, cfg: Config): Promise { return JSON.parse(await fetchText(url, cfg)) as T; } /** Return the list of services (id + path) from the configured source. */ export async function listServices(cfg: Config): Promise { if (cfg.local && cfg.localPath) { return listLocal(cfg.localPath); } // Preferred: a single committed catalog.json served from raw.githubusercontent.com. try { const catalog = await fetchJson(rawUrl(cfg, "services/catalog.json"), cfg); if (Array.isArray(catalog.services)) return catalog.services; } catch { // Fall through to the git trees API below. } try { return await listViaApi(cfg); } catch (err) { throw new Error( `Could not read the catalog from ${cfg.owner}/${cfg.repo}@${cfg.branch}: ${(err as Error).message}. ` + `Check --owner/--repo/--branch (and that the repo is pushed), set HOMELAB_GITHUB_TOKEN for a private repo, or use --local .`, ); } } function listLocal(dir: string): CatalogEntry[] { const entries: CatalogEntry[] = []; let names: string[] = []; try { names = readdirSync(dir); } catch { throw new Error(`Local services directory not found: ${dir}`); } for (const name of names.sort()) { if (!statSync(join(dir, name), { throwIfNoEntry: false })?.isDirectory()) continue; const metaPath = join(dir, name, "metadata.json"); if (!statSync(metaPath, { throwIfNoEntry: false })?.isFile()) continue; try { const meta = parseAndValidate(readFileSync(metaPath, "utf8"), name); entries.push({ id: meta.id, version: meta.version, path: `services/${name}/metadata.json`, }); } catch { // Ignore folders without a valid metadata.json. } } return entries; } async function listViaApi(cfg: Config): Promise { const tree = await fetchJson<{ tree?: { path: string }[] }>( apiUrl(cfg, `git/trees/${cfg.branch}?recursive=1`), cfg, ); const paths = (tree.tree ?? []) .map((t) => t.path) .filter((p) => /^services\/[^/]+\/metadata\.json$/.test(p)); return paths.map((p) => { const id = p.split("/")[1] ?? "unknown"; return { id, version: "unknown", path: p }; }); } /** Fetch and validate a single service's metadata.json. */ export async function fetchMetadata(id: string, cfg: Config): Promise { let raw: string; if (cfg.local && cfg.localPath) { raw = readFileSync(join(cfg.localPath, id, "metadata.json"), "utf8"); } else { raw = await fetchText(rawUrl(cfg, `services/${id}/metadata.json`), cfg, "token"); } return parseAndValidate(raw, id); } export interface ServiceFile { path: string; content: string; } /** Return every extra file in a service folder (excluding metadata.json). */ export async function fetchServiceFiles(id: string, cfg: Config): Promise { if (cfg.local && cfg.localPath) { return listLocalFiles(join(cfg.localPath, id)); } const tree = await fetchJson<{ tree?: { path: string; type?: string }[] }>( apiUrl(cfg, `git/trees/${cfg.branch}?recursive=1`), cfg, ); const prefix = `services/${id}/`; const paths = (tree.tree ?? []) .filter((t) => t.type === "blob" && t.path.startsWith(prefix) && t.path !== `${prefix}metadata.json`) .map((t) => t.path); const files: ServiceFile[] = []; for (const p of paths) { const rel = p.slice(prefix.length); const content = await fetchText(rawUrl(cfg, p), cfg, "token"); files.push({ path: rel, content }); } return files; } function listLocalFiles(dir: string): ServiceFile[] { const out: ServiceFile[] = []; walk(dir, "", out); return out; } function walk(base: string, rel: string, out: ServiceFile[]): void { const full = rel ? join(base, rel) : base; let entries: Dirent[]; try { entries = readdirSync(full, { withFileTypes: true }); } catch { return; } for (const e of entries) { // Never copy dependency dirs, build output, or VCS metadata into the deploy/build context. if ( e.isDirectory() && (e.name === "node_modules" || e.name === "dist" || e.name === ".git") ) { continue } const relPath = rel ? `${rel}/${e.name}` : e.name; if (e.isDirectory()) { walk(base, relPath, out); } else if (!(rel === "" && e.name === "metadata.json")) { out.push({ path: relPath, content: readFileSync(join(base, relPath), "utf8") }); } } }