Skip to content

Commit 49f51e1

Browse files
authored
fix: ship registry reader with framework so Tools→Packages works in fresh apps (#2543)
* fix: ship registry reader with framework so Tools→Packages works in fresh apps The debug bar's Tools→Packages link (and the standalone packagelist page it opens) silently rendered an empty registry list in apps generated with `wheels new`. Public.$loadRegistryPackages gated on FileExists("/cli/lucli/services/packages/Registry.cfc") — that path only exists in the framework dev repo. User apps ship vendor/wheels/ without cli/, so the gate always returned an empty array. Move the registry reader into the framework at vendor/wheels/services/packages/{Registry,HttpClient,ManifestCache}.cfc so it ships with every generated app. Public.cfc now instantiates the bundled component directly and the file-existence gate is gone. Keep the registry list scoped to the standalone Tools→Packages page — the inline Environment > Packages section now shows installed packages only, so the debug bar stays compact and we don't trigger a registry walk on every dev-mode page load. The CLI keeps its own copies under cli/lucli/services/packages/; both contexts have their own classpath mappings (modules.wheels.* vs wheels.*) and dedup is left for a follow-up. Issue #2530. * fix: validate versions array in Registry.fetchManifest before listAll() reads it Address PR #2543 reviewer A. listAll() reads local.m.versions[ArrayLen(local.m.versions)] without first checking that the .versions key exists, is an array, and is non-empty. The existing manifest validation only checked for .name. A registry entry that passed the .name check but lacked .versions would crash with an Expression-level "element VERSIONS is undefined" error — bypassing both the per-package RegistryMalformed skip in listAll() and the three typed catches in Public.$loadRegistryPackages, surfacing as an unhandled stack trace on the Tools → Packages page. Validate the .versions invariant in fetchManifest so cached and fresh manifests share one guard, and the per-package skip catches a typed throw. Mirrored into the CLI's Registry.cfc to keep both copies in sync per the in-file note. New RegistryFetchManifestSpec exercises the framework Registry with a fake HttpClient — covers missing key, non-array, empty array, and the listAll() end-to-end skip path. Adds wheels.tests._assets.packages.FakeHttpClient trimmed to the framework HttpClient surface (no download()). CHANGELOG entry for #2530 also rewritten to match the final UX (registry list scoped to the standalone Tools → Packages page only) and a new entry added for the validation fix. * fix: validate cached manifests on read so stale on-disk entries can't crash listAll Address PR #2543 reviewer B follow-up. The previous fix only validated manifests on the fresh-fetch path before writing to cache. The cache-hit path in fetchManifest returned the on-disk entry without revalidating, so a manifest written by an older Registry version that didn't enforce the versions invariant could still crash listAll() with an Expression-level error. Extract the manifest contract assertion into $validateManifest() and call it from both the cache-hit and fresh-fetch paths. Mirrored into the CLI's Registry.cfc to keep both copies in sync. New spec case writes a stale-bad manifest to the cache directly, marks it fresh, then verifies fetchManifest re-validates on read instead of returning the stale entry.
1 parent 6ea3d8b commit 49f51e1

12 files changed

Lines changed: 749 additions & 211 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ All historical references to "CFWheels" in this changelog have been preserved fo
150150

151151
### Fixed
152152

153-
- Debug bar Packages tab now lists packages available from the `wheels-dev/wheels-packages` registry alongside the installed-packages table, mirroring the existing wire-up in `/wheels/packages`. Previously only the installed-packages table rendered, so developers had to leave the debug bar to discover what else was available. (#2530)
153+
- Debug bar Tools → Packages page now lists packages available from the `wheels-dev/wheels-packages` registry in fresh apps generated with `wheels new`. The previous gate (`FileExists("/cli/lucli/services/packages/Registry.cfc")`) silently returned an empty list because user apps don't ship the CLI alongside the framework. The registry reader now lives at `vendor/wheels/services/packages/{Registry,HttpClient,ManifestCache}.cfc` and ships with every generated app. The registry list stays scoped to the standalone Tools → Packages page; the inline debug-bar Environment panel shows installed packages only, so the bar stays compact and doesn't trigger a registry walk on every dev-mode page load. (#2530)
154+
- `Registry.fetchManifest()` now validates that a manifest contains a non-empty `versions` array before returning, throwing `Wheels.Packages.RegistryMalformed` instead of letting a downstream `local.m.versions[ArrayLen(...)]` access crash with an unhandled `Expression` error. The per-package skip-on-malformed catch in `listAll()` now actually catches every malformed shape, so the Tools → Packages page degrades gracefully when the registry serves a partial manifest. Mirrored into the CLI's `cli/lucli/services/packages/Registry.cfc` to keep both copies in sync. (#2530)
154155
- Snapshot pre-releases on `develop` now publish the full artifact set (`wheels-core-*.zip`, `wheels-base-template-*.zip`, `wheels-cli-*.zip`, `wheels-starter-app-*.zip`) alongside `wheels-module-*`. Previously only the module tarball was attached, which broke Homebrew/Chocolatey distributions that depend on fetching `wheels-core-*.zip` as a companion artifact: users scaffolded a new app and hit "Could not locate the Wheels framework source" at chapter 1 of the tutorial. Snapshots now mirror the main-branch release contents exactly, flagged as pre-release.
155156
- `wheels doctor` now detects when the installed CLI module has no companion framework source (vendor/wheels/) on disk — catches broken package distributions before they surface as a cryptic scaffold error. Previously `doctor` would report missing project directories and recommend `wheels new`, but `wheels new` would then fail with "Could not locate the Wheels framework source." The new `checkFrameworkSourceBundled` check walks the same search paths as `Module.cfc`'s `resolveFrameworkSource()` and reports a CRITICAL issue when none resolve, replacing the misleading `wheels new` recommendation with guidance to reinstall or set `WHEELS_FRAMEWORK_PATH`.
156157
- `wheels new` framework-not-found error now links to the real guides page (`/v4-0-0-snapshot/start-here/installing/`) instead of a 404 (`/docs/getting-started`), and mentions Homebrew/Chocolatey packaging explicitly so users can tell the difference between "I'm in the wrong directory" and "my install is incomplete."

cli/lucli/services/packages/Registry.cfc

Lines changed: 66 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,28 +15,29 @@ component {
1515
variables.DEFAULT_REPO = "wheels-dev/wheels-packages";
1616
variables.DEFAULT_BRANCH = "main";
1717

18-
public Registry function init(
19-
any httpClient = "",
20-
any cache = "",
21-
string registryRepo = "",
22-
string branch = ""
23-
) {
18+
public Registry function init(any httpClient = "", any cache = "", string registryRepo = "", string branch = "") {
2419
variables.http = IsObject(arguments.httpClient)
25-
? arguments.httpClient
26-
: new modules.wheels.services.packages.HttpClient();
20+
? arguments.httpClient
21+
: new modules.wheels.services.packages.HttpClient();
2722
variables.cache = IsObject(arguments.cache)
28-
? arguments.cache
29-
: new modules.wheels.services.packages.ManifestCache();
23+
? arguments.cache
24+
: new modules.wheels.services.packages.ManifestCache();
3025
variables.registryRepo = Len(arguments.registryRepo)
31-
? arguments.registryRepo
32-
: $resolveRepo();
26+
? arguments.registryRepo
27+
: $resolveRepo();
3328
variables.branch = Len(arguments.branch) ? arguments.branch : variables.DEFAULT_BRANCH;
3429
return this;
3530
}
3631

37-
public string function registryRepo() { return variables.registryRepo; }
38-
public string function branch() { return variables.branch; }
39-
public any function cache() { return variables.cache; }
32+
public string function registryRepo() {
33+
return variables.registryRepo;
34+
}
35+
public string function branch() {
36+
return variables.branch;
37+
}
38+
public any function cache() {
39+
return variables.cache;
40+
}
4041

4142
/**
4243
* Returns the list of package names in the registry. Serves cached
@@ -56,10 +57,7 @@ component {
5657
}
5758
local.entries = DeserializeJSON(local.resp.body);
5859
if (!IsArray(local.entries)) {
59-
Throw(
60-
type = "Wheels.Packages.RegistryMalformed",
61-
message = "Registry contents endpoint did not return an array."
62-
);
60+
Throw(type = "Wheels.Packages.RegistryMalformed", message = "Registry contents endpoint did not return an array.");
6361
}
6462
local.names = [];
6563
for (local.entry in local.entries) {
@@ -74,10 +72,18 @@ component {
7472

7573
/**
7674
* Fetches a package's manifest. Cached 24h per package.
75+
*
76+
* Both the cache-hit and fresh-fetch paths run $validateManifest()
77+
* so a manifest written by an older Registry version that lacks
78+
* the `versions` invariant (or any other required field added later)
79+
* still throws RegistryMalformed instead of crashing listAll() with
80+
* an Expression-level error.
7781
*/
7882
public struct function fetchManifest(required string name) {
7983
if (variables.cache.hasFreshManifest(arguments.name)) {
80-
return variables.cache.readManifest(arguments.name);
84+
local.cached = variables.cache.readManifest(arguments.name);
85+
$validateManifest(arguments.name, local.cached);
86+
return local.cached;
8187
}
8288
local.url = "https://raw.githubusercontent.com/#variables.registryRepo#/#variables.branch#/packages/#arguments.name#/manifest.json";
8389
local.resp = variables.http.get(local.url);
@@ -94,14 +100,36 @@ component {
94100
);
95101
}
96102
local.manifest = DeserializeJSON(local.resp.body);
97-
if (!IsStruct(local.manifest) || !StructKeyExists(local.manifest, "name")) {
103+
$validateManifest(arguments.name, local.manifest);
104+
variables.cache.writeManifest(arguments.name, local.manifest);
105+
return local.manifest;
106+
}
107+
108+
/**
109+
* Asserts the listAll() consumption contract: must be a struct with
110+
* `name` and a non-empty `versions` array. Throws RegistryMalformed
111+
* on any violation. Called from both the cache-hit and fresh-fetch
112+
* paths in fetchManifest so stale on-disk manifests written by an
113+
* older Registry version surface as a typed throw instead of an
114+
* Expression-level crash deeper in the call chain.
115+
*/
116+
private void function $validateManifest(required string name, required any manifest) {
117+
if (!IsStruct(arguments.manifest) || !StructKeyExists(arguments.manifest, "name")) {
98118
Throw(
99119
type = "Wheels.Packages.RegistryMalformed",
100120
message = "Manifest for '#arguments.name#' is not a valid manifest struct."
101121
);
102122
}
103-
variables.cache.writeManifest(arguments.name, local.manifest);
104-
return local.manifest;
123+
if (
124+
!StructKeyExists(arguments.manifest, "versions")
125+
|| !IsArray(arguments.manifest.versions)
126+
|| !ArrayLen(arguments.manifest.versions)
127+
) {
128+
Throw(
129+
type = "Wheels.Packages.RegistryMalformed",
130+
message = "Manifest for '#arguments.name#' is missing a non-empty versions array."
131+
);
132+
}
105133
}
106134

107135
/**
@@ -120,24 +148,27 @@ component {
120148
continue;
121149
}
122150
local.latest = local.m.versions[ArrayLen(local.m.versions)];
123-
ArrayAppend(local.out, {
124-
name: local.m.name,
125-
description: local.m.description ?: "",
126-
tags: IsArray(local.m.tags ?: "") ? local.m.tags : [],
127-
homepage: local.m.homepage ?: "",
128-
latestVersion: local.latest.version
129-
});
151+
ArrayAppend(
152+
local.out,
153+
{
154+
name = local.m.name,
155+
description = local.m.description ?: "",
156+
tags = IsArray(local.m.tags ?: "") ? local.m.tags : [],
157+
homepage = local.m.homepage ?: "",
158+
latestVersion = local.latest.version
159+
}
160+
);
130161
}
131162
return local.out;
132163
}
133164

134165
public struct function info() {
135166
local.cacheInfo = variables.cache.info();
136167
return {
137-
registryRepo: variables.registryRepo,
138-
branch: variables.branch,
139-
indexUrl: "https://github.com/#variables.registryRepo#/tree/#variables.branch#/packages",
140-
cache: local.cacheInfo
168+
registryRepo = variables.registryRepo,
169+
branch = variables.branch,
170+
indexUrl = "https://github.com/#variables.registryRepo#/tree/#variables.branch#/packages",
171+
cache = local.cacheInfo
141172
};
142173
}
143174

@@ -154,4 +185,5 @@ component {
154185
}
155186
return variables.DEFAULT_REPO;
156187
}
188+
157189
}

0 commit comments

Comments
 (0)