diff --git a/api/router.ts b/api/router.ts index 2c8332c..56211bf 100644 --- a/api/router.ts +++ b/api/router.ts @@ -3,6 +3,7 @@ import { createRouter, type HonoVariables } from '@@/create-router'; import { registerProxiedPrefix } from '@@/sidecar/proxied-prefixes'; import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection'; import { readSettings, saveRootPath } from './settings'; +import { readWebsiteStatus, cloneWebsite, updateWebsite } from './website'; import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client'; /** @@ -180,6 +181,36 @@ router.put('/_config/root', async (ctx) => { } }); +/** + * The website checkout under the root location — is it there, and put it there. + * + * Under `/_config` so the manifest's `ownerOnly` covers it, and it has to be: these write to the + * filesystem and reach the network with the owner's SSH key. Reading the state is owner-only for the + * same reason rather than a separate rule — it names a path on the machine. + * + * Clone and update return git's own output on failure. It is more use than anything this could write + * in its place: "Permission denied (publickey)" says exactly what is wrong, and a friendlier sentence + * would be a guess at which of a dozen causes it was. + */ +router.get('/_config/website', async (ctx) => { + const user = ctx.get('user'); + return ctx.json(await readWebsiteStatus(user.email)); +}); + +router.post('/_config/website/clone', async (ctx) => { + const user = ctx.get('user'); + const result = await cloneWebsite(user.email); + // The status is re-read either way, so a partial failure shows what actually ended up on disk rather + // than what was asked for. + return ctx.json({ ...result, status: await readWebsiteStatus(user.email) }, result.ok ? 200 : 400); +}); + +router.post('/_config/website/update', async (ctx) => { + const user = ctx.get('user'); + const result = await updateWebsite(user.email); + return ctx.json({ ...result, status: await readWebsiteStatus(user.email) }, result.ok ? 200 : 400); +}); + /** * Is this plugin usable? Asked once by the detail panel rather than by all nine sections. * diff --git a/api/website.ts b/api/website.ts new file mode 100644 index 0000000..d382da6 --- /dev/null +++ b/api/website.ts @@ -0,0 +1,162 @@ +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); +} diff --git a/manifest.ts b/manifest.ts index 673de5d..b529628 100644 --- a/manifest.ts +++ b/manifest.ts @@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest'; */ export const manifest: PluginManifest = { publisher: 'officerdev', - version: '2026.08.29.09', + version: '2026.08.29.10', platform: '>=1.0.0', label: 'Fofos', diff --git a/web/RootLocationBlock.tsx b/web/RootLocationBlock.tsx index 25446be..b62b34f 100644 --- a/web/RootLocationBlock.tsx +++ b/web/RootLocationBlock.tsx @@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'; import { readableError } from './errors'; import { DirectoryPicker } from './DirectoryPicker'; import { Block } from './Block'; +import { WebsiteCheckout } from './WebsiteCheckout'; import { useFlash } from './useFlash'; /** @@ -157,6 +158,12 @@ export const RootLocationBlock = () => {

Not set yet — this is the default until you save it.

)} + {/* The checkout that lives under that path. Same section, because the path and the thing at the + path are one question — split apart, somebody could change the root here and read a status + about the old one there, with both looking equally current. Hidden while editing: it + describes what is SAVED, and a half-typed path has nothing at it. */} + {!editing && } + {/* The block's bottom row. Everything you can DO is here, on its own line, so the path above it gets the full width. */}
diff --git a/web/WebsiteCheckout.tsx b/web/WebsiteCheckout.tsx new file mode 100644 index 0000000..03df88e --- /dev/null +++ b/web/WebsiteCheckout.tsx @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { CircleAlert, CircleCheck, CircleDashed, DownloadCloud, Loader2, RefreshCw } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import { Button } from '@/components/ui/button'; +import { readableError } from './errors'; + +/** + * The website checkout under the root location: is it there, clone it, update it. + * + * ── It lives inside Root location on purpose ── + * + * The path and the thing at the path are one question. Split into its own block, somebody could change + * the root in one place and read a checkout status about the old one in another — and the two would + * look equally current. + * + * ── git's own output, verbatim, when something fails ── + * + * "Permission denied (publickey)" says exactly what is wrong. Anything this could write in its place + * would be a guess at which of a dozen causes it was, and the guess is what sends somebody looking in + * the wrong direction. + */ + +type Status = { + path: string; + present: boolean; + isRepo: boolean; + branch: string | null; + head: string | null; + remote: string | null; + dirty: boolean; + cloneUrl: string; +}; + +type RunResult = { ok: boolean; output: string; status: Status }; + +export const WebsiteCheckout = ({ rootPath }: { rootPath: string }) => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState<'clone' | 'update' | null>(null); + const [output, setOutput] = useState(null); + const [failed, setFailed] = useState(false); + + const load = useCallback(async () => { + try { + setStatus(await clientRef.current.get('/fofos-admin/_config/website')); + } catch { + // Owner-only, so a member gets nothing here. The block above already says why, and repeating it + // would be two refusals for one cause. + setStatus(null); + } + }, []); + + // Re-checked whenever the ROOT changes, because the answer is about `/website` and a stale one + // describes a directory nobody is looking at any more. + useEffect(() => { + void load(); + }, [load, rootPath]); + + const act = async (what: 'clone' | 'update') => { + setBusy(what); + setOutput(null); + setFailed(false); + try { + const result = await clientRef.current.post(`/fofos-admin/_config/website/${what}`, {}); + setStatus(result.status); + setOutput(result.output || null); + setFailed(!result.ok); + } catch (failure) { + // A 400 carries git's own words in the body; `readableError` surfaces them. + setOutput(readableError(failure)); + setFailed(true); + void load(); + } finally { + setBusy(null); + } + }; + + if (!status) return null; + + const Icon = status.isRepo ? CircleCheck : status.present ? CircleAlert : CircleDashed; + const tone = status.isRepo ? 'text-success' : status.present ? 'text-destructive' : 'text-muted-foreground'; + + return ( +
+
+ +
+

{status.path}

+ + {status.isRepo ? ( +

+ {status.branch ?? 'detached'} · {status.head ?? 'no commits'} + {status.dirty && · uncommitted changes} +

+ ) : status.present ? ( + // Present but not a checkout is the one state worth being loud about: cloning will refuse, + // and the reason is something already sitting there. +

Something is already here, and it is not a git checkout.

+ ) : ( +

Not cloned yet.

+ )} + + {/* Only when it is NOT what this plugin would clone. Shown always it is noise; shown on a + mismatch it is the answer to why an update is pulling something unexpected. */} + {status.isRepo && status.remote && status.remote !== status.cloneUrl && ( +

origin: {status.remote}

+ )} +
+ +
+ {status.isRepo ? ( + + ) : ( + + )} +
+
+ + {/* git's own words, in git's own shape. Scrolled rather than truncated — the useful line of a + clone failure is usually the last one. */} + {output && ( +
+          {output}
+        
+ )} +
+ ); +};