import { mkdir, readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { getOwnerHomeDir } from '@@/data-path'; import { readSettings } from './settings'; /** * The website checkout that lives under the root location. * * ── The URL is SSH and it is hardcoded ── * * `fofos/website` is private, so https would ask for a username and there is nobody at a terminal to * answer. The SSH remote is already authenticated by the owner's key — the same one every other clone * on this machine uses. * * Hardcoded because this plugin exists to administer exactly one website. A field for it would be a * setting nobody would ever change and one more thing that can be pointed somewhere wrong. * * ── Nothing here may hang ── * * These run inside an HTTP request, so every one of them is spawned with credential prompting off and * ssh in batch mode. Without that, a key that has lost access does not fail — it BLOCKS, forever, * waiting for a password at a terminal that does not exist, and the panel shows a spinner that never * stops. `GIT_TERMINAL_PROMPT=0` and `BatchMode=yes` are what turn that into an error message. */ const DIR_NAME = 'website'; const REMOTE = 'git@gitea.officer.dev:fofos/website.git'; /** Long enough for a cold clone on a slow link, short enough that a wedged git is not forever. */ const TIMEOUT_MS = 5 * 60 * 1000; export type WebsiteStatus = { /** Absolute, always — `/website`. */ path: string; /** The directory is there. Says nothing about whether it is a checkout. */ present: boolean; /** It is there and it has a `.git`. False with `present` true means something else is in the way. */ isRepo: boolean; branch: string | null; /** `1a2b3c4 the subject line`, for somebody deciding whether to press Update. */ head: string | null; /** Its `origin`. Shown because a checkout pointing somewhere else is worth seeing, not guessing at. */ remote: string | null; /** Uncommitted changes. Update refuses while this is true rather than failing halfway through a pull. */ dirty: boolean; /** What this plugin would clone from. */ cloneUrl: string; }; export type RunResult = { ok: boolean; output: string }; const run = async (argv: string[], cwd: string, email: string): Promise => { const proc = Bun.spawn(argv, { cwd, env: { ...process.env, HOME: getOwnerHomeDir(email), // See the note at the top: these two are the difference between a failure and a hang. GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15', }, stdout: 'pipe', stderr: 'pipe', }); const timer = setTimeout(() => proc.kill(), TIMEOUT_MS); const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); const code = await proc.exited; clearTimeout(timer); // git says almost everything useful on stderr, including progress, so both streams are kept. return { ok: code === 0, output: `${out}${err}`.trim() }; }; /** Empty string rather than null on failure — every caller here treats "could not tell" as "unknown". */ const gitLine = async (args: string[], cwd: string, email: string): Promise => { const result = await run(['git', ...args], cwd, email); const line = result.output.split('\n')[0]?.trim() ?? ''; return result.ok && line ? line : null; }; const exists = async (path: string): Promise => { try { await stat(path); return true; } catch { return false; } }; export const websitePath = async (email: string): Promise => { const settings = await readSettings(email); return join(settings.rootPath, DIR_NAME); }; export async function readWebsiteStatus(email: string): Promise { const path = await websitePath(email); const base: WebsiteStatus = { path, present: false, isRepo: false, branch: null, head: null, remote: null, dirty: false, cloneUrl: REMOTE, }; if (!(await exists(path))) return base; if (!(await exists(join(path, '.git')))) return { ...base, present: true }; return { ...base, present: true, isRepo: true, branch: await gitLine(['rev-parse', '--abbrev-ref', 'HEAD'], path, email), head: await gitLine(['log', '-1', '--format=%h %s'], path, email), remote: await gitLine(['remote', 'get-url', 'origin'], path, email), // `--untracked-files=no`: a build artefact or an .env nobody committed is not a reason to refuse a // fast-forward, and treating it as one makes Update permanently unavailable on a working checkout. dirty: (await run(['git', 'status', '--porcelain', '--untracked-files=no'], path, email)).output.length > 0, }; } /** * Clone it. * * Refuses onto anything already there rather than merging into it. `git clone` itself refuses a * non-empty directory, but it does so with a message about paths that reads as a bug in this plugin; * said here it reads as the fact it is. */ export async function cloneWebsite(email: string): Promise { const path = await websitePath(email); if (await exists(path)) { const entries = await readdir(path).catch(() => [] as string[]); if (entries.length > 0) return { ok: false, output: `${path} already exists and is not empty.` }; } const settings = await readSettings(email); // The root itself may never have been made — it is a default until somebody saves it, and saving it // does not create anything. `recursive`, so a root several levels deep works the first time. await mkdir(settings.rootPath, { recursive: true }); // Full history, not `--depth 1`. This is a working checkout somebody will pull into and read the log // of, and a shallow clone turns both into a support question later. return run(['git', 'clone', '--progress', REMOTE, path], settings.rootPath, email); } /** * Update it — fast-forward only. * * Never a merge. A merge commit made by a button, in a checkout nobody is watching, is a thing * somebody discovers days later; refusing is always recoverable and always legible. */ export async function updateWebsite(email: string): Promise { const status = await readWebsiteStatus(email); if (!status.isRepo) return { ok: false, output: `${status.path} is not a git checkout.` }; if (status.dirty) return { ok: false, output: 'There are uncommitted changes. Sort those out first.' }; return run(['git', 'pull', '--ff-only', '--progress'], status.path, email); }