// A launcher must not depend on `bare` being on the ambient PATH.
//
// `exec bare <entry>` works from an interactive shell and fails under `ssh host 'app ...'`, whose PATH is
// minimal and usually excludes ~/.local/bin. Observed live on ashatron:
//
// $ ssh ashatron 'hyper-proto-peer diag'
// /home/fleeky/.local/bin/hyper-proto-peer: 2: exec: bare: not found
//
// while the byte-identical launcher worked on stortron — a per-host difference caused purely by PATH,
// which is about the worst way for this to fail.
//
// The cause was DRIFT between two copies of resolveBundledRuntime that a comment claimed were "in
// parity": commands/app.js fell back to process.execPath (absolute), runtime-factory.js fell back to the
// bare NAME — and runtime-factory is the one finalizeInstall uses on every install and update. There is
// now one exported implementation; these tests pin its fallback ORDER so the drift cannot reappear.
import test from 'brittle'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { resolveBundledRuntime, defaultRuntimePath } from '../lib/runtime-factory.js'
const HOST = 'linux-x64'
function store (t) {
const root = mkdtempSync(join(tmpdir(), 'pgh-launcher-'))
t.teardown(() => { try { rmSync(root, { recursive: true, force: true }) } catch {} })
const apps = join(root, 'apps')
mkdirSync(apps, { recursive: true })
return {
apps,
app (name) { const d = join(apps, name); mkdirSync(d, { recursive: true }); return d },
bare (name, sub = 'runtime') {
const d = join(apps, name, sub)
mkdirSync(d, { recursive: true })
const p = join(d, 'bare')
writeFileSync(p, '#!/bin/sh\n', { mode: 0o755 })
return p
}
}
}
test('prefers a runtime bundled with THIS install', (t) => {
const s = store(t)
const appDir = s.app('myapp')
const own = s.bare('myapp')
s.bare('pgh') // a sibling exists too — the app's own must still win
t.is(resolveBundledRuntime(appDir, 'bare', HOST, { execPath: '/usr/bin/bare' }), own,
'the app\'s own runtime/bare beats both execPath and the sibling')
})
test('falls back to the runtime currently executing us', (t) => {
const s = store(t)
const appDir = s.app('myapp') // ships no runtime of its own
t.is(resolveBundledRuntime(appDir, 'bare', HOST, { execPath: '/opt/x/bare' }), '/opt/x/bare',
'process.execPath is used when it is already the interp — absolute, and known to run this stack')
})
test('falls back to a SIBLING app\'s runtime when the CLI runs under node', (t) => {
// The ashatron case. The app bundles no runtime, and execPath is 'node' so it cannot supply one —
// previously this returned the bare NAME and produced a PATH-dependent launcher.
const s = store(t)
const appDir = s.app('hyper-proto-peer')
const pghBare = s.bare('pgh')
const got = resolveBundledRuntime(appDir, 'bare', HOST, { execPath: '/usr/bin/node' })
t.is(got, pghBare, 'pgh is bootstrap:true, so on a pgh-managed machine it is present by construction')
t.not(got, 'bare', 'and the result is NOT the bare name — that is the bug this closes')
})
test('multi-arch bundle wins over the root-level one', (t) => {
const s = store(t)
const appDir = s.app('myapp')
s.bare('myapp') // root-level
const byArch = s.bare('myapp', join('by-arch', HOST, 'runtime'))
t.is(resolveBundledRuntime(appDir, 'bare', HOST, { execPath: '/usr/bin/node' }), byArch,
'by-arch/<host>/runtime/bare is the most specific candidate')
})
test('only the genuinely unresolvable case returns the bare name', (t) => {
const s = store(t)
const appDir = s.app('myapp') // no own runtime, no siblings, execPath is not bare
t.is(resolveBundledRuntime(appDir, 'bare', HOST, { execPath: '/usr/bin/node' }), 'bare',
'last resort is still PATH — but now it is genuinely last, not the second step')
})
test('a node app is unaffected', (t) => {
const s = store(t)
const appDir = s.app('nodeapp')
s.bare('pgh')
t.is(resolveBundledRuntime(appDir, 'node', HOST, { execPath: '/usr/bin/node' }), '/usr/bin/node',
'node resolves to the running node; the bare-only sibling search never applies')
})
// defaultRuntimePath was a THIRD copy of this logic, and one that SHADOWED the fixed resolver:
// finalizeInstall does `runtimePath || resolveBundledRuntime(...)`, and defaultRuntimePath's return is
// always truthy, so on the OTA install/update path the five-candidate resolver was never consulted. It
// checked ONE candidate (apps/pgh/runtime/bare) and otherwise returned the bare name — the same
// PATH-dependent launcher this file exists to prevent, reachable by a different route.
function fakeStore (h) {
return { appsDir: h.apps, appPath: (n) => join(h.apps, n) }
}
test('defaultRuntimePath delegates — it finds the app\'s OWN bundled runtime', (t) => {
const h = store(t)
// The old single-candidate version could not see this: it only ever looked at apps/pgh.
const own = h.bare('myapp')
t.is(defaultRuntimePath(fakeStore(h), 'myapp'), own, 'the install\'s own runtime wins')
})
// The sibling is candidate 4, so it is only REACHED when the CLI is not itself running under bare —
// under bare, process.execPath (candidate 3) legitimately wins first. That ordering is the pre-existing
// contract (see 'falls back to a SIBLING app\'s runtime when the CLI runs under node' above); pinning it
// here proves the delegation honours the real order rather than reimplementing a different one.
test('defaultRuntimePath still finds a sibling pgh runtime (no regression)', (t) => {
const h = store(t)
const sib = h.bare('pgh')
h.app('myapp')
t.is(defaultRuntimePath(fakeStore(h), 'myapp', { execPath: '/usr/bin/node' }), sib, 'the sibling is used when the app ships none and the CLI runs under node')
})
// The case that would have saved ashatron: an OTA update is applied BY a process already running under
// bare, so execPath is absolute and runnable even when every bundled lookup misses.
test('defaultRuntimePath falls back to the runtime executing us, not the bare NAME', (t) => {
const h = store(t)
h.app('myapp')
t.is(defaultRuntimePath(fakeStore(h), 'myapp', { execPath: '/opt/bare/bin/bare' }), '/opt/bare/bin/bare', 'execPath is used before giving up')
})
test('defaultRuntimePath returns the bare name only when genuinely unresolvable', (t) => {
const h = store(t)
h.app('myapp')
t.is(defaultRuntimePath(fakeStore(h), 'myapp', { execPath: '/usr/bin/node' }), 'bare', 'last resort is unchanged')
})