diff --git a/assets/disk0/hopper/README.md b/assets/disk0/hopper/README.md new file mode 100644 index 0000000..06f6650 --- /dev/null +++ b/assets/disk0/hopper/README.md @@ -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. \ No newline at end of file diff --git a/assets/disk0/hopper/bin/hopper.js b/assets/disk0/hopper/bin/hopper.js index 72a09e9..0cc0f35 100644 --- a/assets/disk0/hopper/bin/hopper.js +++ b/assets/disk0/hopper/bin/hopper.js @@ -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 / =, <=, >, <, = + 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 => { + // "" or " " + 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 +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 +// 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(" -> ") || ""})` } + } + 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 }