import { mkdir, readdir, stat } from 'node:fs/promises'; import { dirname, 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 }; /** * Several commands, reported as one. * * Each is prefixed with the command line that produced it, so the panel reads like the terminal * somebody would otherwise have opened — and a clone that worked followed by an install that did not * is legible as exactly that, rather than as one opaque failure. */ const sequence = async (steps: Array<{ label: string; run: () => Promise }>): Promise => { const parts: string[] = []; for (const step of steps) { parts.push(`$ ${step.label}`); const result = await step.run(); if (result.output) parts.push(result.output); // Stop at the first failure. Installing into a clone that did not happen produces a second, more // confusing error about a directory that is not there. if (!result.ok) return { ok: false, output: parts.join('\n') }; parts.push(''); } return { ok: true, output: parts.join('\n').trim() }; }; /** * `bun install`, with the bun that is ALREADY RUNNING. * * `process.execPath` rather than the string "bun": the server's PATH is whatever PM2 was started with, * which on more than one host does not include `~/.bun/bin`. Looking bun up by name works in a shell * and fails inside the process, which is the worst version of this bug — it cannot be reproduced by * running the same command by hand. */ const install = (path: string, email: string): Promise => run([process.execPath, 'install'], path, email); /* The server's own PATH does not include bun. Verified on fofos 2026-08-29: PM2 started `officer` with `/usr/local/sbin:…:/snap/bin` and nothing else, while bun lives in `~/.bun/bin`. Spawning by absolute path is enough for `bun install` itself — but a postinstall script that calls `bun` would not find it, and that failure reads as a broken package rather than as a missing PATH entry. */ const BUN_DIR = dirname(process.execPath); const run = async (argv: string[], cwd: string, email: string): Promise => { const proc = Bun.spawn(argv, { cwd, env: { ...process.env, PATH: `${BUN_DIR}:${process.env.PATH ?? ''}`, 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 sequence([ { label: `git clone ${REMOTE} ${path}`, run: () => run(['git', 'clone', '--progress', REMOTE, path], settings.rootPath, email), }, { label: 'bun install', run: () => install(path, 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 sequence([ { label: 'git pull --ff-only', run: () => run(['git', 'pull', '--ff-only', '--progress'], status.path, email) }, { label: 'bun install', run: () => install(status.path, email) }, ]); }