/**
* pgh app tree-based install/update/rollback CLI tests — 0.5f
*
* Uses the default TreeStore location (~/.local/share/store/dependencies)
* and apps dir (~/.local/share/store/apps/<name>) — we restore after each
* test to avoid touching real user state.
*/
import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import crypto from 'hypercore-crypto'
import { run } from '../lib/commands/app.js'
import { TreeStore, DEFAULT_ROOT as DEFAULT_STORE_ROOT } from '../lib/store.js'
import { KIND_FILE, computeTreeHash } from 'hyper-overlay/tree'
// NOTE: pgh app commands default to ~/.local/share and ~/.local/bin. We
// don't try to relocate those — instead, we use UNIQUE random names so
// tests never collide with real app installs, and we clean up on exit.
function uniqueName (label) {
return 'pgh-test-' + label + '-' + crypto.randomBytes(4).toString('hex')
}
function seedTree (store, entries) {
const hash = computeTreeHash(entries)
if (!store.hasTree(hash)) {
fs.mkdirSync(store.treePath(hash), { recursive: true })
for (const e of entries) {
if (e.kind === KIND_FILE) {
// Content isn't required for the CLI tests — the store just needs the dir to exist.
fs.writeFileSync(path.join(store.treePath(hash), e.name), e.content || '')
}
}
store._writeMeta(hash, { pulledAt: Date.now(), schema: TreeStore.BLOB_LAYER_SCHEMA })
}
return hash
}
async function cleanup (name) {
const APPS_DIR = path.join(DEFAULT_STORE_ROOT, 'apps')
const BIN_DIR = path.join(os.homedir(), '.local', 'bin')
try { fs.unlinkSync(path.join(APPS_DIR, name)) } catch {}
try { fs.rmSync(path.join(APPS_DIR, name), { recursive: true, force: true }) } catch {}
try { fs.unlinkSync(path.join(BIN_DIR, name)) } catch {}
}
// ── install-tree ─────────────────────────────────────────────
test('install-tree creates symlink + launcher, increments refcount', async (t) => {
const name = uniqueName('inst')
const store = new TreeStore()
store.init()
const hash = seedTree(store, [
{ kind: KIND_FILE, exec: 0, name: 'index.js', childHash: 'a'.repeat(64), size: 0, content: 'console.log(1)' }
])
try {
const result = await run(null, ['install-tree', name, '--tree', hash])
t.is(result.installed, name)
t.is(result.treeHash, hash)
const installPath = path.join(DEFAULT_STORE_ROOT, 'apps', name)
t.ok(fs.lstatSync(installPath).isSymbolicLink(), 'installed as symlink')
t.is(fs.readlinkSync(installPath), store.treePath(hash))
const launcher = path.join(os.homedir(), '.local', 'bin', name)
t.ok(fs.existsSync(launcher))
t.ok(fs.readFileSync(launcher, 'utf8').includes('bare'))
t.alike(store.getRefs(hash), [name])
} finally {
await cleanup(name)
store.removeRef(hash, name)
}
})
test('install-tree errors if tree not in store', async (t) => {
const name = uniqueName('missing')
const absentHash = 'f'.repeat(64)
let err
try { await run(null, ['install-tree', name, '--tree', absentHash]) } catch (e) { err = e }
t.ok(err)
t.ok(err.message.includes('not in store'))
})
// ── update-tree ──────────────────────────────────────────────
test('update-tree swaps symlink + adjusts refcounts', async (t) => {
const name = uniqueName('upd')
const store = new TreeStore()
store.init()
const hashV1 = seedTree(store, [
{ kind: KIND_FILE, exec: 0, name: 'v', childHash: 'a'.repeat(64), size: 0, content: '1' }
])
const hashV2 = seedTree(store, [
{ kind: KIND_FILE, exec: 0, name: 'v', childHash: 'b'.repeat(64), size: 0, content: '2' }
])
try {
await run(null, ['install-tree', name, '--tree', hashV1])
t.alike(store.getRefs(hashV1), [name])
const result = await run(null, ['update-tree', name, '--tree', hashV2])
t.is(result.updated, name)
t.is(result.previous, hashV1)
t.is(result.current, hashV2)
t.alike(store.getRefs(hashV1), [])
t.alike(store.getRefs(hashV2), [name])
const installPath = path.join(DEFAULT_STORE_ROOT, 'apps', name)
t.is(fs.readlinkSync(installPath), store.treePath(hashV2))
} finally {
await cleanup(name)
store.removeRef(hashV1, name)
store.removeRef(hashV2, name)
}
})
// ── rollback ─────────────────────────────────────────────────
test('rollback swaps back to a previous tree still in store', async (t) => {
const name = uniqueName('roll')
const store = new TreeStore()
store.init()
const v1 = seedTree(store, [
{ kind: KIND_FILE, exec: 0, name: 'a', childHash: '1'.repeat(64), size: 0, content: 'v1' }
])
const v2 = seedTree(store, [
{ kind: KIND_FILE, exec: 0, name: 'a', childHash: '2'.repeat(64), size: 0, content: 'v2' }
])
store.pin(v1) // keep v1 GC-safe so rollback works
try {
await run(null, ['install-tree', name, '--tree', v1])
await run(null, ['update-tree', name, '--tree', v2])
const result = await run(null, ['rollback', name, '--tree', v1])
t.is(result.rolledBack, name)
t.is(result.current, v1)
const installPath = path.join(DEFAULT_STORE_ROOT, 'apps', name)
t.is(fs.readlinkSync(installPath), store.treePath(v1))
} finally {
await cleanup(name)
store.removeRef(v1, name)
store.removeRef(v2, name)
store.unpin(v1)
}
})
test('rollback errors if target tree is no longer in store', async (t) => {
const name = uniqueName('roll-miss')
let err
try {
await run(null, ['rollback', name, '--tree', 'd'.repeat(64)])
} catch (e) { err = e }
t.ok(err)
t.ok(err.message.includes('not in store'))
})
// ── current-tree ─────────────────────────────────────────────
test('current-tree reports the linked tree hash', async (t) => {
const name = uniqueName('cur')
const store = new TreeStore()
store.init()
const hash = seedTree(store, [
{ kind: KIND_FILE, exec: 0, name: 'a', childHash: 'c'.repeat(64), size: 0, content: '' }
])
try {
await run(null, ['install-tree', name, '--tree', hash])
const result = await run(null, ['current-tree', name])
t.is(result.name, name)
t.is(result.treeHash, hash)
} finally {
await cleanup(name)
store.removeRef(hash, name)
}
})
test('current-tree reports installed:false for uninstalled apps', async (t) => {
const result = await run(null, ['current-tree', uniqueName('ghost')])
t.absent(result.installed)
})
// ── remove with refcount decrement ──────────────────────────
test('remove on a tree-installed app decrements refcount', async (t) => {
const name = uniqueName('rm')
const store = new TreeStore()
store.init()
const hash = seedTree(store, [
{ kind: KIND_FILE, exec: 0, name: 'a', childHash: 'e'.repeat(64), size: 0, content: '' }
])
await run(null, ['install-tree', name, '--tree', hash])
t.alike(store.getRefs(hash), [name])
const result = await run(null, ['remove', name])
t.is(result.removed, name)
t.is(result.treeDecremented, hash)
t.alike(store.getRefs(hash), [])
// Tree itself still in store (GC handles it separately)
t.ok(store.hasTree(hash))
await cleanup(name)
})