fix(cli): migration failures reach the CLI exit code - #3105
Conversation
Three reporting-honesty gaps let migrator failures exit 0, so a `wheels migrate latest && ...` CI gate proceeded as if the schema moved. The migrate-side sibling of the #2973/#2987 seeder honesty fix. - migrate latest|up|down: migrateTo() folds a failed up()/down() step into its returned message ("Error migrating to <version>.") instead of throwing, so the /wheels/cli bridge reports success:true and the CLI printed the error inside the green success block at exit 0. runMigration now detects that signature for the schema-mutating actions and throws. - db reset --force: the migrate step's catch swallowed the refusal (ServerNotRunning) / failure with return "" (exit 0). It now rethrows, matching migrate latest and seed. - migrate forget|pretend: server-side refusals (not in tracking table, matching local file exists, already applied, no matching file) came back success:false but printed red and returned "" (exit 0). They now throw. Informational dry-run output (missing <version> / missing --yes) still exits 0. Two public $-prefixed helpers carry the detection logic so the CLI specs can unit-test it; the mcpHiddenTools() structural sweep keeps them off the MCP surface. Refs #3081 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
migrate latest/up/down, db reset --force, and migrate forget/pretend now exit non-zero on failure or refusal (#3081). Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Wheels Bot — Docs updatedAdded a doc commit to this PR:
|
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR: This PR closes three real exit-code honesty gaps in the migrate CLI surface (migrate latest|up|down swallowed-step detection, db reset --force rethrow, migrate forget|pretend refusal throws), with a failing-first spec, a changelog fragment, accurate guide updates, and a clean commit. I verified the failure-signature regex against both Migrator error labels, the MCP hiding sweep, and every throw-propagation path — no blocking findings. Verdict: comment — one test-isolation nit worth fixing (a one-liner), nothing that should hold the merge.
Correctness
All verified clean — citing the load-bearing checks so the approval trail is concrete:
- The regex covers both Migrator error labels.
$migrationOutputIndicatesFailure(cli/lucli/Module.cfc:3811) usesError migrating(\s+to)?\s+[0-9]+\.. The Migrator composes the line as#errorLabel# #version#.(vendor/wheels/Migrator.cfc:387) witherrorLabeldefaulting to"Error migrating to"(Migrator.cfc:351) andmigrateIndividual()passing"Error migrating"(Migrator.cfc:198) — the optional(\s+to)?handles both, and the numeric anchor keeps progress lines (Migrating from 0 up to N.) from matching. - The detection is correctly gated to mutating actions (
mutatingAction &&at Module.cfc:3877), soinfo/doctoroutput can never trip it. - Every new throw actually reaches the exit code.
migrate latest|up|downcatchesMigrationErrorand rethrows (Module.cfc:602-607);forget/pretendpropagate uncaught (Module.cfc:615-618);dbResetrethrows the migrate step (Module.cfc:4126) and the seed step (runSeedat Module.cfc:4132) was already uncaught. - One deliberate default-flip I checked and accept: the old
runForgetOrPretendreadparsed.success ?: false(missing key → red), while$cliMigrationResponseFailedreadssuccess ?: true(Module.cfc:3823). This aligns withparseCliResponse's documented convention ("Returns the parsed struct onsuccess: true(or nosuccesskey)", Module.cfc:6507), and the non-JSON fallback struct setssuccess: falseexplicitly (Module.cfc:3962), so no real producer hits the changed branch. No action needed. - No existing spec asserts the old swallow behavior —
DbCommandSpec's reset tests never pass--force(they short-circuit at the dry-run branch), andMigrateCommandSpecdoesn't touch forget/pretend/reset.
One non-blocking observation, not a finding: signature-sniffing the message string means a successful migration whose echoed output happened to contain the literal error migrating to <digits>. (the match is reFindNoCase) would false-positive. That's contrived, and the PR body already documents the framework-side success=false flip as the layerable robust alternative — fine as-is.
Conventions
- The two helpers being
public+$-prefixed is the documentedcli/CLAUDE.md"public for specs" carve-out, and themcpHiddenTools()structural sweep (Module.cfc:199-210) auto-hides them from the MCPtools/list— confirmedMcpHiddenToolsSpecasserts this structurally (source-text checks), so it won't break on the additions. - The regex avoids the
(.+)-matches-newlines trap thatcli/CLAUDE.mdflags forreFind—\s+/[0-9]+only.
Tests
MigrationExitCodeSpec.cfc mirrors the DbCommandSpec setup (same base class, TestHelper.scaffoldTempProject, mod.__arguments prior art from DeployCommandSpec), covers all three gaps, and fails before the patch by construction. One isolation gap:
-
cli/lucli/tests/specs/commands/MigrationExitCodeSpec.cfc:37— thelucee.jsonneutralization is incomplete; the.envchannel is still open. The spec writesfileWrite(tempRoot & "/lucee.json", "{}")sodetectServerPort()can't resolve the live CI test server (repolucee.jsoncarries"port": 8080, the very porttools/test-cli-local.shbinds). ButTestHelper.scaffoldTempProject()also copies the repo-root.envinto the temp project (cli/lucli/tests/TestHelper.cfc:85-91), anddetectServerPort()consults it forPORT\s*=\s*(\d+)before refusing the common-port fallback (cli/lucli/Module.cfc:6250-6259). The committed.envhas noPORT=line, so the gap-2 test is deterministic in CI today — butPORTin.envis an explicitly documented local config (the command's own hint text: "Set 'port' in lucee.json (or PORT in .env)"). A developer with that line and a running server gets a false test failure and a realmakeBridgePostofmigrateToLatestat their dev server — exactly what the spec's comment says it must never do ("we must never accidentally POST a realreset"). Fix is one line next to the existing neutralization:fileWrite(tempRoot & "/lucee.json", "{}"); fileWrite(tempRoot & "/.env", "");
Docs
Accurate and verified against the code: database.mdx's "Refusals exit non-zero" matches the uncaught forget/pretend throws, "the command exits non-zero" matches the migrate() rethrow, and the db reset line is correct for both migrate (rethrow) and seed (uncaught) failures. Changelog fragment is correctly placed (changelog.d/3081-cli-migration-exit-codes.fixed.md, .fixed.md type) — no direct CHANGELOG.md edit. The PR checklist is honest about the sandbox test-run limitation.
Commits
Single commit 8f7cd0605 — fix(cli): … is a valid type/scope, subject under 100 chars, body explains the why, Signed-off-by matches the author email. Clean.
The CI run on this PR is the authoritative test execution (the author's sandbox note checks out — the spec needs a bound server). The .env nit above is worth folding in before or shortly after merge, but it doesn't block: the committed .env keeps CI deterministic.
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR: Re-review at the new head, whose only delta since the last review is the docs(web/guides) commit updating database.mdx. I independently re-verified the full diff rather than diffing against the prior review: the three exit-code honesty fixes are correct and fully propagated, the docs claims all match the code, and both commits are clean. Verdict: comment — the one test-isolation nit from the previous round (the spec's .env channel) is still open; it remains non-blocking because CI stays deterministic with the committed .env.
Correctness
All verified clean at this head — the load-bearing checks, re-done from the code rather than inherited from the prior review:
- Failure-signature regex covers both Migrator error labels.
$migrationOutputIndicatesFailure(cli/lucli/Module.cfc:3811) usesError migrating(\s+to)?\s+[0-9]+\.. The Migrator composes the line as#errorLabel# #version#.(vendor/wheels/Migrator.cfc:387), witherrorLabeldefaulting to"Error migrating to"(Migrator.cfc:351) andmigrateIndividual()passing"Error migrating"(Migrator.cfc:198). The numeric anchor keeps progress lines (Migrating from 0 up to N.) and theNo pending migrations...no-op from matching. - Detection is gated to schema-mutating actions (
mutatingAction &&atModule.cfc:3877), soinfo/doctoroutput can never trip it. - Every new throw reaches the exit code.
migrate latest|up|downcatchesMigrationErrorand rethrows (Module.cfc:601-607);forget/pretendthrows propagate uncaught frommigrate()(Module.cfc:616-618);dbResetrethrows the migrate step (Module.cfc:4126) and the seed step (runSeedatModule.cfc:4132) was already uncaught.$requireRunningServerthrows typedWheels.ServerNotRunning(Module.cfc:6308), which Picocli maps to non-zero per its own docblock. - The
success ?: truedefault in$cliMigrationResponseFailed(Module.cfc:3823) flips the oldparsed.success ?: falseread, but it aligns withparseCliResponse's documented convention (nosuccesskey → success), and the non-JSON fallback setssuccess: falseexplicitly (Module.cfc:3962) — no real producer hits the changed branch, and even for a hypothetical missing-key producer the exit code is unchanged (0 before, 0 after; only the color differs). No action needed.
Cross-engine
The CLI runs on the bundled Lucee only; the one applicable trap is the (.+)-matches-newlines reFind gotcha from cli/CLAUDE.md, which the regex avoids (\s+/[0-9]+ only). The two new helpers being public + $-prefixed is the documented "public for specs" carve-out, and the mcpHiddenTools() structural sweep (Module.cfc:199-217) auto-hides them from the MCP tools/list.
Tests
MigrationExitCodeSpec.cfc mirrors the DbCommandSpec/MigrateCommandSpec setup exactly (same base class, scaffoldTempProject, vendor/wheels stub) and mod.__arguments follows DeployCommandSpec prior art. It fails before the patch by construction (the helpers don't exist; the old dbReset catch returned ""). One carried-over finding:
-
cli/lucli/tests/specs/commands/MigrationExitCodeSpec.cfc:37— thelucee.jsonneutralization is still incomplete; the.envchannel remains open. Unchanged since the previous review on8f7cd06.TestHelper.scaffoldTempProject()copies the repo-root.envinto the temp project (cli/lucli/tests/TestHelper.cfc:85-91), anddetectServerPort()consults it forPORT\s*=\s*(\d+)(cli/lucli/Module.cfc:6250-6259) before therequireProjectConfigrefusal (Module.cfc:6265). The committed.envhas noPORT=line, so CI is deterministic — but a developer with that documented local config ("Set 'port' in lucee.json (or PORT in .env)"is the command's own hint text) and a running server gets a false test failure and a realmakeBridgePostofmigrateToLatestat their dev server, which the spec's own comment says it must never do. Fix is still one line next to the existing neutralization:fileWrite(tempRoot & "/lucee.json", "{}"); fileWrite(tempRoot & "/.env", "");
Docs
The new commit's database.mdx claims are all accurate against the code: "Refusals exit non-zero" for forget/pretend matches the uncaught throw at Module.cfc:3974; "the command exits non-zero, so a wheels migrate latest && ... CI gate does not proceed" matches the rethrow at Module.cfc:606; the db reset line is correct for both the migrate rethrow (Module.cfc:4126) and the already-uncaught seed step. Changelog fragment is correctly placed (changelog.d/3081-cli-migration-exit-codes.fixed.md, .fixed.md type); no direct CHANGELOG.md edit.
Commits
Two commits: 8f7cd0605 fix(cli): migration failures reach the CLI exit code and 6313e4349 docs(web/guides): note migration failure exit codes in database guide. Both use valid types, subjects under 100 chars, bodies explaining the why, and Signed-off-by matching the author email. Clean.
The CI run on this PR remains the authoritative test execution (the author's sandbox limitation note checks out). The .env one-liner is the only thing worth folding in — non-blocking, since the committed .env keeps CI deterministic.
The gap-2 spec set mod.__arguments externally, which lands in the component's this scope; structuredArgs()'s unscoped read resolves the variables scope in the in-server suite, so db() saw zero args, printed usage help, and returned without throwing. Switch to the mod.db(arg1, arg2) callerArgs form — the same mechanism DbCommandSpec's throwing spec uses — and pin the expected Wheels.ServerNotRunning type. Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR: Third pass at head 8934555, whose only delta since the last review is the test(cli) commit reworking the gap-2 spec to drive db reset --force through the callerArgs path instead of the unreliable __arguments stash. I re-verified the full diff independently (not just the delta): the three exit-code honesty fixes are correct and fully propagated, the new spec mechanism is sound and matches DbCommandSpec prior art, and all three commits are clean. Verdict: comment — the .env test-isolation nit from the previous rounds remains open and remains non-blocking.
Correctness
All clean at this head — the load-bearing checks, re-done from the code:
- The delta commit fixes a real latent spec failure. The old form (
mod.__arguments = ["reset", "--force"]; expect(() => mod.db()).toThrow()) set__argumentsin the component'sthisscope, whichstructuredArgs()'s unscoped read (var raw = __arguments ?: [],cli/lucli/Module.cfc:110) never resolves —db()would have seen zero args, printed usage help, returned"", and thetoThrow()expectation would have failed in the in-server suite. The new form is correct end-to-end:mod.db(arg1 = "reset", arg2 = "--force")hits the callerArgs branch (Module.cfc:112-114),toArgv()emits["reset", "--force"]via numeric-sorted positional indices (services/ArgSpec.cfc:156-158),db()dispatchesdbReset(Module.cfc:2818-2819),--forceflips the guard (Module.cfc:4105), andrunMigration("latest")→$requireRunningServer(requireProjectConfig = true)(Module.cfc:3830-3836) throws typedWheels.ServerNotRunning(Module.cfc:6308-6313) becausedetectServerPortfinds no port: the spec'slucee.jsonis{}, the copied repo.envhas noPORT=match for thePORT\s*=\s*(\d+)probe (Module.cfc:6254), andrequireProjectConfig=truerefuses the common-port fallback (Module.cfc:6265-6267). The pinned throw type is exact, and the call form is the same oneDbCommandSpec.cfc:33already uses (expect(() => mod.db(arg1 = "invalid")).toThrow(type = "Wheels.InvalidArguments")). - Failure-signature regex covers both Migrator error labels.
$migrationOutputIndicatesFailure(Module.cfc:3811) usesError migrating(\s+to)?\s+[0-9]+\.;$runMigrationStepcomposes the line as#arguments.errorLabel# #arguments.migration.version#.(vendor/wheels/Migrator.cfc:387) with the label defaulting to"Error migrating to"(Migrator.cfc:351) andmigrateIndividual()passing"Error migrating"(Migrator.cfc:198). The numeric anchor keepsMigrating from 0 up to N.progress lines and theNo pending migrations...no-op from matching, and the check is gated to mutating actions (mutatingAction &&,Module.cfc:3877) soinfo/doctorcan never trip it. - Every new throw reaches the exit code.
migrate latest|up|down|infoanddoctorcatchMigrationErrorand rethrow (Module.cfc:601-614);forget/pretendthrows propagate uncaught frommigrate()(Module.cfc:615-618) with the informational dry-run branches (missing<version>/ missing--yes) still returning""before the server call (Module.cfc:3920-3932);dbResetrethrows the migrate step (Module.cfc:4126) and the seed step (runSeed,Module.cfc:4132) was already uncaught.
Conventions
- The two helpers being
public+$-prefixed is the documentedcli/CLAUDE.md"public for specs" carve-out, and themcpHiddenTools()structural sweep (Module.cfc:199-217) auto-discovers every$-prefixed public viagetMetaData(this).functions, so neither helper can leak into the MCPtools/list. - Spec base class (
wheels.wheelstest.system.BaseSpec) and scaffold setup mirrorDbCommandSpec/MigrateCommandSpecexactly.
Tests
One open nit, carried from the previous two rounds and still a one-liner:
MigrationExitCodeSpec.cfc:36neutralizeslucee.jsonbut not the copied.env.scaffoldTempProjectcopies the repo root's.envinto the temp project (cli/lucli/tests/TestHelper.cfc:85-91), anddetectServerPortprobes it forPORT=right afterlucee.json(Module.cfc:6250-6259). The committed.envhas noPORT=line, so CI is deterministic — but a developer whose local repo-root.envgainsPORT=8080(the porttools/test-cli-local.shbinds) would have the gap-2 spec resolve the live test server and POST a realmigrateToLatestat it, which is exactly the accidental-reset hazard thelucee.jsonneutralization comment warns about. Suggested fix, same shape as the existing line: addfileWrite(tempRoot & "/.env", "");next to thelucee.jsonwrite inbeforeAll(). Non-blocking.
Docs
database.mdx claims all match the code at this head: migrate latest errors exit non-zero (Module.cfc:601-607), forget/pretend refusals exit non-zero (Module.cfc:3972-3978), and db reset migration/seed failures exit non-zero (Module.cfc:4126, 4132). Changelog fragment changelog.d/3081-cli-migration-exit-codes.fixed.md is present, correctly named, and accurate.
Commits
All three conform to commitlint.config.js: fix(cli): migration failures reach the CLI exit code, docs(web/guides): note migration failure exit codes in database guide, test(cli): drive the db reset refusal spec through the callerArgs path — valid types, headers under 100 chars, DCO sign-offs present. The test(cli) body explains the why (the this-scope vs unscoped-read mismatch) rather than restating the diff — exactly what the convention asks for.
Summary
Three reporting-honesty gaps let migrator failures exit
0, so awheels migrate latest && …CI gate (or any script chaining on the exit code) proceeded as if the schema had moved. This is the migrate-side sibling of the seeder honesty fix in #2973/#2987, and the fix is scoped entirely to the CLI's interpretation of the/wheels/clibridge response — the transaction/rollback behaviour was already correct and is untouched.migrate latest|up|down—Migrator.migrateTo()folds a failedup()/down()step into its returned string (Error migrating to <version>.) instead of throwing, so the bridge reportssuccess:trueandparseCliResponse()'s success→exit-code mapping never trips.runMigration()now detects that signature for the schema-mutating actions via a new helper and throwsMigrationError, so the failure reaches$?.db reset --force— the migrate step'scatchswallowed the refusal (e.g.ServerNotRunning) / failure withreturn ""(exit 0). It nowrethrows, matchingmigrate latestandseed, which already exit non-zero on the same refusal.migrate forget|pretend— server-side refusals (not found in the tracking table,matching local file exists,already applied,no matching file) came backsuccess:falsebut printed red and returned""(exit 0). They now throw. Informational dry-run output (missing<version>/ missing--yes, which precede the server call) still exits 0.The detection logic lives in two public
$-prefixed helpers ($migrationOutputIndicatesFailure,$cliMigrationResponseFailed) so the CLI specs can unit-test it directly; themcpHiddenTools()structural$-prefix sweep keeps them off the MCP tool surface.Why CLI-side and not framework-side
The triage offered two paths: flip the bridge response
success=falseframework-side (preferred), or detect the failure CLI-side. I took the CLI-side path deliberately — it keeps the change out of the migration-execution path (vendor/wheels/migrator/**), which the bot's safety net flags as sensitive, while still satisfying every acceptance criterion. The bridge'ssuccess:true-with-error-in-messagebehaviour is left intact; the CLI now reads it honestly. A reviewer who prefers the framework-side flip can layer it on top without conflicting with this change.Related Issue
Fixes #3081
Type of Change
Feature Completeness Checklist
Signed-off-by:(git commit -s)cli/lucli/tests/specs/commands/MigrationExitCodeSpec.cfc(failing → passing): covers the swallowed-step detection (gap 1), the explicit-refusal mapping (gap 3), anddb reset --forcerethrow (gap 2)bot-update-docs.ymlbot-update-docs.ymlbot-update-docs.ymlchangelog.d/3081-cli-migration-exit-codes.fixed.mdTest Plan
New spec asserts:
$migrationOutputIndicatesFailure()→truefor a swallowed failedup()/down()step (and theIrreversibleMigrationshape),falsefor normal progress and theNo pending migrationsno-op.$cliMigrationResponseFailed()→trueforsuccess:truecarrying a failed-step message (gap 1) and for an explicitsuccess:falserefusal (gap 3);falsefor a clean success.wheels db reset --forceagainst a project with no bound server rethrows theServerNotRunningrefusal (exit non-zero) instead of swallowing it (gap 2). The spec neutralises the temp project'slucee.jsonport so this path is deterministic and never POSTs a realreset.The spec fails before the patch by construction — the two helpers don't exist (gap 1/3) and
dbReset'scatchreturns""rather than rethrowing (gap 2) — and passes after.