Files
pastilhasandClaude Opus 5 bab47526f7 clone and update both end in bun install
a checkout with no node_modules does not run, so cloning without installing left
something that looked finished and was not.

run after an update too, and UNCONDITIONALLY — including when the pull changed
nothing. skipping it on "already up to date" is the shortcut that is right until
the one time it is not, and an install on an unchanged tree is close to free.

`process.execPath`, NEVER the string "bun". verified on fofos today: PM2 started
officer with PATH=/usr/local/sbin:…:/snap/bin and bun lives in ~/.bun/bin, so
looking it up by name fails inside the process and works perfectly when you run the
same command by hand — the worst version of this bug, because it cannot be
reproduced the obvious way. execPath is the bun already running us.

bun's directory is also prepended to PATH for the child, which execPath alone does
not cover: a postinstall script calling `bun` would not find it, and that reads as
a broken package rather than a missing PATH entry.

the two commands report as ONE, each prefixed with the line that produced it, so
the panel reads like the terminal somebody would otherwise have opened. it stops 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 — and a clone that worked
followed by an install that did not is legible as exactly that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:54:58 +01:00

216 lines
8.6 KiB
TypeScript

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 — `<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 };
/**
* 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<RunResult> }>): Promise<RunResult> => {
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<RunResult> => 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<RunResult> => {
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<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 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<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 sequence([
{ label: 'git pull --ff-only', run: () => run(['git', 'pull', '--ff-only', '--progress'], status.path, email) },
{ label: 'bun install', run: () => install(status.path, email) },
]);
}