Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 20 additions & 16 deletions src/core/cli/walkthrough.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
}
}
},
Expand Down
5 changes: 5 additions & 0 deletions src/core/commands/daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
55 changes: 53 additions & 2 deletions src/core/daemon/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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/<hash>/...` 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
Expand All @@ -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<DaemonInstallPlan>} */
() => 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,
}),
)
}

Expand Down
26 changes: 26 additions & 0 deletions src/core/daemon/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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). */
Expand Down
147 changes: 147 additions & 0 deletions test/core/daemon-install-durable-bin.test.js
Original file line number Diff line number Diff line change
@@ -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<import('../../src/core/daemon/types.js').DaemonInstallOptions>} [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')
})
Loading