041a19f060
* feat: add service-managed self-update * chore: add temporary self-update test scripts * chore: simplify temporary script markdown fences * chore: run frontend in temporary self-update scripts * fix: show self-update job progress after apply * chore: add real npm self-update test script * fix: isolate npm self-update prefix * fix: detect npm service installs from bin shim * chore: reset service before real npm update test * chore: expand real npm status diagnostics * fix: preserve service home during self-update * fix(cli): tee self-update helper output to a log file * fix(cli): avoid launchd bootstrap race when reloading the macOS service * fix(backend): run macOS self-update helper once via transient launchd plist * fix(web): show self-update progress and lock the apply button until the update lands * fix(cli): reinstall via the kandev wrapper on Homebrew self-update * chore: remove temp self-update test scripts * fix(web): drop unused apply path from useUpdates hook * fix(backend): snapshot install state once in apply preflight * docs: align self-update docs with transient LaunchAgent flow * chore(release): run cli bundle from apps workspace root * fix(backend): guard against concurrent self-update applies * fix(backend): check scheme and helper paths in self-update apply * fix(backend): drop duplicate binary name from npx manual command * fix(cli): allow --help for service self-update without --intent * fix(cli): harden self-update dir perms and darwin uid resolution * fix(web): set required POST fields last in applyUpdate * docs: use JSON body notation for the apply endpoint * fix(backend): expire self-update guard so a stuck helper cannot wedge apply * refactor(cli): collapse redundant writeSync branch in self-update log
191 lines
6.4 KiB
TypeScript
191 lines
6.4 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { captureLauncher, homebrewShimPath, resolveHomeDir, resolveServiceUser } from "./paths";
|
|
|
|
const originalArgv = [...process.argv];
|
|
const originalBundleDir = process.env.KANDEV_BUNDLE_DIR;
|
|
const originalVersion = process.env.KANDEV_VERSION;
|
|
|
|
describe("resolveServiceUser", () => {
|
|
const originalSudoUser = process.env.SUDO_USER;
|
|
|
|
beforeEach(() => {
|
|
delete process.env.SUDO_USER;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalSudoUser === undefined) delete process.env.SUDO_USER;
|
|
else process.env.SUDO_USER = originalSudoUser;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("returns the current user for user-mode installs", () => {
|
|
vi.spyOn(os, "userInfo").mockReturnValue({
|
|
username: "alice",
|
|
uid: 1000,
|
|
gid: 1000,
|
|
shell: null,
|
|
homedir: "/home/alice",
|
|
});
|
|
process.env.SUDO_USER = "bob"; // ignored in user mode
|
|
expect(resolveServiceUser(false)).toBe("alice");
|
|
});
|
|
|
|
it("prefers SUDO_USER for system-mode installs", () => {
|
|
vi.spyOn(os, "userInfo").mockReturnValue({
|
|
username: "root",
|
|
uid: 0,
|
|
gid: 0,
|
|
shell: null,
|
|
homedir: "/root",
|
|
});
|
|
process.env.SUDO_USER = "alice";
|
|
expect(resolveServiceUser(true)).toBe("alice");
|
|
});
|
|
|
|
it("falls back to current user when SUDO_USER is not set", () => {
|
|
vi.spyOn(os, "userInfo").mockReturnValue({
|
|
username: "root",
|
|
uid: 0,
|
|
gid: 0,
|
|
shell: null,
|
|
homedir: "/root",
|
|
});
|
|
expect(resolveServiceUser(true)).toBe("root");
|
|
});
|
|
|
|
it("ignores SUDO_USER=root and falls through to current user", () => {
|
|
// sudo never sets SUDO_USER=root in practice, but defend against it
|
|
// so we don't accidentally pin the daemon to root by accident.
|
|
vi.spyOn(os, "userInfo").mockReturnValue({
|
|
username: "alice",
|
|
uid: 1000,
|
|
gid: 1000,
|
|
shell: null,
|
|
homedir: "/home/alice",
|
|
});
|
|
process.env.SUDO_USER = "root";
|
|
expect(resolveServiceUser(true)).toBe("alice");
|
|
});
|
|
});
|
|
|
|
describe("captureLauncher", () => {
|
|
afterEach(() => {
|
|
process.argv.splice(0, process.argv.length, ...originalArgv);
|
|
if (originalBundleDir === undefined) delete process.env.KANDEV_BUNDLE_DIR;
|
|
else process.env.KANDEV_BUNDLE_DIR = originalBundleDir;
|
|
if (originalVersion === undefined) delete process.env.KANDEV_VERSION;
|
|
else process.env.KANDEV_VERSION = originalVersion;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("resolves npm global bin symlinks before detecting the install kind", () => {
|
|
delete process.env.KANDEV_BUNDLE_DIR;
|
|
delete process.env.KANDEV_VERSION;
|
|
process.argv[1] = "/tmp/kandev-test/npm-global/bin/kandev";
|
|
vi.spyOn(fs, "existsSync").mockReturnValue(true);
|
|
vi.spyOn(fs, "realpathSync").mockReturnValue(
|
|
"/tmp/kandev-test/npm-global/lib/node_modules/kandev/bin/cli.js",
|
|
);
|
|
|
|
expect(captureLauncher()).toMatchObject({
|
|
cliEntry: "/tmp/kandev-test/npm-global/lib/node_modules/kandev/bin/cli.js",
|
|
kind: "npm",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveHomeDir", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("returns an absolute override as-is (no tilde)", () => {
|
|
expect(resolveHomeDir("/srv/kandev", false)).toBe("/srv/kandev");
|
|
});
|
|
|
|
it("expands a leading ~/ to os.homedir()", () => {
|
|
vi.spyOn(os, "homedir").mockReturnValue("/home/alice");
|
|
expect(resolveHomeDir("~/kandev", false)).toBe("/home/alice/kandev");
|
|
expect(resolveHomeDir("~/srv/nested", false)).toBe("/home/alice/srv/nested");
|
|
});
|
|
|
|
it("expands a bare ~ to os.homedir()", () => {
|
|
vi.spyOn(os, "homedir").mockReturnValue("/home/alice");
|
|
expect(resolveHomeDir("~", false)).toBe("/home/alice");
|
|
});
|
|
|
|
it("does not expand a tilde that isn't at the start", () => {
|
|
expect(resolveHomeDir("/srv/~/data", false)).toBe("/srv/~/data");
|
|
});
|
|
|
|
it("falls back to /var/lib/kandev for system mode with no override", () => {
|
|
expect(resolveHomeDir(undefined, true)).toBe("/var/lib/kandev");
|
|
});
|
|
|
|
it("falls back to ~/.kandev for user mode with no override", () => {
|
|
// KANDEV_HOME_DIR is the constant from ../constants, computed at import
|
|
// time. We just check it ends with .kandev and starts with the home dir.
|
|
const result = resolveHomeDir(undefined, false);
|
|
expect(result.endsWith(".kandev")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("homebrewShimPath", () => {
|
|
it("derives the floating bin shim from a linuxbrew Cellar cli.js path", () => {
|
|
expect(
|
|
homebrewShimPath("/home/linuxbrew/.linuxbrew/Cellar/kandev/0.52.0/libexec/cli/bin/cli.js"),
|
|
).toBe("/home/linuxbrew/.linuxbrew/bin/kandev");
|
|
});
|
|
|
|
it("derives the floating bin shim from an Apple-silicon Cellar path", () => {
|
|
expect(homebrewShimPath("/opt/homebrew/Cellar/kandev/1.2.3/libexec/cli/bin/cli.js")).toBe(
|
|
"/opt/homebrew/bin/kandev",
|
|
);
|
|
});
|
|
|
|
it("returns undefined for a non-Cellar path (npm install)", () => {
|
|
expect(homebrewShimPath("/usr/local/lib/node_modules/kandev/bin/cli.js")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("captureLauncher shim resolution", () => {
|
|
const originalArgv = process.argv;
|
|
|
|
afterEach(() => {
|
|
process.argv = originalArgv;
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it("adopts the floating shim when it exists on disk", () => {
|
|
const cliEntry = "/opt/homebrew/Cellar/kandev/1.2.3/libexec/cli/bin/cli.js";
|
|
const shim = "/opt/homebrew/bin/kandev";
|
|
process.argv = ["/path/to/node", cliEntry];
|
|
vi.stubEnv("KANDEV_BUNDLE_DIR", "/opt/homebrew/Cellar/kandev/1.2.3/libexec");
|
|
// Both the cli.js entry and the shim resolve on disk.
|
|
vi.spyOn(fs, "existsSync").mockImplementation((p) => p === cliEntry || p === shim);
|
|
vi.spyOn(fs, "realpathSync").mockReturnValue(cliEntry);
|
|
|
|
const info = captureLauncher();
|
|
expect(info.kind).toBe("homebrew");
|
|
expect(info.shimPath).toBe(shim);
|
|
});
|
|
|
|
it("falls back to version-pinned paths when the shim is absent on disk", () => {
|
|
const cliEntry = "/opt/homebrew/Cellar/kandev/1.2.3/libexec/cli/bin/cli.js";
|
|
process.argv = ["/path/to/node", cliEntry];
|
|
vi.stubEnv("KANDEV_BUNDLE_DIR", "/opt/homebrew/Cellar/kandev/1.2.3/libexec");
|
|
// The cli.js entry resolves, but the floating shim does not exist.
|
|
vi.spyOn(fs, "existsSync").mockImplementation((p) => p === cliEntry);
|
|
vi.spyOn(fs, "realpathSync").mockReturnValue(cliEntry);
|
|
|
|
const info = captureLauncher();
|
|
expect(info.kind).toBe("homebrew");
|
|
expect(info.shimPath).toBeUndefined();
|
|
});
|
|
});
|