OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / b0dd9d3

0.1.124: launchers must not depend on `bare` being on PATH (and one resolver, not two)
$ ssh ashatron 'hyper-proto-peer diag' /home/fleeky/.local/bin/hyper-proto-peer: 2: exec: bare: not found The byte-identical launcher worked on stortron. The difference was PATH: a non-interactive `ssh host 'app ...'` gets a minimal PATH that excludes ~/.local/bin, so `exec bare <entry>` cannot resolve. A per-host failure caused by ambient environment is about the worst way for this to break — it makes the remote tooling built earlier today (`hyper-proto-peer diag`, `pgh app stale`) unusable on exactly the box that needs it. ROOT CAUSE: resolveBundledRuntime existed TWICE, with a comment in one claiming "Parity with app.js resolveBundledRuntime" — and they had drifted. commands/app.js fell back to process.execPath (absolute); runtime-factory.js fell back to the bare NAME. finalizeInstall is the modern install path, so the PATH-dependent launcher is the one that actually got written on every install and update. The comment asserting parity is what made it invisible. Now ONE exported implementation, imported by both, resolving most specific first: 1-2. a runtime bundled with THIS install (multi-arch, then root-level) 3. the runtime currently executing us — absolute, and by definition able to run this stack 4. a SIBLING app's bundled runtime in the same store; pgh is bootstrap:true so on a pgh-managed machine it is present by construction. This is the ashatron case: the app ships no runtime and the CLI ran under node, so (3) could not supply one. 5. the bare name (PATH) — still the last resort, but genuinely last now. Also converted the three remaining launcher-writing sites in app.js that called resolveRuntimePath directly (install, adopt, and the dev-link path) — same hazard, same fix — and removed resolveRuntimePath, now dead. test/launcher-runtime-path.test.js (6 tests) pins the fallback ORDER with an injected execPath so the drift cannot reappear, including that the ashatron case resolves to a sibling and is NOT the bare name. One pre-existing assertion in runtime-closure-install.test.js pinned the old behaviour by matching the literal 'exec bare ' — i.e. it encoded the PATH-dependent form as correct. Updated to assert the INTENT (the interpreter is bare) and leave the ordering to the new test, which covers it properly. NOTE for deployment: this only affects launchers WRITTEN after it ships. Existing launchers keep their current body until the app is installed/updated again. Suite 267/267, 808/808. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b0dd9d36835099de0204defe8fe41cf1007bc696
by plankatron <fleeky@gmail.com> · 2026-07-28T19:31:31.000Z

Changed files (5) +143 -30

lib/commands/app.js +10 -21
……@@ -13,7 +13,7 @@ import { Client } from 'hyper-svc'
1313
1414import { TreeStore, DEFAULT_ROOT as DEFAULT_STORE_ROOT } from '../store.js'
1515import { installTree, linkApp, unlinkApp, swapApp } from '../tree-install.js'
16import { installComplete } from '../runtime-factory.js'
16import { installComplete, resolveBundledRuntime } from '../runtime-factory.js'
1717import { ReleaseRegistry } from '../release-registry.js'
1818import { NetworkFetcher } from '../network-fetcher.js'
1919import {
……@@ -75,11 +75,6 @@ const SIDECAR_SOCK = process.env.PGH_SIDECAR_SOCK || path.join(homedir(), '.pgh'
7575// on machines without that runtime on PATH (e.g. bare-bootstrap installs
7676// on a host without system bare). Otherwise return the literal name so
7777// the shell does PATH lookup (existing behavior).
78function resolveRuntimePath (runtime) {
79 if (path.basename(process.execPath) === runtime) return process.execPath
80 return runtime
81}
82
8378// Write a launcher script at `launcher`, replacing any existing file OR
8479// symlink there. Plain writeFileSync FOLLOWS a symlink and writes THROUGH it
8580// into the link target — e.g. a stale `~/.local/bin/pgh -> store/apps/pgh/bin/pgh`
……@@ -95,18 +90,12 @@ function writeLauncher (launcher, body) {
9590// self-contained bootstrap-bare tree — runtime/bare + bin/<name>.bare.js), the
9691// launcher/supervisor must exec THAT bare, not a PATH/system bare — otherwise a
9792// machine without system bare can't run it. Checks by-arch/<host>/runtime/bare
98// first (multi-arch trees), then a root-level runtime/bare; falls back to
99// resolveRuntimePath (own execPath / PATH) when no bundled bare is present.
100function resolveBundledRuntime (installPath, runtime, host) {
101 if (runtime === 'bare' && installPath) {
102 const cands = [
103 path.join(installPath, 'by-arch', host, 'runtime', 'bare'),
104 path.join(installPath, 'runtime', 'bare')
105 ]
106 for (const c of cands) { try { if (existsSync(c)) return c } catch {} }
107 }
108 return resolveRuntimePath(runtime)
109}
93// first (multi-arch trees), then a root-level runtime/bare, then the runtime
94// currently executing us, then a sibling app's bundled bare in the same store.
95// NOTE: the implementation now lives in ../runtime-factory.js and is imported above. It used to be
96// duplicated here "in parity", and the two copies had DRIFTED — this one resolved process.execPath, the
97// other fell back to the bare NAME, and the other is the one finalizeInstall uses. That drift is what
98// wrote ashatron a PATH-dependent `exec bare ...` launcher. One implementation, no parity to maintain.
11099
111100// Atomic materialize-and-swap. Build the new tree into a scratch dir, then swap
112101// it into place. This lets pgh update ITSELF safely: the old "rm installPath;
……@@ -341,7 +330,7 @@ export async function run (client, args) {
341330 // Create launcher
342331 mkdirSync(BIN_DIR, { recursive: true })
343332 const launcher = join(BIN_DIR, name)
344 writeLauncher(launcher, '#!/bin/sh\nexec ' + resolveRuntimePath('bare') + ' ' + join(installPath, entry) + ' "$@"\n')
333 writeLauncher(launcher, '#!/bin/sh\nexec ' + resolveBundledRuntime(installPath, 'bare', detectHost()) + ' ' + join(installPath, entry) + ' "$@"\n')
345334
346335 return { installed: name, path: installPath, target, launcher, treeHash }
347336 }
……@@ -1868,7 +1857,7 @@ function cmdAdopt (args) {
18681857 const entryAbs = path.isAbsolute(entrypointRel) ? entrypointRel : path.join(abs, entrypointRel)
18691858 writeLauncher(
18701859 launcher,
1871 '#!/bin/sh\nexec ' + resolveRuntimePath(manifest.runtime === 'bare' ? 'bare' : 'node') + ' ' + entryAbs + ' "$@"\n'
1860 '#!/bin/sh\nexec ' + resolveBundledRuntime(abs, manifest.runtime === 'bare' ? 'bare' : 'node', detectHost()) + ' ' + entryAbs + ' "$@"\n'
18721861 )
18731862
18741863 return { adopted: name, sourcePath: abs, manifest: installManifestFile(name), launcher }
……@@ -2025,7 +2014,7 @@ async function installViaDaemon (client, repo, args) {
20252014 const entry = fs.existsSync(path.join(dest, 'index.js')) ? 'index.js'
20262015 : fs.existsSync(path.join(dest, 'server.js')) ? 'server.js'
20272016 : 'index.js'
2028 writeLauncher(launcher, '#!/bin/sh\nexec ' + resolveRuntimePath('bare') + ' ' + path.join(dest, entry) + ' "$@"\n')
2017 writeLauncher(launcher, '#!/bin/sh\nexec ' + resolveBundledRuntime(dest, 'bare', detectHost()) + ' ' + path.join(dest, entry) + ' "$@"\n')
20292018 console.log('Launcher: ' + launcher)
20302019
20312020 return { installed: name, path: dest, launcher }
lib/runtime-factory.js +35 -7
……@@ -20,6 +20,7 @@
2020 * 3. default fetcher/store/materializer (no hand-wiring).
2121 */
2222
23import process from '#node:process' // NOT a global under bare — execPath below needs it
2324import fs from '#node:fs'
2425import path from '#node:path'
2526import os from '#node:os'
……@@ -70,16 +71,43 @@ function runtimeInterp (runtime) {
7071 return runtime === 'node' ? 'node' : 'bare'
7172}
7273
73// Resolve the launcher's runtime path: prefer a per-install bundled bare
74// (by-arch/<host>/runtime/bare or <install>/runtime/bare), else the interp
75// name on PATH. Parity with app.js resolveBundledRuntime.
76function resolveBundledRuntime (installPath, interp, host) {
74// Resolve the launcher's runtime to an ABSOLUTE path wherever possible.
75//
76// A launcher that says `exec bare <entry>` depends on `bare` being on the ambient PATH, and a
77// non-interactive `ssh host 'app ...'` gets a minimal PATH that usually does not include ~/.local/bin.
78// Observed on ashatron: `ssh ashatron 'hyper-proto-peer diag'` died with `exec: bare: not found` while the
79// identical launcher worked on stortron, purely because of PATH — the worst kind of per-host difference.
80//
81// This used to claim "Parity with app.js resolveBundledRuntime" and did not have it: app.js falls back to
82// resolveRuntimePath (process.execPath when it is already the interp), this one fell back to the bare
83// NAME. finalizeInstall is the modern install path, so the PATH-dependent launcher is the one that got
84// written. Now there is ONE implementation, exported and used by both.
85//
86// Candidates, most specific first:
87// 1-2. a runtime bundled with THIS install (multi-arch, then root-level)
88// 3. the runtime currently executing us — absolute, and by definition able to run this stack
89// 4. a sibling app's bundled runtime in the same store; pgh is bootstrap:true, so on a pgh-managed
90// machine it is present by construction. This covers an app that ships no runtime of its own while
91// the CLI runs under node (execPath is 'node', so 3 does not apply).
92// 5. the bare name, i.e. PATH — last resort, and the only case that can still fail like ashatron did.
93export function resolveBundledRuntime (installPath, interp, host, { execPath = process.execPath } = {}) {
7794 if (interp === 'bare' && installPath) {
78 for (const c of [
95 const cands = [
7996 path.join(installPath, 'by-arch', host, 'runtime', 'bare'),
8097 path.join(installPath, 'runtime', 'bare')
81 ]) {
82 try { if (fs.existsSync(c)) return c } catch {}
98 ]
99 for (const c of cands) { try { if (fs.existsSync(c)) return c } catch {} }
100 }
101 if (execPath && path.basename(execPath) === interp) return execPath
102 if (interp === 'bare' && installPath) {
103 const appsDir = path.dirname(installPath) // <store>/apps
104 for (const sib of ['pgh', 'pgh-sidecar']) {
105 for (const c of [
106 path.join(appsDir, sib, 'by-arch', host, 'runtime', 'bare'),
107 path.join(appsDir, sib, 'runtime', 'bare')
108 ]) {
109 try { if (fs.existsSync(c)) return c } catch {}
110 }
83111 }
84112 }
85113 return interp
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.123",
3 "version": "0.1.124",
44 "description": "pgh — P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
test/launcher-runtime-path.test.js +90 -0
……@@ -0,0 +1,90 @@
1// A launcher must not depend on `bare` being on the ambient PATH.
2//
3// `exec bare <entry>` works from an interactive shell and fails under `ssh host 'app ...'`, whose PATH is
4// minimal and usually excludes ~/.local/bin. Observed live on ashatron:
5//
6// $ ssh ashatron 'hyper-proto-peer diag'
7// /home/fleeky/.local/bin/hyper-proto-peer: 2: exec: bare: not found
8//
9// while the byte-identical launcher worked on stortron — a per-host difference caused purely by PATH,
10// which is about the worst way for this to fail.
11//
12// The cause was DRIFT between two copies of resolveBundledRuntime that a comment claimed were "in
13// parity": commands/app.js fell back to process.execPath (absolute), runtime-factory.js fell back to the
14// bare NAME — and runtime-factory is the one finalizeInstall uses on every install and update. There is
15// now one exported implementation; these tests pin its fallback ORDER so the drift cannot reappear.
16import test from 'brittle'
17import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from '#node:fs'
18import { join } from '#node:path'
19import { tmpdir } from '#node:os'
20import { resolveBundledRuntime } from '../lib/runtime-factory.js'
21
22const HOST = 'linux-x64'
23function store (t) {
24 const root = mkdtempSync(join(tmpdir(), 'pgh-launcher-'))
25 t.teardown(() => { try { rmSync(root, { recursive: true, force: true }) } catch {} })
26 const apps = join(root, 'apps')
27 mkdirSync(apps, { recursive: true })
28 return {
29 apps,
30 app (name) { const d = join(apps, name); mkdirSync(d, { recursive: true }); return d },
31 bare (name, sub = 'runtime') {
32 const d = join(apps, name, sub)
33 mkdirSync(d, { recursive: true })
34 const p = join(d, 'bare')
35 writeFileSync(p, '#!/bin/sh\n', { mode: 0o755 })
36 return p
37 }
38 }
39}
40
41test('prefers a runtime bundled with THIS install', (t) => {
42 const s = store(t)
43 const appDir = s.app('myapp')
44 const own = s.bare('myapp')
45 s.bare('pgh') // a sibling exists too — the app's own must still win
46 t.is(resolveBundledRuntime(appDir, 'bare', HOST, { execPath: '/usr/bin/bare' }), own,
47 'the app\'s own runtime/bare beats both execPath and the sibling')
48})
49
50test('falls back to the runtime currently executing us', (t) => {
51 const s = store(t)
52 const appDir = s.app('myapp') // ships no runtime of its own
53 t.is(resolveBundledRuntime(appDir, 'bare', HOST, { execPath: '/opt/x/bare' }), '/opt/x/bare',
54 'process.execPath is used when it is already the interp — absolute, and known to run this stack')
55})
56
57test('falls back to a SIBLING app\'s runtime when the CLI runs under node', (t) => {
58 // The ashatron case. The app bundles no runtime, and execPath is 'node' so it cannot supply one —
59 // previously this returned the bare NAME and produced a PATH-dependent launcher.
60 const s = store(t)
61 const appDir = s.app('hyper-proto-peer')
62 const pghBare = s.bare('pgh')
63 const got = resolveBundledRuntime(appDir, 'bare', HOST, { execPath: '/usr/bin/node' })
64 t.is(got, pghBare, 'pgh is bootstrap:true, so on a pgh-managed machine it is present by construction')
65 t.not(got, 'bare', 'and the result is NOT the bare name — that is the bug this closes')
66})
67
68test('multi-arch bundle wins over the root-level one', (t) => {
69 const s = store(t)
70 const appDir = s.app('myapp')
71 s.bare('myapp') // root-level
72 const byArch = s.bare('myapp', join('by-arch', HOST, 'runtime'))
73 t.is(resolveBundledRuntime(appDir, 'bare', HOST, { execPath: '/usr/bin/node' }), byArch,
74 'by-arch/<host>/runtime/bare is the most specific candidate')
75})
76
77test('only the genuinely unresolvable case returns the bare name', (t) => {
78 const s = store(t)
79 const appDir = s.app('myapp') // no own runtime, no siblings, execPath is not bare
80 t.is(resolveBundledRuntime(appDir, 'bare', HOST, { execPath: '/usr/bin/node' }), 'bare',
81 'last resort is still PATH — but now it is genuinely last, not the second step')
82})
83
84test('a node app is unaffected', (t) => {
85 const s = store(t)
86 const appDir = s.app('nodeapp')
87 s.bare('pgh')
88 t.is(resolveBundledRuntime(appDir, 'node', HOST, { execPath: '/usr/bin/node' }), '/usr/bin/node',
89 'node resolves to the running node; the bare-only sibling search never applies')
90})
test/runtime-closure-install.test.js +7 -1
……@@ -244,7 +244,13 @@ test('installComplete parity: pkg.pgh-driven launcher + full install manifest',
244244 const launcher = path.join(binDir, 'myapp')
245245 t.ok(fs.existsSync(launcher), 'launcher uses pgh.bin name')
246246 const body = fs.readFileSync(launcher, 'utf8')
247 t.ok(body.includes('exec bare '), 'bare interp fallback (no bundled runtime in temp store)')
247 // The interp must be bare. It used to be asserted as the literal string 'exec bare ', which also pinned
248 // the PATH-DEPENDENT form — and that is the bug that made `ssh ashatron 'hyper-proto-peer diag'` fail
249 // with "exec: bare: not found" while the same launcher worked elsewhere. resolveBundledRuntime now
250 // resolves an absolute runtime whenever one is reachable, so assert the INTENT (which interpreter) and
251 // leave the fallback ORDER to test/launcher-runtime-path.test.js, which injects execPath to cover it.
252 const rt = body.split('\n')[1].split(' ')[1]
253 t.is(path.basename(rt), 'bare', 'bare interp (absolute whenever a runtime can be resolved)')
248254 t.ok(body.includes(path.join(store.appPath('app'), 'main.js')), 'launcher execs pgh.entrypoint')
249255
250256 // full manifest — parity with installViaRegistry