From 71b236d38aeac2f0ebadc72b3df0b9527f975371 Mon Sep 17 00:00:00 2001 From: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:07:22 +0900 Subject: [PATCH] fix(server): update the running npm installation prefix (#5649) --- .../daemon/daemon-self-updater.test.ts | 46 ++++++++++++++++++- .../session/daemon/daemon-self-updater.ts | 12 +++-- .../server/session/daemon/install-origin.ts | 15 ++++++ .../server/session/daemon/npm-global-cli.ts | 18 +++++--- 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/packages/server/src/server/session/daemon/daemon-self-updater.test.ts b/packages/server/src/server/session/daemon/daemon-self-updater.test.ts index faf9491f50..c272a7f11a 100644 --- a/packages/server/src/server/session/daemon/daemon-self-updater.test.ts +++ b/packages/server/src/server/session/daemon/daemon-self-updater.test.ts @@ -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: [] }); diff --git a/packages/server/src/server/session/daemon/daemon-self-updater.ts b/packages/server/src/server/session/daemon/daemon-self-updater.ts index b7ca60eda3..91b5b166d4 100644 --- a/packages/server/src/server/session/daemon/daemon-self-updater.ts +++ b/packages/server/src/server/session/daemon/daemon-self-updater.ts @@ -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; }); diff --git a/packages/server/src/server/session/daemon/install-origin.ts b/packages/server/src/server/session/daemon/install-origin.ts index 3f305ab3ad..cddd905e47 100644 --- a/packages/server/src/server/session/daemon/install-origin.ts +++ b/packages/server/src/server/session/daemon/install-origin.ts @@ -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, diff --git a/packages/server/src/server/session/daemon/npm-global-cli.ts b/packages/server/src/server/session/daemon/npm-global-cli.ts index d1e0444058..2eef49980f 100644 --- a/packages/server/src/server/session/daemon/npm-global-cli.ts +++ b/packages/server/src/server/session/daemon/npm-global-cli.ts @@ -50,8 +50,12 @@ export interface NpmGlobalPaseoInstall { } export interface NpmGlobalPaseoCli { - inspect(): Promise; - installLatest(): Promise; + inspect(options?: NpmGlobalOptions): Promise; + installLatest(options?: NpmGlobalOptions): Promise; +} + +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 { + async inspect(options: NpmGlobalOptions = {}): Promise { + 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 { - return this.runCommand("npm", ["install", "-g", `${PASEO_CLI_PACKAGE}@latest`], { + installLatest(options: NpmGlobalOptions = {}): Promise { + 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, });