From 6623ff62bc8f37b9e39c8ba29011a20403d073d4 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sat, 23 May 2026 22:41:37 +0900 Subject: [PATCH] hopper moved to its own repo. hopper now actually installs/removes --- .gitignore | 13 +- assets/disk0/hopper/README.md | 29 -- assets/disk0/hopper/bin/hop.alias | 1 - assets/disk0/hopper/bin/hopper.hop.per | 12 - assets/disk0/{hopper => tvdos}/bin/hopper.js | 264 +++++++++++++++++-- 5 files changed, 249 insertions(+), 70 deletions(-) delete mode 100644 assets/disk0/hopper/README.md delete mode 100644 assets/disk0/hopper/bin/hop.alias delete mode 100644 assets/disk0/hopper/bin/hopper.hop.per rename assets/disk0/{hopper => tvdos}/bin/hopper.js (73%) diff --git a/.gitignore b/.gitignore index 6987464..7c6b269 100644 --- a/.gitignore +++ b/.gitignore @@ -62,12 +62,15 @@ tsvmman.pdf *.ilg *.ind +assets/disk0/tvdos/bin/tautfont.png + +video_encoder/* + +.idea/vcs.xml + +# in-dev stuffs assets/disk0/home/basic/* assets/disk0/movtestimg/*.jpg assets/disk0/*.mov assets/diskMediabin/* - -video_encoder/* - -assets/disk0/tvdos/bin/tautfont.png -.idea/vcs.xml +assets/disk0/hopper/* diff --git a/assets/disk0/hopper/README.md b/assets/disk0/hopper/README.md deleted file mode 100644 index 06f6650..0000000 --- a/assets/disk0/hopper/README.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/assets/disk0/hopper/bin/hop.alias b/assets/disk0/hopper/bin/hop.alias deleted file mode 100644 index 874a372..0000000 --- a/assets/disk0/hopper/bin/hop.alias +++ /dev/null @@ -1 +0,0 @@ -hopper $0 \ No newline at end of file diff --git a/assets/disk0/hopper/bin/hopper.hop.per b/assets/disk0/hopper/bin/hopper.hop.per deleted file mode 100644 index d46e664..0000000 --- a/assets/disk0/hopper/bin/hopper.hop.per +++ /dev/null @@ -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 diff --git a/assets/disk0/hopper/bin/hopper.js b/assets/disk0/tvdos/bin/hopper.js similarity index 73% rename from assets/disk0/hopper/bin/hopper.js rename to assets/disk0/tvdos/bin/hopper.js index 7b19483..4221641 100644 --- a/assets/disk0/hopper/bin/hopper.js +++ b/assets/disk0/tvdos/bin/hopper.js @@ -3,7 +3,11 @@ * 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 MIRROR_LIST_PATH = `${SYSTEM_PACKEAGE_DEF_DIR}/mirrors.list` @@ -46,23 +50,43 @@ function readManifestFile(path) { return m } -function listInstalledManifests() { - const dir = files.open(SYSTEM_PACKEAGE_DEF_DIR) +function _listManifestsFrom(dirPath, origin) { + const dir = files.open(dirPath) if (!dir.exists || !dir.isDirectory) return [] const out = [] dir.list().forEach(entry => { if (entry.isDirectory) return if (!entry.name.toLowerCase().endsWith(MANIFEST_EXT)) return const m = readManifestFile(entry.fullPath) - if (m !== undefined) out.push(m) + if (m !== undefined) { + m._origin = origin + out.push(m) + } }) 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) { - const direct = `${SYSTEM_PACKEAGE_DEF_DIR}/${name}${MANIFEST_EXT}` - const m = readManifestFile(direct) - if (m !== undefined) return m + // Prefer user-installed copy when a system package with the same name + // also exists -- but that combination is normally refused at install. + 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() for (let i = 0; i < all.length; i++) { if ((all[i].HopperPackageName || "") === name) return all[i] @@ -70,10 +94,6 @@ 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]" @@ -83,6 +103,97 @@ function confirm(prompt, defaultYes) { 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 // ============================================================ @@ -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) { let query = undefined @@ -603,28 +779,64 @@ function cmdInstall(args) { const changing = plan.filter(a => a.action !== "keep") 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("") 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 => { + ensureUserDirs() + + let failed = 0 + 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") { - println(` ${a.action} ${a.name} ${a.version}`) + println(`${a.action} ${a.name} ${a.version}`) } else { - println(` ${a.action} ${a.name} ${a.from} -> ${a.to}`) + println(`${a.action} ${a.name} ${a.from} -> ${a.to}`) } - }) - println("(dummy install: no files were actually created)") + if (!_installOne(a.action, cand)) { + failed++ + printerrln(` ! ${a.name}: aborted`) + break + } + } + if (failed > 0) { + printerrln(`${failed} package(s) failed to install.`) + return 7 + } + + println("Done.") return 0 } // ============================================================ -// Remove (dry-run; resolves file list from manifest) +// Remove // ============================================================ // Convert a SystemPackagePath entry (e.g. "/tvdos/bin/taut*") into a @@ -669,6 +881,10 @@ function cmdRemove(args) { printerrln(`Package not installed: ${query}`) 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 ver = m.HopperPackageVersion || "?" @@ -676,7 +892,7 @@ function cmdRemove(args) { const paths = splitList(m.SystemPackagePath || "") println("") - println("The following files would be deleted:") + println("The following files will be deleted:") if (paths.length === 0) { println(" (manifest declares no files)") } @@ -697,7 +913,9 @@ function cmdRemove(args) { 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 }