181 lines
5.6 KiB
TypeScript
181 lines
5.6 KiB
TypeScript
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<string, string> {
|
|
return cfg.token ? { Authorization: `${scheme} ${cfg.token}` } : {};
|
|
}
|
|
|
|
async function fetchText(
|
|
url: string,
|
|
cfg: Config,
|
|
scheme: "token" | "bearer" = "bearer",
|
|
): Promise<string> {
|
|
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<T>(url: string, cfg: Config): Promise<T> {
|
|
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<CatalogEntry[]> {
|
|
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<Catalog>(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 <path>.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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<CatalogEntry[]> {
|
|
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<ServiceMetadata> {
|
|
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<ServiceFile[]> {
|
|
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") });
|
|
}
|
|
}
|
|
}
|