From 487eedd0c01bd19cf180f4ed8ca9dc10d4a52e45 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 15 Jun 2026 11:37:00 -0700 Subject: [PATCH 1/2] fix(plugin): make the deprecated plugins/ directory optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy plugins/ directory is superseded by vendor// packages and apps are expected to remove it. But two code paths still listed it unconditionally and threw when it was absent on engines whose directory listing errors on a missing path (e.g. RustCFML; Lucee/Adobe return empty): - The scaffold's public/Application.cfc jar-scan (this.javaSettings.LoadPaths loop) — guarded with DirectoryExists; mirrored into the demo app and the tweet/starter-app examples. - The framework plugin loader Plugins.cfc $folders()/$files() — now short-circuit to an empty query when the plugins directory does not exist. Behavior is unchanged when plugins/ exists (the scan runs as before); when it is absent, no plugins load and no error is raised. Adds pluginsMissingDirSpec (init + $folders()/$files() against a non-existent path). The lookup is deprecated and slated for removal in the next major. Signed-off-by: Peter Amiri --- changelog.d/plugins-dir-optional.fixed.md | 1 + .../templates/app/public/Application.cfc | 13 +++-- examples/starter-app/public/Application.cfc | 13 +++-- examples/tweet/public/Application.cfc | 13 +++-- public/Application.cfc | 13 +++-- vendor/wheels/Plugins.cfc | 12 ++++ .../tests/specs/pluginsMissingDirSpec.cfc | 55 +++++++++++++++++++ 7 files changed, 96 insertions(+), 24 deletions(-) create mode 100644 changelog.d/plugins-dir-optional.fixed.md create mode 100644 vendor/wheels/tests/specs/pluginsMissingDirSpec.cfc diff --git a/changelog.d/plugins-dir-optional.fixed.md b/changelog.d/plugins-dir-optional.fixed.md new file mode 100644 index 0000000000..796041ec1e --- /dev/null +++ b/changelog.d/plugins-dir-optional.fixed.md @@ -0,0 +1 @@ +- The legacy `plugins/` directory is now optional. The scaffold's `Application.cfc` jar-scan and the framework plugin loader (`Plugins.cfc` `$folders()`/`$files()`) now guard their directory listing with `DirectoryExists`, so an app that has removed `plugins/` (the common case now that packages live in `vendor//`) no longer errors at startup on engines whose directory listing throws on a missing path — Lucee/Adobe tolerate a missing dir, but stricter engines (e.g. RustCFML) did not. The plugins-directory lookup is deprecated and slated for removal in the next major diff --git a/cli/lucli/templates/app/public/Application.cfc b/cli/lucli/templates/app/public/Application.cfc index d7d0bfc1f9..0a38c6aace 100644 --- a/cli/lucli/templates/app/public/Application.cfc +++ b/cli/lucli/templates/app/public/Application.cfc @@ -24,13 +24,14 @@ component output="false" { this.sessionManagement = true; // If a plugin has a jar or class file, automatically add the mapping to this.javasettings. + // Legacy plugins system (DEPRECATED — superseded by vendor// packages). + // Only scan when a plugins/ directory exists, so a removed plugins/ dir does not + // error on engines whose directoryList() throws on a missing path (e.g. RustCFML; + // Lucee tolerates it). This lookup is slated for removal in the next major. this.wheels.pluginDir = this.appDir & "../plugins"; - this.wheels.pluginFolders = DirectoryList( - this.wheels.pluginDir, - "true", - "path", - "*.class|*.jar|*.java" - ); + this.wheels.pluginFolders = DirectoryExists(this.wheels.pluginDir) + ? DirectoryList(this.wheels.pluginDir, "true", "path", "*.class|*.jar|*.java") + : []; for (this.wheels.folder in this.wheels.pluginFolders) { if (!StructKeyExists(this, "javaSettings")) { diff --git a/examples/starter-app/public/Application.cfc b/examples/starter-app/public/Application.cfc index 5c7a53d5e4..954ba4e7dc 100644 --- a/examples/starter-app/public/Application.cfc +++ b/examples/starter-app/public/Application.cfc @@ -26,13 +26,14 @@ component output="false" { this.sessionManagement = true; // If a plugin has a jar or class file, automatically add the mapping to this.javasettings. + // Legacy plugins system (DEPRECATED — superseded by vendor// packages). + // Only scan when a plugins/ directory exists, so a removed plugins/ dir does not + // error on engines whose directoryList() throws on a missing path (e.g. RustCFML; + // Lucee tolerates it). This lookup is slated for removal in the next major. this.wheels.pluginDir = this.appDir & "../plugins"; - this.wheels.pluginFolders = DirectoryList( - this.wheels.pluginDir, - "true", - "path", - "*.class|*.jar|*.java" - ); + this.wheels.pluginFolders = DirectoryExists(this.wheels.pluginDir) + ? DirectoryList(this.wheels.pluginDir, "true", "path", "*.class|*.jar|*.java") + : []; for (this.wheels.folder in this.wheels.pluginFolders) { if (!StructKeyExists(this, "javaSettings")) { diff --git a/examples/tweet/public/Application.cfc b/examples/tweet/public/Application.cfc index 5c7a53d5e4..954ba4e7dc 100755 --- a/examples/tweet/public/Application.cfc +++ b/examples/tweet/public/Application.cfc @@ -26,13 +26,14 @@ component output="false" { this.sessionManagement = true; // If a plugin has a jar or class file, automatically add the mapping to this.javasettings. + // Legacy plugins system (DEPRECATED — superseded by vendor// packages). + // Only scan when a plugins/ directory exists, so a removed plugins/ dir does not + // error on engines whose directoryList() throws on a missing path (e.g. RustCFML; + // Lucee tolerates it). This lookup is slated for removal in the next major. this.wheels.pluginDir = this.appDir & "../plugins"; - this.wheels.pluginFolders = DirectoryList( - this.wheels.pluginDir, - "true", - "path", - "*.class|*.jar|*.java" - ); + this.wheels.pluginFolders = DirectoryExists(this.wheels.pluginDir) + ? DirectoryList(this.wheels.pluginDir, "true", "path", "*.class|*.jar|*.java") + : []; for (this.wheels.folder in this.wheels.pluginFolders) { if (!StructKeyExists(this, "javaSettings")) { diff --git a/public/Application.cfc b/public/Application.cfc index ab004fb993..b1eb617a63 100644 --- a/public/Application.cfc +++ b/public/Application.cfc @@ -37,13 +37,14 @@ component output="false" { this.sessionManagement = true; // If a plugin has a jar or class file, automatically add the mapping to this.javasettings. + // Legacy plugins system (DEPRECATED — superseded by vendor// packages). + // Only scan when a plugins/ directory exists, so a removed plugins/ dir does not + // error on engines whose directoryList() throws on a missing path (e.g. RustCFML; + // Lucee tolerates it). This lookup is slated for removal in the next major. this.wheels.pluginDir = this.appDir & "../plugins"; - this.wheels.pluginFolders = DirectoryList( - this.wheels.pluginDir, - "true", - "path", - "*.class|*.jar|*.java" - ); + this.wheels.pluginFolders = DirectoryExists(this.wheels.pluginDir) + ? DirectoryList(this.wheels.pluginDir, "true", "path", "*.class|*.jar|*.java") + : []; for (this.wheels.folder in this.wheels.pluginFolders) { if (!StructKeyExists(this, "javaSettings")) { diff --git a/vendor/wheels/Plugins.cfc b/vendor/wheels/Plugins.cfc index 5fcec9b62d..3386df7be8 100644 --- a/vendor/wheels/Plugins.cfc +++ b/vendor/wheels/Plugins.cfc @@ -1041,6 +1041,14 @@ component output="false" extends="wheels.Global"{ } public query function $folders() { + // The legacy plugins/ directory is deprecated (superseded by vendor// + // packages) and may be absent. Skip the scan when it does not exist so + // engines whose directory listing throws on a missing path (e.g. RustCFML) + // don't fail at boot; Lucee/Adobe return empty for a missing dir anyway. + // Slated for removal with the plugins system in the next major. + if (!DirectoryExists(variables.$class.pluginPathFull)) { + return QueryNew("name,directory,type"); + } local.query = $directory( action = "list", directory = variables.$class.pluginPathFull, @@ -1086,6 +1094,10 @@ component output="false" extends="wheels.Global"{ } public query function $files() { + // See $folders(): the deprecated plugins/ directory may be absent. + if (!DirectoryExists(variables.$class.pluginPathFull)) { + return QueryNew("name,directory,type"); + } local.query = $directory( action = "list", directory = variables.$class.pluginPathFull, diff --git a/vendor/wheels/tests/specs/pluginsMissingDirSpec.cfc b/vendor/wheels/tests/specs/pluginsMissingDirSpec.cfc new file mode 100644 index 0000000000..caecc3d17a --- /dev/null +++ b/vendor/wheels/tests/specs/pluginsMissingDirSpec.cfc @@ -0,0 +1,55 @@ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo + + // The legacy plugins/ directory is deprecated (superseded by vendor// + // packages) and apps are expected to remove it. The plugin loader must not + // error when it is absent — Lucee/Adobe return empty for a missing dir, but + // stricter engines (e.g. RustCFML) throw on directory listing of a missing + // path, which previously failed onApplicationStart. $folders()/$files() now + // short-circuit to an empty query when the directory does not exist. + describe("plugin loader with an absent plugins/ directory", () => { + + missingPath = "/wheels/tests/_assets/plugins/__this_directory_does_not_exist__" + + it("initializes without throwing when the plugins directory is missing", () => { + var config = { + path = "wheels", + fileName = "Plugins", + method = "$init", + pluginPath = missingPath, + deletePluginDirectories = false, + overwritePlugins = false, + loadIncompatiblePlugins = true + } + var state = {thrown = false} + try { + pluginObj = $pluginObj(config) + } catch (any e) { + state.thrown = true + } + expect(state.thrown).toBeFalse() + }) + + it("$folders() returns an empty query for a missing directory", () => { + var config = { + path = "wheels", fileName = "Plugins", method = "$init", pluginPath = missingPath, + deletePluginDirectories = false, overwritePlugins = false, loadIncompatiblePlugins = true + } + var pluginObj = $pluginObj(config) + expect(pluginObj.$folders().recordCount).toBe(0) + }) + + it("$files() returns an empty query for a missing directory", () => { + var config = { + path = "wheels", fileName = "Plugins", method = "$init", pluginPath = missingPath, + deletePluginDirectories = false, overwritePlugins = false, loadIncompatiblePlugins = true + } + var pluginObj = $pluginObj(config) + expect(pluginObj.$files().recordCount).toBe(0) + }) + }) + } +} From 9b049afbd11d8cabb5346b89030f4c0dbf63291f Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 16 Jun 2026 13:09:34 -0700 Subject: [PATCH 2/2] test(plugin): exercise the missing-dir guard via the $pluginObj helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec called $pluginObj(config) without defining the helper the four sibling plugin specs use, so it resolved to the parameterless Global.$pluginObj() that WheelsTest auto-binds — which ignores config and returns the cached PluginObj pointing at the real plugins/ dir. The missing-path branch (the $folders()/$files() DirectoryExists guards, the actual fix) was never executed; the assertions passed for the wrong reason. Add the same component-level $pluginObj helper the siblings use so $createObjectFromRoot dispatches $init with pluginPath=missingPath, building a Plugins instance bound to the non-existent path. The three assertions now genuinely exercise the guard. Addresses wheels-bot review on #3211. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Peter Amiri --- vendor/wheels/tests/specs/pluginsMissingDirSpec.cfc | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/vendor/wheels/tests/specs/pluginsMissingDirSpec.cfc b/vendor/wheels/tests/specs/pluginsMissingDirSpec.cfc index caecc3d17a..7267433988 100644 --- a/vendor/wheels/tests/specs/pluginsMissingDirSpec.cfc +++ b/vendor/wheels/tests/specs/pluginsMissingDirSpec.cfc @@ -52,4 +52,16 @@ component extends="wheels.WheelsTest" { }) }) } + + // Mirror the sibling plugin specs (pluginsSpec.cfc:549, pluginsModernSpec, + // pluginsSemverSpec, pluginsManifestIntegrationSpec): a component-level + // helper that instantiates wheels.Plugins via $createObjectFromRoot and + // dispatches $init with the full config — INCLUDING pluginPath. Without it, + // $pluginObj(config) resolves to the parameterless Global.$pluginObj() that + // WheelsTest auto-binds, which ignores config and returns the cached PluginObj + // pointing at the real plugins/ dir — so the missing-path branch (the fix) + // never runs and these specs pass for the wrong reason. + function $pluginObj(required struct config) { + return g.$createObjectFromRoot(argumentCollection = arguments.config) + } }