more hopper stuffs

This commit is contained in:
minjaesong
2026-05-21 23:59:16 +09:00
parent 277693989b
commit 0b82d4b32c
2 changed files with 436 additions and 32 deletions

View File

@@ -0,0 +1,29 @@
Hopper is a package manager for TVDOS.
---
## For End Users
---
## For Package Managers
A Hopper package is declared using the Hopper Manifest. Hopper Manifest has the following fields:
- **HopperManifestVersion.** The manifest version, always `1`
- **HopperPackageName.** Package name that Hopper understands
- **HopperPackageVersion.** The version. MUST STRICTLY follow Semantic Versioning 2.0.0
1. MAJOR version when you make incompatible API changes
2. MINOR version when you add functionality in a backward compatible manner
3. PATCH version when you make backward compatible bug fixes
- **HopperPackageMaintainer.** The maintainer of the package
- **HopperProvides.** (plural) What does your package provides
- **HopperRequires.** (plural) Dependencies
- **ProperName.** The displayed name of the package. Must be human-readable
- **ProperAuthor.** The displayed author of the package. Must be human-readable
- **ProperDescription.** Human-readable description of the package
- **Licence.** Licence of the package (e.g. `MIT`, `GPL-2.0-only`)
- **SupportMe.** (optional, plural) Any donation links
- **SystemPackagePath.** (for packages shipped with TVDOS only) path descriptor for the package file(s)
- **PackageFileList.** (for upstream packages, plural) HTTP(S) path for the files.

View File

@@ -67,50 +67,374 @@ function findInstalledManifest(name) {
return undefined
}
function isSystemPackage(manifest) {
return !!(manifest.SystemPackagePath) // true if the field is truthy (not undefined, not empty string, not string '0', etc.)
}
// Yes/no prompt. Empty input falls back to `defaultYes`.
function confirm(prompt, defaultYes) {
const hint = defaultYes ? "[Y/n]" : "[y/N]"
print(`${prompt} ${hint} `)
const ans = (read() || "").trim().toLowerCase()
if (ans === "") return !!defaultYes
return ans === "y" || ans === "yes"
}
// ============================================================
// SemVer (strict X.Y.Z) and constraint matching
// ============================================================
//
// Versions are strict Semantic Versioning: three non-negative integer
// components MAJOR.MINOR.PATCH. No pre-release / build metadata.
//
// Constraint grammar (intentionally small, expandable later):
// * any version
// X.* major X, any minor/patch
// X.Y.* major X, minor Y, any patch
// X.Y.Z exact
// ^X.Y.Z >= X.Y.Z and < (X+1).0.0 (major-compatible)
// ~X.Y.Z >= X.Y.Z and < X.(Y+1).0 (minor-compatible)
// >=X.Y.Z / >X.Y.Z / <=X.Y.Z / <X.Y.Z / =X.Y.Z
//
// Multiple comma-separated constraints are AND-ed: "^1.2.0,<1.5.0".
function parseVersion(v) {
const m = String(v || "0.0.0").trim().match(/^(\d+)\.(\d+)\.(\d+)$/)
if (!m) return [0, 0, 0]
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]
}
function compareVersion(a, b) {
const A = parseVersion(a), B = parseVersion(b)
for (let i = 0; i < 3; i++) {
if (A[i] !== B[i]) return (A[i] < B[i]) ? -1 : 1
}
return 0
}
function _matchSingleConstraint(version, c) {
c = c.trim()
if (c === "" || c === "*") return true
// Operator form: ^, ~, >=, <=, >, <, =
let opMatch = c.match(/^(\^|~|>=|<=|>|<|=)\s*(\d+\.\d+\.\d+)$/)
if (opMatch) {
const op = opMatch[1]
const target = opMatch[2]
const cmp = compareVersion(version, target)
const [tM, tm] = parseVersion(target)
switch (op) {
case "=": return cmp === 0
case ">": return cmp > 0
case ">=": return cmp >= 0
case "<": return cmp < 0
case "<=": return cmp <= 0
case "^": return cmp >= 0 && compareVersion(version, `${tM + 1}.0.0`) < 0
case "~": return cmp >= 0 && compareVersion(version, `${tM}.${tm + 1}.0`) < 0
}
}
// Wildcard form: X.*, X.Y.*, X.x, X.Y.x, or exact X.Y.Z
const parts = c.split(".")
const vparts = parseVersion(version)
for (let i = 0; i < parts.length && i < 3; i++) {
if (parts[i] === "*" || parts[i] === "x" || parts[i] === "X") return true
const expected = parseInt(parts[i], 10)
if (isNaN(expected) || vparts[i] !== expected) return false
}
// All listed parts matched literally; remaining parts (if any) must be 0
for (let i = parts.length; i < 3; i++) {
if (vparts[i] !== 0) return false
}
return true
}
function satisfies(version, constraint) {
if (!constraint) return true
return constraint.split(",").every(c => _matchSingleConstraint(version, c))
}
function parseRequires(s) {
const out = []
splitList(s || "").forEach(entry => {
// "<name>" or "<name> <constraint>"
const idx = entry.search(/\s+/)
if (idx < 0) {
out.push({ name: entry, constraint: "*" })
} else {
out.push({ name: entry.substring(0, idx), constraint: entry.substring(idx + 1).trim() })
}
})
return out
}
// ============================================================
// Candidate index (installed + upstream)
// ============================================================
function _manifestToCandidate(m, source) {
return {
name: m.HopperPackageName || "",
version: m.HopperPackageVersion || "0.0.0",
requires: parseRequires(m.HopperRequires || ""),
provides: splitList(m.HopperProvides || ""),
source: source, // "installed" | "upstream"
manifest: m
}
}
// Returns map: packageName -> array<Candidate>
function buildCandidateIndex() {
const idx = new Map()
function add(c) {
if (!idx.has(c.name)) idx.set(c.name, [])
// De-dupe (name+version+source)
const arr = idx.get(c.name)
if (arr.some(x => x.version === c.version && x.source === c.source)) return
arr.push(c)
}
listInstalledManifests().forEach(m => add(_manifestToCandidate(m, "installed")))
fetchRemoteCandidates().forEach(m => add(_manifestToCandidate(m, "upstream")))
return idx
}
// Anything that satisfies a requirement on `name`: package whose own name is
// `name`, OR whose HopperProvides includes `name`.
function findProviders(idx, name) {
const direct = idx.get(name) ? idx.get(name).slice() : []
const indirect = []
idx.forEach(candidates => {
candidates.forEach(c => {
if (c.name === name) return // already in `direct`
if (c.provides.indexOf(name) >= 0) indirect.push(c)
})
})
return direct.concat(indirect)
}
// Sort: installed first (no churn), then highest version, then upstream order.
function sortCandidates(cands) {
return cands.slice().sort((a, b) => {
if (a.source !== b.source) return (a.source === "installed") ? -1 : 1
return -compareVersion(a.version, b.version)
})
}
// ============================================================
// Resolver (snapshot-based backtracking; precursor to a SAT solver)
// ============================================================
//
// State: chosen :: Map<packageName, Candidate>
// At every choice point we snapshot the whole map so that backtracking
// also undoes any transitive picks. The candidate ordering encodes the
// preference policy:
//
// 1. Keep installed if it satisfies the constraint.
// 2. Otherwise pick the newest upstream version that satisfies.
// 3. If newer versions cause downstream conflicts, walk older versions
// (downgrade) until either something fits or candidates are exhausted.
//
// The structure is intentionally close to DPLL: each "decision" is the
// candidate we assign to a variable, and "unit propagation" is the
// recursive resolve() call over each requirement. Replacing this with
// clause learning / a watched-literals scheme later would be local.
function resolveAll(idx, requirements) {
const chosen = new Map()
const issues = []
function snapshot() { return new Map(chosen) }
function restore(snap) { chosen.clear(); snap.forEach((v, k) => chosen.set(k, v)) }
function _resolve(reqName, constraint, trail) {
const existing = chosen.get(reqName)
if (existing !== undefined) {
return satisfies(existing.version, constraint)
? { ok: true }
: { ok: false, reason: `${reqName} pinned to ${existing.version}, but ${trail.join(" -> ")} requires ${constraint}` }
}
const providers = findProviders(idx, reqName)
if (providers.length === 0) {
return { ok: false, reason: `no package provides "${reqName}" (required by ${trail.join(" -> ") || "<root>"})` }
}
const matching = sortCandidates(providers.filter(c => satisfies(c.version, constraint)))
if (matching.length === 0) {
const versions = providers.map(p => `${p.version}[${p.source}]`).join(", ")
return { ok: false, reason: `no version of "${reqName}" satisfies ${constraint} (available: ${versions})` }
}
let lastReason = null
for (let i = 0; i < matching.length; i++) {
const cand = matching[i]
const snap = snapshot()
chosen.set(cand.name, cand)
let allOk = true
const subTrail = trail.concat([`${cand.name}@${cand.version}`])
for (let j = 0; j < cand.requires.length; j++) {
const req = cand.requires[j]
const r = _resolve(req.name, req.constraint, subTrail)
if (!r.ok) {
allOk = false
lastReason = r.reason
break
}
}
if (allOk) return { ok: true }
restore(snap)
}
return { ok: false, reason: lastReason || `no working candidate for "${reqName}"` }
}
requirements.forEach(req => {
const r = _resolve(req.name, req.constraint, [])
if (!r.ok) issues.push(r.reason)
})
return { chosen, issues }
}
// Compare resolved assignment against currently-installed state.
function classifyPlan(idx, chosen) {
const installedByName = new Map()
listInstalledManifests().forEach(m => installedByName.set(m.HopperPackageName, m))
const actions = []
chosen.forEach((cand, name) => {
const inst = installedByName.get(name)
if (cand.source === "installed") {
actions.push({ action: "keep", name, version: cand.version })
}
else if (inst === undefined) {
actions.push({ action: "install", name, version: cand.version })
}
else {
const cmp = compareVersion(cand.version, inst.HopperPackageVersion)
if (cmp > 0) actions.push({ action: "upgrade", name, from: inst.HopperPackageVersion, to: cand.version })
else if (cmp < 0) actions.push({ action: "downgrade", name, from: inst.HopperPackageVersion, to: cand.version })
else actions.push({ action: "reinstall", name, version: cand.version })
}
})
return actions
}
function printPlan(actions, target) {
const changing = actions.filter(a => a.action !== "keep")
if (changing.length === 0) {
println(`Nothing to do: ${target} is already installed and satisfied.`)
return
}
println("Plan:")
changing.forEach(a => {
switch (a.action) {
case "install": println(` + install ${a.name} ${a.version}`); break
case "upgrade": println(` ^ upgrade ${a.name} ${a.from} -> ${a.to}`); break
case "downgrade": println(` v downgrade ${a.name} ${a.from} -> ${a.to}`); break
case "reinstall": println(` = reinstall ${a.name} ${a.version}`); break
}
})
}
// ============================================================
// Search
// ============================================================
// Dummy "remote" repository -- pretends to be a network query result.
// Multiple entries per HopperPackageName represent multiple available
// versions; the resolver picks among them.
const FAKE_REMOTE_PACKAGES = [
// doomster: single version, needs libgl 1.*
{
HopperPackageName: "doomster",
HopperPackageVersion: "0.9.3",
ProperName: "Doomster",
ProperAuthor: "id Sortware",
HopperPackageName: "doomster", HopperPackageVersion: "0.9.3",
ProperName: "Doomster", ProperAuthor: "id Sortware",
ProperDescription: "First-person shooter game for TSVM",
HopperProvides: "doomster;",
HopperRequires: "tvdos 1.*;libgl 1.*"
HopperProvides: "doomster;", HopperRequires: "tvdos 1.*;libgl 1.*"
},
// libfft: three versions
{
HopperPackageName: "libfft",
HopperPackageVersion: "0.1.0",
ProperName: "LibFFT",
ProperAuthor: "Soraya Vaughn",
HopperPackageName: "libfft", HopperPackageVersion: "0.1.0",
ProperName: "LibFFT", ProperAuthor: "Soraya Vaughn",
ProperDescription: "Fast Fourier Transform library for TSVM",
HopperProvides: "libfft;",
HopperRequires: "tvdos 1.*"
HopperProvides: "libfft;", HopperRequires: "tvdos 1.*"
},
{
HopperPackageName: "chatlite",
HopperPackageVersion: "2.1.5",
ProperName: "ChatLite",
ProperAuthor: "TerraNetworks Co.",
HopperPackageName: "libfft", HopperPackageVersion: "0.2.0",
ProperName: "LibFFT", ProperAuthor: "Soraya Vaughn",
ProperDescription: "Fast Fourier Transform library for TSVM",
HopperProvides: "libfft;", HopperRequires: "tvdos 1.*"
},
{
HopperPackageName: "libfft", HopperPackageVersion: "1.0.0",
ProperName: "LibFFT", ProperAuthor: "Soraya Vaughn",
ProperDescription: "Fast Fourier Transform library for TSVM",
HopperProvides: "libfft;", HopperRequires: "tvdos 1.*"
},
// chatlite: 2.1.5 fits installed wintex 1.*; 3.0.0 demands wintex 2.*
{
HopperPackageName: "chatlite", HopperPackageVersion: "2.1.5",
ProperName: "ChatLite", ProperAuthor: "TerraNetworks Co.",
ProperDescription: "Lightweight IRC-style chat client",
HopperProvides: "chatlite;",
HopperRequires: "tvdos 1.*;wintex 1.*"
HopperProvides: "chatlite;", HopperRequires: "tvdos 1.*;wintex 1.*"
},
{
HopperPackageName: "snakey",
HopperPackageVersion: "1.4.0",
ProperName: "Snakey",
ProperAuthor: "Iben Holst",
HopperPackageName: "chatlite", HopperPackageVersion: "3.0.0",
ProperName: "ChatLite", ProperAuthor: "TerraNetworks Co.",
ProperDescription: "Lightweight IRC-style chat client",
HopperProvides: "chatlite;", HopperRequires: "tvdos 1.*;wintex 2.*"
},
// snakey
{
HopperPackageName: "snakey", HopperPackageVersion: "1.4.0",
ProperName: "Snakey", ProperAuthor: "Iben Holst",
ProperDescription: "Classic snake game with TerranBASIC scripting",
HopperProvides: "snakey;",
HopperRequires: "tvdos 1.*;libterranbasic 1.*"
}
HopperProvides: "snakey;", HopperRequires: "tvdos 1.*;libterranbasic 1.*"
},
// libgl future version (lets superchef pull in an upgrade)
{
HopperPackageName: "libgl", HopperPackageVersion: "2.0.0",
ProperName: "LibGL", ProperAuthor: "CuriousTorvald",
ProperDescription: "TVDOS Graphics Library, next-generation",
HopperProvides: "libgl;", HopperRequires: ""
},
// superchef: requires libgl 2.* -- triggers an upgrade of installed libgl
{
HopperPackageName: "superchef", HopperPackageVersion: "1.0.0",
ProperName: "SuperChef", ProperAuthor: "Pavlo Kvasnik",
ProperDescription: "Recipe-driven build automation",
HopperProvides: "superchef;", HopperRequires: "tvdos 1.*;libgl 2.*"
},
// phantomedit: needs something that does not exist -> unresolvable
{
HopperPackageName: "phantomedit", HopperPackageVersion: "0.1.0",
ProperName: "PhantomEdit", ProperAuthor: "anonymous",
ProperDescription: "Editor for non-existent files",
HopperProvides: "phantomedit;", HopperRequires: "libquantum 1.*"
},
// fake updated version of Microtone
{
HopperPackageName: "microtone", HopperPackageVersion: "1.3.0",
ProperName: "Microtone", ProperAuthor: "CuriousTorvald",
ProperDescription: "Fake updated version of Microtone",
HopperProvides: "microtone;", HopperRequires: "tvdos 1.*;libgl 2.*"
},
]
// Indirection point: in the future this should hit a real upstream.
function fetchRemoteCandidates() {
return FAKE_REMOTE_PACKAGES
}
function fieldCandidates(manifest, field) {
switch (field) {
case "provides": return splitList(manifest.HopperProvides || "")
@@ -181,14 +505,60 @@ function cmdInstall(args) {
return 1
}
const verSuffix = version ? ` (v${version})` : ""
const targetConstraint = version || "*"
const verSuffix = (targetConstraint !== "*") ? ` (${targetConstraint})` : ""
println(`Resolving ${query}${verSuffix} ...`)
println(`Fetching manifest from remote ...`)
println(`Resolving dependencies ...`)
println(`Downloading package payload ...`)
println(`Verifying integrity ...`)
println(`Writing manifest to ${SYSTEM_PACKEAGE_DEF_DIR}/${query}${MANIFEST_EXT} ...`)
println(`Installed ${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)
if (issues.length > 0) {
printerrln("Resolution failed:")
issues.forEach(reason => printerrln(` - ${reason}`))
printerrln("")
printerrln("No solution found -- not installable.")
return 3
}
const plan = classifyPlan(idx, chosen)
printPlan(plan, query)
const changing = plan.filter(a => a.action !== "keep")
if (changing.length === 0) return 0
println("")
if (!confirm("Proceed with installation?", true)) {
println("Aborted.")
return 0
}
println("Fetching manifests from remote ...")
println("Downloading package payloads ...")
println("Verifying integrity ...")
changing.forEach(a => {
if (a.action === "install" || a.action === "reinstall") {
println(` ${a.action} ${a.name} ${a.version}`)
} else {
println(` ${a.action} ${a.name} ${a.from} -> ${a.to}`)
}
})
println("(dummy install: no files were actually created)")
return 0
}
@@ -262,6 +632,11 @@ function cmdRemove(args) {
println(` ${m._manifestPath}`)
println("")
if (!confirm("Proceed with removal?", false)) {
println("Aborted.")
return 0
}
println("(dry-run: no files were actually deleted)")
return 0
}