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
187 lines
7.2 KiB
TypeScript
187 lines
7.2 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { KANDEV_HOME_DIR } from "../constants";
|
|
|
|
/** Service unit/plist locations. Single source of truth for where kandev */
|
|
/** writes/reads its unit files — linux.ts, macos.ts, config.ts, stale_check.ts */
|
|
/** all consume these. Exposed as functions (not eager constants) so tests can */
|
|
/** mock `os.homedir()` between cases. */
|
|
export const SERVICE_NAME = "kandev";
|
|
export const LAUNCHD_LABEL = "com.kdlbs.kandev";
|
|
|
|
export const LINUX_SYSTEM_UNIT_DIR = "/etc/systemd/system";
|
|
export const MACOS_SYSTEM_DAEMON_DIR = "/Library/LaunchDaemons";
|
|
|
|
export function linuxUserUnitDir(): string {
|
|
return path.join(os.homedir(), ".config", "systemd", "user");
|
|
}
|
|
export function linuxUserUnitPath(): string {
|
|
return path.join(linuxUserUnitDir(), `${SERVICE_NAME}.service`);
|
|
}
|
|
export function linuxSystemUnitPath(): string {
|
|
return path.join(LINUX_SYSTEM_UNIT_DIR, `${SERVICE_NAME}.service`);
|
|
}
|
|
|
|
export function macosUserAgentDir(): string {
|
|
return path.join(os.homedir(), "Library", "LaunchAgents");
|
|
}
|
|
export function macosUserPlistPath(): string {
|
|
return path.join(macosUserAgentDir(), `${LAUNCHD_LABEL}.plist`);
|
|
}
|
|
export function macosSystemPlistPath(): string {
|
|
return path.join(MACOS_SYSTEM_DAEMON_DIR, `${LAUNCHD_LABEL}.plist`);
|
|
}
|
|
|
|
export type LauncherKind = "homebrew" | "npm" | "npx" | "unknown";
|
|
|
|
export type LauncherInfo = {
|
|
/** Absolute path to node executable (process.execPath at install time). */
|
|
nodePath: string;
|
|
/** Absolute path to the cli.js entry point. */
|
|
cliEntry: string;
|
|
/** Best-guess of how kandev was installed. Used to seed env vars. */
|
|
kind: LauncherKind;
|
|
/** KANDEV_BUNDLE_DIR if set (Homebrew sets this). */
|
|
bundleDir?: string;
|
|
/** KANDEV_VERSION if set (Homebrew sets this). */
|
|
version?: string;
|
|
/**
|
|
* Absolute path to the floating Homebrew launcher shim
|
|
* (`<prefix>/bin/kandev`), set only for Homebrew installs where the shim
|
|
* exists on disk. When present, the unit is rendered to exec this shim
|
|
* instead of the version-pinned Cellar node + cli.js, so it survives
|
|
* `brew upgrade`. See {@link homebrewShimPath}.
|
|
*/
|
|
shimPath?: string;
|
|
};
|
|
|
|
// Homebrew is POSIX-only, so the Cellar segment is a hardcoded "/Cellar/"
|
|
// rather than `path.sep`-based. On a Windows CI runner `path.sep` would be
|
|
// "\\", which would never match a POSIX Cellar path and silently break shim
|
|
// derivation (and its tests).
|
|
const HOMEBREW_CELLAR_SEGMENT = "/Cellar/";
|
|
|
|
/**
|
|
* Derive the floating Homebrew launcher shim from a Cellar-installed cli.js path.
|
|
*
|
|
* Homebrew installs the CLI under `<prefix>/Cellar/kandev/<version>/...` and
|
|
* symlinks a version-independent shim at `<prefix>/bin/kandev`. That shim sets
|
|
* KANDEV_BUNDLE_DIR / KANDEV_VERSION itself and execs cli.js via the floating
|
|
* `opt/node` symlink, so it keeps working after `brew upgrade` deletes the old
|
|
* Cellar dir. Returns undefined when `cliEntry` isn't a Cellar layout (npm /
|
|
* unknown installs), so callers fall back to the version-pinned paths.
|
|
*/
|
|
export function homebrewShimPath(cliEntry: string): string | undefined {
|
|
const idx = cliEntry.indexOf(HOMEBREW_CELLAR_SEGMENT);
|
|
if (idx === -1) return undefined;
|
|
const prefix = cliEntry.slice(0, idx);
|
|
// Homebrew layout is POSIX; use path.posix.join so the result keeps forward
|
|
// slashes regardless of the host the install/tests run on.
|
|
return path.posix.join(prefix, "bin", SERVICE_NAME);
|
|
}
|
|
|
|
/**
|
|
* Snapshot the current invocation so the service unit can faithfully reproduce it.
|
|
*
|
|
* The unit file hard-codes absolute paths because systemd/launchd start with an
|
|
* empty PATH and may not see the user's `node` or `kandev` shim. By recording
|
|
* `process.execPath` (node) and the resolved CLI entry at install time we avoid
|
|
* any PATH lookups at service-run time.
|
|
*/
|
|
export function captureLauncher(): LauncherInfo {
|
|
const nodePath = process.execPath;
|
|
const cliEntry = resolveCliEntry();
|
|
const bundleDir = process.env.KANDEV_BUNDLE_DIR;
|
|
const version = process.env.KANDEV_VERSION;
|
|
const kind: LauncherKind = bundleDir
|
|
? "homebrew"
|
|
: looksLikeNpxEntry(cliEntry)
|
|
? "npx"
|
|
: cliEntry.includes(`${path.sep}node_modules${path.sep}`)
|
|
? "npm"
|
|
: "unknown";
|
|
// For Homebrew installs, prefer the floating bin shim so the unit survives
|
|
// `brew upgrade` (which deletes the versioned Cellar dir baked into nodePath
|
|
// /cliEntry). Only adopt it when the shim actually exists on disk; otherwise
|
|
// fall back to the version-pinned paths below.
|
|
let shimPath: string | undefined;
|
|
if (kind === "homebrew") {
|
|
const candidate = homebrewShimPath(cliEntry);
|
|
if (candidate && fs.existsSync(candidate)) shimPath = candidate;
|
|
}
|
|
return { nodePath, cliEntry, kind, bundleDir, version, shimPath };
|
|
}
|
|
|
|
function looksLikeNpxEntry(cliEntry: string): boolean {
|
|
return cliEntry.includes(`${path.sep}_npx${path.sep}`);
|
|
}
|
|
|
|
function resolveCliEntry(): string {
|
|
const argvEntry = process.argv[1];
|
|
if (argvEntry && fs.existsSync(argvEntry)) {
|
|
return path.resolve(fs.realpathSync(argvEntry));
|
|
}
|
|
throw new Error(
|
|
"could not resolve the kandev CLI entry path from process.argv[1]; " +
|
|
"rerun via the kandev binary",
|
|
);
|
|
}
|
|
|
|
/** Resolve the home directory used for the unit's KANDEV_HOME_DIR env. */
|
|
export function resolveHomeDir(override: string | undefined, runAsRoot: boolean): string {
|
|
if (override) {
|
|
// Node's path.resolve doesn't expand `~`; users often type `--home-dir ~/foo`
|
|
// (especially via shell escapes that defer expansion), so do it ourselves.
|
|
const expanded =
|
|
override === "~" || override.startsWith(`~${path.sep}`)
|
|
? path.join(os.homedir(), override.slice(1))
|
|
: override;
|
|
return path.resolve(expanded);
|
|
}
|
|
if (runAsRoot) {
|
|
// System units default to /var/lib/kandev so root-owned data lives outside any
|
|
// single user's $HOME (where it would be unreachable to other users).
|
|
return "/var/lib/kandev";
|
|
}
|
|
return KANDEV_HOME_DIR;
|
|
}
|
|
|
|
/** Absolute path to the log directory used by the unit for stdout/stderr. */
|
|
export function resolveLogDir(homeDir: string): string {
|
|
return path.join(homeDir, "logs");
|
|
}
|
|
|
|
/** Current username (the EUID the CLI is running as). */
|
|
export function currentUsername(): string {
|
|
return os.userInfo().username;
|
|
}
|
|
|
|
/**
|
|
* Resolve which user the service should run as.
|
|
*
|
|
* For user-mode installs this is always the current user (matters for hints
|
|
* printed back to the user, not for the unit itself — user units don't set
|
|
* `User=`).
|
|
*
|
|
* For system-mode installs the CLI is typically invoked via sudo, which makes
|
|
* `os.userInfo().username` resolve to `root`. We prefer `SUDO_USER` so the
|
|
* daemon runs as the human who installed it (with access to their `~/.kandev`,
|
|
* git config, agent CLI credentials, etc) rather than as root.
|
|
*
|
|
* If the user genuinely wants a root-owned daemon they can run sudo with
|
|
* `-E` stripped or pass `--run-as root` (future flag) — but the common case
|
|
* (`sudo kandev service install --system`) gets the safe default.
|
|
*/
|
|
export function resolveServiceUser(isSystem: boolean): string {
|
|
if (!isSystem) {
|
|
return currentUsername();
|
|
}
|
|
const sudoUser = process.env.SUDO_USER;
|
|
if (sudoUser && sudoUser !== "root") {
|
|
return sudoUser;
|
|
}
|
|
return currentUsername();
|
|
}
|