#!/usr/bin/env node
/**
* keep-latest-only — aggressive deploy-side cleanup.
*
* For each app in the published-index, identify the LATEST version
* (and any pinned versions) and compute the set of tree hashes those
* versions need. Rewrite refs.json to reference only those hashes.
* Subsequent `pgh store gc --max-age 0` drops the rest.
*
* Rationale:
* - `pgh app install <name>` always fetches the latest entry
* - rollback uses LOCAL hardlinks, not deploy-server fetches
* - holding 33 versions of a 1 GB tree is the v1 bloat pattern
* reborn
*
* SAFETY:
* - Backs up refs.json before rewriting
* - Does NOT modify the published-index (versions stay listed even
* if their trees get gc'd later — the index is the source of
* truth, gc is purely about disk reclamation)
* - Does NOT touch any identity / corestore / app install dir
* - Idempotent
*
* Usage:
* node scripts/keep-latest-only.js [--dry-run] [--keep-pinned]
* [--store-root <path>] [--index <path>]
*
* --keep-pinned also keep any version marked pinned (default: true)
* --no-pinned drop pinned versions too (most aggressive)
*
* Recommended flow:
* 1. node scripts/keep-latest-only.js --dry-run
* 2. node scripts/keep-latest-only.js
* 3. node bin/pgh store gc --max-age 0 --dry-run
* 4. node bin/pgh store gc --max-age 0
* 5. restart pgh-sidecar
*/
import fs from 'fs'
import path from 'path'
import { homedir } from 'os'
function flag (name) {
const i = process.argv.indexOf(name)
return i === -1 ? undefined : process.argv[i + 1]
}
function bool (name) { return process.argv.includes(name) }
const dryRun = bool('--dry-run')
const dropPinned = bool('--no-pinned')
const STORE = flag('--store-root') || process.env.PGH_STORE_ROOT ||
path.join(homedir(), '.local', 'share', 'store')
const INDEX = flag('--index') ||
path.join(homedir(), '.config', 'pgh', 'published-index.json')
const REFS = path.join(STORE, 'refs.json')
const DEPS = path.join(STORE, 'dependencies')
if (!fs.existsSync(INDEX)) {
console.error('no index at ' + INDEX)
process.exit(1)
}
if (!fs.existsSync(REFS)) {
console.error('no refs.json at ' + REFS)
process.exit(1)
}
const index = JSON.parse(fs.readFileSync(INDEX, 'utf8'))
const before = JSON.parse(fs.readFileSync(REFS, 'utf8'))
console.log('=== keep-latest-only ===')
console.log(' store: ' + STORE)
console.log(' index: ' + INDEX)
console.log(' dryRun: ' + dryRun)
console.log(' keepPinned:' + (!dropPinned))
console.log('')
// Build the kept set: latest version of each app + (optionally) pinned versions
const kept = new Map() // hash -> Set(appNames)
let appsConsidered = 0
function addHash (hash, appName) {
if (!hash) return
if (!kept.has(hash)) kept.set(hash, new Set())
kept.get(hash).add(appName)
}
function addVersionTrees (appName, v) {
if (!v) return
addHash(v.rootTreeHash, appName)
const lockfile = v.lockfile || {}
for (const dep of lockfile.deps || []) {
addHash(dep.tree, appName)
if (dep.prebuilds && typeof dep.prebuilds === 'object') {
for (const archHash of Object.values(dep.prebuilds)) {
addHash(archHash, appName)
}
}
}
}
for (const [appName, info] of Object.entries(index.apps || {})) {
appsConsidered++
const versions = (info.versions || []).slice().sort((a, b) => {
const ta = Date.parse(a.publishedAt || '') || 0
const tb = Date.parse(b.publishedAt || '') || 0
return tb - ta // newest first
})
if (versions.length === 0) continue
// Always keep the latest version
addVersionTrees(appName, versions[0])
// Optionally keep pinned versions for explicit rollback runway
if (!dropPinned) {
for (const v of versions) {
if (v.pinned && v !== versions[0]) addVersionTrees(appName, v)
}
}
}
console.log('considered apps: ' + appsConsidered)
console.log('hashes in kept set: ' + kept.size)
console.log('hashes in current refs: ' + Object.keys(before).length)
console.log('hashes on disk: ' +
(fs.existsSync(DEPS) ? fs.readdirSync(DEPS).length : 0))
// Compute disk-byte impact
let bytesKept = 0
let bytesDropped = 0
let countDropped = 0
if (fs.existsSync(DEPS)) {
for (const hash of fs.readdirSync(DEPS)) {
let bytes = 0
try {
bytes = dirSize(path.join(DEPS, hash))
} catch {}
if (kept.has(hash)) bytesKept += bytes
else { bytesDropped += bytes; countDropped++ }
}
}
console.log('')
console.log(' total kept: ' + fmtBytes(bytesKept))
console.log(' total dropped: ' + fmtBytes(bytesDropped) + ' (' + countDropped + ' trees)')
if (dryRun) {
console.log('')
console.log(' dry-run: no changes written.')
process.exit(0)
}
const backup = REFS + '.backup-' + new Date().toISOString().replace(/[:.]/g, '-')
fs.copyFileSync(REFS, backup)
console.log(' backup: ' + backup)
const newRefs = {}
for (const [hash, apps] of kept.entries()) {
newRefs[hash] = [...apps]
}
fs.writeFileSync(REFS, JSON.stringify(newRefs, null, 2))
console.log(' wrote refs.json (' + kept.size + ' entries)')
console.log('')
console.log(' next: pgh store gc --max-age 0 to actually free the bytes')
// ── helpers ──────────────────────────────────────────────────
function fmtBytes (n) {
if (n < 1024) return n + ' B'
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'
return (n / 1073741824).toFixed(2) + ' GB'
}
function dirSize (dir) {
let total = 0
function walk (p) {
let stat
try { stat = fs.lstatSync(p) } catch { return }
if (stat.isSymbolicLink()) return
if (stat.isFile()) { total += stat.size; return }
if (stat.isDirectory()) {
let entries
try { entries = fs.readdirSync(p) } catch { return }
for (const e of entries) walk(path.join(p, e))
}
}
walk(dir)
return total
}