From cf2b3f4bb31c8f6041b156d9da54c06fea12c86f Mon Sep 17 00:00:00 2001
From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com>
Date: Mon, 5 Oct 2026 08:04:00 +0200
Subject: [PATCH] Name a linked global install as the reason daemon self-update
is refused (#6095)
* fix(server): name a linked global install as the reason self-update is refused
npm 7 and later print no link flag in `npm ls --json --long`, so a global
install made with `npm install -g
` or `npm link` was never detected as
linked and self-update reported that the daemon was not running from the npm
global install. Detect the link from the install directory being a symlink,
the same way npm decides it.
Closes #6086
* fix(server): fail the self-update inspection when the install path can't be read
An unreadable install path now surfaces as the update error instead of being
treated as a normal install. Tests build real install directories for both
the linked and the normal case.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
---
.../daemon/daemon-self-updater.test.ts | 8 +-
.../session/daemon/npm-global-cli.test.ts | 78 ++++++++++++++-----
.../server/session/daemon/npm-global-cli.ts | 6 +-
3 files changed, 70 insertions(+), 22 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 c272a7f11a..711fd11d80 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,5 +1,7 @@
-import { describe, expect, test } from "vitest";
+import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
import path from "node:path";
+import { describe, expect, onTestFinished, test } from "vitest";
import {
DaemonSelfUpdateInProgressError,
DaemonSelfUpdater,
@@ -106,9 +108,11 @@ 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 prefix = mkdtempSync(path.join(tmpdir(), "custom npm prefix "));
+ onTestFinished(() => rmSync(prefix, { recursive: true, force: true }));
const root = process.platform === "win32" ? prefix : path.join(prefix, "lib");
const packagePath = path.join(root, "node_modules", "@getpaseo", "cli");
+ mkdirSync(packagePath, { recursive: true });
const commands: string[][] = [];
let version = "0.1.15";
const npm = new DefaultNpmGlobalPaseoCli(async (_command, args) => {
diff --git a/packages/server/src/server/session/daemon/npm-global-cli.test.ts b/packages/server/src/server/session/daemon/npm-global-cli.test.ts
index aece7eb09a..ba852e232e 100644
--- a/packages/server/src/server/session/daemon/npm-global-cli.test.ts
+++ b/packages/server/src/server/session/daemon/npm-global-cli.test.ts
@@ -1,5 +1,7 @@
+import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
+import { tmpdir } from "node:os";
import path from "node:path";
-import { describe, expect, test } from "vitest";
+import { afterEach, describe, expect, test } from "vitest";
import { DefaultNpmGlobalPaseoCli } from "./npm-global-cli.js";
interface CommandCall {
@@ -10,25 +12,65 @@ interface CommandCall {
}
const globalRoot = path.join(path.sep, "global", "lib");
-const globalNodeModules = path.join(globalRoot, "node_modules");
-const cliPackagePath = path.join(globalNodeModules, "@getpaseo", "cli");
-function npmGlobalPaseoCliJson(version: string, options?: { linked?: boolean }): string {
- return JSON.stringify({
- name: "lib",
- path: globalRoot,
- dependencies: {
- "@getpaseo/cli": {
- version,
- path: cliPackagePath,
- link: options?.linked === true,
- },
- },
- });
+interface GlobalInstall {
+ root: string;
+ packagePath: string;
+ // npm 7+ `ls --json --long` marks neither kind of install with a link flag.
+ npmLsJson: string;
}
describe("DefaultNpmGlobalPaseoCli", () => {
+ const tempDirs: string[] = [];
+
+ afterEach(() => {
+ for (const dir of tempDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ function createGlobalInstall(options: { linked: boolean }): GlobalInstall {
+ const dir = mkdtempSync(path.join(tmpdir(), "paseo-npm-global-"));
+ tempDirs.push(dir);
+ const root = path.join(dir, "prefix", "lib");
+ const packagePath = path.join(root, "node_modules", "@getpaseo", "cli");
+ const checkoutCli = path.join(dir, "checkout", "packages", "cli");
+ if (options.linked) {
+ mkdirSync(checkoutCli, { recursive: true });
+ mkdirSync(path.dirname(packagePath), { recursive: true });
+ symlinkSync(checkoutCli, packagePath, "junction");
+ } else {
+ mkdirSync(packagePath, { recursive: true });
+ }
+ const npmLsJson = JSON.stringify({
+ name: "lib",
+ path: root,
+ dependencies: {
+ "@getpaseo/cli": {
+ version: "0.1.15",
+ resolved: options.linked
+ ? `file:${path.relative(root, checkoutCli)}`
+ : "https://registry.npmjs.org/@getpaseo/cli/-/cli-0.1.15.tgz",
+ path: packagePath,
+ },
+ },
+ });
+ return { root, packagePath, npmLsJson };
+ }
+
+ test("reports a global install linked to a local checkout as linked", async () => {
+ const install = createGlobalInstall({ linked: true });
+ const cli = new DefaultNpmGlobalPaseoCli(async () => ({
+ exitCode: 0,
+ stdout: install.npmLsJson,
+ stderr: "",
+ }));
+
+ await expect(cli.inspect()).resolves.toMatchObject({ isLinked: true });
+ });
+
test("inspects the npm global cli install with npm -g ls", async () => {
+ const install = createGlobalInstall({ linked: false });
const calls: CommandCall[] = [];
const cli = new DefaultNpmGlobalPaseoCli(async (command, args, options) => {
calls.push({
@@ -37,13 +79,13 @@ describe("DefaultNpmGlobalPaseoCli", () => {
timeout: options?.timeout,
maxBuffer: options?.maxBuffer,
});
- return { exitCode: 0, stdout: npmGlobalPaseoCliJson("0.1.15"), stderr: "" };
+ return { exitCode: 0, stdout: install.npmLsJson, stderr: "" };
});
await expect(cli.inspect()).resolves.toEqual({
version: "0.1.15",
- packagePath: cliPackagePath,
- globalRootPath: globalRoot,
+ packagePath: install.packagePath,
+ globalRootPath: install.root,
isLinked: false,
});
expect(calls).toEqual([
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 2eef49980f..6133dcd9d1 100644
--- a/packages/server/src/server/session/daemon/npm-global-cli.ts
+++ b/packages/server/src/server/session/daemon/npm-global-cli.ts
@@ -1,3 +1,4 @@
+import { lstatSync } from "node:fs";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import { z } from "zod";
import { execCommand } from "../../../utils/spawn.js";
@@ -19,7 +20,6 @@ const NpmGlobalCliPackageSchema = z
.object({
version: z.string(),
path: z.string(),
- link: z.boolean().optional(),
})
.passthrough();
@@ -112,7 +112,9 @@ function parseNpmGlobalPaseoInstall(stdout: string): NpmGlobalPaseoInstall | nul
version: cliPackage.data.version,
packagePath: cliPackage.data.path,
globalRootPath: list.data.path ?? null,
- isLinked: cliPackage.data.link === true,
+ // npm links an install by making its node_modules entry a symlink (a junction
+ // on Windows), and `npm ls --json` reports no flag for it.
+ isLinked: lstatSync(cliPackage.data.path).isSymbolicLink(),
};
}