`<root>/website` is now checked, and the block says which of three things it is: not there, there but not a checkout, or a checkout with its branch and its head commit. the middle one is the state worth being loud about — cloning will refuse, and the reason is something already sitting in the way. SSH, hardcoded. the repo is private, so https asks for a username with nobody at a terminal to answer, and the SSH remote is already authenticated by the owner's key. hardcoded because this plugin administers exactly one website: a field for it would be a setting nobody changes and one more thing that can point somewhere wrong. when the checkout's origin is NOT that URL, the block says so — shown always it is noise, shown on a mismatch it answers why an update pulled something unexpected. NOTHING HERE MAY HANG. these run inside an HTTP request, so git gets GIT_TERMINAL_PROMPT=0 and ssh gets BatchMode=yes. without them a key that lost access does not fail, it BLOCKS forever waiting for a password at a terminal that does not exist, and the panel spins until somebody restarts the server. there is a five-minute kill on top for everything else that can wedge. update is `--ff-only`, never a merge. a merge commit made by a button in a checkout nobody is watching is a thing somebody finds days later; refusing is recoverable and legible. it also refuses on a dirty tree rather than failing halfway — and dirty is `--untracked-files=no`, because a build artefact or an uncommitted .env is not a reason to make Update permanently unavailable. clone takes FULL history, not --depth 1. this is a working checkout somebody will pull into and read the log of, and shallow turns both into a support question. it sits inside Root location rather than in a block of its own: the path and the thing at the path are one question, and split apart somebody could change the root in one place and read a status about the old one in the other, both looking equally current. hidden while editing, because it describes what is SAVED. git's own output is shown verbatim on failure. "Permission denied (publickey)" says exactly what is wrong; anything written in its place would be a guess at which of a dozen causes it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
6.4 KiB
TypeScript
163 lines
6.4 KiB
TypeScript
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 — `<root>/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<RunResult> => {
|
|
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<string | null> => {
|
|
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<boolean> => {
|
|
try {
|
|
await stat(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export const websitePath = async (email: string): Promise<string> => {
|
|
const settings = await readSettings(email);
|
|
return join(settings.rootPath, DIR_NAME);
|
|
};
|
|
|
|
export async function readWebsiteStatus(email: string): Promise<WebsiteStatus> {
|
|
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<RunResult> {
|
|
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<RunResult> {
|
|
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);
|
|
}
|