diff --git a/src/core/cli/walkthrough.js b/src/core/cli/walkthrough.js index b159d328..a3cbfc1e 100644 --- a/src/core/cli/walkthrough.js +++ b/src/core/cli/walkthrough.js @@ -11,7 +11,6 @@ import { readObservabilityEnv } from '../observability/env.js' import { discoverBundledPlugins } from '../runtime/bundled.js' import { isWithinDir } from '../runtime/contribution_names.js' import { buildPluginCatalog } from '../plugin_catalog.js' -import { ensureDurableBinForNpx } from './global_install.js' import { detectPickerSources } from './detect.js' import { multiselect, select, text } from './tui/index.js' import { isPromptCancelledError } from './tui/runtime.js' @@ -817,25 +816,17 @@ export async function runPickerFinale(args) { }, async (span) => { const installMod = await import('../daemon/install.js') - let binPath = finale.binPath ?? (process.argv[1] ?? '') - if (!dryRun && !finale.binPath && binPath) { - const durable = await ensureDurableBinForNpx({ binPath, env, stdout, stderr }) - binPath = durable.binPath - summary.globalInstall = { - skipped: durable.skipped, - installed: durable.installed, - binPath: durable.binPath, - ...(durable.packageSpec ? { packageSpec: durable.packageSpec } : {}), - } - if (span && typeof span.setAttribute === 'function') { - span.setAttribute('global_install_skipped', durable.skipped) - span.setAttribute('global_install_installed', durable.installed) - } - } + const binPath = finale.binPath ?? (process.argv[1] ?? '') /** @type {DaemonInstallOptions} */ const options = { binPath, configPath, + // The npx->durable global-bin upgrade now lives inside + // installDaemon so every enrollment path inherits it; the + // walkthrough only signals whether binPath came from an + // explicit --bin and reads the result back off the plan. + binExplicit: finale.binPath !== undefined, + durableBin: { env, stdout, stderr }, ...(homeDir ? { homeDir } : {}), } if (dryRun) { @@ -858,10 +849,23 @@ export async function runPickerFinale(args) { dryRun: false, targetPath: plan.targetPath, } + const durable = plan.globalInstall + if (durable) { + summary.globalInstall = { + skipped: durable.skipped, + installed: durable.installed, + binPath: durable.binPath, + ...(durable.packageSpec ? { packageSpec: durable.packageSpec } : {}), + } + } if (span && typeof span.setAttribute === 'function') { span.setAttribute('target_path', plan.targetPath) span.setAttribute('bin_path', plan.binPath) span.setAttribute('platform', plan.platform) + if (durable) { + span.setAttribute('global_install_skipped', durable.skipped) + span.setAttribute('global_install_installed', durable.installed) + } } } }, diff --git a/src/core/commands/daemon.js b/src/core/commands/daemon.js index 33ded658..2a9d2273 100644 --- a/src/core/commands/daemon.js +++ b/src/core/commands/daemon.js @@ -189,6 +189,11 @@ export async function runDaemonInstall(argv, ctx) { /** @type {DaemonInstallOptions} */ const options = { binPath, + // An explicit --bin is the escape hatch: installDaemon must keep it + // verbatim. A default binPath from process.argv[1] under npx points + // into the _npx cache, so installDaemon upgrades it to a durable + // global bin (LLP 0025: join stays a wrapper over this same path). + binExplicit: parsed.binPath !== undefined, ...(parsed.configPath !== undefined ? { configPath: parsed.configPath } : {}), ...(homeDir !== undefined ? { homeDir } : {}), ...(parsed.platform !== undefined ? { platform: parsed.platform } : {}), diff --git a/src/core/daemon/install.js b/src/core/daemon/install.js index af0bfa43..ef9ad71d 100644 --- a/src/core/daemon/install.js +++ b/src/core/daemon/install.js @@ -3,6 +3,7 @@ import process from 'node:process' import { Attr, getLogger, withSpan } from '../observability/index.js' +import { ensureDurableBinForNpx, isNpxBinPath } from '../cli/global_install.js' import { LAUNCH_LABEL, @@ -38,6 +39,7 @@ function defaultLabelFor(platform) { * DaemonUninstallOptions, * DaemonServiceOptions, * } from '../../../src/core/daemon/types.js' + * @import { DurableBinResult } from '../../../src/core/cli/types.js' */ export class DaemonInstallError extends Error { @@ -150,6 +152,43 @@ function withDaemonOp(op, platform, label, fn, okFields) { ) } +/** + * The single choke point that keeps a daemon from ever being pinned to + * an ephemeral npx bin. When `npx hypaware` installs the daemon, the + * resolved binPath points into npm's `~/.npm/_npx//...` cache, + * which vanishes the moment npx exits, leaving the host recorded but + * with no `hyp` control surface (status/policy/detach/uninstall all + * impossible). Every non-dry-run install funnels through `installDaemon` + * (walkthrough finale, `hyp daemon install`, and the join/enroll lane), + * so upgrading to a durable global bin here makes "a daemon is never + * installed against an `_npx` bin" a single invariant instead of a + * per-call-site obligation only the walkthrough remembered to honor. + * + * Escape hatches survive: an explicit `--bin` sets `binExplicit`, and + * dry-run never reaches here (it renders through `planDaemonInstall`). + * + * @param {DaemonInstallOptions} options + * @returns {Promise<{ binPath: string, globalInstall: DurableBinResult }>} + */ +async function resolveDurableBinPath(options) { + const seam = options.durableBin ?? {} + const env = seam.env ?? process.env + if (options.binExplicit || !isNpxBinPath(options.binPath, env)) { + return { + binPath: options.binPath, + globalInstall: { binPath: options.binPath, installed: false, skipped: true }, + } + } + const durable = await ensureDurableBinForNpx({ + binPath: options.binPath, + env, + stdout: seam.stdout ?? process.stdout, + stderr: seam.stderr ?? process.stderr, + ...(seam.runner ? { runner: seam.runner } : {}), + }) + return { binPath: durable.binPath, globalInstall: durable } +} + /** * Install the platform-appropriate persistent service. Wraps the * platform-specific call in a `daemon.install` span and emits a @@ -173,9 +212,21 @@ export async function installDaemon(options) { 'install', platform, merged.label ?? defaultLabelFor(platform), + // @ref LLP 0025#seed-config-mode [implements]: durable-bin upgrade lives here so join/login inherit it, not just the walkthrough /** @returns {Promise} */ - () => platform === 'darwin' ? macos.installLaunchAgent(merged) : linux.installSystemdUnit(merged), - (plan) => ({ target_path: plan.targetPath }), + async () => { + const { binPath, globalInstall } = await resolveDurableBinPath(merged) + const withBin = binPath === merged.binPath ? merged : { ...merged, binPath } + const plan = platform === 'darwin' + ? await macos.installLaunchAgent(withBin) + : await linux.installSystemdUnit(withBin) + return Object.assign(plan, { globalInstall }) + }, + (plan) => ({ + target_path: plan.targetPath, + global_install_installed: plan.globalInstall?.installed ?? false, + global_install_skipped: plan.globalInstall?.skipped ?? true, + }), ) } diff --git a/src/core/daemon/types.d.ts b/src/core/daemon/types.d.ts index e4a378e4..d04667d9 100644 --- a/src/core/daemon/types.d.ts +++ b/src/core/daemon/types.d.ts @@ -9,6 +9,7 @@ import type { ExtendedSourceRegistry, } from '../registry/types.d.ts' import type { KernelRuntime } from '../runtime/types.d.ts' +import type { CommandRunner, DurableBinResult } from '../cli/types.d.ts' /** * Daemon health states the smoke and `hyp daemon status` rely on. @@ -331,6 +332,8 @@ export interface SystemdInstallPlan { nodePath: string unitDir: string manageCommands: string[][] + /** Result of the npx->durable global-bin upgrade installDaemon ran (if any). */ + globalInstall?: DurableBinResult } export interface LaunchctlResult { @@ -383,13 +386,36 @@ export interface LaunchAgentInstallPlan { nodePath: string plistDir: string manageCommands: string[][] + /** Result of the npx->durable global-bin upgrade installDaemon ran (if any). */ + globalInstall?: DurableBinResult } export type DaemonInstallPlan = LaunchAgentInstallPlan | SystemdInstallPlan +/** + * Override seam for the npx->durable global-bin upgrade installDaemon + * runs before writing the service unit. Production leaves this unset + * (process.env / process streams / the real npm runner); tests inject a + * fake env + runner so no global npm install actually happens. + */ +export interface DurableBinUpgradeSeam { + env?: NodeJS.ProcessEnv + stdout?: { write(chunk: string): unknown } + stderr?: { write(chunk: string): unknown } + runner?: CommandRunner +} + export interface DaemonInstallOptions { /** Absolute path to the HypAware CLI entrypoint. */ binPath: string + /** + * binPath came from an explicit `--bin`, so installDaemon must NOT + * rewrite it into a durable global bin even if it looks like an + * `_npx` path (the explicit-bin escape hatch). + */ + binExplicit?: boolean + /** Override seam for the npx->durable global-bin upgrade (tests only). */ + durableBin?: DurableBinUpgradeSeam /** Config path passed to the daemon (defaults to ~/.hyp/hypaware-config.json). */ configPath?: string /** Override the launch label (defaults to com.hyperparam.hypaware). */ diff --git a/test/core/daemon-install-durable-bin.test.js b/test/core/daemon-install-durable-bin.test.js new file mode 100644 index 00000000..79c5ae81 --- /dev/null +++ b/test/core/daemon-install-durable-bin.test.js @@ -0,0 +1,147 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { installDaemon, renderDaemonInstall } from '../../src/core/daemon/install.js' +import { isNpxBinPath, globalHypawareBin } from '../../src/core/cli/global_install.js' +import { runDaemonInstall } from '../../src/core/commands/daemon.js' + +/** + * @import { CommandRunContext } from '../../hypaware-plugin-kernel-types.js' + */ + +// Regression for #384: `ensureDurableBinForNpx` had a single call site in +// the walkthrough finale, so `hyp daemon install` and the join/enroll +// lane installed launchd/systemd against the ephemeral `_npx` bin. When +// npx exits that bin is gone and the host has no `hyp` control surface. +// The upgrade now lives inside installDaemon, so every enrollment path +// inherits "a daemon is never installed against an `_npx` bin." + +const OK = { exitCode: 0, stdout: '', stderr: '' } + +/** A launchctl that reports the agent as not loaded and bootstraps cleanly. */ +function fakeLaunchctl() { + return { + print: () => Promise.resolve({ exitCode: 1, stdout: '', stderr: '' }), + bootout: () => Promise.resolve(OK), + bootstrap: () => Promise.resolve(OK), + kickstart: () => Promise.resolve(OK), + } +} + +const NPX_BIN = '/Users/hyp/.npm/_npx/deadbeef/node_modules/hypaware/bin/hypaware.js' +const NPX_ENV = { npm_config_cache: '/Users/hyp/.npm' } +const GLOBAL_PREFIX = '/Users/hyp/.npm-global' +const GLOBAL_BIN = globalHypawareBin(GLOBAL_PREFIX, 'darwin') + +const tmpHome = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hyp-durable-bin-')) + +/** A durable-bin runner seam so no real `npm install -g` happens. */ +function fakeNpmRunner() { + /** @type {{ cmd: string, args: string[] }[]} */ + const calls = [] + const runner = async (cmd, args) => { + calls.push({ cmd, args }) + if (args.join(' ') === 'config get prefix') { + return { exitCode: 0, stdout: `${GLOBAL_PREFIX}\n`, stderr: '' } + } + return { exitCode: 0, stdout: '', stderr: '' } + } + return { calls, runner } +} + +/** + * @param {string} homeDir + * @param {{ runner?: import('../../src/core/cli/types.js').CommandRunner }} [durable] + * @param {Partial} [extra] + */ +function darwinOpts(homeDir, durable, extra) { + return { + binPath: NPX_BIN, + platform: /** @type {NodeJS.Platform} */ ('darwin'), + homeDir, + plistDir: path.join(homeDir, 'LaunchAgents'), + nodePath: '/x/node', + configPath: path.join(homeDir, 'hypaware-config.json'), + launchctl: fakeLaunchctl(), + userDomain: 'gui/501', + sleep: async function() {}, + durableBin: { env: NPX_ENV, stdout: { write() {} }, stderr: { write() {} }, ...(durable ?? {}) }, + ...(extra ?? {}), + } +} + +test('installDaemon upgrades an _npx binPath to a durable global bin before writing the service unit', async () => { + const home = tmpHome() + const { calls, runner } = fakeNpmRunner() + + const plan = await installDaemon(darwinOpts(home, { runner })) + + // The written service unit must not pin the ephemeral npx bin. + const content = fs.readFileSync(plan.targetPath, 'utf8') + assert.equal(isNpxBinPath(plan.binPath, NPX_ENV), false, 'resolved bin is durable, not _npx') + assert.equal(plan.binPath, GLOBAL_BIN, 'resolved bin is the global CLI') + assert.ok(!content.includes('_npx'), 'plist does not reference the _npx cache') + assert.ok(content.includes(GLOBAL_BIN), 'plist references the durable global bin') + + // The upgrade actually ran and is reported on the plan. + assert.equal(plan.globalInstall?.installed, true) + assert.equal(plan.globalInstall?.skipped, false) + assert.deepEqual(calls[0], { cmd: 'npm', args: ['install', '-g', plan.globalInstall?.packageSpec] }) +}) + +test('explicit --bin bypasses the durable upgrade even for an _npx-looking path', async () => { + const home = tmpHome() + let ran = false + const runner = async () => { ran = true; return OK } + + const plan = await installDaemon(darwinOpts(home, { runner }, { binExplicit: true })) + + assert.equal(ran, false, 'no npm install for an explicit --bin') + assert.equal(plan.binPath, NPX_BIN, 'explicit bin kept verbatim') + const content = fs.readFileSync(plan.targetPath, 'utf8') + assert.ok(content.includes('_npx'), 'explicit --bin escape hatch preserved') + assert.equal(plan.globalInstall?.skipped, true) +}) + +test('dry-run render never triggers the durable upgrade (renders the _npx path as-is)', () => { + const home = tmpHome() + const plan = renderDaemonInstall(darwinOpts(home)) + assert.equal(plan.binPath, NPX_BIN, 'dry-run leaves binPath untouched') +}) + +test('hyp daemon install (no --bin) upgrades the process argv _npx bin to a durable global bin', async () => { + const home = tmpHome() + const { runner } = fakeNpmRunner() + + // Simulate `npx hypaware daemon install` where process.argv[1] is the + // _npx bin. The command resolves binPath from argv, so we drive the + // install seam directly to prove the non-walkthrough command path also + // funnels through the durable upgrade. + const plan = await installDaemon({ + ...darwinOpts(home, { runner }), + // binExplicit omitted -> false, matching `runDaemonInstall` with no --bin + }) + + assert.equal(isNpxBinPath(plan.binPath, NPX_ENV), false) + assert.equal(plan.binPath, GLOBAL_BIN) +}) + +test('runDaemonInstall dry-run still surfaces the _npx bin without a global install (escape hatch)', async () => { + const home = tmpHome() + let out = '' + const ctx = /** @type {any} */ ({ + env: { HOME: home, ...NPX_ENV }, + stdout: { write(c) { out += String(c) } }, + stderr: { write() {} }, + }) + // Force platform + a known argv-style bin by passing --bin so the test + // is host-independent; --dry-run must render without any npm install. + const code = await runDaemonInstall(['--dry-run', '--bin', NPX_BIN, '--platform', 'darwin'], /** @type {CommandRunContext} */ (ctx)) + assert.equal(code, 0) + assert.ok(out.includes(NPX_BIN), 'dry-run renders the given bin, no durable upgrade') +})