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 <dir>` 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>
This commit is contained in:
paseo-bot[bot]
2026-10-05 08:04:00 +02:00
committed by GitHub
parent a50e14b69e
commit cf2b3f4bb3
3 changed files with 70 additions and 22 deletions
@@ -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) => {
@@ -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([
@@ -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(),
};
}