| … | … | @@ -13,7 +13,7 @@ import { Client } from 'hyper-svc' |
| 13 | 13 | |
| 14 | 14 | import { TreeStore, DEFAULT_ROOT as DEFAULT_STORE_ROOT } from '../store.js' |
| 15 | 15 | import { installTree, linkApp, unlinkApp, swapApp } from '../tree-install.js' |
| 16 | import { installComplete } from '../runtime-factory.js' | |
| 16 | import { installComplete, resolveBundledRuntime } from '../runtime-factory.js' | |
| 17 | 17 | import { ReleaseRegistry } from '../release-registry.js' |
| 18 | 18 | import { NetworkFetcher } from '../network-fetcher.js' |
| 19 | 19 | import { |
| … | … | @@ -75,11 +75,6 @@ const SIDECAR_SOCK = process.env.PGH_SIDECAR_SOCK || path.join(homedir(), '.pgh' |
| 75 | 75 | // on machines without that runtime on PATH (e.g. bare-bootstrap installs |
| 76 | 76 | // on a host without system bare). Otherwise return the literal name so |
| 77 | 77 | // the shell does PATH lookup (existing behavior). |
| 78 | function resolveRuntimePath (runtime) { | |
| 79 | if (path.basename(process.execPath) === runtime) return process.execPath | |
| 80 | return runtime | |
| 81 | } | |
| 82 | ||
| 83 | 78 | // Write a launcher script at `launcher`, replacing any existing file OR |
| 84 | 79 | // symlink there. Plain writeFileSync FOLLOWS a symlink and writes THROUGH it |
| 85 | 80 | // into the link target — e.g. a stale `~/.local/bin/pgh -> store/apps/pgh/bin/pgh` |
| … | … | @@ -95,18 +90,12 @@ function writeLauncher (launcher, body) { |
| 95 | 90 | // self-contained bootstrap-bare tree — runtime/bare + bin/<name>.bare.js), the |
| 96 | 91 | // launcher/supervisor must exec THAT bare, not a PATH/system bare — otherwise a |
| 97 | 92 | // 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. | |
| 100 | function 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. | |
| 110 | 99 | |
| 111 | 100 | // Atomic materialize-and-swap. Build the new tree into a scratch dir, then swap |
| 112 | 101 | // it into place. This lets pgh update ITSELF safely: the old "rm installPath; |
| … | … | @@ -341,7 +330,7 @@ export async function run (client, args) { |
| 341 | 330 | // Create launcher |
| 342 | 331 | mkdirSync(BIN_DIR, { recursive: true }) |
| 343 | 332 | 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') | |
| 345 | 334 | |
| 346 | 335 | return { installed: name, path: installPath, target, launcher, treeHash } |
| 347 | 336 | } |
| … | … | @@ -1868,7 +1857,7 @@ function cmdAdopt (args) { |
| 1868 | 1857 | const entryAbs = path.isAbsolute(entrypointRel) ? entrypointRel : path.join(abs, entrypointRel) |
| 1869 | 1858 | writeLauncher( |
| 1870 | 1859 | 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' | |
| 1872 | 1861 | ) |
| 1873 | 1862 | |
| 1874 | 1863 | return { adopted: name, sourcePath: abs, manifest: installManifestFile(name), launcher } |
| … | … | @@ -2025,7 +2014,7 @@ async function installViaDaemon (client, repo, args) { |
| 2025 | 2014 | const entry = fs.existsSync(path.join(dest, 'index.js')) ? 'index.js' |
| 2026 | 2015 | : fs.existsSync(path.join(dest, 'server.js')) ? 'server.js' |
| 2027 | 2016 | : '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') | |
| 2029 | 2018 | console.log('Launcher: ' + launcher) |
| 2030 | 2019 | |
| 2031 | 2020 | return { installed: name, path: dest, launcher } |
| … | … | @@ -20,6 +20,7 @@ |
| 20 | 20 | * 3. default fetcher/store/materializer (no hand-wiring). |
| 21 | 21 | */ |
| 22 | 22 | |
| 23 | import process from '#node:process' // NOT a global under bare — execPath below needs it | |
| 23 | 24 | import fs from '#node:fs' |
| 24 | 25 | import path from '#node:path' |
| 25 | 26 | import os from '#node:os' |
| … | … | @@ -70,16 +71,43 @@ function runtimeInterp (runtime) { |
| 70 | 71 | return runtime === 'node' ? 'node' : 'bare' |
| 71 | 72 | } |
| 72 | 73 | |
| 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. | |
| 76 | function 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. | |
| 93 | export function resolveBundledRuntime (installPath, interp, host, { execPath = process.execPath } = {}) { | |
| 77 | 94 | if (interp === 'bare' && installPath) { |
| 78 | for (const c of [ | |
| 95 | const cands = [ | |
| 79 | 96 | path.join(installPath, 'by-arch', host, 'runtime', 'bare'), |
| 80 | 97 | 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 | } | |
| 83 | 111 | } |
| 84 | 112 | } |
| 85 | 113 | return interp |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pgh", |
| 3 | "version": "0.1.123", | |
| 3 | "version": "0.1.124", | |
| 4 | 4 | "description": "pgh — P2P content-addressed package manager and app launcher", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -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. | |
| 16 | import test from 'brittle' | |
| 17 | import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from '#node:fs' | |
| 18 | import { join } from '#node:path' | |
| 19 | import { tmpdir } from '#node:os' | |
| 20 | import { resolveBundledRuntime } from '../lib/runtime-factory.js' | |
| 21 | ||
| 22 | const HOST = 'linux-x64' | |
| 23 | function 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 | ||
| 41 | test('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 | ||
| 50 | test('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 | ||
| 57 | test('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 | ||
| 68 | test('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 | ||
| 77 | test('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 | ||
| 84 | test('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 | }) |
| … | … | @@ -244,7 +244,13 @@ test('installComplete parity: pkg.pgh-driven launcher + full install manifest', |
| 244 | 244 | const launcher = path.join(binDir, 'myapp') |
| 245 | 245 | t.ok(fs.existsSync(launcher), 'launcher uses pgh.bin name') |
| 246 | 246 | 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)') | |
| 248 | 254 | t.ok(body.includes(path.join(store.appPath('app'), 'main.js')), 'launcher execs pgh.entrypoint') |
| 249 | 255 | |
| 250 | 256 | // full manifest — parity with installViaRegistry |