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>
This commit is contained in:
2026-08-29 00:54:58 +01:00
co-authored by Claude Opus 5
parent df394e29a4
commit bab47526f7
2 changed files with 57 additions and 4 deletions
+56 -3
View File
@@ -1,5 +1,5 @@
import { mkdir, readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { getOwnerHomeDir } from '@@/data-path';
import { readSettings } from './settings';
@@ -49,11 +49,55 @@ export type WebsiteStatus = {
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',
@@ -144,7 +188,13 @@ export async function cloneWebsite(email: string): Promise<RunResult> {
// 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);
return sequence([
{
label: `git clone ${REMOTE} ${path}`,
run: () => run(['git', 'clone', '--progress', REMOTE, path], settings.rootPath, email),
},
{ label: 'bun install', run: () => install(path, email) },
]);
}
/**
@@ -158,5 +208,8 @@ export async function updateWebsite(email: string): Promise<RunResult> {
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);
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) },
]);
}
+1 -1
View File
@@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
*/
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '2026.08.29.10',
version: '2026.08.29.11',
platform: '>=1.0.0',
label: 'Fofos',