Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 112 additions & 1 deletion apps/cli/src/__tests__/update-global-extra.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execFileSync } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -210,6 +211,116 @@ describe("global update helpers", () => {
);
});

it("runs managed Linux installs in a transient unit with rollback boundaries", async () => {
if (process.platform !== "linux") return;
spawnSyncMock.mockReturnValueOnce({ status: 0, stdout: "null", stderr: "" });
const child = new FakeChild();
registrySpawnMock.mockReturnValueOnce({ child });
const { installGlobalSpec } = await import("../core/update.js");

const installing = installGlobalSpec("latest", { managed: true });
const [command, args] = registrySpawnMock.mock.calls[0] as [string, string[]];
expect(command).toBe("systemd-run");
expect(args).toEqual(expect.arrayContaining(["--wait", "--collect", "--pipe", "--unit"]));
expect(args).toContain("first-tree-update.service");
const script = args[args.indexOf("-c") + 1];
expect(script).toContain("systemctl");
expect(script).toContain("stop 'first-tree.service'");
expect(script).toContain("start 'first-tree.service'");
expect(script).toContain("first-tree-update-backup");
expect(() => execFileSync("/bin/sh", ["-n", "-c", script], { encoding: "utf8" })).not.toThrow();

child.stdout.emit("data", Buffer.from("+ first-tree@1.2.3\n"));
child.emit("exit", 0, null);
await expect(installing).resolves.toEqual({ ok: true, mode: "global", installedVersion: "1.2.3" });
});

it("rolls back the moved npm tree before restarting after a failed managed install", async () => {
if (process.platform !== "linux") return;
spawnSyncMock.mockReturnValueOnce({ status: 0, stdout: "null", stderr: "" });
const child = new FakeChild();
registrySpawnMock.mockReturnValueOnce({ child });
const { installGlobalSpec } = await import("../core/update.js");
const installing = installGlobalSpec("latest", { managed: true });
const [, args] = registrySpawnMock.mock.calls[0] as [string, string[]];
const script = args[args.indexOf("-c") + 1];

const root = mkdtempSync(join(tmpdir(), "ft-managed-update-rollback-"));
try {
const prefix = join(root, "prefix");
const packageRoot = join(prefix, "lib", "node_modules");
const packageDir = join(packageRoot, "first-tree");
const binDir = join(prefix, "bin");
const fakeBin = join(root, "fake-bin");
const logPath = join(root, "systemctl.log");
const fakeNpm = join(fakeBin, "npm");
mkdirSync(packageDir, { recursive: true });
mkdirSync(binDir, { recursive: true });
mkdirSync(fakeBin, { recursive: true });
writeFileSync(join(packageDir, "sentinel"), "old-package");
writeFileSync(join(binDir, "first-tree"), "old-bin");
writeFileSync(join(binDir, "ft"), "old-alias");
writeFileSync(join(fakeBin, "systemctl"), '#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$SYSTEMCTL_LOG"\nexit 0\n', {
mode: 0o755,
});
writeFileSync(
fakeNpm,
[
"#!/bin/sh",
`root=${JSON.stringify(packageRoot)}`,
`prefix=${JSON.stringify(prefix)}`,
'if [ "$1" = "root" ]; then printf \'%s\\n\' "$root"; exit 0; fi',
'if [ "$1" = "prefix" ]; then printf \'%s\\n\' "$prefix"; exit 0; fi',
'mkdir -p "$root/first-tree"',
"printf 'partial\\n' > \"$root/first-tree/partial\"",
"exit 42",
].join("\n"),
{ mode: 0o755 },
);

const env = {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
SYSTEMCTL_LOG: logPath,
};
expect(() =>
execFileSync(
"/bin/sh",
["-c", script, "first-tree-npm-update", fakeNpm, "install", "-g", "first-tree@latest"],
{ env },
),
).toThrow();
expect(readFileSync(join(packageDir, "sentinel"), "utf8")).toBe("old-package");
expect(() => readFileSync(join(packageDir, "partial"))).toThrow();
expect(readFileSync(join(binDir, "first-tree"), "utf8")).toBe("old-bin");
expect(readFileSync(join(binDir, "ft"), "utf8")).toBe("old-alias");
expect(readFileSync(logPath, "utf8")).toMatch(/stop first-tree\.service/);
expect(readFileSync(logPath, "utf8")).toMatch(/start first-tree\.service/);
} finally {
rmSync(root, { recursive: true, force: true });
}

child.emit("exit", 0, null);
await expect(installing).resolves.toEqual({ ok: true, mode: "global", installedVersion: null });
});

it("refuses a managed install when systemd-run cannot be started", async () => {
if (process.platform !== "linux") return;
spawnSyncMock.mockReturnValueOnce({ status: 0, stdout: "null", stderr: "" });
const spawnError = Object.assign(new Error("systemd-run not found"), { code: "ENOENT" });
registrySpawnMock.mockImplementationOnce(() => {
throw spawnError;
});
const { installGlobalSpec } = await import("../core/update.js");

await expect(installGlobalSpec("latest", { managed: true })).resolves.toMatchObject({
ok: false,
retryable: false,
reasonCode: "systemd_update_runner_unavailable",
reason: expect.stringContaining("systemd-run is unavailable"),
});
});

it("prefers sibling npm, tolerates empty engine stdout, and keeps non-semver installed labels", async () => {
existsSyncMock.mockImplementation(
(path: unknown) => String(path).endsWith("/npm") || String(path).endsWith("\\npm.cmd"),
Expand Down
7 changes: 4 additions & 3 deletions apps/cli/src/core/supervisor/systemd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,10 @@ export function renderSystemdUnit(
// Restart policy split:
// - on-failure → operator-issued `systemctl stop` (clean exit 0) really stops.
// - SuccessExitStatus=0 makes that explicit.
// - RestartForceExitStatus=75 keeps the self-update path working: the
// UpdateManager exits 75 after `npm i -g`, systemd sees it as a
// "must restart" signal and brings up the new binary.
// - RestartForceExitStatus=75 remains the fallback self-update path for
// portable installs and environments that do not use the managed Linux
// npm handoff. Managed Linux npm updates run in a transient unit that
// stops this service before touching its global package tree.
// StartLimit* caps a crash storm (10 failures in 5 min → systemd holds back).
// Normal client diagnostics go through the rotating NDJSON `client.log` when
// FIRST_TREE_SERVICE_MODE=1; journald is only the supervisor fallback for
Expand Down
13 changes: 9 additions & 4 deletions apps/cli/src/core/update-glue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@ function createInstallOutputLog(log: UpdateLogger | undefined): ((chunk: string)
* Build the command-layer `executeUpdate` callback.
*
* `managed=true` means a process supervisor (launchd / systemd / Docker
* `restart`) is expected to relaunch us after `process.exit` — the callback
* installs the new bits and exits with `SELF_RESTART_EXIT_CODE` so the
* relaunch picks up the new binary.
* `restart`) owns the daemon lifecycle. Portable updates and non-Linux
* managed paths install the new bits and exit with `SELF_RESTART_EXIT_CODE`;
* managed Linux npm updates hand off to a transient systemd unit that stops
* and starts the service around the install.
*
* `managed=false` means the process is running standalone (e.g. manual
* `client start`, `login <code> --no-start`, CI without a supervisor).
Expand Down Expand Up @@ -176,9 +177,13 @@ export function createExecuteUpdate({
? `Switching portable ${channelConfig.binName} to ${targetVersion}...`
: `Running \`npm install -g ${pkgSpec}@${targetVersion}\`...`,
);
const installOptions = {
managed,
...(installOutput ? { output: installOutput } : {}),
};
const result = isPortable
? await installPortableSpec(targetVersion)
: await installGlobalSpec(targetVersion, installOutput ? { output: installOutput } : undefined);
: await installGlobalSpec(targetVersion, installOptions);
if (!result.ok) {
emit("warn", `Install failed: ${result.reason}`);
recordUpdateAttempt({
Expand Down
Loading
Loading