hopper: upgrade

This commit is contained in:
minjaesong
2026-06-07 23:42:40 +09:00
parent 95ac8c53dd
commit c17f4828b0
2 changed files with 260 additions and 46 deletions

View File

@@ -17,6 +17,8 @@ const net = require("net")
// hopper {search,se} [--provides, --requires, --description, --author] query
//// default searches from ProperName
// hopper {install,in} query [-v version]
// hopper {upgrade,up} [package...]
//// no package names upgrades every user-installed package
// hopper {remove,rm} query
// ============================================================
@@ -372,14 +374,31 @@ function findProviders(idx, name) {
return out
}
// Sort: installed first (no churn), then highest version, then upstream order.
function sortCandidates(cands) {
// Sort candidates by preference. Normally installed-first (no churn) then
// highest version; with `preferNewest` (used by `upgrade`) the installed
// bias is dropped so the newest package version wins regardless of source.
function sortCandidates(cands, preferNewest) {
return cands.slice().sort((a, b) => {
if (a.source !== b.source) return (a.source === "installed") ? -1 : 1
if (!preferNewest && a.source !== b.source) return (a.source === "installed") ? -1 : 1
return -compareVersion(a.version, b.version)
})
}
// Highest *package* version (HopperPackageVersion) among the candidates that
// ARE the package `name` -- installed or upstream -- or null if there are
// none. `upgrade` uses this to decide whether a newer build exists. It
// deliberately ignores HopperProvides versions (which advance independently
// of the package version) and other packages that merely provide `name`;
// the candidate index is keyed by package name, so idx.get(name) is exactly
// the builds of that package.
function latestInstallableVersion(idx, name) {
const arr = idx.get(name)
if (!arr || arr.length === 0) return null
let best = null
arr.forEach(c => { if (best === null || compareVersion(c.version, best) > 0) best = c.version })
return best
}
// ============================================================
// Resolver (snapshot-based backtracking; precursor to a SAT solver)
// ============================================================
@@ -399,7 +418,7 @@ function sortCandidates(cands) {
// recursive resolve() call over each requirement. Replacing this with
// clause learning / a watched-literals scheme later would be local.
function resolveAll(idx, requirements) {
function resolveAll(idx, requirements, upgradeSet, pkgVersionPins) {
const chosen = new Map()
const issues = []
@@ -407,10 +426,16 @@ function resolveAll(idx, requirements) {
function restore(snap) { chosen.clear(); snap.forEach((v, k) => chosen.set(k, v)) }
function _resolve(reqName, constraint, trail) {
// A package-version pin (from `install -v`) constrains the chosen
// build's HopperPackageVersion -- the version `search` shows -- and is
// matched separately from `constraint`, which always works in the
// HopperProvides capability space.
const pin = (pkgVersionPins && pkgVersionPins.get(reqName)) || null
const existing = chosen.get(reqName)
if (existing !== undefined) {
const v = providedVersionOf(existing, reqName)
return satisfies(v, constraint)
return (satisfies(v, constraint) && (!pin || satisfies(existing.version, pin)))
? { ok: true }
: { ok: false, reason: `${reqName} pinned to ${v}, but ${trail.join(" -> ")} requires ${constraint}` }
}
@@ -421,9 +446,19 @@ function resolveAll(idx, requirements) {
}
// Satisfaction checks the virtual version the candidate exposes
// for `reqName` (HopperProvides), not necessarily the package's
// own HopperPackageVersion.
const matching = sortCandidates(providers.filter(c => satisfies(providedVersionOf(c, reqName), constraint)))
// own HopperPackageVersion. A package-version pin is applied on top,
// against the build's own version.
const preferNewest = !!(upgradeSet && upgradeSet.has(reqName))
const provMatched = providers.filter(c => satisfies(providedVersionOf(c, reqName), constraint))
const matching = sortCandidates(provMatched.filter(c => !pin || satisfies(c.version, pin)), preferNewest)
if (matching.length === 0) {
// When the package-version pin is what eliminated the candidates,
// report it in package-version space (matching `search`); a plain
// capability mismatch stays in HopperProvides space.
if (pin && provMatched.length > 0) {
const versions = providers.map(p => `${p.version}[${p.source}]`).join(", ")
return { ok: false, reason: `no build of "${reqName}" has version ${pin} (available: ${versions})` }
}
const versions = providers.map(p => `${providedVersionOf(p, reqName)}[${p.source}]`).join(", ")
return { ok: false, reason: `no version of "${reqName}" satisfies ${constraint} (available: ${versions})` }
}
@@ -728,43 +763,11 @@ function _installOne(action, candidate) {
return true
}
function cmdInstall(args) {
let query = undefined
let version = undefined
for (let i = 0; i < args.length; i++) {
if (args[i] === "-v") { version = args[i + 1]; i++ }
else if (args[i].startsWith("--")) { printerrln(`Unknown option: ${args[i]}`); return 1 }
else query = args[i]
}
if (query === undefined) {
printerrln("Usage: hopper install <package> [-v <version>]")
return 1
}
const targetConstraint = version || "*"
const verSuffix = (targetConstraint !== "*") ? ` (${targetConstraint})` : ""
println(`Resolving ${query}${verSuffix} ...`)
const idx = buildCandidateIndex()
// Sanity check: target must exist in the index (installed or upstream).
if (findProviders(idx, query).length === 0) {
printerrln(`Error: package "${query}" not found (not on upstream, not installed).`)
return 4
}
// Seed order matters: the target goes FIRST so its (possibly tight)
// constraints can drive upgrades of dependencies. The installed-set
// requirements follow at "*" so the resolver still has to keep them
// alive (preferring installed candidates when their version still fits,
// otherwise upgrading or downgrading them).
const seed = [{ name: query, constraint: targetConstraint }]
listInstalledManifests().forEach(m => {
if (m.HopperPackageName === query) return
seed.push({ name: m.HopperPackageName, constraint: "*" })
})
const { chosen, issues } = resolveAll(idx, seed)
// Shared tail for `install` and `upgrade`: turn a resolver result into an
// actual on-disk change. Prints the plan, runs the pre-flight checks
// (system-package and missing-payload blockers, modem availability), asks
// for confirmation, then fetches and writes every changing package.
function commitResolution(idx, chosen, issues, planLabel, confirmMsg) {
if (issues.length > 0) {
printerrln("Resolution failed:")
issues.forEach(reason => printerrln(` - ${reason}`))
@@ -774,7 +777,7 @@ function cmdInstall(args) {
}
const plan = classifyPlan(idx, chosen)
printPlan(plan, query)
printPlan(plan, planLabel)
const changing = plan.filter(a => a.action !== "keep")
if (changing.length === 0) return 0
@@ -804,7 +807,7 @@ function cmdInstall(args) {
}
println("")
if (!confirm("Proceed with installation?", true)) {
if (!confirm(confirmMsg, true)) {
println("Aborted.")
return 0
}
@@ -835,6 +838,132 @@ function cmdInstall(args) {
return 0
}
function cmdInstall(args) {
let query = undefined
let version = undefined
for (let i = 0; i < args.length; i++) {
if (args[i] === "-v") { version = args[i + 1]; i++ }
else if (args[i].startsWith("--")) { printerrln(`Unknown option: ${args[i]}`); return 1 }
else query = args[i]
}
if (query === undefined) {
printerrln("Usage: hopper install <package> [-v <version>]")
return 1
}
const verSuffix = version ? ` (${version})` : ""
println(`Resolving ${query}${verSuffix} ...`)
const idx = buildCandidateIndex()
// Sanity check: target must exist in the index (installed or upstream).
if (findProviders(idx, query).length === 0) {
printerrln(`Error: package "${query}" not found (not on upstream, not installed).`)
return 4
}
// Seed order matters: the target goes FIRST so its (possibly tight)
// constraints can drive upgrades of dependencies. The installed-set
// requirements follow at "*" so the resolver still has to keep them
// alive (preferring installed candidates when their version still fits,
// otherwise upgrading or downgrading them).
//
// A user-supplied `-v` pins the target's PACKAGE version (the version
// `hopper search` displays), NOT its HopperProvides capability version
// which the resolver otherwise matches against. So the seed constraint
// stays "*" (capability space) and the version goes into a package-
// version pin; dependencies keep resolving in capability space.
const seed = [{ name: query, constraint: "*" }]
listInstalledManifests().forEach(m => {
if (m.HopperPackageName === query) return
seed.push({ name: m.HopperPackageName, constraint: "*" })
})
const pkgVersionPins = new Map()
if (version) pkgVersionPins.set(query, version)
const { chosen, issues } = resolveAll(idx, seed, null, pkgVersionPins)
return commitResolution(idx, chosen, issues, query, "Proceed with installation?")
}
// ============================================================
// Upgrade
// ============================================================
//
// `upgrade` is `install` without the "keep what's installed" bias. For
// every named package -- or, with no names, every user-installed package
// -- it forces the resolver to pick a version strictly newer than what is
// installed, which makes it choose the latest build the mirrors offer
// (the resolver still backtracks to a lower-but-newer version if the
// newest one would break a dependency). Packages that are already at
// their newest version are skipped, and system packages are read-only.
function cmdUpgrade(args) {
const names = []
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith("-")) { printerrln(`Unknown option: ${args[i]}`); return 1 }
names.push(args[i])
}
const idx = buildCandidateIndex()
// Target set: the named packages, or -- when none are named -- every
// user-installed package. System packages cannot be upgraded.
const targets = []
if (names.length === 0) {
listInstalledManifests().forEach(m => {
if (m._origin === "system") return
targets.push({ name: m.HopperPackageName, installed: m.HopperPackageVersion || "0.0.0" })
})
if (targets.length === 0) {
println("No user-installed packages to upgrade.")
return 0
}
} else {
for (let i = 0; i < names.length; i++) {
const m = findInstalledManifest(names[i])
if (m === undefined) { printerrln(`Package not installed: ${names[i]}`); return 2 }
if (m._origin === "system") { printerrln(`Cannot upgrade ${names[i]}: it is a system package.`); return 6 }
targets.push({ name: m.HopperPackageName, installed: m.HopperPackageVersion || "0.0.0" })
}
}
// Keep only the targets that actually have a newer *package* version
// available. Compare HopperPackageVersion (the real build), NOT the
// HopperProvides capability version, which can advance independently.
const upgradeNames = new Set()
targets.forEach(t => {
const latest = latestInstallableVersion(idx, t.name)
if (latest !== null && compareVersion(latest, t.installed) > 0) upgradeNames.add(t.name)
})
if (upgradeNames.size === 0) {
println("Everything is up to date.")
return 0
}
// Seed the resolver. Upgrade targets stay at "*" but join the upgrade
// set, which flips their candidate preference from "keep what is
// installed" to "pick the newest build". A version constraint cannot do
// this: satisfaction is tested against the HopperProvides version, which
// is decoupled from the package version, so an installed copy whose
// provided version is already high would wrongly satisfy ">installed".
// Everything else stays at "*" so it is kept unless a dependency drags
// it along.
const seed = []
upgradeNames.forEach(n => seed.push({ name: n, constraint: "*" }))
listInstalledManifests().forEach(m => {
if (upgradeNames.has(m.HopperPackageName)) return
seed.push({ name: m.HopperPackageName, constraint: "*" })
})
const label = (names.length === 1) ? names[0] : "the selected packages"
println(`Resolving upgrade for ${label} ...`)
const { chosen, issues } = resolveAll(idx, seed, upgradeNames)
return commitResolution(idx, chosen, issues, label, "Proceed with upgrade?")
}
// ============================================================
// Remove
// ============================================================
@@ -929,6 +1058,7 @@ function printUsage() {
println("Usage:")
println(" hopper {search,se} [--provides|--requires|--description|--author] <query>")
println(" hopper {install,in} <package> [-v <version>]")
println(" hopper {upgrade,up} [<package>...]")
println(" hopper {remove,rm} <package>")
}
@@ -943,6 +1073,9 @@ switch (_hopperCmd) {
case "install":
case "in":
return cmdInstall(_hopperRest)
case "upgrade":
case "up":
return cmdUpgrade(_hopperRest)
case "remove":
case "rm":
return cmdRemove(_hopperRest)

View File

@@ -0,0 +1,81 @@
{
"tsfVersion": "1.0",
"name": "hopper",
"summary": "Package manager for TVDOS",
"description": "Hopper resolves package dependencies across the installed set (system packages shipped with TVDOS plus user packages under A:/hopper) and any remote mirrors listed in A:/tvdos/hopper/mirrors.list, then installs, upgrades, downgrades or removes user packages. System packages are read-only: install and remove refuse to touch them. Versions are strict SemVer (MAJOR.MINOR.PATCH); constraints support *, X.*, X.Y.*, exact, ^, ~ and >=/>/<=/< operators, comma-separated for AND.",
"symbols": {
"search": { "kind": "subcommand", "name": "search", "summary": "Search installed packages and remote mirrors (alias: se)" },
"install": { "kind": "subcommand", "name": "install", "summary": "Resolve dependencies and install a package (alias: in)" },
"upgrade": { "kind": "subcommand", "name": "upgrade", "summary": "Upgrade packages to the latest available version; all user packages when none named (alias: up)" },
"remove": { "kind": "subcommand", "name": "remove", "summary": "Remove a user-installed package (alias: rm)" },
"provides": { "kind": "option", "long": "--provides", "summary": "Match against the HopperProvides field instead of the name" },
"requires": { "kind": "option", "long": "--requires", "summary": "Match against the HopperRequires field instead of the name" },
"description": { "kind": "option", "long": "--description", "summary": "Match against the package description instead of the name" },
"author": { "kind": "option", "long": "--author", "summary": "Match against the package author instead of the name" },
"searchFields": {
"kind": "group",
"summary": "Search-field selectors",
"members": ["provides", "requires", "description", "author"]
},
"version": {
"kind": "option",
"short": "-v",
"summary": "Install a specific package version or range",
"value": {
"name": "VERSION",
"type": "string",
"required": true,
"summary": "Package version as shown by search, or a constraint, e.g. 1.2.0, ^1.2.0, ~1.2, 2.*"
}
},
"query": { "kind": "positional", "type": "string", "name": "QUERY", "summary": "Substring matched against the package name (or the selected field)" },
"pkgInstall": { "kind": "positional", "type": "string", "name": "PACKAGE", "summary": "Name of the package (or virtual capability) to install" },
"pkgUpgrade": { "kind": "positional", "type": "string", "name": "PACKAGE", "summary": "Package(s) to upgrade; upgrades every user package when omitted" },
"pkgRemove": { "kind": "positional", "type": "string", "name": "PACKAGE", "summary": "Name of the user-installed package to remove" }
},
"synopsis": {
"type": "choice",
"children": [
{
"type": "sequence",
"children": [
{ "type": "reference", "symbol": "search" },
{ "type": "repeat", "child": { "type": "reference", "symbol": "searchFields" } },
{ "type": "reference", "symbol": "query" }
]
},
{
"type": "sequence",
"children": [
{ "type": "reference", "symbol": "install" },
{ "type": "reference", "symbol": "pkgInstall" },
{ "type": "optional", "child": { "type": "reference", "symbol": "version" } }
]
},
{
"type": "sequence",
"children": [
{ "type": "reference", "symbol": "upgrade" },
{ "type": "repeat", "child": { "type": "reference", "symbol": "pkgUpgrade" } }
]
},
{
"type": "sequence",
"children": [
{ "type": "reference", "symbol": "remove" },
{ "type": "reference", "symbol": "pkgRemove" }
]
}
]
},
"constraints": [
{
"type": "cardinality",
"symbols": ["provides", "requires", "description", "author"],
"maximum": 1
}
]
}