Skip to content

fix(cli): wheels start auto-stages SQLite driver to close fresh-VM F8 - #2349

Merged
bpamiri merged 3 commits into
developfrom
claude/pedantic-cori-bae999
Apr 28, 2026
Merged

bpamiri merged 3 commits into
developfrom
claude/pedantic-cori-bae999

Conversation

@bpamiri

@bpamiri bpamiri commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #2326. After this PR, wheels new + wheels start works with the zero-config SQLite default datasource on any install path (brew, chocolatey, dev checkout, manual) without the user needing to drop a JAR by hand.

Background

Lucee 7's stock Express distribution ships JDBC drivers for MySQL, MSSQL, PostgreSQL, and HSQLDB but not SQLite. wheels new writes SQLite as the zero-config default. Without a JAR somewhere Lucee can find — Tomcat classpath via lib/ext/, or an OSGi bundle in lucee-server/bundles/ — every fresh app fails on the first request with ClassException: org.sqlite.JDBC. That's finding F8 in the fresh-VM onboarding journals.

The brew formula already drops the upstream xerial JAR into ~/.wheels/express/<lucee>/lib/ext/ via its wrapper script, but that only covers brew installs. Chocolatey, dev checkouts, and manual installs all hit F8 on the first VM cycle.

What this changes

File Purpose
cli/lucli/services/BundleStager.cfc (new) Extracted for unit-testability. Two methods: projectUsesSqliteDatasource(projectRoot) (handles current and legacy app.cfm formats) and stageIntoLibExt(bundleSrc, expressRoot, jarFileName) (idempotent multi-version copy).
cli/lucli/Module.cfc start() calls $ensureWheelsBundles() both before and after delegating to LuCLI's server start. Pre-stage covers the case where express already exists; post-stage covers the very first invocation that just extracted express. Either way, the JAR is on the classpath before Lucee resolves any SQLite datasource.
cli/lucli/resources/extensions/sqlite/org.xerial.sqlite-jdbc-3.49.1.0.jar (~13 MB) Patched JAR baked into the wheels-module distribution. Built from tools/lucee-extensions/sqlite/build.sh (#2347), which patches upstream's strict Require-Capability: osgi.ee;version=1.8 (exact match — fails on Felix + Java 21) to the (version>=1.8) filter PostgreSQL uses.
cli/lucli/tests/specs/services/BundleStagerSpec.cfc (new, 10 specs) Detection edge cases, multi-version Express layouts, idempotency, defensive behavior when bundle source or express dir is missing.

$resolveLucliHome() follows LuCLI's own resolution order: $LUCLI_HOME env var → $HOME/.<lucli.binary.name> (e.g. ~/.wheels for wheels, ~/.lucli for bare lucli) → $HOME/.lucli fallback.

Bypass cases (separate follow-ups)

  • wheels server start --force is a LuCLI built-in passthrough (not routed through Module.cfc). Users who hit this should use wheels start --force instead. F5 in the journal already tracks updating the error message that points users at the bypass form.
  • Lucee caches per-process negative bundle resolution. If a user hits a SQLite endpoint before the JAR is staged, restart the server. The pre-stage in start() only matters for a hypothetical end-user who races the staging; the post-stage always seeds the next start.

Verification

  • Framework suite: 3333 passed, no regressions
  • CLI suite: 462 passed (10 new BundleStagerSpec covers projectUsesSqliteDatasource detection × 5 + stageIntoLibExt × 5), 3 fail — pre-existing DoctorSpec issues (#2260), unrelated
  • End-to-end on macOS arm64 + JDK 21, no SQLite JAR anywhere:
    $ wheels new finalapp
    $ cd finalapp
    $ wheels start
    $ curl http://localhost:8080/dbtest.cfm
      OK datasource=finalapp result=1
    
    Pre-stage logs confirmed the JAR was copied into ~/.wheels/express/7.0.0.395/lib/ext/. wheels start --force follows the same path and works.
  • Idempotency: re-running wheels start on an already-staged express dir is a no-op (skipped, not staged).

🤖 Generated with Claude Code

bpamiri and others added 3 commits April 27, 2026 20:51
…le gap

Build, install, and ship a Lucee CFML extension that packages the xerial
sqlite-jdbc OSGi bundle so Lucee 7 can resolve `org.sqlite.JDBC` for
`wheels new` SQLite-by-default datasources without manual JAR drops.

This addresses fresh-VM onboarding finding F8 — the SQLite class-load
cliff that blocks every fresh `wheels migrate latest` because Lucee 7
ships drivers for MySQL/MSSQL/PostgreSQL/HSQLDB but not SQLite.

What's in tools/lucee-extensions/sqlite/:

- src/build.properties + src/SQLite.cfc + src/sqlite-jdbc-3.49.1.0.jar
  (vendored from Maven Central) — extension source.
- build.sh produces dist/org.xerial.sqlite-jdbc-3.49.1.0.lex (~14 MB).
  Mirrors the layout/manifest of lucee/extension-jdbc-postgresql and
  lucee/extension-jdbc-duckdb but uses bash+zip instead of Ant.
- install.sh drops the patched bundle straight into a Lucee server's
  lucee-server/bundles/ directory — deterministic on Lucee 7 and the
  recommended path until the deploy/ auto-install quirk is solved.
- README documents three install paths (bundle/.lex/admin UI), the
  upstream upgrade plan, and the OSGi manifest patch.

The non-obvious fix: xerial's JAR is a valid OSGi bundle, but it
declares `Require-Capability: osgi.ee;version=1.8` (exact match in some
resolvers). Felix on Java 21 cannot satisfy that, so the bundle is
silently rejected. build.sh patches the JAR's MANIFEST.MF to
`(version>=1.8)` (matching PostgreSQL's filter) and drops the
`;singleton:=true` directive on Bundle-SymbolicName. Original JAR in
src/ is untouched — the patch is applied during build.

Verified end-to-end on macOS arm64 + Lucee 7.0.0.395:

  BEFORE install.sh: dbtest -> FAIL "org.xerial.sqlite-jdbc not available locally"
  AFTER  install.sh: dbtest -> OK datasource=sqliteapp result=1
  AFTER  install.sh: wheels migrate latest creates tables in db/development.sqlite

Follow-ups (separate PRs):
1. Wire `wheels new` (or first `wheels start`) to auto-run install.sh
   against the freshly-created server so SQLite-by-default works
   zero-config on fresh VMs.
2. Debug Lucee 7's deploy/ -> failed-to-deploy/ silent quarantine so
   the canonical deploy/ install path works without falling back to
   the bundle drop.
3. File upstream PR as lucee/extension-jdbc-sqlite once stable so the
   Lucee community gets it via the standard update server, and a
   separate issue with xerial/sqlite-jdbc to relax their OSGi
   Require-Capability filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two compounding bugs caused the dev toolbar / /wheels/info to report
"Wheels Version: 0.0.0-dev" on installed (brew/chocolatey) apps even
though every release artifact already carries the precise SNAPSHOT
build version. Both are fixed here.

## Bug 1 — wheels-module tarball never substitutes module.json

release.yml's "Build Wheels Module Tarball" step tars cli/lucli/
directly with no version substitution, while wheels-core/wheels-base
(which run through prepare-*.sh) get @build.version@ properly replaced.
As a result every wheels-module-*.tar.gz on the GitHub releases page
ships with module.json's hardcoded "version": "4.0.0" — even though
the tag itself is "v4.0.0-SNAPSHOT+1625".

That hardcoded value is then read by BaseModule.version() at runtime
and fed into FrameworkInstaller.rewriteVersionPlaceholder as the
cliVersion fallback (added in #2343 / #2333), so on installs whose
vendor/wheels/box.json went through that fallback path, the framework
"version" is the imprecise "4.0.0" instead of the build's
"4.0.0-SNAPSHOT+1625".

Fix: change cli/lucli/module.json to use "version": "@build.version@"
(matching every other manifest in the repo), and add the same find +
sed substitution release.yml's other build steps already do, in the
"Build Wheels Module Tarball" step before tar/zip. Uses an env: block
to satisfy our workflow injection lint and avoids inlining the version
into shell commands.

## Bug 2 — runtime fallback only recognizes the monorepo's box.json

When vendor/wheels/box.json's @build.version@ leaks through scaffold-
time substitution (e.g. user's CLI is pre-#2343, or any other gap in
the install pipeline), Global.cfc:$readFrameworkVersion's fallback
walks two levels up looking for the wheels-dev/wheels monorepo's
box.json — and only synthesizes "<rootversion>-dev" when slug=="wheels"
or name=="Wheels.fw". An installed app's app-root box.json comes from
wheels-base-template (slug=wheels-base-template), which carries the
precise framework SNAPSHOT version stamped at release time — but the
fallback rejects it because the monorepo signal is absent. Fall-through
returns the bare "0.0.0-dev" sentinel.

Fix: extend the fallback so when the monorepo signal is missing AND
the parent's box.json self-identifies as wheels-base-template (slug or
name match), use its version verbatim (no -dev suffix — that version is
the actual installed framework version, not an upcoming dev build).
The monorepo signal still wins when both could match; both signals are
ignored if their version is itself the unreplaced placeholder.

Three new specs in frameworkVersionSpec.cfc cover the new path,
including the precedence ordering and the placeholder guard. Existing
specs (including the regression for #2291) still pass.

## Verification

- Framework suite: 3333 passed (no regressions).
- CLI suite: 452 pass, 3 fail — pre-existing DoctorSpec issues (#2260),
  unrelated.
- Local dry-run of the release.yml sed substitution: cli/lucli/module.json
  picks up MODULE_VERSION, no other false positives in cli/lucli/.

Closes #2326.
Closes #2326. After this PR, `wheels new` + `wheels start` works with the
zero-config SQLite default datasource on any install path — brew, choco,
dev checkout, manual — without the user needing to drop a JAR by hand.

## Background

Lucee 7's stock Express distribution ships JDBC drivers for MySQL, MSSQL,
PostgreSQL, and HSQLDB but not SQLite. `wheels new`, however, writes
SQLite as the zero-config default (`class: "org.sqlite.JDBC"`,
`connectionString: "jdbc:sqlite:..."`). Without a SQLite JAR somewhere
Lucee can find it (Tomcat classpath via lib/ext/ or an OSGi bundle in
lucee-server/bundles/), every fresh app fails on the first request:

  ClassException: org.sqlite.JDBC

The brew formula already drops the upstream xerial JAR into
`~/.wheels/express/<lucee>/lib/ext/` from a wrapper script, but that only
covers brew installs. Chocolatey, dev checkouts, and manual installs all
hit F8 on the first VM cycle.

## What this changes

- `cli/lucli/services/BundleStager.cfc` (new) — extracted from Module.cfc
  for unit-testability. Two methods:
  - `projectUsesSqliteDatasource(projectRoot)` — true if config/app.cfm
    references `jdbc:sqlite:`, `org.sqlite.JDBC`, or
    `org.xerial.sqlite-jdbc`. Detection covers both the legacy app.cfm
    format (which carried the bundleName hint) and the current format
    post-#2304 which omits the hint and relies on classpath resolution.
  - `stageIntoLibExt(bundleSrc, expressRoot, jarFileName)` — copies the
    JAR into every Lucee version's `lib/ext/`. Idempotent, best-effort,
    returns `{staged, skipped, failed}` for testability.

- `cli/lucli/Module.cfc` — `start()` calls `$ensureWheelsBundles()` both
  before and after delegating to LuCLI's `server start`. Pre-stage covers
  the case where the express dir already exists; post-stage covers the
  very first invocation that just extracted express. Either way, `wheels
  start` and `wheels start --force` end up with the JAR on the classpath
  before Lucee resolves any SQLite datasource.

  `$resolveLucliHome()` follows LuCLI's own resolution order:
    1. `$LUCLI_HOME` env var (e.g. brew wrapper exports `$HOME/.wheels`),
    2. `$HOME/.<lucli.binary.name>` — picks `~/.wheels` for `wheels`
       invocations and `~/.lucli` for bare `lucli`,
    3. `$HOME/.lucli` fallback.

- `cli/lucli/resources/extensions/sqlite/org.xerial.sqlite-jdbc-3.49.1.0.jar`
  (~13 MB) — patched JAR baked into the wheels-module distribution. Built
  from `tools/lucee-extensions/sqlite/build.sh` (#2347), which patches
  upstream's `Require-Capability: osgi.ee;version=1.8` (exact match — fails
  on Felix + Java 21) to `(version>=1.8)` matching the PostgreSQL bundle's
  filter.

- `cli/lucli/tests/specs/services/BundleStagerSpec.cfc` (new, 10 specs) —
  detection edge cases, multi-version Express layouts, idempotency, and
  defensive behavior when the bundle source or express dir is missing.

## Bypass cases (separate follow-ups)

- `wheels server start --force` is a LuCLI built-in passthrough (not
  routed through Module.cfc). Users hit by this can use `wheels start
  --force` instead. F5 in the journal already tracks updating the error
  message that points users at the bypass form.
- Lucee caches a per-process negative bundle resolution. If a user hits a
  SQLite endpoint before the JAR is staged, restart the server. The
  pre-stage in `start()` makes this only matter on a single end-of-train
  user; the post-stage seeds for the next start.

## Verification

- Framework suite: 3333 passed (no regressions).
- CLI suite: 462 passed, 3 fail — pre-existing DoctorSpec issues (#2260),
  unrelated. Of the 462, 10 are new BundleStagerSpec coverage.
- End-to-end: on a clean macOS arm64 + JDK 21 env with no SQLite JAR
  anywhere, `wheels new finalapp` + `wheels start` + `curl /dbtest.cfm`
  returns `OK datasource=finalapp result=1` zero-config. Pre-stage logs
  show the JAR copied into `~/.wheels/express/7.0.0.395/lib/ext/`.
@bpamiri
bpamiri merged commit da9058f into develop Apr 28, 2026
4 checks passed
@bpamiri
bpamiri deleted the claude/pedantic-cori-bae999 branch April 28, 2026 16:16
bpamiri added a commit that referenced this pull request Apr 29, 2026
* fix(cli): cfml-escape commit subject baked into BuildInfo.cfc

prepare-core.sh sed-substitutes the latest commit subject into
BuildInfo.cfc inside a double-quoted CFML string. # is CFML's
variable-interpolation delimiter, so a commit subject ending in a
parenthesized PR ref like "(#2352)" produces a syntactically broken
.cfc that crashes Lucee on the first request — bricking every fresh
brew/chocolatey install. Onboarding finding F1.

Escape both # → ## and " → "" before the substitution, then escape sed
replacement specials (\, &) so the value transports through the
existing sed pipeline cleanly. Add an inline post-substitution check
that fails the build if any unescaped # or " remains in the produced
commitSubject literal — a regression-stop without standing up a new
test harness.

Verified end-to-end: with COMMIT_SUBJECT="feat(config): introduce
BuildInfo.cfc as the runtime version source (#2352)", the produced
BuildInfo.cfc reads `commitSubject: "...source (##2352)",` (valid
CFML); reverting the escape pipeline triggers the self-check with
exit 1.

* fix(cli): scaffold writes plural resource name to routes.cfm

generateScaffold() takes a singular model name (e.g. "Post") and
correctly derives a pluralized form for the controller, views, and
migration — but updateRoutes() was being passed the raw singular,
producing .resources("post") in routes.cfm. Onboarding finding F4.

Wheels' resource convention is plural (PostsController maps to
.resources("posts")), so the singular variant either creates a phantom
second route that conflicts with any hand-added plural route, or
silently fails to bind to the controller scaffolded a few lines
earlier. The scaffold runs through cleanly otherwise — this is the one
piece that drifted from the convention.

Pass pluralName, which is already computed for the model/controller/
view generation right above. Add a regression spec that asserts
routes.cfm contains .resources("articles") (plural) and not
.resources("article") (singular) after scaffolding "Article".

* fix(cli): also stage sqlite driver into per-server lucee-server/bundles/

The previous fix (#2349) staged the SQLite JDBC driver into every
Lucee Express install's lib/ext/ — Tomcat's parent classpath. That
satisfies Class.forName("org.sqlite.JDBC") but Lucee 7's datasource
resolver consults its OSGi bundle loader for driver instantiation,
not the Tomcat classloader. Without the bundle in
<server>/lucee-server/bundles/, the very first cfquery against a
SQLite datasource fails — and the Wheels migrator goes through
cfquery, so `wheels migrate latest` blows up on every fresh app.
Onboarding finding F2.

Other engines (MySQL, Postgres) avoid the issue because their bundles
ship inside lucee-7.x.x.jar and Lucee auto-deploys them; the SQLite
extension is third-party and doesn't get the same treatment, so
seeding bundles/ is on us.

Add BundleStager.stageIntoServerBundles() alongside the existing
stageIntoLibExt() — same idempotent / best-effort contract, walks
LUCLI_HOME/servers/<name>/lucee-server/bundles/. Wire it into
$ensureWheelsBundles() so every wheels start populates both targets.
The OSGi-symbolic-name filename (org.xerial.sqlite-jdbc-3.49.1.0.jar)
is required for Lucee's bundle loader to recognize it.

5 new specs cover detection, idempotency, missing serversRoot,
missing bundleSrc, and partial-server-context tolerance. CLI suite
462 → 467 pass on this worktree.

* fix(cli): wheels reload purges per-server cfclasses cache

LuCLI's dev-server Lucee config defaults to `inspectTemplate=once`,
which means Lucee compiles each CFC once, caches the .class on disk
under `<server>/lucee-server/context/cfclasses/`, and never rechecks
the source timestamp. Wheels' `?reload=true` resets framework state
via applicationStop() but does not invalidate Lucee's template cache,
so source edits to models, controllers, and config silently keep
returning the old compiled class. Even `wheels stop && wheels start`
doesn't help — the .class on disk persists. The only path that picks
up changes is physically wiping cfclasses. Onboarding finding F5; the
single-biggest time sink across the tutorial run.

`wheels reload` now physically wipes the cfclasses dir for the
project's registered server before triggering the HTTP reload. Order
matters: purge first, then reload — the application reset on the
next request gets the fresh compile.

Extract the deletion into CfclassesPurger.cfc (matching the
BundleStager extraction pattern) so the logic is unit-testable
without touching a live server. 5 new specs cover happy path, dir
preservation, missing path, empty path, and idempotency.

Best-effort: per-file failures (Lucee-locked .class mid-compile,
Windows file lock) are swallowed rather than aborting the reload —
the user is better served by a partial-purge than a full crash.

* fix(cli): refuse wheels start/stop in non-wheels-project directories

LuCLI's `server start` and `server stop` derive the server name from
the cwd basename when no registered server matches. Running `wheels
start` or `wheels stop` from a parent directory or unrelated dir
(easy to do by accident, especially in shell sessions where cwd
resets between commands) silently registers a phantom server context
named after that dir. The user ends up with ghost entries like `ws`
or `Downloads` in `wheels server list`, with `<unknown>` webroots
and no path to clean up. Onboarding finding F6.

Add a `$isWheelsProjectDir(path)` helper that checks for
`config/settings.cfm` — the file `wheels new` always writes and that
no other tool creates (box.json and Application.cfc are not specific
enough).

  - `wheels start` refuses up-front when not in a Wheels project,
    pointing the user at `wheels new`.
  - `wheels stop` already had a guard for the case where the cwd has
    no registered server but other servers are running (reports them);
    extend it to also refuse fallthrough when no servers are running
    at all, so we never reach LuCLI's name-from-cwd code path.

* fix(cli): wheels browser test defaults to app dir and app test runner

Two bugs in `wheels browser test` made it useless from the app for
running browser specs. Onboarding finding F11.

  - Default directory was `wheels.tests.specs.wheelstest`, the
    framework's internal browser-DSL test directory. Apps live at
    `tests/specs/browser/`, so the runner reported 0 tests against
    a fresh app spec.

  - Test URL hit `/wheels/core/tests`, the framework's core test
    runner. That endpoint only knows about specs under
    `vendor/wheels/tests/specs/`. App-side specs are mounted by
    `/wheels/app/tests`, the app test runner.

Default directory is now `tests.specs.browser` and the runner hits
`/wheels/app/tests`. `--directory=...` still overrides for advanced
use.

Two related sub-issues from the same finding (running browser specs
corrupts the dev server's cfclasses cache for other specs;
WHEELS_ENV=test wheels migrate latest looks for an unconfigured
`wheelstestdb_sqlite` datasource) are out of scope for this fix and
tracked separately.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Homepage still shows 0.0.0-dev in new apps (issue persists after fix)

1 participant