Report installer preflight and cleanup failures

This commit is contained in:
Ryan Ward
2026-10-26 07:07:45 +00:00
parent 005aa8cdb8
commit 7ce0236825
4 changed files with 131 additions and 15 deletions
@@ -5,7 +5,8 @@
"packageManager": "yarn@1.22.22",
"type": "module",
"scripts": {
"start": "tsx src/index.tsx"
"start": "tsx src/index.tsx",
"test": "node --test test/*.test.mjs"
},
"dependencies": {
"ink": "^5.1.0",
@@ -182,6 +182,7 @@ function StepLine({ step }: { step: Step }) {
}
function App() {
const [failureMessage, setFailureMessage] = useState<string>();
const [steps, setSteps] = useState<Step[]>([
{ label: "Check working directory", status: "pending" },
{ label: "Pull current branch", status: "pending" },
@@ -205,6 +206,22 @@ function App() {
);
}, []);
const reportFailure = useCallback((error: unknown, context?: string) => {
const detail = error instanceof Error ? error.message : String(error);
const message = context ? `${context}: ${detail}` : detail;
process.exitCode = 1;
// Preserve the diagnostic even if the process exits before Ink redraws.
process.stderr.write(`Installation failed: ${message}\n`);
setFailureMessage((previous) => previous ?? message);
setSteps((previous) =>
previous.map((step) =>
step.status === "running"
? { ...step, status: "error" as StepStatus, detail: message }
: step
)
);
}, []);
useEffect(() => {
(async () => {
let prepared: Awaited<ReturnType<typeof prepareGalaxyBuild>> | undefined;
@@ -298,22 +315,19 @@ function App() {
WORKSPACE_ROOT, (line) => appendLog(5, line)
);
updateStep(5, { status: "done" });
} catch (err: any) {
process.exitCode = 1;
setSteps((prev) =>
prev.map((s) =>
s.status === "running"
? { ...s, status: "error" as StepStatus, detail: err.message }
: s
)
);
} catch (error) {
reportFailure(error);
} finally {
await prepared?.release();
try {
await prepared?.release();
} catch (error) {
reportFailure(error, "Failed to remove temporary build files");
}
}
})();
}, []);
}, [reportFailure]);
const hasError = steps.some((s) => s.status === "error");
const hasError = failureMessage !== undefined || steps.some((s) => s.status === "error");
const allDone = hasError || steps.every((s) => s.status === "done");
return (
@@ -332,7 +346,7 @@ function App() {
<Box marginTop={1}>
{hasError ? (
<Text color="red" bold>
✗ Installation failed.
✗ Installation failed{failureMessage ? `: ${failureMessage}` : "."}
</Text>
) : (
<Text color="green" bold>
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import * as fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, test } from "node:test";
import { fileURLToPath } from "node:url";
const project = fileURLToPath(new URL("../", import.meta.url));
const fixtures = [];
afterEach(async () => {
for (const root of fixtures.splice(0)) {
await fs.rm(root, { recursive: true, force: true });
}
});
async function fixture() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "galaxy-install-preflight-"));
fixtures.push(root);
const app = path.join(root, "script/build-and-install-to-applications");
const lib = path.join(root, "script/lib");
const bin = path.join(root, "bin");
await fs.mkdir(path.join(app, "src"), { recursive: true });
await fs.mkdir(lib, { recursive: true });
await fs.mkdir(bin);
await fs.copyFile(path.join(project, "src/index.tsx"), path.join(app, "src/index.tsx"));
await fs.copyFile(path.join(project, "package.json"), path.join(app, "package.json"));
await fs.copyFile(path.join(project, "../lib/prepare-galaxy-build.mjs"), path.join(lib, "prepare-galaxy-build.mjs"));
await fs.symlink(path.join(project, "node_modules"), path.join(app, "node_modules"), "dir");
// A regression must never progress into a real Git, build, or install operation.
const gitSentinel = path.join(root, "unexpected-git-command");
await fs.writeFile(path.join(bin, "git"), "#!/bin/sh\ntouch unexpected-git-command\nexit 97\n", { mode: 0o755 });
const run = () => {
const result = spawnSync(process.execPath, [path.join(project, "node_modules/tsx/dist/cli.mjs"), path.join(app, "src/index.tsx")], {
cwd: root,
env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH || ""}`, CI: "true", FORCE_COLOR: "0" },
encoding: "utf8",
timeout: 15000,
});
assert.equal(result.error, undefined);
assert.equal(result.status, 1);
assert.match(result.stdout, /Installation failed/);
return result;
};
return { root, lib, run, gitSentinel };
}
test("cache initialization errors are visible before any normal step starts", async () => {
const { root, run, gitSentinel } = await fixture();
await fs.mkdir(path.join(root, "target"));
await fs.writeFile(path.join(root, "target/galaxy-build-cache"), "not a directory");
const result = run();
assert.match(result.stderr, /Installation failed:.*EEXIST/);
assert.match(result.stderr, /galaxy-build-cache/);
await assert.rejects(fs.access(gitSentinel));
});
test("stale locks report the exact lock path and retain the lock for manual recovery", async () => {
const { root, run, gitSentinel } = await fixture();
const lock = path.join(root, "target/galaxy-build-cache/prepare.lock");
await fs.mkdir(path.dirname(lock), { recursive: true });
await fs.symlink(JSON.stringify({ pid: 99999999, start: "old process", token: "abandoned" }), lock);
const result = run();
assert.match(result.stderr, /Installation failed: Stale Galaxy build lock/);
assert.ok(result.stderr.includes(lock));
assert.ok((await fs.lstat(lock)).isSymbolicLink());
await assert.rejects(fs.access(gitSentinel));
});
test("failures without an active step still appear in the final output", async () => {
const { lib, run, gitSentinel } = await fixture();
await fs.writeFile(path.join(lib, "prepare-galaxy-build.mjs"), `
export async function prepareGalaxyBuild() {
throw new Error("fixture early failure");
}
`);
const result = run();
assert.match(result.stderr, /Installation failed: fixture early failure/);
assert.match(result.stdout, /Installation failed: fixture early failure/);
await assert.rejects(fs.access(gitSentinel));
});
test("cleanup failures are reported without hiding the original failure", async () => {
const { root, lib, run, gitSentinel } = await fixture();
await fs.writeFile(path.join(lib, "prepare-galaxy-build.mjs"), `
export async function prepareGalaxyBuild(root) {
return {
appPath: root + "/missing/Galaxy.app",
release: async () => { throw new Error("fixture cleanup failure"); },
};
}
`);
const result = run();
assert.match(result.stderr, /Installation failed: Built app not found/);
assert.match(result.stderr, /Failed to remove temporary build files: fixture cleanup failure/);
assert.match(result.stdout, /Installation failed:\s*Built app not found/);
await assert.rejects(fs.access(gitSentinel));
await assert.rejects(fs.access(path.join(root, "target")));
});
+2 -1
View File
@@ -165,9 +165,10 @@ export async function prepareGalaxyBuild(workspaceRoot, observer = {}, options =
const update = typeof observer === 'function' ? observer : observer.updateStep?.bind(observer) || (() => {});
const log = typeof observer === 'function' ? (() => {}) : observer.appendLog?.bind(observer) || observer.log?.bind(observer) || (() => {});
const command = (name, args, step) => run(name, args, { cwd: root, env, capture: !(name === 'cargo' && args[0] === 'bundle' && args.includes('--release')), onLog: (line) => log(step, line) });
update(0, { status: 'running', detail: 'Acquiring build lock' });
const unlock = await acquireLock(cache);
try {
update(0, { status: 'running' });
update(0, { status: 'running', detail: undefined });
const changes = await command('git', ['status', '--porcelain', '--untracked-files=all'], 0);
if (changes.trim()) {
for (const line of changes.split('\n')) log(0, line);