From 3ac03d7d5e9fbb06852f9c6615eb345f51d7bcf3 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Thu, 23 Apr 2026 17:01:58 -0700 Subject: [PATCH 1/2] feat(cli): add wheels packages command for registry-backed install (#2270 Phase 3) Phase 3 of #2243. Adds `wheels packages list|search|show|install|update|remove` plus `wheels packages registry refresh|info` over the wheels-packages registry. - Registry client with 24h FS cache keyed at ~/.wheels/cache/packages/ - Installer: HTTPS download, SHA-256 verify against manifest, tar -xzf into vendor//. Hard-aborts on checksum mismatch; refuses overwrite without --force. - VersionResolver reuses vendor/wheels/SemVer.cfc (shipped in #2231) so the CLI and PackageLoader agree on wheelsVersion satisfaction. - Explicit-only updates: `update ` and `update --all` require --yes. `update --all` continues on per-package failures and throws at the end so the shell exit code reflects partial failure. - WHEELS_PACKAGES_REGISTRY env var overrides the default registry repo. - Docs: web/sites/guides/.../command-line-tools/commands/packages/ and CLAUDE.md Package System section updated. - 22 new specs under cli/lucli/tests/specs/packages/, network-free via FakeHttpClient + a fixture tarball. --- CLAUDE.md | 21 +- cli/lucli/Module.cfc | 118 +++++++ cli/lucli/services/packages/HttpClient.cfc | 63 ++++ cli/lucli/services/packages/Installer.cfc | 188 ++++++++++++ cli/lucli/services/packages/ManifestCache.cfc | 126 ++++++++ .../services/packages/PackagesMainCli.cfc | 290 ++++++++++++++++++ .../services/packages/PackagesRegistryCli.cfc | 36 +++ cli/lucli/services/packages/Registry.cfc | 130 ++++++++ .../services/packages/VersionResolver.cfc | 128 ++++++++ .../packages/wheels-fake-1.0.0.tar.gz | Bin 0 -> 631 bytes .../tests/specs/packages/InstallerSpec.cfc | 168 ++++++++++ .../specs/packages/ManifestCacheSpec.cfc | 70 +++++ .../specs/packages/PackagesMainCliSpec.cfc | 225 ++++++++++++++ .../tests/specs/packages/RegistrySpec.cfc | 130 ++++++++ .../specs/packages/VersionResolverSpec.cfc | 81 +++++ .../specs/packages/_stubs/FakeHttpClient.cfc | 39 +++ .../commands/packages/index.mdx | 61 ++++ .../commands/packages/install.mdx | 55 ++++ .../commands/packages/list.mdx | 35 +++ .../commands/packages/registry/index.mdx | 17 + .../commands/packages/registry/info.mdx | 31 ++ .../commands/packages/registry/refresh.mdx | 29 ++ .../commands/packages/remove.mdx | 30 ++ .../commands/packages/search.mdx | 23 ++ .../commands/packages/show.mdx | 34 ++ .../commands/packages/update.mdx | 43 +++ 26 files changed, 2165 insertions(+), 6 deletions(-) create mode 100644 cli/lucli/services/packages/HttpClient.cfc create mode 100644 cli/lucli/services/packages/Installer.cfc create mode 100644 cli/lucli/services/packages/ManifestCache.cfc create mode 100644 cli/lucli/services/packages/PackagesMainCli.cfc create mode 100644 cli/lucli/services/packages/PackagesRegistryCli.cfc create mode 100644 cli/lucli/services/packages/Registry.cfc create mode 100644 cli/lucli/services/packages/VersionResolver.cfc create mode 100644 cli/lucli/tests/_fixtures/packages/wheels-fake-1.0.0.tar.gz create mode 100644 cli/lucli/tests/specs/packages/InstallerSpec.cfc create mode 100644 cli/lucli/tests/specs/packages/ManifestCacheSpec.cfc create mode 100644 cli/lucli/tests/specs/packages/PackagesMainCliSpec.cfc create mode 100644 cli/lucli/tests/specs/packages/RegistrySpec.cfc create mode 100644 cli/lucli/tests/specs/packages/VersionResolverSpec.cfc create mode 100644 cli/lucli/tests/specs/packages/_stubs/FakeHttpClient.cfc create mode 100644 web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/index.mdx create mode 100644 web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/install.mdx create mode 100644 web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/list.mdx create mode 100644 web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/registry/index.mdx create mode 100644 web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/registry/info.mdx create mode 100644 web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/registry/refresh.mdx create mode 100644 web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/remove.mdx create mode 100644 web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/search.mdx create mode 100644 web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/show.mdx create mode 100644 web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/update.mdx diff --git a/CLAUDE.md b/CLAUDE.md index 5b6b7620f2..c994a88b0d 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -372,14 +372,23 @@ plugins/ # DEPRECATED: legacy plugins still work with warning ### Installing a Package -The Wheels 4.1 CLI will ship `wheels packages install ` which resolves names against the `wheels-dev/wheels-packages` registry. Until then, interim install is a manual clone: +Use the `wheels packages` CLI. Resolves names against the `wheels-dev/wheels-packages` registry, verifies sha256, extracts to `vendor//`. ```bash -gh repo clone wheels-dev/wheels-sentry vendor/wheels-sentry # install -rm -rf vendor/wheels-sentry # remove -``` - -Restart or reload the app after install. +wheels packages list # browse the registry +wheels packages search # name/description/tag match +wheels packages show # detail page +wheels packages install # latest compat version +wheels packages install @ # pin +wheels packages install --force # overwrite an existing vendor/ +wheels packages update --yes # explicit update +wheels packages update --all --yes # update every installed package +wheels packages remove # delete vendor/ +wheels packages registry refresh # bust the 24h cache +wheels packages registry info # show registry URL + cache state +``` + +Override the registry with `WHEELS_PACKAGES_REGISTRY=/` (defaults to `wheels-dev/wheels-packages`). Restart or `wheels reload` after install. ### Error Isolation diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index a57ff3880a..9d2a2b691f 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -1588,6 +1588,124 @@ component extends="modules.BaseModule" { return opts; } + // ───────────────────────────────────────────────── + // packages — registry-backed package manager + // ───────────────────────────────────────────────── + + /** + * hint: Install, update, and list packages from the wheels-packages registry + * + * Usage: + * wheels packages list [--tag=] + * wheels packages search + * wheels packages show + * wheels packages install [@] [--force] + * wheels packages update --yes + * wheels packages update --all --yes + * wheels packages remove + * wheels packages registry refresh + * wheels packages registry info + */ + public string function packages() { + var args = getArgs(arguments); + var opts = $packagesArgsToOptions(args); + var positional = $packagesStripFlags(args); + var sub = arrayLen(positional) >= 1 ? positional[1] : "list"; + + switch (sub) { + case "list": + var mainCli = new cli.lucli.services.packages.PackagesMainCli(); + return mainCli.list(opts); + case "search": + if (arrayLen(positional) < 2) { + throw(message="search requires a query: wheels packages search "); + } + opts.query = positional[2]; + var mainCli = new cli.lucli.services.packages.PackagesMainCli(); + return mainCli.search(opts); + case "show": + if (arrayLen(positional) < 2) { + throw(message="show requires a name: wheels packages show "); + } + opts.name = positional[2]; + var mainCli = new cli.lucli.services.packages.PackagesMainCli(); + return mainCli.show(opts); + case "install": + if (arrayLen(positional) < 2) { + throw(message="install requires a name: wheels packages install [@]"); + } + opts.target = positional[2]; + var mainCli = new cli.lucli.services.packages.PackagesMainCli(); + return mainCli.install(opts); + case "update": + opts.target = arrayLen(positional) >= 2 ? positional[2] : ""; + var mainCli = new cli.lucli.services.packages.PackagesMainCli(); + return mainCli.update(opts); + case "remove": + if (arrayLen(positional) < 2) { + throw(message="remove requires a name: wheels packages remove "); + } + opts.target = positional[2]; + var mainCli = new cli.lucli.services.packages.PackagesMainCli(); + return mainCli.remove(opts); + case "registry": + if (arrayLen(positional) < 2) { + throw(message="wheels packages registry requires a verb (refresh or info)"); + } + var regVerb = positional[2]; + if (!listFindNoCase("refresh,info", regVerb)) { + throw(message="Unknown wheels packages registry verb: #regVerb#"); + } + var regCli = new cli.lucli.services.packages.PackagesRegistryCli(); + return invoke(regCli, regVerb, [opts]); + default: + throw(message="Unknown packages subcommand: #sub#"); + } + } + + private struct function $packagesArgsToOptions(required array args) { + var opts = {}; + var n = arrayLen(arguments.args); + var i = 1; + while (i <= n) { + var a = arguments.args[i]; + if (a == "--all") { + opts.all = true; + } else if (a == "--yes") { + opts.yes = true; + } else if (a == "--force") { + opts.force = true; + } else if (left(a, 6) == "--tag=") { + opts.tag = mid(a, 7, 99999); + } else if (a == "--tag" && i < n) { + opts.tag = arguments.args[i+1]; + i++; + } + i++; + } + return opts; + } + + private array function $packagesStripFlags(required array args) { + var out = []; + var n = arrayLen(arguments.args); + var i = 1; + while (i <= n) { + var a = arguments.args[i]; + if (left(a, 2) == "--") { + var booleans = "--all,--yes,--force"; + if (!find("=", a) && !listFindNoCase(booleans, a) && i < n && left(arguments.args[i+1], 2) != "--") { + i++; + } + i++; + continue; + } + arrayAppend(out, a); + i++; + } + return out; + } + private array function $deployStripFlags(required array args) { var out = []; var n = arrayLen(arguments.args); diff --git a/cli/lucli/services/packages/HttpClient.cfc b/cli/lucli/services/packages/HttpClient.cfc new file mode 100644 index 0000000000..ff7d66bac9 --- /dev/null +++ b/cli/lucli/services/packages/HttpClient.cfc @@ -0,0 +1,63 @@ +/** + * Thin HTTP GET wrapper. Exists so Registry can be tested offline with + * a fake that returns canned responses keyed by URL. + * + * Uses cfhttp (script syntax) for cross-engine portability — `new http()` + * is Lucee-specific and the framework test matrix also runs these specs + * against Adobe CF. + */ +component { + + public HttpClient function init(numeric timeoutSeconds = 30) { + variables.timeout = arguments.timeoutSeconds; + return this; + } + + /** + * @return struct { status: numeric, body: string } + */ + public struct function get(required string url, struct headers = {}) { + cfhttp( + url = arguments.url, + method = "GET", + timeout = variables.timeout, + result = "local.result" + ) { + for (local.name in arguments.headers) { + cfhttpparam(type = "header", name = local.name, value = arguments.headers[local.name]); + } + // GitHub's API prefers an explicit User-Agent on unauth requests. + cfhttpparam(type = "header", name = "User-Agent", value = "wheels-cli"); + } + return { + status: Val(ListFirst(local.result.statusCode, " ")), + body: local.result.fileContent ?: "" + }; + } + + /** + * Downloads bytes to disk. Separate from get() because tarballs are + * large binary and `fileContent` as a string is the wrong abstraction. + * Returns the destination path on success, throws on non-200. + */ + public string function download(required string url, required string destPath) { + cfhttp( + url = arguments.url, + method = "GET", + timeout = variables.timeout, + getAsBinary = "yes", + result = "local.result" + ) { + cfhttpparam(type = "header", name = "User-Agent", value = "wheels-cli"); + } + local.status = Val(ListFirst(local.result.statusCode, " ")); + if (local.status != 200) { + Throw( + type = "Wheels.Packages.DownloadFailed", + message = "Download failed: HTTP #local.status# for #arguments.url#" + ); + } + FileWrite(arguments.destPath, local.result.fileContent); + return arguments.destPath; + } +} diff --git a/cli/lucli/services/packages/Installer.cfc b/cli/lucli/services/packages/Installer.cfc new file mode 100644 index 0000000000..93f2790f5c --- /dev/null +++ b/cli/lucli/services/packages/Installer.cfc @@ -0,0 +1,188 @@ +/** + * Fetches a resolved package version, verifies sha256, extracts to + * vendor//. + * + * Flow: + * 1. Refuse if vendor// already exists, unless force=true. + * 2. Download tarball URL from the version entry to a temp file. + * 3. Compute SHA-256, compare to the manifest's sha256. Mismatch = hard abort. + * 4. Extract via `tar -xzf` into vendor/. The tarball's top-level dir + * is the package name by construction (mirror-tarball.yml + * `mv src/ /` before taring), so vendor// appears naturally. + * 5. Clean up the temp file. + * + * Tarball extraction shells out to `tar`. All target platforms (macOS, + * Linux, Windows 10+) ship it. If Windows Server compat becomes a real + * need, swap the $extract() body for a Commons Compress JAR-based impl + * without changing the Installer surface. + */ +component { + + public Installer function init( + any httpClient = "", + string projectRoot = "" + ) { + variables.http = IsObject(arguments.httpClient) + ? arguments.httpClient + : new cli.lucli.services.packages.HttpClient(); + variables.projectRoot = Len(arguments.projectRoot) + ? arguments.projectRoot + : ExpandPath("./"); + if (Right(variables.projectRoot, 1) != "/") { + variables.projectRoot &= "/"; + } + return this; + } + + /** + * @name Package name (becomes vendor//). + * @version Version entry struct (must have tarball + sha256). + * @force Overwrite vendor// if it exists. + * @return Absolute path of vendor//. + * @throws Wheels.Packages.AlreadyInstalled + * Wheels.Packages.ChecksumMismatch + * Wheels.Packages.ExtractionFailed + */ + public string function install( + required string name, + required struct version, + boolean force = false + ) { + if (!StructKeyExists(arguments.version, "tarball") || !Len(arguments.version.tarball)) { + Throw( + type = "Wheels.Packages.ManifestIncomplete", + message = "Version entry for '#arguments.name#' has no tarball URL. " + & "The registry mirror may not have populated this version yet." + ); + } + if (!StructKeyExists(arguments.version, "sha256") || !Len(arguments.version.sha256)) { + Throw( + type = "Wheels.Packages.ManifestIncomplete", + message = "Version entry for '#arguments.name#' has no sha256. Refusing to install unverified content." + ); + } + + local.vendorDir = variables.projectRoot & "vendor/"; + local.target = local.vendorDir & arguments.name; + + if (DirectoryExists(local.target)) { + if (!arguments.force) { + Throw( + type = "Wheels.Packages.AlreadyInstalled", + message = "Package '#arguments.name#' is already installed at #local.target#. " + & "Use --force to overwrite." + ); + } + DirectoryDelete(local.target, true); + } + + if (!DirectoryExists(local.vendorDir)) { + DirectoryCreate(local.vendorDir, true); + } + + // Download to a temp file. + local.tmpFile = GetTempDirectory() & "wheels-pkg-" & CreateUUID() & ".tar.gz"; + try { + variables.http.download(arguments.version.tarball, local.tmpFile); + + // Verify sha256. + local.actual = LCase($sha256File(local.tmpFile)); + local.expected = LCase(arguments.version.sha256); + if (local.actual != local.expected) { + Throw( + type = "Wheels.Packages.ChecksumMismatch", + message = "sha256 mismatch for '#arguments.name#@#arguments.version.version#'. " + & "Expected #local.expected#, got #local.actual#. " + & "Refusing to install — the tarball does not match the registry's record." + ); + } + + // Extract. + $extract(local.tmpFile, local.vendorDir); + + if (!DirectoryExists(local.target)) { + Throw( + type = "Wheels.Packages.ExtractionFailed", + message = "Extraction completed but vendor/#arguments.name#/ was not produced. " + & "The tarball layout does not match the expected '/...' convention." + ); + } + } finally { + if (FileExists(local.tmpFile)) { + FileDelete(local.tmpFile); + } + } + + return local.target; + } + + /** + * Deletes vendor// after a safety check that it has a package.json. + * Throws if the dir doesn't exist or doesn't look like a Wheels package. + */ + public void function uninstall(required string name) { + local.target = variables.projectRoot & "vendor/" & arguments.name; + if (!DirectoryExists(local.target)) { + Throw( + type = "Wheels.Packages.NotInstalled", + message = "Package '#arguments.name#' is not installed (no #local.target#)." + ); + } + if (!FileExists(local.target & "/package.json")) { + Throw( + type = "Wheels.Packages.NotAPackage", + message = "vendor/#arguments.name# has no package.json — refusing to delete. " + & "Remove it manually if you're sure." + ); + } + DirectoryDelete(local.target, true); + } + + public boolean function isInstalled(required string name) { + return DirectoryExists(variables.projectRoot & "vendor/" & arguments.name); + } + + public string function installedVersion(required string name) { + local.pkgJson = variables.projectRoot & "vendor/" & arguments.name & "/package.json"; + if (!FileExists(local.pkgJson)) return ""; + try { + local.parsed = DeserializeJSON(FileRead(local.pkgJson)); + return local.parsed.version ?: ""; + } catch (any e) { + return ""; + } + } + + // ── Private ───────────────────────────────────────────── + + private string function $sha256File(required string path) { + local.bin = FileReadBinary(arguments.path); + return Hash(local.bin, "SHA-256"); + } + + private void function $extract(required string tarballPath, required string destDir) { + // Wrap in a try so a missing `tar` produces a clear error message. + try { + cfexecute( + name = "tar", + arguments = "-xzf #arguments.tarballPath# -C #arguments.destDir#", + timeout = 120, + variable = "local.stdout", + errorVariable = "local.stderr" + ); + } catch (any e) { + Throw( + type = "Wheels.Packages.ExtractionFailed", + message = "Failed to extract tarball. Is `tar` on PATH?", + extendedInfo = e.message + ); + } + if (StructKeyExists(local, "stderr") && Len(Trim(local.stderr))) { + Throw( + type = "Wheels.Packages.ExtractionFailed", + message = "tar reported an error during extraction.", + extendedInfo = local.stderr + ); + } + } +} diff --git a/cli/lucli/services/packages/ManifestCache.cfc b/cli/lucli/services/packages/ManifestCache.cfc new file mode 100644 index 0000000000..d07c0627f2 --- /dev/null +++ b/cli/lucli/services/packages/ManifestCache.cfc @@ -0,0 +1,126 @@ +/** + * 24h FS cache for registry data — the package index and per-package + * manifests. Keeps `wheels packages list/search/show` fast and respects + * GitHub's 60 req/hr unauthenticated rate limit. + * + * Layout: + * / + * index.json — { fetchedAt, names: ["wheels-sentry", ...] } + * manifests/ + * .json — { fetchedAt, manifest: {...} } + * + * `refresh()` nukes the whole dir. Explicit, no partial invalidation — + * keeps the mental model simple. + */ +component { + + variables.DEFAULT_TTL_SECONDS = 86400; // 24h + + public ManifestCache function init(string root = "", numeric ttlSeconds = 0) { + variables.root = Len(arguments.root) ? arguments.root : $defaultRoot(); + variables.ttl = arguments.ttlSeconds > 0 ? arguments.ttlSeconds : variables.DEFAULT_TTL_SECONDS; + return this; + } + + public string function root() { return variables.root; } + + public numeric function ttlSeconds() { return variables.ttl; } + + // ── Index ─────────────────────────────────────────────── + + public boolean function hasFreshIndex() { + return $freshFile($indexPath()); + } + + public array function readIndex() { + if (!FileExists($indexPath())) return []; + local.data = DeserializeJSON(FileRead($indexPath())); + return IsArray(local.data.names ?: "") ? local.data.names : []; + } + + public void function writeIndex(required array names) { + $ensureDir(variables.root); + FileWrite($indexPath(), SerializeJSON({ + fetchedAt: DateTimeFormat(Now(), "iso"), + names: arguments.names + })); + } + + // ── Manifests ─────────────────────────────────────────── + + public boolean function hasFreshManifest(required string name) { + return $freshFile($manifestPath(arguments.name)); + } + + public struct function readManifest(required string name) { + local.path = $manifestPath(arguments.name); + if (!FileExists(local.path)) { + Throw(type = "Wheels.Packages.CacheMiss", message = "No cached manifest for '#arguments.name#'."); + } + local.data = DeserializeJSON(FileRead(local.path)); + return local.data.manifest ?: {}; + } + + public void function writeManifest(required string name, required struct manifest) { + $ensureDir($manifestsDir()); + FileWrite($manifestPath(arguments.name), SerializeJSON({ + fetchedAt: DateTimeFormat(Now(), "iso"), + manifest: arguments.manifest + })); + } + + // ── Maintenance ───────────────────────────────────────── + + public struct function info() { + local.indexFreshness = ""; + if (FileExists($indexPath())) { + local.info = GetFileInfo($indexPath()); + local.indexFreshness = DateTimeFormat(local.info.lastmodified, "iso"); + } + return { + root: variables.root, + ttlSeconds: variables.ttl, + indexFetchedAt: local.indexFreshness, + exists: DirectoryExists(variables.root) + }; + } + + public void function refresh() { + if (DirectoryExists(variables.root)) { + DirectoryDelete(variables.root, true); + } + } + + // ── Private ───────────────────────────────────────────── + + private string function $indexPath() { + return variables.root & "/index.json"; + } + + private string function $manifestsDir() { + return variables.root & "/manifests"; + } + + private string function $manifestPath(required string name) { + return $manifestsDir() & "/" & arguments.name & ".json"; + } + + private boolean function $freshFile(required string path) { + if (!FileExists(arguments.path)) return false; + local.info = GetFileInfo(arguments.path); + local.ageSeconds = DateDiff("s", local.info.lastmodified, Now()); + return local.ageSeconds < variables.ttl; + } + + private void function $ensureDir(required string path) { + if (!DirectoryExists(arguments.path)) { + DirectoryCreate(arguments.path, true); + } + } + + private string function $defaultRoot() { + local.sys = CreateObject("java", "java.lang.System"); + local.home = local.sys.getProperty("user.home"); + return local.home & "/.wheels/cache/packages"; + } +} diff --git a/cli/lucli/services/packages/PackagesMainCli.cfc b/cli/lucli/services/packages/PackagesMainCli.cfc new file mode 100644 index 0000000000..16718a43ca --- /dev/null +++ b/cli/lucli/services/packages/PackagesMainCli.cfc @@ -0,0 +1,290 @@ +/** + * User-facing dispatcher for `wheels packages `. + * + * Verbs: + * list [--tag=] + * search + * show + * install [@] [--force] + * update [--yes] + * update --all --yes + * remove + * + * Outputs plain text suitable for a terminal. Exit-code semantics are + * the caller's job (Module.cfc throws to signal non-zero on fatal error; + * `update --all` swallows per-package failures but throws at the end if + * any failed, so the shell still sees a non-zero exit). + */ +component { + + public PackagesMainCli function init( + any registry = "", + any installer = "", + any resolver = "", + string runtimeVersion = "" + ) { + variables.registry = IsObject(arguments.registry) + ? arguments.registry + : new cli.lucli.services.packages.Registry(); + variables.installer = IsObject(arguments.installer) + ? arguments.installer + : new cli.lucli.services.packages.Installer(); + variables.resolver = IsObject(arguments.resolver) + ? arguments.resolver + : new cli.lucli.services.packages.VersionResolver(); + variables.runtime = Len(arguments.runtimeVersion) + ? arguments.runtimeVersion + : $detectRuntime(); + return this; + } + + // ── Verbs ─────────────────────────────────────────────── + + public string function list(struct opts = {}) { + local.names = variables.registry.listPackageNames(); + local.rows = []; + for (local.name in local.names) { + try { + local.m = variables.registry.fetchManifest(local.name); + } catch (any e) { + // Don't let one bad manifest break `list` — show the name, + // flag the failure. Matches `dnf list` behaviour on dead repos. + ArrayAppend(local.rows, {name: local.name, tags: [], description: ""}); + continue; + } + local.row = { + name: local.m.name ?: local.name, + tags: IsArray(local.m.tags ?: "") ? local.m.tags : [], + description: local.m.description ?: "" + }; + if (Len(arguments.opts.tag ?: "")) { + if (!ArrayFindNoCase(local.row.tags, arguments.opts.tag)) { + continue; + } + } + ArrayAppend(local.rows, local.row); + } + if (!ArrayLen(local.rows)) { + return "No packages found." & Chr(10); + } + return $renderList(local.rows); + } + + public string function search(struct opts = {}) { + local.query = Trim(arguments.opts.query ?: ""); + if (!Len(local.query)) { + Throw(type = "Wheels.Packages.BadInput", message = "search requires a query: wheels packages search "); + } + local.names = variables.registry.listPackageNames(); + local.rows = []; + for (local.name in local.names) { + try { + local.m = variables.registry.fetchManifest(local.name); + } catch (any e) { + continue; + } + local.haystack = LCase( + (local.m.name ?: local.name) & " " + & (local.m.description ?: "") & " " + & ArrayToList(IsArray(local.m.tags ?: "") ? local.m.tags : [], " ") + ); + if (Find(LCase(local.query), local.haystack)) { + ArrayAppend(local.rows, { + name: local.m.name ?: local.name, + tags: IsArray(local.m.tags ?: "") ? local.m.tags : [], + description: local.m.description ?: "" + }); + } + } + if (!ArrayLen(local.rows)) { + return "No packages matched '#local.query#'." & Chr(10); + } + return $renderList(local.rows); + } + + public string function show(struct opts = {}) { + local.name = Trim(arguments.opts.name ?: ""); + if (!Len(local.name)) { + Throw(type = "Wheels.Packages.BadInput", message = "show requires a package name: wheels packages show "); + } + local.m = variables.registry.fetchManifest(local.name); + local.buf = []; + ArrayAppend(local.buf, local.m.name & " — " & (local.m.description ?: "")); + if (StructKeyExists(local.m, "homepage")) ArrayAppend(local.buf, "Homepage: " & local.m.homepage); + if (StructKeyExists(local.m, "documentation")) ArrayAppend(local.buf, "Documentation: " & local.m.documentation); + if (StructKeyExists(local.m, "license")) ArrayAppend(local.buf, "License: " & local.m.license); + if (IsArray(local.m.maintainers ?: "") && ArrayLen(local.m.maintainers)) { + ArrayAppend(local.buf, "Maintainers: " & ArrayToList(local.m.maintainers, ", ")); + } + if (IsArray(local.m.tags ?: "") && ArrayLen(local.m.tags)) { + ArrayAppend(local.buf, "Tags: " & ArrayToList(local.m.tags, ", ")); + } + if (variables.installer.isInstalled(local.name)) { + ArrayAppend(local.buf, "Installed: " & (Len(variables.installer.installedVersion(local.name)) + ? variables.installer.installedVersion(local.name) + : "(unknown version)")); + } + ArrayAppend(local.buf, ""); + ArrayAppend(local.buf, "Compatible versions (runtime #variables.runtime#):"); + local.compatible = variables.resolver.compatibleVersions(local.m, variables.runtime); + if (!ArrayLen(local.compatible)) { + ArrayAppend(local.buf, " (none — this runtime is out of range for every published version)"); + } else { + for (local.v in local.compatible) { + ArrayAppend(local.buf, " " & local.v.version + & " [wheelsVersion " & (local.v.wheelsVersion ?: "*") & "]" + & (StructKeyExists(local.v, "publishedAt") ? " published " & local.v.publishedAt : "")); + } + } + ArrayAppend(local.buf, ""); + return ArrayToList(local.buf, Chr(10)) & Chr(10); + } + + public string function install(struct opts = {}) { + local.target = Trim(arguments.opts.target ?: ""); + if (!Len(local.target)) { + Throw(type = "Wheels.Packages.BadInput", message = "install requires a package name: wheels packages install [@]"); + } + local.parsed = $parseTarget(local.target); + local.force = arguments.opts.force ?: false; + return $doInstall(local.parsed.name, local.parsed.pin, local.force); + } + + public string function update(struct opts = {}) { + if (arguments.opts.all ?: false) { + return $updateAll(arguments.opts); + } + local.name = Trim(arguments.opts.target ?: ""); + if (!Len(local.name)) { + Throw(type = "Wheels.Packages.BadInput", message = "update requires or --all."); + } + if (!variables.installer.isInstalled(local.name)) { + Throw( + type = "Wheels.Packages.NotInstalled", + message = "Package '#local.name#' is not installed. Use `wheels packages install #local.name#`." + ); + } + if (!(arguments.opts.yes ?: false)) { + Throw( + type = "Wheels.Packages.ConfirmationRequired", + message = "Update is explicit. Re-run with --yes to confirm updating '#local.name#' to the latest compatible version." + ); + } + return $doInstall(local.name, "", true); + } + + public string function remove(struct opts = {}) { + local.name = Trim(arguments.opts.target ?: ""); + if (!Len(local.name)) { + Throw(type = "Wheels.Packages.BadInput", message = "remove requires a package name."); + } + variables.installer.uninstall(local.name); + return "Removed vendor/#local.name#." & Chr(10); + } + + public string function runtime() { return variables.runtime; } + + // ── Private ───────────────────────────────────────────── + + private string function $doInstall(required string name, required string pin, required boolean force) { + local.manifest = variables.registry.fetchManifest(arguments.name); + local.picked = variables.resolver.pick(local.manifest, variables.runtime, arguments.pin); + local.vendor = variables.installer.install(arguments.name, local.picked, arguments.force); + return "Installed " & arguments.name & "@" & local.picked.version & " → " & local.vendor & Chr(10) + & "Run `wheels reload` to activate it." & Chr(10); + } + + private string function $updateAll(struct opts) { + if (!(arguments.opts.yes ?: false)) { + Throw( + type = "Wheels.Packages.ConfirmationRequired", + message = "Mass update is explicit. Re-run with --yes to confirm updating every installed package." + ); + } + // Discover installed packages from vendor/. We only touch dirs + // that have a package.json — skipping vendor/wheels/ (framework). + local.installed = $discoverInstalled(); + if (!ArrayLen(local.installed)) { + return "No installed packages to update." & Chr(10); + } + local.report = []; + local.failures = []; + for (local.name in local.installed) { + try { + local.manifest = variables.registry.fetchManifest(local.name); + local.picked = variables.resolver.pick(local.manifest, variables.runtime); + if (variables.installer.installedVersion(local.name) == local.picked.version) { + ArrayAppend(local.report, " #local.name#: already at #local.picked.version#"); + continue; + } + variables.installer.install(local.name, local.picked, true); + ArrayAppend(local.report, " #local.name#: updated → #local.picked.version#"); + } catch (any e) { + ArrayAppend(local.failures, local.name); + ArrayAppend(local.report, " #local.name#: FAILED (#e.message#)"); + } + } + local.out = "Update report:" & Chr(10) & ArrayToList(local.report, Chr(10)) & Chr(10); + if (ArrayLen(local.failures)) { + Throw( + type = "Wheels.Packages.PartialUpdateFailure", + message = "#ArrayLen(local.failures)# of #ArrayLen(local.installed)# package(s) failed to update: " + & ArrayToList(local.failures, ", "), + extendedInfo = local.out + ); + } + return local.out; + } + + private array function $discoverInstalled() { + local.vendorDir = ExpandPath("./vendor"); + if (!DirectoryExists(local.vendorDir)) return []; + local.all = DirectoryList(local.vendorDir, false, "query"); + local.names = []; + cfloop(query = local.all) { + if (local.all.type != "Dir") continue; + if (local.all.name == "wheels") continue; + if (FileExists(local.vendorDir & "/" & local.all.name & "/package.json")) { + ArrayAppend(local.names, local.all.name); + } + } + return local.names; + } + + private struct function $parseTarget(required string target) { + if (Find("@", arguments.target)) { + local.at = Find("@", arguments.target); + return { + name: Left(arguments.target, local.at - 1), + pin: Mid(arguments.target, local.at + 1, Len(arguments.target)) + }; + } + return {name: arguments.target, pin: ""}; + } + + private string function $renderList(required array rows) { + local.buf = []; + local.maxName = 0; + for (local.r in arguments.rows) { + if (Len(local.r.name) > local.maxName) local.maxName = Len(local.r.name); + } + for (local.r in arguments.rows) { + local.pad = RepeatString(" ", local.maxName - Len(local.r.name) + 2); + local.tags = ArrayLen(local.r.tags) ? " [" & ArrayToList(local.r.tags, ", ") & "]" : ""; + ArrayAppend(local.buf, local.r.name & local.pad & local.r.description & local.tags); + } + return ArrayToList(local.buf, Chr(10)) & Chr(10); + } + + private string function $detectRuntime() { + try { + local.global = CreateObject("component", "wheels.Global"); + return local.global.$readFrameworkVersion(); + } catch (any e) { + // In test contexts the framework may not be discoverable from + // the webroot. Fall back to a permissive sentinel that matches + // any wheelsVersion constraint (SemVer's "*" behaviour). + return "0.0.0-dev"; + } + } +} diff --git a/cli/lucli/services/packages/PackagesRegistryCli.cfc b/cli/lucli/services/packages/PackagesRegistryCli.cfc new file mode 100644 index 0000000000..00b15f07a5 --- /dev/null +++ b/cli/lucli/services/packages/PackagesRegistryCli.cfc @@ -0,0 +1,36 @@ +/** + * `wheels packages registry ` surface. + * + * Verbs: + * refresh — bust the 24h cache so the next list/show hits the network. + * info — print registry URL, branch, cache location, freshness. + */ +component { + + public PackagesRegistryCli function init(any registry = "") { + variables.registry = IsObject(arguments.registry) + ? arguments.registry + : new cli.lucli.services.packages.Registry(); + return this; + } + + public string function refresh(struct opts = {}) { + variables.registry.refresh(); + return "Cache cleared. Next `wheels packages list` will re-fetch from the registry." & Chr(10); + } + + public string function info(struct opts = {}) { + local.i = variables.registry.info(); + local.buf = []; + ArrayAppend(local.buf, "Registry: " & local.i.registryRepo); + ArrayAppend(local.buf, "Branch: " & local.i.branch); + ArrayAppend(local.buf, "Browse: " & local.i.indexUrl); + ArrayAppend(local.buf, "Cache dir: " & local.i.cache.root); + ArrayAppend(local.buf, "Cache TTL: " & local.i.cache.ttlSeconds & "s"); + ArrayAppend(local.buf, "Cache present: " & (local.i.cache.exists ? "yes" : "no")); + if (Len(local.i.cache.indexFetchedAt)) { + ArrayAppend(local.buf, "Index fetched: " & local.i.cache.indexFetchedAt); + } + return ArrayToList(local.buf, Chr(10)) & Chr(10); + } +} diff --git a/cli/lucli/services/packages/Registry.cfc b/cli/lucli/services/packages/Registry.cfc new file mode 100644 index 0000000000..6eab7735d5 --- /dev/null +++ b/cli/lucli/services/packages/Registry.cfc @@ -0,0 +1,130 @@ +/** + * Reads the wheels-packages registry over HTTPS. + * + * Two data sources: + * - GitHub contents API for the list of package dirs (rate-limited, + * 60 req/hr unauthenticated — cached 24h). + * - raw.githubusercontent.com for per-package manifests (also cached). + * + * Both are overridable via the `registryRepo` constructor arg or the + * `WHEELS_PACKAGES_REGISTRY` env var (default "wheels-dev/wheels-packages"). + * Useful for forks, mirrors, and tests. + */ +component { + + variables.DEFAULT_REPO = "wheels-dev/wheels-packages"; + variables.DEFAULT_BRANCH = "main"; + + public Registry function init( + any httpClient = "", + any cache = "", + string registryRepo = "", + string branch = "" + ) { + variables.http = IsObject(arguments.httpClient) + ? arguments.httpClient + : new cli.lucli.services.packages.HttpClient(); + variables.cache = IsObject(arguments.cache) + ? arguments.cache + : new cli.lucli.services.packages.ManifestCache(); + variables.registryRepo = Len(arguments.registryRepo) + ? arguments.registryRepo + : $resolveRepo(); + variables.branch = Len(arguments.branch) ? arguments.branch : variables.DEFAULT_BRANCH; + return this; + } + + public string function registryRepo() { return variables.registryRepo; } + public string function branch() { return variables.branch; } + public any function cache() { return variables.cache; } + + /** + * Returns the list of package names in the registry. Serves cached + * data if fresh; otherwise hits the GitHub contents API. + */ + public array function listPackageNames() { + if (variables.cache.hasFreshIndex()) { + return variables.cache.readIndex(); + } + local.url = "https://api.github.com/repos/#variables.registryRepo#/contents/packages?ref=#variables.branch#"; + local.resp = variables.http.get(local.url); + if (local.resp.status != 200) { + Throw( + type = "Wheels.Packages.RegistryUnavailable", + message = "Failed to list packages from registry (HTTP #local.resp.status#). URL: #local.url#" + ); + } + local.entries = DeserializeJSON(local.resp.body); + if (!IsArray(local.entries)) { + Throw( + type = "Wheels.Packages.RegistryMalformed", + message = "Registry contents endpoint did not return an array." + ); + } + local.names = []; + for (local.entry in local.entries) { + if ((local.entry.type ?: "") == "dir") { + ArrayAppend(local.names, local.entry.name); + } + } + ArraySort(local.names, "text"); + variables.cache.writeIndex(local.names); + return local.names; + } + + /** + * Fetches a package's manifest. Cached 24h per package. + */ + public struct function fetchManifest(required string name) { + if (variables.cache.hasFreshManifest(arguments.name)) { + return variables.cache.readManifest(arguments.name); + } + local.url = "https://raw.githubusercontent.com/#variables.registryRepo#/#variables.branch#/packages/#arguments.name#/manifest.json"; + local.resp = variables.http.get(local.url); + if (local.resp.status == 404) { + Throw( + type = "Wheels.Packages.UnknownPackage", + message = "Package '#arguments.name#' not found in registry '#variables.registryRepo#'." + ); + } + if (local.resp.status != 200) { + Throw( + type = "Wheels.Packages.RegistryUnavailable", + message = "Failed to fetch manifest for '#arguments.name#' (HTTP #local.resp.status#)." + ); + } + local.manifest = DeserializeJSON(local.resp.body); + if (!IsStruct(local.manifest) || !StructKeyExists(local.manifest, "name")) { + Throw( + type = "Wheels.Packages.RegistryMalformed", + message = "Manifest for '#arguments.name#' is not a valid manifest struct." + ); + } + variables.cache.writeManifest(arguments.name, local.manifest); + return local.manifest; + } + + public struct function info() { + local.cacheInfo = variables.cache.info(); + return { + registryRepo: variables.registryRepo, + branch: variables.branch, + indexUrl: "https://github.com/#variables.registryRepo#/tree/#variables.branch#/packages", + cache: local.cacheInfo + }; + } + + public void function refresh() { + variables.cache.refresh(); + } + + // ── Private ───────────────────────────────────────────── + + private string function $resolveRepo() { + local.env = CreateObject("java", "java.lang.System").getenv("WHEELS_PACKAGES_REGISTRY"); + if (!IsNull(local.env) && Len(local.env)) { + return local.env; + } + return variables.DEFAULT_REPO; + } +} diff --git a/cli/lucli/services/packages/VersionResolver.cfc b/cli/lucli/services/packages/VersionResolver.cfc new file mode 100644 index 0000000000..26ca9f1adc --- /dev/null +++ b/cli/lucli/services/packages/VersionResolver.cfc @@ -0,0 +1,128 @@ +/** + * Picks a version from a manifest's versions[] array, filtering by + * framework compatibility and an optional user pin. + * + * Reuses wheels.SemVer (shipped with #2231) so the CLI and PackageLoader + * agree byte-for-byte on what "satisfies" means. + * + * Framework version comes from wheels.Global::$readFrameworkVersion() — + * the caller passes it in so this component stays pure and testable + * without needing an application scope. + */ +component { + + public VersionResolver function init(any semver = "") { + variables.semver = IsObject(arguments.semver) + ? arguments.semver + : new wheels.SemVer(); + return this; + } + + /** + * @manifest Parsed manifest struct (has `versions` array). + * @runtime Current framework version string (e.g. "4.0.0"). + * @pin Optional user pin — either an exact version ("1.2.3") + * or a SemVer constraint ("^1.0.0", ">=1.0 <2.0"), or "". + * @return Chosen version entry struct (element of versions[]). + * @throws Wheels.Packages.NoCompatibleVersion when nothing matches. + */ + public struct function pick( + required struct manifest, + required string runtime, + string pin = "" + ) { + if (!StructKeyExists(arguments.manifest, "versions") + || !IsArray(arguments.manifest.versions) + || !ArrayLen(arguments.manifest.versions)) { + Throw( + type = "Wheels.Packages.NoVersions", + message = "Manifest has no versions to choose from." + ); + } + + local.candidates = []; + for (local.entry in arguments.manifest.versions) { + if (!StructKeyExists(local.entry, "version") || !Len(local.entry.version)) { + continue; + } + // Framework compatibility gate. + local.constraint = StructKeyExists(local.entry, "wheelsVersion") + ? Trim(local.entry.wheelsVersion) + : ""; + if (Len(local.constraint) + && !variables.semver.satisfiesAll(arguments.runtime, local.constraint)) { + continue; + } + // User pin gate. + if (Len(arguments.pin) + && !variables.semver.satisfiesAll(local.entry.version, arguments.pin)) { + continue; + } + ArrayAppend(local.candidates, local.entry); + } + + if (!ArrayLen(local.candidates)) { + local.known = []; + for (local.any in arguments.manifest.versions) { + ArrayAppend(local.known, local.any.version ?: "?"); + } + Throw( + type = "Wheels.Packages.NoCompatibleVersion", + message = "No version of '#(arguments.manifest.name ?: "package")#' " + & "satisfies runtime '#arguments.runtime#'" + & (Len(arguments.pin) ? " and pin '#arguments.pin#'" : "") & ".", + extendedInfo = "Available versions: " & ArrayToList(local.known, ", ") + ); + } + + // Highest SemVer wins. + local.best = local.candidates[1]; + for (local.i = 2; local.i <= ArrayLen(local.candidates); local.i++) { + if (variables.semver.compare(local.candidates[local.i].version, local.best.version) > 0) { + local.best = local.candidates[local.i]; + } + } + return local.best; + } + + /** + * Returns every version compatible with the runtime (no pin), ordered + * highest → lowest. Used by `wheels packages show` to display history. + */ + public array function compatibleVersions( + required struct manifest, + required string runtime + ) { + local.compatible = []; + if (!StructKeyExists(arguments.manifest, "versions") + || !IsArray(arguments.manifest.versions)) { + return local.compatible; + } + for (local.entry in arguments.manifest.versions) { + if (!StructKeyExists(local.entry, "version") || !Len(local.entry.version)) { + continue; + } + local.constraint = StructKeyExists(local.entry, "wheelsVersion") + ? Trim(local.entry.wheelsVersion) + : ""; + if (Len(local.constraint) + && !variables.semver.satisfiesAll(arguments.runtime, local.constraint)) { + continue; + } + ArrayAppend(local.compatible, local.entry); + } + // Sort highest first. Simple insertion sort to sidestep ArraySort + // callback quirks across Lucee/Adobe and avoid arrow-fn parsing pitfalls. + for (local.i = 2; local.i <= ArrayLen(local.compatible); local.i++) { + local.cur = local.compatible[local.i]; + local.j = local.i - 1; + while (local.j >= 1 + && variables.semver.compare(local.compatible[local.j].version, local.cur.version) < 0) { + local.compatible[local.j + 1] = local.compatible[local.j]; + local.j--; + } + local.compatible[local.j + 1] = local.cur; + } + return local.compatible; + } +} diff --git a/cli/lucli/tests/_fixtures/packages/wheels-fake-1.0.0.tar.gz b/cli/lucli/tests/_fixtures/packages/wheels-fake-1.0.0.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..b5c54106dc8185bc4a69e7fca75b3565939de66c GIT binary patch literal 631 zcmV--0*L(|iwFQZuj*+41MQgGZqqOThSNz15w79d3YUQABtEQ@iWFF-Rod9-s%pEM ztk#9l=0K8-N>iVJ2Y@>+cr_jbXKmHAly;SpAhrG^%Zbl%^4ot5ufKj0Vi3RX@xIWC z0U^W)0rxQ-$C7D`kcDkv5|dfPVwhO~nKnaM1E`?!%yAPZJd#5RMIxdVa&`S4iqu88)FTL;$a-$V=gC)l<$ zFwA)zOWAS#+X#4x>xr7l7_A<#Dz%_#Qu|`EB!bK2T5~=$ z9H)KMKMP-8_O4baJ(-QbyzY|DK0kC8PTNqWV~;|MX^I|0h`LpAeV6f5fth@_(hE{NGNK)&I;3=DQB8)&F1qk1}d2|5paf z{U1AENCxHqww|p1i`o6x>mN~basQV~fZG4d;04Hh0lMB2aRPeoFu92Y1n+|ca1aST z03q-6`9}ejP`8VD|37`ppX0#KuaC|BKeezr|1AgQ|96_@{y*P!U~~V^7*prJrLf%p z6U3?vsq^3MC#(NrcK-+H-(K|pRC)rn|Chm?4uIivPZ)>5$b1aOt2E;eWVry Rg+ieymOq~EwZH%-004cHNyh*H literal 0 HcmV?d00001 diff --git a/cli/lucli/tests/specs/packages/InstallerSpec.cfc b/cli/lucli/tests/specs/packages/InstallerSpec.cfc new file mode 100644 index 0000000000..0e7f1eb5ef --- /dev/null +++ b/cli/lucli/tests/specs/packages/InstallerSpec.cfc @@ -0,0 +1,168 @@ +component extends="wheels.wheelstest.system.BaseSpec" { + + function run() { + describe("Installer", () => { + + // Fixture tarball, built once and committed to tests/_fixtures/packages. + // Its sha256 is computed live below (BSD vs GNU tar produce different + // bytes but both are valid for our purposes — we just need a known- + // good hash to verify the checksum path works). + var fixturePath = ExpandPath("/cli/lucli/tests/_fixtures/packages/wheels-fake-1.0.0.tar.gz"); + + var $scratch = () => { + var root = GetTempDirectory() & "wheels-proj-" & CreateUUID() & "/"; + DirectoryCreate(root, true); + return root; + }; + + var $sha = (path) => { + return LCase(Hash(FileReadBinary(path), "SHA-256")); + }; + + // Seeds a FakeHttpClient to serve the fixture tarball bytes at + // the URL the Installer will request. Named `href` here because + // `url` is a CFML reserved scope and shadows inside closures. + var $seededClient = (href) => { + var fake = new cli.lucli.tests.specs.packages._stubs.FakeHttpClient(); + fake.seed(href, {status: 200, body: FileReadBinary(fixturePath)}); + return fake; + }; + + it("fails loudly if the fixture tarball is missing", () => { + expect(FileExists(fixturePath)).toBeTrue(); + }); + + it("downloads, verifies checksum, extracts to vendor//", () => { + var proj = $scratch(); + var tarballHref = "https://example/wheels-fake-1.0.0.tar.gz"; + var installer = new cli.lucli.services.packages.Installer( + httpClient = $seededClient(tarballHref), + projectRoot = proj + ); + var path = installer.install("wheels-fake", { + version: "1.0.0", + tarball: tarballHref, + sha256: $sha(fixturePath) + }); + expect(DirectoryExists(path)).toBeTrue(); + expect(FileExists(path & "/package.json")).toBeTrue(); + expect(installer.isInstalled("wheels-fake")).toBeTrue(); + expect(installer.installedVersion("wheels-fake")).toBe("1.0.0"); + DirectoryDelete(proj, true); + }); + + it("aborts with ChecksumMismatch on bad sha256", () => { + var proj = $scratch(); + var tarballHref = "https://example/wheels-fake-1.0.0.tar.gz"; + var installer = new cli.lucli.services.packages.Installer( + httpClient = $seededClient(tarballHref), + projectRoot = proj + ); + var threw = false; + try { + installer.install("wheels-fake", { + version: "1.0.0", + tarball: tarballHref, + sha256: "0000000000000000000000000000000000000000000000000000000000000000" + }); + } catch (any e) { + threw = true; + expect(e.type).toBe("Wheels.Packages.ChecksumMismatch"); + } + expect(threw).toBeTrue(); + expect(DirectoryExists(proj & "vendor/wheels-fake")).toBeFalse(); + DirectoryDelete(proj, true); + }); + + it("refuses to overwrite an existing package without --force", () => { + var proj = $scratch(); + DirectoryCreate(proj & "vendor/wheels-fake", true); + var tarballHref = "https://example/wheels-fake-1.0.0.tar.gz"; + var installer = new cli.lucli.services.packages.Installer( + httpClient = $seededClient(tarballHref), + projectRoot = proj + ); + var threw = false; + try { + installer.install("wheels-fake", { + version: "1.0.0", + tarball: tarballHref, + sha256: $sha(fixturePath) + }); + } catch (any e) { + threw = true; + expect(e.type).toBe("Wheels.Packages.AlreadyInstalled"); + } + expect(threw).toBeTrue(); + DirectoryDelete(proj, true); + }); + + it("overwrites when force=true", () => { + var proj = $scratch(); + DirectoryCreate(proj & "vendor/wheels-fake", true); + FileWrite(proj & "vendor/wheels-fake/leftover.txt", "old"); + var tarballHref = "https://example/wheels-fake-1.0.0.tar.gz"; + var installer = new cli.lucli.services.packages.Installer( + httpClient = $seededClient(tarballHref), + projectRoot = proj + ); + installer.install("wheels-fake", { + version: "1.0.0", + tarball: tarballHref, + sha256: $sha(fixturePath) + }, true); + expect(FileExists(proj & "vendor/wheels-fake/package.json")).toBeTrue(); + expect(FileExists(proj & "vendor/wheels-fake/leftover.txt")).toBeFalse(); + DirectoryDelete(proj, true); + }); + + it("refuses to install a version missing tarball URL", () => { + var installer = new cli.lucli.services.packages.Installer( + httpClient = new cli.lucli.tests.specs.packages._stubs.FakeHttpClient(), + projectRoot = $scratch() + ); + var threw = false; + try { + installer.install("x", {version: "1.0.0", sha256: "abc"}); + } catch (any e) { + threw = true; + expect(e.type).toBe("Wheels.Packages.ManifestIncomplete"); + } + expect(threw).toBeTrue(); + }); + + it("uninstall refuses dirs without a package.json", () => { + var proj = $scratch(); + DirectoryCreate(proj & "vendor/scary", true); + FileWrite(proj & "vendor/scary/not-a-package.txt", "x"); + var installer = new cli.lucli.services.packages.Installer( + httpClient = new cli.lucli.tests.specs.packages._stubs.FakeHttpClient(), + projectRoot = proj + ); + var threw = false; + try { + installer.uninstall("scary"); + } catch (any e) { + threw = true; + expect(e.type).toBe("Wheels.Packages.NotAPackage"); + } + expect(threw).toBeTrue(); + expect(DirectoryExists(proj & "vendor/scary")).toBeTrue(); + DirectoryDelete(proj, true); + }); + + it("uninstall removes a real package", () => { + var proj = $scratch(); + DirectoryCreate(proj & "vendor/wheels-fake", true); + FileWrite(proj & "vendor/wheels-fake/package.json", "{""name"":""wheels-fake""}"); + var installer = new cli.lucli.services.packages.Installer( + httpClient = new cli.lucli.tests.specs.packages._stubs.FakeHttpClient(), + projectRoot = proj + ); + installer.uninstall("wheels-fake"); + expect(DirectoryExists(proj & "vendor/wheels-fake")).toBeFalse(); + DirectoryDelete(proj, true); + }); + }); + } +} diff --git a/cli/lucli/tests/specs/packages/ManifestCacheSpec.cfc b/cli/lucli/tests/specs/packages/ManifestCacheSpec.cfc new file mode 100644 index 0000000000..c9840f298e --- /dev/null +++ b/cli/lucli/tests/specs/packages/ManifestCacheSpec.cfc @@ -0,0 +1,70 @@ +component extends="wheels.wheelstest.system.BaseSpec" { + + function run() { + describe("ManifestCache", () => { + + var $tmpRoot = () => { + var dir = GetTempDirectory() & "wheels-cache-" & CreateUUID() & "/"; + return dir; + }; + + it("writes and reads the index round-trip", () => { + var root = $tmpRoot(); + var cache = new cli.lucli.services.packages.ManifestCache(root = root); + cache.writeIndex(["wheels-sentry", "wheels-hotwire"]); + expect(cache.hasFreshIndex()).toBeTrue(); + var names = cache.readIndex(); + expect(names).toBe(["wheels-sentry", "wheels-hotwire"]); + DirectoryDelete(root, true); + }); + + it("reports cache miss when index is absent", () => { + var cache = new cli.lucli.services.packages.ManifestCache(root = $tmpRoot()); + expect(cache.hasFreshIndex()).toBeFalse(); + }); + + it("writes and reads manifests round-trip", () => { + var root = $tmpRoot(); + var cache = new cli.lucli.services.packages.ManifestCache(root = root); + var m = {name: "wheels-sentry", versions: [{version: "1.0.0"}]}; + cache.writeManifest("wheels-sentry", m); + expect(cache.hasFreshManifest("wheels-sentry")).toBeTrue(); + var round = cache.readManifest("wheels-sentry"); + expect(round.name).toBe("wheels-sentry"); + expect(round.versions[1].version).toBe("1.0.0"); + DirectoryDelete(root, true); + }); + + it("honours TTL — index stale after expiry", () => { + var root = $tmpRoot(); + var cache = new cli.lucli.services.packages.ManifestCache(root = root, ttlSeconds = 1); + cache.writeIndex(["x"]); + expect(cache.hasFreshIndex()).toBeTrue(); + Sleep(1500); + expect(cache.hasFreshIndex()).toBeFalse(); + DirectoryDelete(root, true); + }); + + it("refresh() wipes the cache directory", () => { + var root = $tmpRoot(); + var cache = new cli.lucli.services.packages.ManifestCache(root = root); + cache.writeIndex(["x"]); + cache.writeManifest("x", {name: "x", versions: []}); + expect(DirectoryExists(root)).toBeTrue(); + cache.refresh(); + expect(DirectoryExists(root)).toBeFalse(); + }); + + it("info() reports cache location and freshness", () => { + var root = $tmpRoot(); + var cache = new cli.lucli.services.packages.ManifestCache(root = root); + cache.writeIndex([]); + var info = cache.info(); + expect(info.root).toBe(root); + expect(info.exists).toBeTrue(); + expect(Len(info.indexFetchedAt)).toBeGT(0); + DirectoryDelete(root, true); + }); + }); + } +} diff --git a/cli/lucli/tests/specs/packages/PackagesMainCliSpec.cfc b/cli/lucli/tests/specs/packages/PackagesMainCliSpec.cfc new file mode 100644 index 0000000000..0975814ff8 --- /dev/null +++ b/cli/lucli/tests/specs/packages/PackagesMainCliSpec.cfc @@ -0,0 +1,225 @@ +component extends="wheels.wheelstest.system.BaseSpec" { + + function run() { + describe("PackagesMainCli", () => { + + var fixturePath = ExpandPath("/cli/lucli/tests/_fixtures/packages/wheels-fake-1.0.0.tar.gz"); + + var $scratch = () => { + var root = GetTempDirectory() & "wheels-proj-" & CreateUUID() & "/"; + DirectoryCreate(root, true); + return root; + }; + + var $sha = (path) => LCase(Hash(FileReadBinary(path), "SHA-256")); + + // Builds a Registry pre-seeded with two fake packages + a FakeHttpClient. + var $buildStack = (projRoot) => { + var fake = new cli.lucli.tests.specs.packages._stubs.FakeHttpClient(); + var cacheRoot = GetTempDirectory() & "wheels-cache-" & CreateUUID() & "/"; + var cache = new cli.lucli.services.packages.ManifestCache(root = cacheRoot); + var registry = new cli.lucli.services.packages.Registry( + httpClient = fake, cache = cache, registryRepo = "test/reg" + ); + fake.seed( + "https://api.github.com/repos/test/reg/contents/packages?ref=main", + {status: 200, body: SerializeJSON([ + {name: "wheels-fake", type: "dir"}, + {name: "wheels-other", type: "dir"} + ])} + ); + fake.seed( + "https://raw.githubusercontent.com/test/reg/main/packages/wheels-fake/manifest.json", + {status: 200, body: SerializeJSON({ + name: "wheels-fake", + description: "Fake package for tests", + tags: ["monitoring", "test"], + versions: [{ + version: "1.0.0", wheelsVersion: ">=4.0", + tarball: "https://example/wheels-fake-1.0.0.tar.gz", + sha256: $sha(fixturePath) + }] + })} + ); + fake.seed( + "https://raw.githubusercontent.com/test/reg/main/packages/wheels-other/manifest.json", + {status: 200, body: SerializeJSON({ + name: "wheels-other", + description: "Another one", + tags: ["ui"], + versions: [{ + version: "2.0.0", wheelsVersion: ">=4.0", + tarball: "https://example/wheels-other-2.0.0.tar.gz", + sha256: "deadbeef" + }] + })} + ); + fake.seed( + "https://example/wheels-fake-1.0.0.tar.gz", + {status: 200, body: FileReadBinary(fixturePath)} + ); + var installer = new cli.lucli.services.packages.Installer( + httpClient = fake, projectRoot = projRoot + ); + var cli = new cli.lucli.services.packages.PackagesMainCli( + registry = registry, installer = installer, runtimeVersion = "4.0.0" + ); + return {cli: cli, fake: fake, cache: cache}; + }; + + it("list returns all packages when no filter", () => { + var proj = $scratch(); + var stack = $buildStack(proj); + var out = stack.cli.list(); + expect(out).toInclude("wheels-fake"); + expect(out).toInclude("wheels-other"); + stack.cache.refresh(); + DirectoryDelete(proj, true); + }); + + it("list --tag filters to matching tag", () => { + var proj = $scratch(); + var stack = $buildStack(proj); + var out = stack.cli.list({tag: "ui"}); + expect(out).toInclude("wheels-other"); + expect(out).notToInclude("wheels-fake"); + stack.cache.refresh(); + DirectoryDelete(proj, true); + }); + + it("search matches against name, description, and tags", () => { + var proj = $scratch(); + var stack = $buildStack(proj); + expect(stack.cli.search({query: "monitoring"})).toInclude("wheels-fake"); + expect(stack.cli.search({query: "another"})).toInclude("wheels-other"); + expect(stack.cli.search({query: "zzzz-no-match"})).toInclude("No packages matched"); + stack.cache.refresh(); + DirectoryDelete(proj, true); + }); + + it("show renders package details and compatible versions", () => { + var proj = $scratch(); + var stack = $buildStack(proj); + var out = stack.cli.show({name: "wheels-fake"}); + expect(out).toInclude("wheels-fake"); + expect(out).toInclude("Fake package for tests"); + expect(out).toInclude("1.0.0"); + stack.cache.refresh(); + DirectoryDelete(proj, true); + }); + + it("install fetches manifest, picks latest compat, and extracts to vendor/", () => { + var proj = $scratch(); + var stack = $buildStack(proj); + var out = stack.cli.install({target: "wheels-fake"}); + expect(out).toInclude("Installed wheels-fake@1.0.0"); + expect(DirectoryExists(proj & "vendor/wheels-fake")).toBeTrue(); + expect(FileExists(proj & "vendor/wheels-fake/package.json")).toBeTrue(); + stack.cache.refresh(); + DirectoryDelete(proj, true); + }); + + it("install honours @version pin", () => { + var proj = $scratch(); + var stack = $buildStack(proj); + var out = stack.cli.install({target: "wheels-fake@1.0.0"}); + expect(out).toInclude("1.0.0"); + stack.cache.refresh(); + DirectoryDelete(proj, true); + }); + + it("update without --yes throws ConfirmationRequired", () => { + var proj = $scratch(); + var stack = $buildStack(proj); + DirectoryCreate(proj & "vendor/wheels-fake", true); + FileWrite(proj & "vendor/wheels-fake/package.json", "{""name"":""wheels-fake"",""version"":""1.0.0""}"); + var threw = false; + try { + stack.cli.update({target: "wheels-fake"}); + } catch (any e) { + threw = true; + expect(e.type).toBe("Wheels.Packages.ConfirmationRequired"); + } + expect(threw).toBeTrue(); + stack.cache.refresh(); + DirectoryDelete(proj, true); + }); + + it("update --all without --yes throws ConfirmationRequired", () => { + var proj = $scratch(); + var stack = $buildStack(proj); + var threw = false; + try { + stack.cli.update({all: true}); + } catch (any e) { + threw = true; + expect(e.type).toBe("Wheels.Packages.ConfirmationRequired"); + } + expect(threw).toBeTrue(); + stack.cache.refresh(); + DirectoryDelete(proj, true); + }); + + it("remove refuses a dir without package.json", () => { + var proj = $scratch(); + var stack = $buildStack(proj); + DirectoryCreate(proj & "vendor/scary", true); + var threw = false; + try { + stack.cli.remove({target: "scary"}); + } catch (any e) { + threw = true; + expect(e.type).toBe("Wheels.Packages.NotAPackage"); + } + expect(threw).toBeTrue(); + stack.cache.refresh(); + DirectoryDelete(proj, true); + }); + + it("install throws BadInput when target is missing", () => { + var proj = $scratch(); + var stack = $buildStack(proj); + var threw = false; + try { + stack.cli.install({}); + } catch (any e) { + threw = true; + expect(e.type).toBe("Wheels.Packages.BadInput"); + } + expect(threw).toBeTrue(); + stack.cache.refresh(); + DirectoryDelete(proj, true); + }); + }); + + describe("PackagesRegistryCli", () => { + + it("info prints registry details", () => { + var fake = new cli.lucli.tests.specs.packages._stubs.FakeHttpClient(); + var cacheRoot = GetTempDirectory() & "wheels-cache-" & CreateUUID() & "/"; + var cache = new cli.lucli.services.packages.ManifestCache(root = cacheRoot); + var registry = new cli.lucli.services.packages.Registry( + httpClient = fake, cache = cache, registryRepo = "acme/pkgs" + ); + var cli = new cli.lucli.services.packages.PackagesRegistryCli(registry = registry); + var out = cli.info(); + expect(out).toInclude("acme/pkgs"); + expect(out).toInclude("main"); + }); + + it("refresh wipes the cache", () => { + var fake = new cli.lucli.tests.specs.packages._stubs.FakeHttpClient(); + var cacheRoot = GetTempDirectory() & "wheels-cache-" & CreateUUID() & "/"; + var cache = new cli.lucli.services.packages.ManifestCache(root = cacheRoot); + cache.writeIndex(["a", "b"]); + expect(DirectoryExists(cacheRoot)).toBeTrue(); + var registry = new cli.lucli.services.packages.Registry( + httpClient = fake, cache = cache, registryRepo = "acme/pkgs" + ); + var cli = new cli.lucli.services.packages.PackagesRegistryCli(registry = registry); + cli.refresh(); + expect(DirectoryExists(cacheRoot)).toBeFalse(); + }); + }); + } +} diff --git a/cli/lucli/tests/specs/packages/RegistrySpec.cfc b/cli/lucli/tests/specs/packages/RegistrySpec.cfc new file mode 100644 index 0000000000..2d1bb7d5f4 --- /dev/null +++ b/cli/lucli/tests/specs/packages/RegistrySpec.cfc @@ -0,0 +1,130 @@ +component extends="wheels.wheelstest.system.BaseSpec" { + + function run() { + describe("Registry", () => { + + var $freshCache = () => { + var root = GetTempDirectory() & "wheels-registry-" & CreateUUID() & "/"; + return new cli.lucli.services.packages.ManifestCache(root = root); + }; + + var $sentryManifest = SerializeJSON({ + name: "wheels-sentry", + description: "Sentry for Wheels", + source: {type: "github", repo: "wheels-dev/wheels-sentry"}, + versions: [{version: "1.0.0", wheelsVersion: ">=4.0", tarball: "x", sha256: "y"}] + }); + + var $contentsBody = SerializeJSON([ + {name: "wheels-sentry", type: "dir"}, + {name: "wheels-hotwire", type: "dir"}, + {name: "README.md", type: "file"} + ]); + + it("lists package names, filtering out non-dirs and sorting", () => { + var fake = new cli.lucli.tests.specs.packages._stubs.FakeHttpClient(); + var cache = $freshCache(); + var r = new cli.lucli.services.packages.Registry( + httpClient = fake, cache = cache, registryRepo = "acme/pkgs" + ); + fake.seed( + "https://api.github.com/repos/acme/pkgs/contents/packages?ref=main", + {status: 200, body: $contentsBody} + ); + var names = r.listPackageNames(); + expect(names).toBe(["wheels-hotwire", "wheels-sentry"]); // sorted + }); + + it("serves the index from cache on the second call (no second HTTP hit)", () => { + var fake = new cli.lucli.tests.specs.packages._stubs.FakeHttpClient(); + var cache = $freshCache(); + var r = new cli.lucli.services.packages.Registry( + httpClient = fake, cache = cache, registryRepo = "acme/pkgs" + ); + fake.seed( + "https://api.github.com/repos/acme/pkgs/contents/packages?ref=main", + {status: 200, body: $contentsBody} + ); + r.listPackageNames(); + r.listPackageNames(); + expect(ArrayLen(fake.calls())).toBe(1); + cache.refresh(); + }); + + it("fetches a manifest and parses it", () => { + var fake = new cli.lucli.tests.specs.packages._stubs.FakeHttpClient(); + var cache = $freshCache(); + var r = new cli.lucli.services.packages.Registry( + httpClient = fake, cache = cache, registryRepo = "acme/pkgs" + ); + fake.seed( + "https://raw.githubusercontent.com/acme/pkgs/main/packages/wheels-sentry/manifest.json", + {status: 200, body: $sentryManifest} + ); + var m = r.fetchManifest("wheels-sentry"); + expect(m.name).toBe("wheels-sentry"); + expect(m.versions[1].version).toBe("1.0.0"); + cache.refresh(); + }); + + it("throws Wheels.Packages.UnknownPackage on 404", () => { + var fake = new cli.lucli.tests.specs.packages._stubs.FakeHttpClient(); + var cache = $freshCache(); + var r = new cli.lucli.services.packages.Registry( + httpClient = fake, cache = cache, registryRepo = "acme/pkgs" + ); + // No seed — FakeHttpClient returns 404 for unknown URLs. + var threw = false; + try { + r.fetchManifest("nope"); + } catch (any e) { + threw = true; + expect(e.type).toBe("Wheels.Packages.UnknownPackage"); + } + expect(threw).toBeTrue(); + cache.refresh(); + }); + + it("throws Wheels.Packages.RegistryUnavailable on other errors", () => { + var fake = new cli.lucli.tests.specs.packages._stubs.FakeHttpClient(); + var cache = $freshCache(); + var r = new cli.lucli.services.packages.Registry( + httpClient = fake, cache = cache, registryRepo = "acme/pkgs" + ); + fake.seed( + "https://api.github.com/repos/acme/pkgs/contents/packages?ref=main", + {status: 500, body: "boom"} + ); + var threw = false; + try { + r.listPackageNames(); + } catch (any e) { + threw = true; + expect(e.type).toBe("Wheels.Packages.RegistryUnavailable"); + } + expect(threw).toBeTrue(); + cache.refresh(); + }); + + it("info() reports repo and cache details", () => { + var fake = new cli.lucli.tests.specs.packages._stubs.FakeHttpClient(); + var cache = $freshCache(); + var r = new cli.lucli.services.packages.Registry( + httpClient = fake, cache = cache, registryRepo = "acme/pkgs" + ); + var info = r.info(); + expect(info.registryRepo).toBe("acme/pkgs"); + expect(info.branch).toBe("main"); + expect(Find("acme/pkgs", info.indexUrl)).toBeGT(0); + }); + + it("uses default repo when env override is absent", () => { + var fake = new cli.lucli.tests.specs.packages._stubs.FakeHttpClient(); + var cache = $freshCache(); + var r = new cli.lucli.services.packages.Registry(httpClient = fake, cache = cache); + // We can't assert on env state, but we can assert the fallback was hit. + expect(Len(r.registryRepo())).toBeGT(0); + }); + }); + } +} diff --git a/cli/lucli/tests/specs/packages/VersionResolverSpec.cfc b/cli/lucli/tests/specs/packages/VersionResolverSpec.cfc new file mode 100644 index 0000000000..67c67acb7b --- /dev/null +++ b/cli/lucli/tests/specs/packages/VersionResolverSpec.cfc @@ -0,0 +1,81 @@ +component extends="wheels.wheelstest.system.BaseSpec" { + + function run() { + describe("VersionResolver", () => { + + var manifest = { + name: "wheels-sentry", + versions: [ + {version: "1.0.0", wheelsVersion: ">=4.0", tarball: "x", sha256: "a"}, + {version: "1.1.0", wheelsVersion: ">=4.0", tarball: "x", sha256: "b"}, + {version: "1.2.0", wheelsVersion: ">=5.0", tarball: "x", sha256: "c"}, + {version: "0.9.0", wheelsVersion: ">=3.0", tarball: "x", sha256: "d"} + ] + }; + + it("picks the highest version satisfying both runtime and pin", () => { + var r = new cli.lucli.services.packages.VersionResolver(); + var chosen = r.pick(manifest, "4.0.0"); + expect(chosen.version).toBe("1.1.0"); + }); + + it("honours an exact pin even if a higher compat version exists", () => { + var r = new cli.lucli.services.packages.VersionResolver(); + var chosen = r.pick(manifest, "4.0.0", "1.0.0"); + expect(chosen.version).toBe("1.0.0"); + }); + + it("honours a caret pin", () => { + var r = new cli.lucli.services.packages.VersionResolver(); + var chosen = r.pick(manifest, "4.0.0", "^1.0.0"); + expect(chosen.version).toBe("1.1.0"); + }); + + it("skips versions whose wheelsVersion constraint fails", () => { + var r = new cli.lucli.services.packages.VersionResolver(); + // 1.2.0 requires >=5.0, runtime is 4.0 → must not be chosen + var chosen = r.pick(manifest, "4.0.0"); + expect(chosen.version).notToBe("1.2.0"); + }); + + it("throws Wheels.Packages.NoCompatibleVersion when nothing matches", () => { + var r = new cli.lucli.services.packages.VersionResolver(); + var threw = false; + try { + r.pick(manifest, "2.0.0"); // nothing satisfies <4.0 except 0.9.0 (needs >=3.0 which 2.0 fails) + } catch (any e) { + threw = true; + expect(e.type).toBe("Wheels.Packages.NoCompatibleVersion"); + } + expect(threw).toBeTrue(); + }); + + it("throws Wheels.Packages.NoVersions when versions array is empty", () => { + var r = new cli.lucli.services.packages.VersionResolver(); + var threw = false; + try { + r.pick({name: "x", versions: []}, "4.0.0"); + } catch (any e) { + threw = true; + expect(e.type).toBe("Wheels.Packages.NoVersions"); + } + expect(threw).toBeTrue(); + }); + + it("treats missing wheelsVersion as 'any runtime'", () => { + var r = new cli.lucli.services.packages.VersionResolver(); + var m = {name: "x", versions: [{version: "1.0.0", tarball: "t", sha256: "s"}]}; + var chosen = r.pick(m, "2.0.0"); + expect(chosen.version).toBe("1.0.0"); + }); + + it("compatibleVersions returns every match, highest first", () => { + var r = new cli.lucli.services.packages.VersionResolver(); + var list = r.compatibleVersions(manifest, "4.0.0"); + expect(ArrayLen(list)).toBe(3); // 0.9.0, 1.0.0, 1.1.0 match + expect(list[1].version).toBe("1.1.0"); // highest first + expect(list[ArrayLen(list)].version).toBe("0.9.0"); + }); + }); + } +} diff --git a/cli/lucli/tests/specs/packages/_stubs/FakeHttpClient.cfc b/cli/lucli/tests/specs/packages/_stubs/FakeHttpClient.cfc new file mode 100644 index 0000000000..3189d429a9 --- /dev/null +++ b/cli/lucli/tests/specs/packages/_stubs/FakeHttpClient.cfc @@ -0,0 +1,39 @@ +/** + * Records every call; returns canned responses keyed by URL. Use seed() + * to pre-populate responses. Unseeded URLs return 404 with an empty body + * so tests fail fast on typos. + */ +component { + + public FakeHttpClient function init() { + variables.responses = {}; + variables.calls = []; + return this; + } + + public void function seed(required string url, required struct response) { + variables.responses[arguments.url] = arguments.response; + } + + public struct function get(required string url, struct headers = {}) { + ArrayAppend(variables.calls, {url: arguments.url, headers: arguments.headers}); + if (StructKeyExists(variables.responses, arguments.url)) { + return variables.responses[arguments.url]; + } + return {status: 404, body: ""}; + } + + public string function download(required string url, required string destPath) { + ArrayAppend(variables.calls, {url: arguments.url, destPath: arguments.destPath, download: true}); + if (StructKeyExists(variables.responses, arguments.url)) { + local.resp = variables.responses[arguments.url]; + if (local.resp.status == 200) { + FileWrite(arguments.destPath, local.resp.body ?: ""); + return arguments.destPath; + } + } + Throw(type = "Wheels.Packages.DownloadFailed", message = "Fake: no seeded response for #arguments.url#"); + } + + public array function calls() { return variables.calls; } +} diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/index.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/index.mdx new file mode 100644 index 0000000000..4e5e4b4811 --- /dev/null +++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/index.mdx @@ -0,0 +1,61 @@ +--- +title: "wheels packages" +description: Install, update, and list Wheels packages from the wheels-packages registry. No ForgeBox or CommandBox dependency. +type: reference +sidebar: + order: 12 +--- + +`wheels packages` is the CLI surface for the [wheels-packages registry](https://github.com/wheels-dev/wheels-packages) — a curated, git-based distribution channel for Wheels ecosystem packages. Every verb talks to the registry over plain HTTPS and installs into `vendor//`, where `PackageLoader` picks it up on next reload. + +There is no ForgeBox and no CommandBox. The registry manifest is authoritative, tarballs live on the registry's GitHub Releases, and every tarball has a sha256 in the manifest that the installer verifies before extraction. Supply-chain attacks via force-pushed tags or drifted source archives are defeated by this design. + +## Synopsis + +``` +wheels packages list [--tag=] +wheels packages search +wheels packages show +wheels packages install [@] [--force] +wheels packages update --yes +wheels packages update --all --yes +wheels packages remove +wheels packages registry refresh +wheels packages registry info +``` + +## Verbs + +| Verb | Description | +|------|-------------| +| [`list`](./list) | Show every package in the registry, optionally filtered by `--tag`. | +| [`search`](./search) | Substring match against name, description, and tags. | +| [`show`](./show) | Detail page for a package: versions, homepage, license, install state. | +| [`install`](./install) | Download, verify, extract into `vendor//`. | +| [`update`](./update) | Re-install the latest compatible version. Explicit: requires `--yes`. | +| [`remove`](./remove) | Delete `vendor//`. Refuses dirs without a `package.json`. | +| [`registry refresh`](./registry-refresh) | Bust the 24h cache. | +| [`registry info`](./registry-info) | Print registry URL, branch, cache state. | + +## Design principles + +- **Explicit updates only.** `update` is never implicit. There is no auto-pull on reload, no background upgrade. This is the only defense against malicious version-bump attacks — the user reviews every bump. +- **Registry-hosted tarballs.** `tarball` URLs always point at `wheels-dev/wheels-packages` releases, never at the author's repo. GitHub's source-archive URLs drift; release assets don't. +- **sha256 is mandatory.** Installation aborts on mismatch. There is no `--skip-checksum` flag. +- **24h manifest cache.** `~/.wheels/cache/packages/`. Respects GitHub's 60 req/hr unauthenticated limit. `registry refresh` busts it. + +## Configuration + +| Env var | Purpose | Default | +|---------|---------|---------| +| `WHEELS_PACKAGES_REGISTRY` | Override the registry `/`. Used for forks, mirrors, and test registries. | `wheels-dev/wheels-packages` | + +## Writing a package + +See the registry's [CONTRIBUTING.md](https://github.com/wheels-dev/wheels-packages/blob/main/CONTRIBUTING.md) for the full submission checklist. The tl;dr: + +1. Build your package in its own repo with a valid `package.json` (same schema as the first-party packages — see [`wheels-sentry`](https://github.com/wheels-dev/wheels-sentry) for a reference). +2. Tag a release on your repo (e.g. `v1.0.0`). +3. Open a PR to `wheels-packages` adding `packages//manifest.json` with the version entry (leave `tarball` and `sha256` blank — CI fills them). +4. After merge, the `mirror-tarball` workflow packages your tag, uploads it as a registry release asset, computes the sha256, and commits it back. +5. Users can now `wheels packages install `. diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/install.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/install.mdx new file mode 100644 index 0000000000..0d57b04faa --- /dev/null +++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/install.mdx @@ -0,0 +1,55 @@ +--- +title: "wheels packages install" +description: Download, verify sha256, extract into vendor//. Refuses to overwrite without --force. +type: reference +sidebar: + label: "install" + order: 4 +--- + +Installs a package into `vendor//` from the registry. On the next `wheels reload`, `PackageLoader` auto-discovers the new `vendor//package.json` and activates the package. + +## Synopsis + +``` +wheels packages install [@] [--force] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--force` | Overwrite `vendor//` if it already exists. | + +## Version selection + +Without a pin, picks the highest `versions[]` entry whose `wheelsVersion` constraint is satisfied by the running framework. With a pin: + +- `@1.2.3` — exact match. +- `@^1.0.0` — caret (compatible-with): ≥1.0.0, <2.0.0. +- `@~1.2.0` — tilde (patch-level): ≥1.2.0, <1.3.0. +- `@>=1.0 <2.0` — space-separated range. + +The same `SemVer` matcher that `PackageLoader` uses at runtime. + +## Failure modes + +| Error | Cause | +|-------|-------| +| `Wheels.Packages.UnknownPackage` | Name not in the registry. | +| `Wheels.Packages.NoCompatibleVersion` | No version satisfies both the runtime and the pin. | +| `Wheels.Packages.ChecksumMismatch` | Tarball sha256 differs from the manifest's record. Refusing to install. | +| `Wheels.Packages.AlreadyInstalled` | `vendor//` exists. Use `--force`. | +| `Wheels.Packages.ManifestIncomplete` | Registry hasn't mirrored this version yet. Wait for the CI run to finish. | + +## Example + +``` +$ wheels packages install wheels-sentry +Installed wheels-sentry@1.0.0 → /Users/me/app/vendor/wheels-sentry +Run `wheels reload` to activate it. + +$ wheels packages install wheels-sentry@1.0.0 --force +Installed wheels-sentry@1.0.0 → /Users/me/app/vendor/wheels-sentry +Run `wheels reload` to activate it. +``` diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/list.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/list.mdx new file mode 100644 index 0000000000..8e347ba344 --- /dev/null +++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/list.mdx @@ -0,0 +1,35 @@ +--- +title: "wheels packages list" +description: List every package in the registry, optionally filtered by tag. +type: reference +sidebar: + label: "list" + order: 1 +--- + +Prints one line per package in the registry. Served from the 24h manifest cache when fresh; otherwise fetched live. + +## Synopsis + +``` +wheels packages list [--tag=] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--tag=` | Only show packages whose manifest has `` in its `tags[]`. | + +## Example + +``` +$ wheels packages list +wheels-basecoat UI component primitives for Wheels [ui, components] +wheels-hotwire Hotwire (Turbo + Stimulus) integration [frontend, spa] +wheels-legacy-adapter 3.x → 4.x compatibility shims [migration] +wheels-sentry Sentry error tracking [monitoring, errors, observability] + +$ wheels packages list --tag=monitoring +wheels-sentry Sentry error tracking [monitoring, errors, observability] +``` diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/registry/index.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/registry/index.mdx new file mode 100644 index 0000000000..b4da069d87 --- /dev/null +++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/registry/index.mdx @@ -0,0 +1,17 @@ +--- +title: "wheels packages registry" +description: Manage the local registry cache and inspect registry configuration. +type: reference +sidebar: + label: "registry" + order: 7 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Verbs for managing the local registry cache. + + + + + diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/registry/info.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/registry/info.mdx new file mode 100644 index 0000000000..9aa3d22637 --- /dev/null +++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/registry/info.mdx @@ -0,0 +1,31 @@ +--- +title: "wheels packages registry info" +description: Print the registry URL, branch, cache directory, TTL, and cache freshness. +type: reference +sidebar: + label: "info" + order: 2 +--- + +Prints diagnostic information about the registry and the local cache. + +## Synopsis + +``` +wheels packages registry info +``` + +## Example + +``` +$ wheels packages registry info +Registry: wheels-dev/wheels-packages +Branch: main +Browse: https://github.com/wheels-dev/wheels-packages/tree/main/packages +Cache dir: /Users/me/.wheels/cache/packages +Cache TTL: 86400s +Cache present: yes +Index fetched: 2026-04-23T16:42:10+00:00 +``` + +Setting `WHEELS_PACKAGES_REGISTRY=acme/our-packages` before running the command changes the `Registry:` line and isolates the cache under the same path (entries keyed by package name only — if you swap registries, run `registry refresh` to avoid mixing manifests). diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/registry/refresh.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/registry/refresh.mdx new file mode 100644 index 0000000000..cb08685840 --- /dev/null +++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/registry/refresh.mdx @@ -0,0 +1,29 @@ +--- +title: "wheels packages registry refresh" +description: Delete the local manifest cache so the next list/show/install hits the registry over the network. +type: reference +sidebar: + label: "refresh" + order: 1 +--- + +Removes `~/.wheels/cache/packages/` entirely. The next registry-touching command repopulates it. + +## Synopsis + +``` +wheels packages registry refresh +``` + +## When to use + +- A package was just published and the 24h cache is serving stale data. +- Debugging an install problem and you want to rule out cache corruption. +- You changed `WHEELS_PACKAGES_REGISTRY` and want to discard the old registry's cache. + +## Example + +``` +$ wheels packages registry refresh +Cache cleared. Next `wheels packages list` will re-fetch from the registry. +``` diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/remove.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/remove.mdx new file mode 100644 index 0000000000..29ffadce7e --- /dev/null +++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/remove.mdx @@ -0,0 +1,30 @@ +--- +title: "wheels packages remove" +description: Delete vendor//. Refuses to delete dirs without a package.json as a safety check. +type: reference +sidebar: + label: "remove" + order: 6 +--- + +Removes an installed package by deleting `vendor//`. As a safety check, the command refuses if the directory has no `package.json` — that shape suggests the dir isn't a Wheels package and we shouldn't touch it. + +## Synopsis + +``` +wheels packages remove +``` + +## Failure modes + +| Error | Cause | +|-------|-------| +| `Wheels.Packages.NotInstalled` | `vendor//` does not exist. | +| `Wheels.Packages.NotAPackage` | `vendor//` has no `package.json`. Remove it manually if you're sure. | + +## Example + +``` +$ wheels packages remove wheels-sentry +Removed vendor/wheels-sentry. +``` diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/search.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/search.mdx new file mode 100644 index 0000000000..2c75ff63a8 --- /dev/null +++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/search.mdx @@ -0,0 +1,23 @@ +--- +title: "wheels packages search" +description: Substring match against package name, description, and tags. +type: reference +sidebar: + label: "search" + order: 2 +--- + +Case-insensitive substring match across `name`, `description`, and `tags[]`. Use `list --tag=` for exact-tag filtering. + +## Synopsis + +``` +wheels packages search +``` + +## Example + +``` +$ wheels packages search sentry +wheels-sentry Sentry error tracking [monitoring, errors, observability] +``` diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/show.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/show.mdx new file mode 100644 index 0000000000..5fc6187955 --- /dev/null +++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/show.mdx @@ -0,0 +1,34 @@ +--- +title: "wheels packages show" +description: Show package metadata and every version compatible with the running framework. +type: reference +sidebar: + label: "show" + order: 3 +--- + +Renders the manifest's metadata and the subset of `versions[]` whose `wheelsVersion` constraint is satisfied by the running framework. If the package is already installed, the installed version appears in the header. + +## Synopsis + +``` +wheels packages show +``` + +## Example + +``` +$ wheels packages show wheels-sentry +wheels-sentry — Sentry error tracking for Wheels with framework-aware context enrichment. +Homepage: https://github.com/wheels-dev/wheels-sentry +Documentation: https://wheels.dev/packages/wheels-sentry +License: Apache-2.0 +Maintainers: @bpamiri +Tags: monitoring, errors, observability +Installed: 1.0.0 + +Compatible versions (runtime 4.0.0): + 1.0.0 [wheelsVersion >=4.0] published 2026-04-23T00:00:00Z +``` + +A package with no compatible versions for the current runtime still renders its metadata — the "Compatible versions" section shows `(none)`. diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/update.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/update.mdx new file mode 100644 index 0000000000..f29e23517a --- /dev/null +++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/update.mdx @@ -0,0 +1,43 @@ +--- +title: "wheels packages update" +description: Bump an installed package (or all of them) to the latest compatible version. Explicit — requires --yes. +type: reference +sidebar: + label: "update" + order: 5 +--- + +Updates are always explicit. There is no auto-update on reload and no implicit upgrade when running `install` on an already-installed package. This is the registry's defense against malicious version-bump attacks — every version change is the user's conscious choice. + +## Synopsis + +``` +wheels packages update --yes +wheels packages update --all --yes +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--yes` | **Required.** Confirms the update. Missing `--yes` throws `Wheels.Packages.ConfirmationRequired`. | +| `--all` | Update every installed package. Still requires `--yes`. | + +## Mass update semantics + +`update --all --yes` iterates every dir under `vendor/` (except `vendor/wheels/`) that has a `package.json`. Failures in one package do not halt the others — the run continues and prints a per-package report at the end. If any package failed, the command throws `Wheels.Packages.PartialUpdateFailure` so the shell exit code is non-zero; the aggregated report is in `extendedInfo`. + +## Example + +``` +$ wheels packages update wheels-sentry --yes +Installed wheels-sentry@1.1.0 → /Users/me/app/vendor/wheels-sentry +Run `wheels reload` to activate it. + +$ wheels packages update --all --yes +Update report: + wheels-sentry: updated → 1.1.0 + wheels-hotwire: already at 2.0.0 + wheels-basecoat: FAILED (Wheels.Packages.RegistryUnavailable — HTTP 503) +# exit 1 — partial failure +``` From 7b5fe354a963d12e05539feb66f3d774f00d1eed Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Thu, 23 Apr 2026 17:15:31 -0700 Subject: [PATCH 2/2] fix(docs): escape SemVer ranges in packages install.mdx to unblock MDX build MDX parses bare `<2.0.0` as a JSX tag (`Unexpected character `2` before name`). Wrap the compatible-with range in backticks so the MDX rollup plugin treats it as code. --- .../command-line-tools/commands/packages/install.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/install.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/install.mdx index 0d57b04faa..d144816964 100644 --- a/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/install.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/command-line-tools/commands/packages/install.mdx @@ -26,8 +26,8 @@ wheels packages install [@] [--force] Without a pin, picks the highest `versions[]` entry whose `wheelsVersion` constraint is satisfied by the running framework. With a pin: - `@1.2.3` — exact match. -- `@^1.0.0` — caret (compatible-with): ≥1.0.0, <2.0.0. -- `@~1.2.0` — tilde (patch-level): ≥1.2.0, <1.3.0. +- `@^1.0.0` — caret (compatible-with): `>=1.0.0 <2.0.0`. +- `@~1.2.0` — tilde (patch-level): `>=1.2.0 <1.3.0`. - `@>=1.0 <2.0` — space-separated range. The same `SemVer` matcher that `PackageLoader` uses at runtime.