/**
* pgh app commands — install and manage Pear apps locally
*
* Subcommands: install, update, list, remove
*
* Apps are downloaded via smart Hyperdrive access (arch-aware, prebuild-filtered),
* installed to ~/.local/share/<name>/, and a launcher script is created at
* ~/.local/bin/<name> that runs `bare <entry>`. pear-runtime handles OTA updates.
*/
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import subprocess from 'bare-subprocess'
import Corestore from 'corestore'
import Hyperdrive from 'hyperdrive'
import Hyperswarm from 'hyperswarm'
import Localdrive from 'localdrive'
import idEncoding from 'hypercore-id-encoding'
import { table, jsonOut, log, error } from '../output.js'
const HOME = os.homedir()
const SHARE_DIR = path.join(HOME, '.local', 'share')
const BIN_DIR = path.join(HOME, '.local', 'bin')
function detectHost () {
return Bare.platform + '-' + Bare.arch // e.g. linux-x64, darwin-arm64
}
/**
* Smart download from a Hyperdrive — arch-aware with prebuild filtering.
*
* 1. Decode pear:// key, open Hyperdrive via temp Corestore + Hyperswarm
* 2. Read root /package.json for app name
* 3. Check for by-arch/{host}/app/{name}/ layout
* 4. Mirror with prefix + prebuild filter (only host platform prebuilds)
* 5. Cleanup temp corestore storage
*
* Returns { pkg, byArch } where pkg is parsed root package.json.
*/
async function smartDownload (key, destDir, opts = {}) {
const verbose = opts.verbose || false
const host = detectHost()
const appName = opts.name || null
// Decode pear:// key
const driveKey = idEncoding.decode(key)
// Temp corestore for download
const tmpStorage = path.join(os.tmpdir(), 'pgh-store-' + Date.now())
const store = new Corestore(tmpStorage)
const swarm = new Hyperswarm()
swarm.on('connection', (conn) => store.replicate(conn))
const drive = new Hyperdrive(store, driveKey)
await drive.ready()
if (verbose) log('Drive key: ' + idEncoding.encode(drive.key))
if (verbose) log('Discovery key: ' + idEncoding.encode(drive.discoveryKey))
// Join swarm as client (download only)
const discovery = swarm.join(drive.discoveryKey, { client: true, server: false })
await discovery.flushed()
// Wait for drive content to be available
if (drive.core.length === 0) {
if (verbose) log('Waiting for drive metadata...')
await drive.update({ wait: true })
}
if (drive.blobs) await drive.blobs.core.update({ wait: true })
// Read root package.json
let pkg = {}
try {
const buf = await drive.get('/package.json')
if (buf) pkg = JSON.parse(buf.toString())
} catch {
// Will fail gracefully — name required via --name flag
}
const name = appName || pkg.name
// Prebuild filter — skip foreign platform prebuilds
const prebuildFilter = (key) => {
const m = key.match(/\/prebuilds\/([^/]+)\//)
if (!m) return true
return m[1] === host
}
// Check for by-arch layout
const archPrefix = '/by-arch/' + host + '/app/' + name
let byArch = false
let prefix = '/'
if (name) {
try {
const entry = await drive.entry(archPrefix + '/package.json')
if (entry) {
byArch = true
prefix = archPrefix
if (verbose) log('Detected by-arch layout (' + host + ')')
}
} catch {}
}
if (verbose) log('Mirroring with prefix: ' + prefix)
// Mirror to local destination
const local = new Localdrive(destDir)
const mirror = drive.mirror(local, { prefix, filter: prebuildFilter })
for await (const diff of mirror) {
if (verbose && diff.op === 'add') log(' + ' + diff.key)
}
// If by-arch, also copy root package.json (needed for upgrade key / version)
if (byArch) {
try {
const rootPkg = await drive.get('/package.json')
if (rootPkg) {
fs.writeFileSync(path.join(destDir, 'package.json'), rootPkg)
}
} catch {}
}
// Cleanup
await swarm.destroy()
await drive.close()
await store.close()
cleanup(tmpStorage)
return { pkg, byArch }
}
export async function appCommand (client, sub, args, flags) {
switch (sub) {
case 'install':
return appInstall(args[0], flags)
case 'update':
return appUpdate(args[0], flags)
case 'list':
return appList(flags)
case 'remove':
return appRemove(args[0], flags)
default:
appHelp()
}
}
async function appInstall (keyOrRepo, flags) {
// Git-based install: --app REPO_NAME
if (flags.app) {
return appInstallGit(flags.app, flags)
}
// Legacy Hyperdrive install: pear://KEY
if (!keyOrRepo || !keyOrRepo.startsWith('pear://')) {
error('Usage: pgh app install <pear://key> [--name <name>]\n pgh app install --app <repo> [--name <name>]')
}
return appInstallHyperdrive(keyOrRepo, flags)
}
/**
* Git-based install: clone release branch from pear-git server.
* Tries HTTP archive download first (fast, no git/P2P needed), falls back to git clone.
*/
async function appInstallGit (repoName, flags) {
const name = flags.name || repoName
const installDir = path.join(SHARE_DIR, name)
// Smart install: detect existing install and auto-update
if (existsSync(installDir) && existsSync(path.join(installDir, 'package.json')) && !flags.force) {
let pkg = {}
try { pkg = JSON.parse(fs.readFileSync(path.join(installDir, 'package.json'), 'utf-8')) } catch {}
const oldVersion = pkg.version || 'unknown'
if (existsSync(path.join(installDir, '.git'))) {
// Git-cloned install — update from local bare repo (synced via federation)
const localBareRepo = path.join(HOME, 'git-repos', repoName + '.git')
const fetchSource = existsSync(localBareRepo) ? localBareRepo : 'origin'
log(name + ' v' + oldVersion + ' already installed, updating from ' + (fetchSource === 'origin' ? 'P2P origin' : 'local bare repo') + '...')
try {
await execGitInDir(installDir, ['fetch', fetchSource, 'release', '--depth', '1'])
await execGitInDir(installDir, ['reset', '--hard', 'FETCH_HEAD'])
} catch (err) {
error('Git update failed: ' + err.message)
}
} else {
// Archive install — re-download archive over existing dir
log(name + ' v' + oldVersion + ' already installed, updating via archive...')
const httpServer = resolveHttpServer(flags)
if (httpServer) {
const archiveUrl = buildArchiveUrl(httpServer, repoName)
try {
await downloadAndExtract(archiveUrl, installDir, { token: flags.token || readTokenFromConfig() })
} catch (err) {
error('Archive update failed: ' + err.message)
}
} else {
error('No HTTP server configured for archive update. Use --force to reinstall via git clone.')
}
}
return finishInstall(repoName, name, installDir, flags, existsSync(path.join(installDir, '.git')) ? 'git' : 'archive')
}
// Fresh install: resolve server config
let cloneUrl = null
let httpServer = null
try {
const configPath = path.join(HOME, '.config', 'pear-git', 'pgh.json')
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (flags.server || config.server) {
httpServer = (flags.server || config.server).replace(/\/$/, '')
}
if (config.serverKey) {
cloneUrl = `punch://${config.serverKey}/${repoName}`
} else if (httpServer) {
cloneUrl = `${httpServer}/${repoName}.git`
}
} catch {}
// P2P git clone first (gives OTA updates via PearGitUpdater)
if (cloneUrl) {
// Connectivity check: ls-remote is lightweight (~1KB) and fast.
// If the P2P transport can't reach the server, skip the expensive clone.
let p2pReachable = false
try {
log('Checking P2P connectivity to ' + cloneUrl + '...')
await execGitInDir(os.tmpdir(), ['ls-remote', '--exit-code', cloneUrl, 'refs/heads/release'], { timeout: 20000 })
p2pReachable = true
log('P2P connection OK')
} catch (err) {
log('P2P unreachable (' + err.message.split('\n')[0] + '), skipping clone')
}
if (p2pReachable) {
if (flags.force) cleanup(installDir)
const host = detectHost()
log('Cloning ' + repoName + ' (release branch, partial clone) from ' + cloneUrl + '...')
try {
fs.mkdirSync(path.dirname(installDir), { recursive: true })
// Partial clone: download tree metadata only (~10 MB), no blobs
await execGitInDir(path.dirname(installDir), [
'clone', '--filter=blob:none', '--sparse', '--depth', '1',
'-b', 'release', cloneUrl, name
])
// Sparse checkout: fetch only this platform's blobs
const byArchPath = `by-arch/${host}/app/${repoName}`
try {
await execGitInDir(installDir, ['sparse-checkout', 'init', '--cone'])
await execGitInDir(installDir, ['sparse-checkout', 'set', byArchPath])
log('Sparse checkout: ' + byArchPath)
} catch {
// No by-arch layout — fetch all blobs (small repos like hyper-overlay)
await execGitInDir(installDir, ['sparse-checkout', 'disable'])
log('No by-arch layout, full checkout')
}
return finishInstall(repoName, name, installDir, flags, 'git')
} catch (err) {
log('P2P git clone failed (' + err.message + '), falling back to HTTP archive...')
cleanup(installDir)
}
}
}
// Fall back to HTTP archive download
if (httpServer && !flags.noArchive) {
const archiveUrl = buildArchiveUrl(httpServer, repoName)
try {
log('Downloading ' + repoName + ' archive from ' + archiveUrl + '...')
if (flags.force) cleanup(installDir)
fs.mkdirSync(installDir, { recursive: true })
await downloadAndExtract(archiveUrl, installDir, { token: flags.token || readTokenFromConfig() })
return finishInstall(repoName, name, installDir, flags, 'archive')
} catch (err) {
log('Archive download failed: ' + err.message)
cleanup(installDir)
}
}
if (!cloneUrl && !httpServer) {
error('No pear-git server configured. Set serverKey in ~/.config/pear-git/pgh.json or use --server URL')
}
error('Install failed: both P2P clone and HTTP archive failed for ' + repoName)
}
/**
* Legacy Hyperdrive install: download from pear://KEY
*/
async function appInstallHyperdrive (key, flags) {
log('Downloading app from ' + key + '...')
// First pass: download to temp dir to read package.json for name
const tmpDir = path.join(os.tmpdir(), 'pgh-install-' + Date.now())
fs.mkdirSync(tmpDir, { recursive: true })
let result
try {
result = await smartDownload(key, tmpDir, { verbose: flags.verbose, name: flags.name })
} catch (err) {
cleanup(tmpDir)
error('Download failed: ' + err.message)
}
const name = flags.name || result.pkg.name
if (!name) {
cleanup(tmpDir)
error('Could not determine app name. Use --name <name>')
}
const installDir = path.join(SHARE_DIR, name)
// Clean previous install to remove stale files
cleanup(installDir)
// Move temp download to install dir
fs.renameSync(tmpDir, installDir)
// chmod executables in build/
const buildDir = path.join(installDir, 'build')
if (existsSync(buildDir)) {
chmodExecutables(buildDir)
}
// Read package.json from install dir
let pkg = result.pkg
try {
pkg = JSON.parse(fs.readFileSync(path.join(installDir, 'package.json'), 'utf-8'))
} catch {}
const resolvedEntry = pkg.main || 'index.js'
// Create launcher script
fs.mkdirSync(BIN_DIR, { recursive: true })
const launcherPath = path.join(BIN_DIR, name)
const launcherContent = `#!/bin/sh
exec bare ${path.join(installDir, resolvedEntry)} "$@"
`
fs.writeFileSync(launcherPath, launcherContent)
fs.chmodSync(launcherPath, 0o755)
if (flags.json) {
jsonOut({ name, key, entry: resolvedEntry, installDir, launcher: launcherPath, method: 'hyperdrive' })
return
}
log('')
log('Installed ' + name)
log(' Location: ' + installDir)
log(' Launcher: ' + launcherPath)
log(' Entry: ' + resolvedEntry)
log('')
log('Run: ' + name)
}
async function appUpdate (name, flags) {
if (!name) error('Usage: pgh app update <name> [--verbose]')
const installDir = path.join(SHARE_DIR, name)
if (!existsSync(installDir)) {
error('App not found: ' + name + '. Run: pgh app list')
}
// Check if this is a git-installed app
const updaterConfigPath = path.join(installDir, '.pear-git-updater.json')
const isGitInstall = existsSync(path.join(installDir, '.git')) || existsSync(updaterConfigPath)
if (isGitInstall) {
return appUpdateGit(name, installDir, flags)
}
return appUpdateHyperdrive(name, installDir, flags)
}
async function appUpdateGit (name, installDir, flags) {
log('Updating ' + name + ' via git pull...')
try {
// Fetch and reset to latest release
await execGitInDir(installDir, ['fetch', 'origin', 'release', '--depth', '1'])
const oldHash = await execGitInDir(installDir, ['rev-parse', 'HEAD'])
await execGitInDir(installDir, ['reset', '--hard', 'origin/release'])
const newHash = await execGitInDir(installDir, ['rev-parse', 'HEAD'])
// chmod executables in build/
const buildDir = path.join(installDir, 'build')
if (existsSync(buildDir)) {
chmodExecutables(buildDir)
}
// Recreate launcher (entry point might have changed)
let pkg = {}
try {
pkg = JSON.parse(fs.readFileSync(path.join(installDir, 'package.json'), 'utf-8'))
} catch {}
const entry = pkg.main || 'index.js'
const launcherPath = path.join(BIN_DIR, name)
const launcherContent = `#!/bin/sh
exec bare ${path.join(installDir, entry)} "$@"
`
fs.writeFileSync(launcherPath, launcherContent)
fs.chmodSync(launcherPath, 0o755)
// Update config
try {
let config = {}
try {
config = JSON.parse(fs.readFileSync(path.join(installDir, '.pear-git-updater.json'), 'utf-8'))
} catch {}
config.lastCommitHash = newHash.trim()
config.lastUpdated = Date.now()
fs.writeFileSync(
path.join(installDir, '.pear-git-updater.json'),
JSON.stringify(config, null, 2) + '\n'
)
} catch {}
if (flags.json) {
jsonOut({ name, method: 'git', oldHash: oldHash.trim(), newHash: newHash.trim(), installDir })
return
}
if (oldHash.trim() === newHash.trim()) {
log('Already up to date: ' + name + ' (' + newHash.trim().slice(0, 8) + ')')
} else {
log('')
log('Updated ' + name + ' (git)')
log(' Commit: ' + oldHash.trim().slice(0, 8) + ' -> ' + newHash.trim().slice(0, 8))
log(' Location: ' + installDir)
log('')
}
} catch (err) {
error('Git update failed: ' + err.message)
}
}
async function appUpdateHyperdrive (name, installDir, flags) {
// Read existing package.json to get upgrade key
const pkgPath = path.join(installDir, 'package.json')
let pkg = {}
try {
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
} catch {
error('No package.json in ' + installDir)
}
const key = typeof pkg.upgrade === 'string' ? pkg.upgrade : (pkg.upgrade && pkg.upgrade.key)
if (!key) {
error('No upgrade key in package.json. Cannot update ' + name)
}
const oldVersion = pkg.version || 'unknown'
log('Updating ' + name + ' from ' + key + '...')
// Download to temp dir
const tmpDir = path.join(os.tmpdir(), 'pgh-update-' + Date.now())
fs.mkdirSync(tmpDir, { recursive: true })
let result
try {
result = await smartDownload(key, tmpDir, { verbose: flags.verbose, name })
} catch (err) {
cleanup(tmpDir)
error('Download failed: ' + err.message)
}
// Read new version
let newPkg = {}
try {
newPkg = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf-8'))
} catch {}
const newVersion = newPkg.version || result.pkg.version || 'unknown'
// Replace install dir
cleanup(installDir)
fs.renameSync(tmpDir, installDir)
// chmod executables in build/
const buildDir = path.join(installDir, 'build')
if (existsSync(buildDir)) {
chmodExecutables(buildDir)
}
// Re-read package.json from installDir
try {
newPkg = JSON.parse(fs.readFileSync(path.join(installDir, 'package.json'), 'utf-8'))
} catch {}
// Recreate launcher (entry point might have changed)
const entry = newPkg.main || 'index.js'
const launcherPath = path.join(BIN_DIR, name)
const launcherContent = `#!/bin/sh
exec bare ${path.join(installDir, entry)} "$@"
`
fs.writeFileSync(launcherPath, launcherContent)
fs.chmodSync(launcherPath, 0o755)
if (flags.json) {
jsonOut({ name, key, method: 'hyperdrive', oldVersion, newVersion, installDir })
return
}
log('')
log('Updated ' + name)
log(' Version: ' + oldVersion + ' -> ' + newVersion)
log(' Location: ' + installDir)
log('')
}
async function appList (flags) {
if (!existsSync(SHARE_DIR)) {
if (flags.json) { jsonOut([]); return }
log('No apps installed.')
return
}
const entries = fs.readdirSync(SHARE_DIR)
const apps = []
for (const name of entries) {
const pkgPath = path.join(SHARE_DIR, name, 'package.json')
if (!existsSync(pkgPath)) continue
let pkg = {}
try {
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
} catch {
continue
}
// Include apps installed via pgh (pear-runtime or git)
const updaterConfigPath = path.join(SHARE_DIR, name, '.pear-git-updater.json')
const isGitInstall = existsSync(updaterConfigPath)
if (!pkg.pear && !pkg.upgrade && !isGitInstall) continue
apps.push({
name,
version: pkg.version || '-',
method: isGitInstall ? 'git' : 'hyperdrive',
key: (pkg.pear && pkg.pear.name) || (pkg.upgrade && pkg.upgrade.key) || '-',
entry: pkg.main || 'index.js'
})
}
if (flags.json) {
jsonOut(apps)
return
}
if (apps.length === 0) {
log('No apps installed.')
log('Run: pgh app install <pear://key> --name <name>')
return
}
table(apps, [
{ key: 'name', label: 'NAME', width: 25 },
{ key: 'version', label: 'VERSION', width: 15 },
{ key: 'method', label: 'METHOD', width: 12 },
{ key: 'key', label: 'KEY', width: 35 },
{ key: 'entry', label: 'ENTRY', width: 20 }
])
}
async function appRemove (name, flags) {
if (!name) error('Usage: pgh app remove <name>')
const installDir = path.join(SHARE_DIR, name)
const launcherPath = path.join(BIN_DIR, name)
if (!existsSync(installDir)) {
error('App not found: ' + name)
}
cleanup(installDir)
if (existsSync(launcherPath)) {
fs.unlinkSync(launcherPath)
}
if (flags.json) {
jsonOut({ removed: name })
return
}
log('Removed ' + name)
}
// --- Archive Install Helpers ---
/**
* Build the HTTP archive URL for a given repo on a pear-git server.
* @param {string} serverUrl - The server base URL (e.g. 'http://localhost:5005/git')
* @param {string} repoName - The repository name
* @returns {string} Full archive URL
*/
export function buildArchiveUrl (serverUrl, repoName, opts = {}) {
const base = serverUrl.replace(/\/$/, '')
const arch = opts.arch || detectHost()
return `${base}/tabertron/${repoName}/archive/release.tar.gz?arch=${arch}`
}
/**
* Download a tar.gz archive via curl and extract it into destDir.
* Pipes curl stdout directly into tar stdin.
* @param {string} url - Archive URL
* @param {string} destDir - Destination directory (must exist)
* @param {object} [opts] - Options
* @param {string} [opts.token] - Bearer token for Authorization header
*/
function downloadAndExtract (url, destDir, opts = {}) {
return new Promise((resolve, reject) => {
const curlArgs = ['-f', '--progress-bar', '--max-time', '600']
if (opts.token) {
curlArgs.push('-H', 'Authorization: Bearer ' + opts.token)
}
curlArgs.push(url)
const curlProc = subprocess.spawn('curl', curlArgs, {
stdio: ['ignore', 'pipe', 'pipe']
})
const tarProc = subprocess.spawn('tar', ['xz', '--strip-components=1', '-C', destDir], {
stdio: ['pipe', 'pipe', 'pipe']
})
// bare-subprocess only accepts strings in stdio — pipe manually
curlProc.stdout.pipe(tarProc.stdin)
tarProc.stdin.on('error', (err) => {
if (err.code !== 'EPIPE') console.error('[Archive] stdin error:', err.message)
})
const errChunks = []
curlProc.stderr.on('data', (c) => errChunks.push(c))
tarProc.stderr.on('data', (c) => errChunks.push(c))
const timeout = setTimeout(() => {
curlProc.kill('SIGTERM')
tarProc.kill('SIGTERM')
reject(new Error('Archive download timed out after 300s'))
}, 300000)
curlProc.on('error', (err) => {
clearTimeout(timeout)
tarProc.kill('SIGTERM')
reject(err)
})
tarProc.on('exit', (code) => {
clearTimeout(timeout)
if (code === 0) {
resolve()
} else {
const errMsg = Buffer.concat(errChunks).toString('utf-8').trim()
reject(new Error(errMsg || 'Archive extraction failed with code ' + code))
}
})
tarProc.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
})
}
/**
* Read bearer token from pgh config file.
* @returns {string|null}
*/
function readTokenFromConfig () {
try {
const configPath = path.join(HOME, '.config', 'pear-git', 'pgh.json')
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
return config.token || null
} catch { return null }
}
function resolveHttpServer (flags) {
try {
const configPath = path.join(HOME, '.config', 'pear-git', 'pgh.json')
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
const server = flags.server || config.server
return server ? server.replace(/\/$/, '') : null
} catch { return flags.server ? flags.server.replace(/\/$/, '') : null }
}
/**
* Shared post-install logic for both git and archive installs.
* Handles: npm install, chmod, package.json read, updater config, launcher, output.
*/
async function finishInstall (repoName, name, installDir, flags, method) {
// Install dependencies if no node_modules
if (!existsSync(path.join(installDir, 'node_modules'))) {
log('Installing dependencies...')
try {
await execInDir(installDir, 'npm', ['install', '--production'])
} catch (err) {
error('npm install failed: ' + err.message)
}
}
// chmod executables in build/
const buildDir = path.join(installDir, 'build')
if (existsSync(buildDir)) {
chmodExecutables(buildDir)
}
// Read package.json
let pkg = {}
try {
pkg = JSON.parse(fs.readFileSync(path.join(installDir, 'package.json'), 'utf-8'))
} catch {}
// Resolve entry point — prefer by-arch layout if present
const host = detectHost()
const byArchEntry = path.join('by-arch', host, 'app', repoName, pkg.main || 'index.js')
const rootEntry = pkg.main || 'index.js'
const resolvedEntry = existsSync(path.join(installDir, byArchEntry)) ? byArchEntry : rootEntry
// Store deploy info for self-update
try {
const updaterConfig = {
repoName,
installMethod: method,
installedAt: Date.now(),
serverUrl: flags.server || null
}
fs.writeFileSync(
path.join(installDir, '.pear-git-updater.json'),
JSON.stringify(updaterConfig, null, 2) + '\n'
)
} catch {}
// Ensure bare repo exists in ~/git-repos/ for sync system
// The sync system (Protomux manifest exchange) only tracks repos in ~/git-repos/.
// Without this, the app won't receive updates via federation sync.
const defaultReposDir = path.join(HOME, 'git-repos')
const bareRepoPath = path.join(defaultReposDir, repoName + '.git')
if (!existsSync(bareRepoPath)) {
try {
fs.mkdirSync(defaultReposDir, { recursive: true })
await execGitInDir(defaultReposDir, ['init', '--bare', repoName + '.git'])
// Push current release branch to the bare repo so sync has a baseline
if (existsSync(path.join(installDir, '.git'))) {
await execGitInDir(installDir, ['push', bareRepoPath, 'HEAD:refs/heads/release'])
}
log(' Created bare repo: ' + bareRepoPath)
} catch (err) {
// Non-fatal — sync will still work via direct P2P, just won't have local cache
log(' Note: Could not create bare repo (' + err.message + ')')
}
}
// Create launcher script
fs.mkdirSync(BIN_DIR, { recursive: true })
const launcherPath = path.join(BIN_DIR, name)
const entryAbsolute = path.join(installDir, resolvedEntry)
const entryDir = path.dirname(entryAbsolute)
const entryFile = path.basename(entryAbsolute)
const launcherContent = `#!/bin/sh\ncd ${entryDir}\nexec bare ${entryFile} "$@"\n`
fs.writeFileSync(launcherPath, launcherContent)
fs.chmodSync(launcherPath, 0o755)
// Create helper launchers for CLI tools (pgh, git-remote-punch, etc.)
// Points to the installed copy so they stay current with updates.
const binDir = path.join(installDir, 'bin')
if (existsSync(binDir)) {
try {
const bins = fs.readdirSync(binDir)
for (const bin of bins) {
if (bin === name) continue
const binPath = path.join(BIN_DIR, bin)
const content = `#!/bin/sh\nexec bare ${path.join(binDir, bin)} "$@"\n`
fs.writeFileSync(binPath, content)
fs.chmodSync(binPath, 0o755)
}
} catch {}
}
if (flags.json) {
jsonOut({ name, repoName, entry: resolvedEntry, installDir, launcher: launcherPath, method })
return
}
log('')
log('Installed ' + name + ' (' + method + ')')
log(' Location: ' + installDir)
log(' Launcher: ' + launcherPath)
log(' Entry: ' + resolvedEntry)
log('')
log('Run: ' + name)
}
// --- Helpers ---
function execGitInDir (dir, args) {
return new Promise((resolve, reject) => {
const proc = subprocess.spawn('git', args, {
cwd: dir,
stdio: ['ignore', 'pipe', 'pipe']
})
const chunks = []
const errChunks = []
const timeout = setTimeout(() => {
proc.kill('SIGTERM')
reject(new Error('Git command timed out after 120s'))
}, 120000)
proc.stdout.on('data', (c) => chunks.push(c))
proc.stderr.on('data', (c) => errChunks.push(c))
proc.on('exit', (code) => {
clearTimeout(timeout)
if (code === 0) {
resolve(Buffer.concat(chunks).toString('utf-8'))
} else {
reject(new Error(Buffer.concat(errChunks).toString('utf-8') || `git exited with code ${code}`))
}
})
proc.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
})
}
function execInDir (dir, cmd, args) {
return new Promise((resolve, reject) => {
const proc = subprocess.spawn(cmd, args, {
cwd: dir,
stdio: ['ignore', 'pipe', 'pipe']
})
const chunks = []
const errChunks = []
const timeout = setTimeout(() => {
proc.kill('SIGTERM')
reject(new Error(`${cmd} timed out after 300s`))
}, 300000)
proc.stdout.on('data', (c) => chunks.push(c))
proc.stderr.on('data', (c) => errChunks.push(c))
proc.on('exit', (code) => {
clearTimeout(timeout)
if (code === 0) {
resolve(Buffer.concat(chunks).toString('utf-8'))
} else {
reject(new Error(Buffer.concat(errChunks).toString('utf-8') || `${cmd} exited with code ${code}`))
}
})
proc.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
})
}
function existsSync (p) {
try {
fs.statSync(p)
return true
} catch {
return false
}
}
function chmodExecutables (dir) {
const entries = fs.readdirSync(dir)
for (const entry of entries) {
const fullPath = path.join(dir, entry)
const stat = fs.statSync(fullPath)
if (stat.isDirectory()) {
chmodExecutables(fullPath)
} else {
const ext = path.extname(entry)
if (!ext || ext === '.so' || ext === '.dylib' || ext === '.node') {
fs.chmodSync(fullPath, 0o755)
}
}
}
}
function cleanup (dir) {
try {
fs.rmSync(dir, { recursive: true })
} catch {
// Best effort cleanup
}
}
export function appHelp () {
log(`Usage: pgh app <subcommand> [args] [flags]
Subcommands:
install Install an app (git or Hyperdrive)
update <name> Update an installed app to latest version
list List installed apps
remove <name> Uninstall an app
Install Methods:
pgh app install --app <repo> [--name <name>] Git clone (release branch)
pgh app install <pear://key> [--name <name>] Legacy Hyperdrive download
Install Flags:
--app REPO Install by repo name (archive or git clone)
--name NAME App name (default: repo name or from package.json)
--server URL Pear-git server URL (default: from pgh.json)
--force Force reinstall (remove existing)
--no-archive Skip HTTP archive download, go straight to git clone
--token TOKEN Bearer token for archive download auth
--verbose Show download progress
Other Flags:
--json Output as JSON
Git Install Flow:
1. Try HTTP archive download (curl|tar, fast, no git needed)
2. Fall back to git clone --depth 1 -b release if archive unavailable
3. Smart update: existing install → git pull (no full reclone)
4. npm install if no node_modules
5. chmod executables in build/
6. Create launcher at ~/.local/bin/<name>
7. Store deploy info in .pear-git-updater.json
Hyperdrive Install Flow (legacy):
1. Connect to Hyperdrive via Hyperswarm (P2P)
2. Detect by-arch/ layout → download only this platform's files
3. Filter out foreign platform prebuilds (saves ~660MB)
4. chmod executables in build/
5. Create launcher at ~/.local/bin/<name>
Examples:
pgh app install --app hyper-note --name hypernote
pgh app install pear://6d5asjamx... --name godot-keet
pgh app update hypernote
pgh app list
pgh app remove hypernote`)
}