feat: add remote daemon self-update from client (#1513)

* feat: add remote daemon self-update from client

Adds a daemon.update.request RPC that lets the client trigger a remote
daemon update over WebSocket. The daemon runs npm update -g @getpaseo/cli,
reports progress via daemon_update_progress status events, then restarts
through the supervisor to load the new code.

New feature flag server_info.features.daemonSelfUpdate gates the UI.
The UpdateDaemonCard appears on the host settings page when the daemon
supports self-update and the app/daemon versions differ.

* fix: clean up progress subscription on unmount, use typed phase cast

Address Greptile review:
- P1: store unsubscribe ref and clean up on component unmount to prevent
  listener leak during in-flight update
- P2: use DaemonUpdateProgressStatusPayload type instead of { phase?: string }

* fix: use npm install@latest and global concurrency guard

Address Greptile review:
- npm update -g can silently exit 0 without resolving major-version
  gaps; switch to npm install -g @getpaseo/cli@latest
- Move daemonUpdateInProgress from per-session instance to module-level
  variable so concurrent clients share the same guard

* feat(daemon): harden remote self-update

Probe the global npm CLI install before updating, keep request handling in a daemon self-update session controller, and emit scoped daemon.update.progress messages.

* test(daemon): make self-update paths platform-stable

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Davy
2026-06-26 16:13:29 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 9ad49fca92
commit 40265782d4
26 changed files with 1536 additions and 8 deletions
@@ -1,6 +1,7 @@
import type { Dirent } from "node:fs";
import { readdir, realpath, stat } from "node:fs/promises";
import path from "node:path";
import { isPathInsideRoot } from "./path.js";
export interface SearchHomeDirectoriesOptions {
homeDir: string;
@@ -786,11 +787,6 @@ function normalizeRelativePath(homeRoot: string, absolutePath: string): string {
return relative.split(path.sep).join("/");
}
function isPathInsideRoot(root: string, target: string): boolean {
const relative = path.relative(root, target);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null {
const typedQuery = query.trim().replace(/\\/g, "/");
let normalized = typedQuery;
+15 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { areEquivalentPaths, createPathEquivalenceMatcher } from "./path.js";
import { areEquivalentPaths, createPathEquivalenceMatcher, isPathInsideRoot } from "./path.js";
describe("path equivalence", () => {
test.each([
@@ -19,4 +19,18 @@ describe("path equivalence", () => {
areEquivalentPaths("/Users/Administrator/GhostFactory", "/users/administrator/ghostfactory"),
).toBe(false);
});
test("checks POSIX root containment without prefix false positives", () => {
expect(isPathInsideRoot("/opt/paseo", "/opt/paseo/node_modules/@getpaseo/server")).toBe(true);
expect(isPathInsideRoot("/opt/paseo", "/opt/paseo-other")).toBe(false);
});
test("checks Windows root containment case-insensitively", () => {
expect(
isPathInsideRoot("C:\\Paseo\\node_modules", "c:/paseo/node_modules/@getpaseo/server"),
).toBe(true);
expect(isPathInsideRoot("C:\\Paseo\\node_modules", "C:\\Paseo\\node_modules-other")).toBe(
false,
);
});
});
+19
View File
@@ -58,6 +58,25 @@ export function createRealpathAwarePathMatcher(target: string): (candidate: stri
};
}
export function isPathInsideRoot(root: string, candidate: string): boolean {
const compareAsWindows = shouldCompareAsWindows(root, candidate);
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
const normalizedRoot = normalizePathForComparison(root, compareAsWindows);
const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
const relative = platformPath.relative(normalizedRoot, normalizedCandidate);
return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative));
}
export function isRealpathInsideRoot(root: string, candidate: string): boolean {
const rootVariants = collectPathVariants(root);
const candidateVariants = collectPathVariants(candidate);
return rootVariants.some((rootVariant) =>
candidateVariants.some((candidateVariant) => isPathInsideRoot(rootVariant, candidateVariant)),
);
}
function collectPathVariants(value: string): string[] {
const variants = new Set<string>([value]);
for (const realpath of resolveRealpathVariants(value)) {