mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-10 06:54:04 +09:00
hopper moved to its own repo. hopper now actually installs/removes
This commit is contained in:
13
.gitignore
vendored
13
.gitignore
vendored
@@ -62,12 +62,15 @@ tsvmman.pdf
|
|||||||
*.ilg
|
*.ilg
|
||||||
*.ind
|
*.ind
|
||||||
|
|
||||||
|
assets/disk0/tvdos/bin/tautfont.png
|
||||||
|
|
||||||
|
video_encoder/*
|
||||||
|
|
||||||
|
.idea/vcs.xml
|
||||||
|
|
||||||
|
# in-dev stuffs
|
||||||
assets/disk0/home/basic/*
|
assets/disk0/home/basic/*
|
||||||
assets/disk0/movtestimg/*.jpg
|
assets/disk0/movtestimg/*.jpg
|
||||||
assets/disk0/*.mov
|
assets/disk0/*.mov
|
||||||
assets/diskMediabin/*
|
assets/diskMediabin/*
|
||||||
|
assets/disk0/hopper/*
|
||||||
video_encoder/*
|
|
||||||
|
|
||||||
assets/disk0/tvdos/bin/tautfont.png
|
|
||||||
.idea/vcs.xml
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
hopper $0
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
HopperManifestVersion:1
|
|
||||||
HopperPackageName:hopper
|
|
||||||
HopperPackageVersion:0.0.1
|
|
||||||
HopperPackageMaintainer:CuriousTorvald
|
|
||||||
HopperProvides:hopper;
|
|
||||||
HopperRequires:tvdos 1.*
|
|
||||||
ProperName:Hopper
|
|
||||||
ProperAuthor:CuriousTorvald
|
|
||||||
ProperDescription:Package manager for TVDOS
|
|
||||||
Licence:MIT
|
|
||||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
|
||||||
PackageFileList:https://raw.githubusercontent.com/curioustorvald/hopper/refs/heads/master/hopper.js;https://raw.githubusercontent.com/curioustorvald/hopper/refs/heads/master/hop.alias
|
|
||||||
@@ -3,7 +3,11 @@
|
|||||||
* Created by CuriousTorvald on 2026-04-16
|
* Created by CuriousTorvald on 2026-04-16
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SYSTEM_PACKEAGE_DEF_DIR = "A:/tvdos/hopper"
|
const SYSTEM_PACKEAGE_DEF_DIR = "A:/tvdos/hopper"
|
||||||
|
const USER_BASE_DIR = "A:/hopper"
|
||||||
|
const USER_PACKAGE_DEF_DIR = `${USER_BASE_DIR}/manifests`
|
||||||
|
const USER_PACKAGE_BIN_DIR = `${USER_BASE_DIR}/bin`
|
||||||
|
const USER_PACKAGE_INCLUDE_DIR = `${USER_BASE_DIR}/include`
|
||||||
const MANIFEST_EXT = "hop.per"
|
const MANIFEST_EXT = "hop.per"
|
||||||
const MIRROR_LIST_PATH = `${SYSTEM_PACKEAGE_DEF_DIR}/mirrors.list`
|
const MIRROR_LIST_PATH = `${SYSTEM_PACKEAGE_DEF_DIR}/mirrors.list`
|
||||||
|
|
||||||
@@ -46,23 +50,43 @@ function readManifestFile(path) {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
function listInstalledManifests() {
|
function _listManifestsFrom(dirPath, origin) {
|
||||||
const dir = files.open(SYSTEM_PACKEAGE_DEF_DIR)
|
const dir = files.open(dirPath)
|
||||||
if (!dir.exists || !dir.isDirectory) return []
|
if (!dir.exists || !dir.isDirectory) return []
|
||||||
const out = []
|
const out = []
|
||||||
dir.list().forEach(entry => {
|
dir.list().forEach(entry => {
|
||||||
if (entry.isDirectory) return
|
if (entry.isDirectory) return
|
||||||
if (!entry.name.toLowerCase().endsWith(MANIFEST_EXT)) return
|
if (!entry.name.toLowerCase().endsWith(MANIFEST_EXT)) return
|
||||||
const m = readManifestFile(entry.fullPath)
|
const m = readManifestFile(entry.fullPath)
|
||||||
if (m !== undefined) out.push(m)
|
if (m !== undefined) {
|
||||||
|
m._origin = origin
|
||||||
|
out.push(m)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System packages (shipped with TVDOS) live in SYSTEM_PACKAGE_DEF_DIR
|
||||||
|
// and are read-only as far as hopper is concerned. User packages,
|
||||||
|
// installed by `hopper install`, live under USER_PACKAGE_DEF_DIR. The
|
||||||
|
// resolver treats both as "installed", but the install/remove paths
|
||||||
|
// refuse to modify anything tagged `_origin === "system"`.
|
||||||
|
function listInstalledManifests() {
|
||||||
|
return _listManifestsFrom(SYSTEM_PACKEAGE_DEF_DIR, "system")
|
||||||
|
.concat(_listManifestsFrom(USER_PACKAGE_DEF_DIR, "user"))
|
||||||
|
}
|
||||||
|
|
||||||
function findInstalledManifest(name) {
|
function findInstalledManifest(name) {
|
||||||
const direct = `${SYSTEM_PACKEAGE_DEF_DIR}/${name}${MANIFEST_EXT}`
|
// Prefer user-installed copy when a system package with the same name
|
||||||
const m = readManifestFile(direct)
|
// also exists -- but that combination is normally refused at install.
|
||||||
if (m !== undefined) return m
|
const userDirect = `${USER_PACKAGE_DEF_DIR}/${name}.${MANIFEST_EXT}`
|
||||||
|
let m = readManifestFile(userDirect)
|
||||||
|
if (m !== undefined) { m._origin = "user"; return m }
|
||||||
|
|
||||||
|
const sysDirect = `${SYSTEM_PACKEAGE_DEF_DIR}/${name}.${MANIFEST_EXT}`
|
||||||
|
m = readManifestFile(sysDirect)
|
||||||
|
if (m !== undefined) { m._origin = "system"; return m }
|
||||||
|
|
||||||
const all = listInstalledManifests()
|
const all = listInstalledManifests()
|
||||||
for (let i = 0; i < all.length; i++) {
|
for (let i = 0; i < all.length; i++) {
|
||||||
if ((all[i].HopperPackageName || "") === name) return all[i]
|
if ((all[i].HopperPackageName || "") === name) return all[i]
|
||||||
@@ -70,10 +94,6 @@ function findInstalledManifest(name) {
|
|||||||
return undefined
|
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`.
|
// Yes/no prompt. Empty input falls back to `defaultYes`.
|
||||||
function confirm(prompt, defaultYes) {
|
function confirm(prompt, defaultYes) {
|
||||||
const hint = defaultYes ? "[Y/n]" : "[y/N]"
|
const hint = defaultYes ? "[Y/n]" : "[y/N]"
|
||||||
@@ -83,6 +103,97 @@ function confirm(prompt, defaultYes) {
|
|||||||
return ans === "y" || ans === "yes"
|
return ans === "y" || ans === "yes"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Install layout helpers
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// User-installed packages live under `A:/hopper/`. Files are routed
|
||||||
|
// by extension: `.mjs` includes go under `include/`, everything else
|
||||||
|
// (`.js`, `.alias`, `.lfs`, data blobs, ...) lands in `bin/`. The
|
||||||
|
// downloaded manifest is saved under `manifests/` with a
|
||||||
|
// `SystemPackagePath` field appended that lists the resulting paths.
|
||||||
|
|
||||||
|
// Strip query/fragment and take the last `/`-separated component of `url`.
|
||||||
|
function urlBasename(url) {
|
||||||
|
let s = String(url || "")
|
||||||
|
const qm = s.indexOf("?"); if (qm >= 0) s = s.substring(0, qm)
|
||||||
|
const hash = s.indexOf("#"); if (hash >= 0) s = s.substring(0, hash)
|
||||||
|
const slash = s.lastIndexOf("/")
|
||||||
|
return (slash < 0) ? s : s.substring(slash + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeForBasename(name) {
|
||||||
|
return (String(name || "").toLowerCase().endsWith(".mjs"))
|
||||||
|
? USER_PACKAGE_INCLUDE_DIR
|
||||||
|
: USER_PACKAGE_BIN_DIR
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a USER_BASE_DIR-relative absolute path ("A:/hopper/bin/foo.js")
|
||||||
|
// into its declarable form ("/hopper/bin/foo.js"), matching the
|
||||||
|
// `SystemPackagePath` convention used by the system manifests.
|
||||||
|
function declarablePath(absPath) {
|
||||||
|
let p = String(absPath || "").replace(/\\/g, "/")
|
||||||
|
if (/^[A-Za-z]:/.test(p)) p = p.substring(2)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse PackageFileList (semicolon-separated full URLs) into a list of
|
||||||
|
// download descriptors: { url, basename, localPath }.
|
||||||
|
function parsePackageFileList(s) {
|
||||||
|
const out = []
|
||||||
|
splitList(s || "").forEach(url => {
|
||||||
|
const base = urlBasename(url)
|
||||||
|
if (base.length === 0) return
|
||||||
|
const dir = routeForBasename(base)
|
||||||
|
out.push({ url: url, basename: base, localPath: `${dir}/${base}` })
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureUserDirs() {
|
||||||
|
[USER_BASE_DIR, USER_PACKAGE_BIN_DIR, USER_PACKAGE_INCLUDE_DIR, USER_PACKAGE_DEF_DIR].forEach(p => {
|
||||||
|
const d = files.open(p)
|
||||||
|
if (!d.exists) d.mkDir()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-emit a parsed manifest, preserving insertion order, dropping
|
||||||
|
// internal `_*` keys, and replacing any pre-existing SystemPackagePath
|
||||||
|
// with the locally-computed one so the field always reflects what is
|
||||||
|
// actually on disk.
|
||||||
|
function serializeManifest(manifestObj, installedPathStr) {
|
||||||
|
const lines = []
|
||||||
|
Object.keys(manifestObj).forEach(k => {
|
||||||
|
if (k.length > 0 && k[0] === "_") return
|
||||||
|
if (k === "SystemPackagePath") return
|
||||||
|
lines.push(`${k}:${manifestObj[k]}`)
|
||||||
|
})
|
||||||
|
lines.push(`SystemPackagePath:${installedPathStr}`)
|
||||||
|
return lines.join("\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete every file declared in `manifest.SystemPackagePath` plus the
|
||||||
|
// manifest file itself. Wildcards are expanded via `expandSystemPath`.
|
||||||
|
function deleteInstalledFiles(manifest) {
|
||||||
|
const removed = []
|
||||||
|
splitList(manifest.SystemPackagePath || "").forEach(p => {
|
||||||
|
expandSystemPath(p).forEach(abs => {
|
||||||
|
const fd = files.open(abs)
|
||||||
|
if (!fd.exists) return
|
||||||
|
try { fd.remove(); removed.push(abs) }
|
||||||
|
catch (e) { printerrln(` ! failed to remove ${abs}: ${e}`) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (manifest._manifestPath) {
|
||||||
|
const mfd = files.open(manifest._manifestPath)
|
||||||
|
if (mfd.exists) {
|
||||||
|
try { mfd.remove(); removed.push(manifest._manifestPath) }
|
||||||
|
catch (e) { printerrln(` ! failed to remove ${manifest._manifestPath}: ${e}`) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// SemVer (strict X.Y.Z) and constraint matching
|
// SemVer (strict X.Y.Z) and constraint matching
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -549,8 +660,73 @@ function cmdSearch(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Install (pure dummy)
|
// Install
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
//
|
||||||
|
// Each upstream manifest declares its payload via `PackageFileList`,
|
||||||
|
// a semicolon-separated list of full URLs. Hopper fetches each URL and
|
||||||
|
// drops the result in /hopper/bin (default) or /hopper/include (.mjs).
|
||||||
|
// The locally-saved manifest gets a `SystemPackagePath` field appended
|
||||||
|
// listing the resulting absolute paths, which is what `cmdRemove` later
|
||||||
|
// walks to clean up.
|
||||||
|
|
||||||
|
function _installOne(action, candidate) {
|
||||||
|
const m = candidate.manifest
|
||||||
|
const files_ = parsePackageFileList(m.PackageFileList)
|
||||||
|
if (files_.length === 0) {
|
||||||
|
printerrln(` ! ${candidate.name}: upstream manifest has no PackageFileList; cannot install`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch first, write second: a single 404 should not leave a
|
||||||
|
// half-installed package behind.
|
||||||
|
const fetched = []
|
||||||
|
for (let i = 0; i < files_.length; i++) {
|
||||||
|
const f = files_[i]
|
||||||
|
println(` fetch ${f.url}`)
|
||||||
|
const body = net.fetchText(f.url)
|
||||||
|
if (body === null || body === undefined) {
|
||||||
|
printerrln(` ! failed to fetch ${f.url}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fetched.push({ entry: f, body: body })
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are replacing an existing user-installed copy, remove its
|
||||||
|
// old files first so a renamed payload doesn't leave orphans.
|
||||||
|
if (action !== "install") {
|
||||||
|
const oldManifestPath = `${USER_PACKAGE_DEF_DIR}/${candidate.name}.${MANIFEST_EXT}`
|
||||||
|
const old = readManifestFile(oldManifestPath)
|
||||||
|
if (old !== undefined) {
|
||||||
|
splitList(old.SystemPackagePath || "").forEach(p => {
|
||||||
|
expandSystemPath(p).forEach(abs => {
|
||||||
|
const fd = files.open(abs)
|
||||||
|
if (fd.exists) {
|
||||||
|
try { fd.remove() }
|
||||||
|
catch (e) { printerrln(` ! could not remove old ${abs}: ${e}`) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write payload files.
|
||||||
|
fetched.forEach(item => {
|
||||||
|
const fd = files.open(item.entry.localPath)
|
||||||
|
if (!fd.exists) fd.mkFile()
|
||||||
|
fd.swrite(item.body)
|
||||||
|
println(` write ${item.entry.localPath}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save the manifest with SystemPackagePath appended.
|
||||||
|
const sysPath = fetched.map(item => declarablePath(item.entry.localPath)).join(";")
|
||||||
|
const manifestPath = `${USER_PACKAGE_DEF_DIR}/${candidate.name}.${MANIFEST_EXT}`
|
||||||
|
const mfd = files.open(manifestPath)
|
||||||
|
if (!mfd.exists) mfd.mkFile()
|
||||||
|
mfd.swrite(serializeManifest(m, sysPath))
|
||||||
|
println(` write ${manifestPath}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function cmdInstall(args) {
|
function cmdInstall(args) {
|
||||||
let query = undefined
|
let query = undefined
|
||||||
@@ -603,28 +779,64 @@ function cmdInstall(args) {
|
|||||||
const changing = plan.filter(a => a.action !== "keep")
|
const changing = plan.filter(a => a.action !== "keep")
|
||||||
if (changing.length === 0) return 0
|
if (changing.length === 0) return 0
|
||||||
|
|
||||||
|
// Pre-flight: refuse to clobber system packages, and require every
|
||||||
|
// upstream candidate to actually carry a payload list.
|
||||||
|
const blockers = []
|
||||||
|
changing.forEach(a => {
|
||||||
|
const cand = chosen.get(a.name)
|
||||||
|
const inst = findInstalledManifest(a.name)
|
||||||
|
if (inst && inst._origin === "system") {
|
||||||
|
blockers.push(`${a.name}: cannot ${a.action} -- a system package with that name is already installed`)
|
||||||
|
}
|
||||||
|
if (cand && cand.source === "upstream" && !(cand.manifest.PackageFileList && cand.manifest.PackageFileList.length > 0)) {
|
||||||
|
blockers.push(`${a.name}: upstream manifest declares no PackageFileList`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (blockers.length > 0) {
|
||||||
|
printerrln("Cannot proceed:")
|
||||||
|
blockers.forEach(b => printerrln(` - ${b}`))
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!net.isAvailable()) {
|
||||||
|
printerrln("No HTTP modem attached; cannot fetch package files.")
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
|
||||||
println("")
|
println("")
|
||||||
if (!confirm("Proceed with installation?", true)) {
|
if (!confirm("Proceed with installation?", true)) {
|
||||||
println("Aborted.")
|
println("Aborted.")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
println("Fetching manifests from remote ...")
|
ensureUserDirs()
|
||||||
println("Downloading package payloads ...")
|
|
||||||
println("Verifying integrity ...")
|
let failed = 0
|
||||||
changing.forEach(a => {
|
for (let i = 0; i < changing.length; i++) {
|
||||||
|
const a = changing[i]
|
||||||
|
const cand = chosen.get(a.name)
|
||||||
if (a.action === "install" || a.action === "reinstall") {
|
if (a.action === "install" || a.action === "reinstall") {
|
||||||
println(` ${a.action} ${a.name} ${a.version}`)
|
println(`${a.action} ${a.name} ${a.version}`)
|
||||||
} else {
|
} else {
|
||||||
println(` ${a.action} ${a.name} ${a.from} -> ${a.to}`)
|
println(`${a.action} ${a.name} ${a.from} -> ${a.to}`)
|
||||||
}
|
}
|
||||||
})
|
if (!_installOne(a.action, cand)) {
|
||||||
println("(dummy install: no files were actually created)")
|
failed++
|
||||||
|
printerrln(` ! ${a.name}: aborted`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
printerrln(`${failed} package(s) failed to install.`)
|
||||||
|
return 7
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Done.")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Remove (dry-run; resolves file list from manifest)
|
// Remove
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
// Convert a SystemPackagePath entry (e.g. "/tvdos/bin/taut*") into a
|
// Convert a SystemPackagePath entry (e.g. "/tvdos/bin/taut*") into a
|
||||||
@@ -669,6 +881,10 @@ function cmdRemove(args) {
|
|||||||
printerrln(`Package not installed: ${query}`)
|
printerrln(`Package not installed: ${query}`)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
if (m._origin === "system") {
|
||||||
|
printerrln(`Cannot remove ${query}: it is a system package.`)
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
|
||||||
const name = m.ProperName || m.HopperPackageName || query
|
const name = m.ProperName || m.HopperPackageName || query
|
||||||
const ver = m.HopperPackageVersion || "?"
|
const ver = m.HopperPackageVersion || "?"
|
||||||
@@ -676,7 +892,7 @@ function cmdRemove(args) {
|
|||||||
|
|
||||||
const paths = splitList(m.SystemPackagePath || "")
|
const paths = splitList(m.SystemPackagePath || "")
|
||||||
println("")
|
println("")
|
||||||
println("The following files would be deleted:")
|
println("The following files will be deleted:")
|
||||||
if (paths.length === 0) {
|
if (paths.length === 0) {
|
||||||
println(" (manifest declares no files)")
|
println(" (manifest declares no files)")
|
||||||
}
|
}
|
||||||
@@ -697,7 +913,9 @@ function cmdRemove(args) {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
println("(dry-run: no files were actually deleted)")
|
const removed = deleteInstalledFiles(m)
|
||||||
|
removed.forEach(p => println(` removed ${p}`))
|
||||||
|
if (removed.length === 0) println(" (nothing was removed)")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user