fix(server): update the running npm installation prefix (#5649)

This commit is contained in:
Bae Seongmin
2026-10-04 23:07:22 +02:00
committed by GitHub
parent 8a4ce5a482
commit 71b236d38a
4 changed files with 81 additions and 10 deletions
@@ -1,11 +1,16 @@
import { describe, expect, test } from "vitest";
import path from "node:path";
import {
DaemonSelfUpdateInProgressError,
DaemonSelfUpdater,
type DaemonSelfUpdateRuntime,
type DaemonSelfUpdatePhase,
} from "./daemon-self-updater.js";
import type { CommandResult, NpmGlobalPaseoInstall } from "./npm-global-cli.js";
import {
DefaultNpmGlobalPaseoCli,
type CommandResult,
type NpmGlobalPaseoInstall,
} from "./npm-global-cli.js";
interface TestLogger {
errors: Array<{ obj: object; msg?: string }>;
@@ -100,6 +105,45 @@ async function runUpdate(input: {
}
describe("DaemonSelfUpdater", () => {
test("probes and updates the running install's prefix when npm defaults elsewhere", async () => {
const prefix = path.resolve("custom npm prefix");
const root = process.platform === "win32" ? prefix : path.join(prefix, "lib");
const packagePath = path.join(root, "node_modules", "@getpaseo", "cli");
const commands: string[][] = [];
let version = "0.1.15";
const npm = new DefaultNpmGlobalPaseoCli(async (_command, args) => {
commands.push(args);
if (args[args.indexOf("--prefix") + 1] !== prefix) {
return { exitCode: 1, stdout: "{}", stderr: "No CLI in npm's default prefix" };
}
if (args.includes("install")) version = "0.1.96";
return {
exitCode: 0,
stderr: "",
stdout: JSON.stringify({
path: root,
dependencies: { "@getpaseo/cli": { version, path: packagePath } },
}),
};
});
const { result } = await runUpdate({
runtime: {
npm,
installOrigin: {
resolveCurrentServerPackageRoot: () =>
path.join(packagePath, "node_modules", "@getpaseo", "server"),
},
},
});
expect(result).toEqual({ success: true, error: null, newVersion: "0.1.96" });
expect(commands).toHaveLength(3);
expect(commands.map((args) => args.slice(-2))).toEqual([
["--prefix", prefix],
["--prefix", prefix],
["--prefix", prefix],
]);
});
test("refuses a Desktop-managed daemon without touching npm", async () => {
const calls: RuntimeCall[] = [];
const runtime = createRuntime({ calls, inspections: [] });
@@ -1,6 +1,7 @@
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import {
daemonInstallOriginRuntime,
resolveNpmGlobalPrefix,
validateDaemonInstallOrigin,
type DaemonInstallOriginRuntime,
} from "./install-origin.js";
@@ -63,7 +64,12 @@ export class DaemonSelfUpdater {
this.inProgress = true;
try {
input.onProgress("starting");
const install = await this.runtime.npm.inspect();
const npmOptions = {
prefix: resolveNpmGlobalPrefix(
this.runtime.installOrigin.resolveCurrentServerPackageRoot(),
),
};
const install = await this.runtime.npm.inspect(npmOptions);
const unsupportedReason = validateDaemonInstallOrigin(
install,
input.daemonVersion,
@@ -76,7 +82,7 @@ export class DaemonSelfUpdater {
input.onProgress("downloading");
input.onProgress("installing");
const result = await this.runtime.npm.installLatest();
const result = await this.runtime.npm.installLatest(npmOptions);
if (result.exitCode !== 0) {
const error =
result.stderr.trim() || result.stdout.trim() || `npm exited with code ${result.exitCode}`;
@@ -87,7 +93,7 @@ export class DaemonSelfUpdater {
return { success: false, error, newVersion: null };
}
const updatedInstall = await this.runtime.npm.inspect().catch((error: unknown) => {
const updatedInstall = await this.runtime.npm.inspect(npmOptions).catch((error: unknown) => {
input.logger.warn({ err: error }, "Unable to read updated npm package version");
return null;
});
@@ -15,6 +15,21 @@ export const daemonInstallOriginRuntime: DaemonInstallOriginRuntime = {
resolveCurrentServerPackageRoot,
};
export function resolveNpmGlobalPrefix(serverPackageRoot: string | null): string | undefined {
if (!serverPackageRoot) return undefined;
let directory = serverPackageRoot;
let prefix: string | undefined;
while (path.dirname(directory) !== directory) {
if (path.basename(directory) === "node_modules") {
const root = path.dirname(directory);
if (process.platform === "win32") prefix = root;
else if (path.basename(root) === "lib") prefix = path.dirname(root);
}
directory = path.dirname(directory);
}
return prefix;
}
export function validateDaemonInstallOrigin(
install: NpmGlobalPaseoInstall,
daemonVersion: string | null,
@@ -50,8 +50,12 @@ export interface NpmGlobalPaseoInstall {
}
export interface NpmGlobalPaseoCli {
inspect(): Promise<NpmGlobalPaseoInstall>;
installLatest(): Promise<CommandResult>;
inspect(options?: NpmGlobalOptions): Promise<NpmGlobalPaseoInstall>;
installLatest(options?: NpmGlobalOptions): Promise<CommandResult>;
}
interface NpmGlobalOptions {
prefix?: string;
}
export type CommandRunner = (
@@ -115,10 +119,11 @@ function parseNpmGlobalPaseoInstall(stdout: string): NpmGlobalPaseoInstall | nul
export class DefaultNpmGlobalPaseoCli implements NpmGlobalPaseoCli {
constructor(private readonly runCommand: CommandRunner = runExternalCommand) {}
async inspect(): Promise<NpmGlobalPaseoInstall> {
async inspect(options: NpmGlobalOptions = {}): Promise<NpmGlobalPaseoInstall> {
const prefixArgs = options.prefix ? ["--prefix", options.prefix] : [];
const result = await this.runCommand(
"npm",
["-g", "ls", PASEO_CLI_PACKAGE, "--json", "--depth=0", "--long"],
["-g", "ls", PASEO_CLI_PACKAGE, "--json", "--depth=0", "--long", ...prefixArgs],
{
timeout: NPM_PROBE_TIMEOUT_MS,
maxBuffer: NPM_MAX_BUFFER_BYTES,
@@ -136,8 +141,9 @@ export class DefaultNpmGlobalPaseoCli implements NpmGlobalPaseoCli {
return install;
}
installLatest(): Promise<CommandResult> {
return this.runCommand("npm", ["install", "-g", `${PASEO_CLI_PACKAGE}@latest`], {
installLatest(options: NpmGlobalOptions = {}): Promise<CommandResult> {
const prefixArgs = options.prefix ? ["--prefix", options.prefix] : [];
return this.runCommand("npm", ["install", "-g", `${PASEO_CLI_PACKAGE}@latest`, ...prefixArgs], {
timeout: NPM_INSTALL_TIMEOUT_MS,
maxBuffer: NPM_MAX_BUFFER_BYTES,
});