Skip to content

Commit d0d2781

Browse files
wheels-bot[bot]github-actions[bot]bpamiri
authored
fix(cli): migration failures reach the CLI exit code (#3105)
* fix(cli): migration failures reach the CLI exit code 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> * docs(web/guides): note migration failure exit codes in database guide 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> * test(cli): drive the db reset refusal spec through the callerArgs path 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> --------- Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Peter Amiri <peter@alurium.com>
1 parent 32380d9 commit d0d2781

4 files changed

Lines changed: 211 additions & 10 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- CLI: migration failures now reach the exit code. A failed `up()`/`down()` step that `migrateTo()` folds into its output (e.g. `Error migrating to <version>.`) made `wheels migrate latest|up|down` print the error inside the green success block and still exit 0; `wheels db reset --force` exited 0 when it refused on a `ServerNotRunning` check; and `wheels migrate forget|pretend` refusals (not in tracking table, matching local file exists, already applied, no matching file) printed red but exited 0. All three now exit non-zero, so a `wheels migrate latest && …` CI gate no longer proceeds as if the schema moved. Informational dry-run output (missing `<version>` / missing `--yes`) still exits 0. The migrate-side sibling of the #2973/#2987 seeder honesty fix (#3081)

cli/lucli/Module.cfc

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3941,6 +3941,39 @@ component extends="modules.BaseModule" {
39413941

39423942
// ── Migration Execution ──────────────────────────
39433943

3944+
/**
3945+
* True when migrator output carries the failed-step signature that
3946+
* Migrator.$runMigrationStep() emits — "Error migrating to <version>."
3947+
* migrateTo() concatenates that line into its returned string instead of
3948+
* throwing, so the /wheels/cli bridge reports success:true and the CLI's
3949+
* parseCliResponse() success->exit-code mapping never trips. Detecting the
3950+
* signature lets a failed up()/down() reach the exit code — the
3951+
* migrate-side sibling of the #2973/#2987 seeder honesty fix (#3081).
3952+
*
3953+
* Anchored on the literal label plus a numeric version so normal progress
3954+
* lines ("Migrating from 0 up to N.") never match. Public ONLY so the CLI
3955+
* specs can reach it (cli/CLAUDE.md "public for specs" carve-out); the
3956+
* mcpHiddenTools() structural $-prefix sweep keeps it off the MCP surface.
3957+
*/
3958+
public boolean function $migrationOutputIndicatesFailure(required string output) {
3959+
return reFindNoCase("Error migrating(\s+to)?\s+[0-9]+\.", arguments.output) > 0;
3960+
}
3961+
3962+
/**
3963+
* True when a /wheels/cli migration-style response should map to a non-zero
3964+
* CLI exit: either an explicit success:false (forget/pretend refusals such
3965+
* as "not found in the tracking table" or "matching local file exists", or
3966+
* a bridge-surfaced error) OR the subtler honesty gap where the bridge
3967+
* reports success:true while migrateTo() folded a failed step into the
3968+
* message. Public for specs; hidden from MCP via the structural sweep (#3081).
3969+
*/
3970+
public boolean function $cliMigrationResponseFailed(required struct response) {
3971+
if (!(arguments.response.success ?: true)) {
3972+
return true;
3973+
}
3974+
return $migrationOutputIndicatesFailure(arguments.response.message ?: "");
3975+
}
3976+
39443977
private string function runMigration(required string action) {
39453978
var serverPort = $requireRunningServer(
39463979
hints = [
@@ -3983,6 +4016,20 @@ component extends="modules.BaseModule" {
39834016
// the previous code silently treated it as success. See issue #2315.
39844017
var result = parseCliResponse(httpResult, "Migration #action#");
39854018

4019+
// Honesty gap (#3081): migrateTo() folds a failed up()/down() step into
4020+
// its returned message ("Error migrating to <version>.") instead of
4021+
// throwing, so the bridge reports success:true and parseCliResponse()
4022+
// above doesn't trip. Detect that signature for the schema-mutating
4023+
// actions so a failed migration reaches the exit code — the migrate-side
4024+
// sibling of the #2973/#2987 seeder honesty fix.
4025+
if (mutatingAction && $migrationOutputIndicatesFailure(result.message ?: "")) {
4026+
out(result.message ?: "", "red");
4027+
throw(
4028+
type = "MigrationError",
4029+
message = "Migration #arguments.action# failed — a migration step reported an error (see output above)."
4030+
);
4031+
}
4032+
39864033
// For `doctor`, switch the output color to yellow when the report
39874034
// signals unhealthy state (orphans or pending migrations). Green
39884035
// on an unhealthy result reads as "everything's fine" when it
@@ -4061,14 +4108,24 @@ component extends="modules.BaseModule" {
40614108
}
40624109

40634110
var parsed = isJSON(httpResult) ? deserializeJSON(httpResult) : {success: false, message: "Invalid response"};
4064-
var success = parsed.success ?: false;
40654111
var msg = parsed.message ?: "";
40664112

4067-
if (success) {
4068-
out(msg, "green");
4069-
} else {
4070-
out(msg, "red");
4113+
// Honesty gap (#3081): forget/pretend refusals ("not found in the
4114+
// tracking table", "matching local file exists", "already applied",
4115+
// "no matching file") come back as success:false but previously printed
4116+
// red and returned "" — exit 0, indistinguishable from a real mutation
4117+
// in a script. Throw so the refusal reaches the exit code. The
4118+
// informational dry-run branches above (missing <version> / missing
4119+
// --yes) still return "" (exit 0) because they precede the server call.
4120+
if ($cliMigrationResponseFailed(parsed)) {
4121+
out(Len(msg) ? msg : "#verb# refused.", "red");
4122+
throw(
4123+
type = "MigrationError",
4124+
message = Len(msg) ? msg : "#verb# refused — no change made."
4125+
);
40714126
}
4127+
4128+
out(msg, "green");
40724129
return "";
40734130
}
40744131

@@ -4209,7 +4266,12 @@ component extends="modules.BaseModule" {
42094266
runMigration("latest");
42104267
} catch (any e) {
42114268
out("Migration failed: #e.message#", "red");
4212-
return "";
4269+
// #3081: a refusal (e.g. ServerNotRunning) or a failed migration
4270+
// must reach the exit code — swallowing it (return "") made
4271+
// `db reset --force` report success while the schema never moved.
4272+
// `wheels migrate latest` and `wheels seed` already exit non-zero
4273+
// on the same refusal; this aligns `db reset` with them.
4274+
rethrow;
42134275
}
42144276

42154277
// Step 2: Seed (unless skipped)
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* Migration failures must reach the CLI exit code (issue #3081).
3+
*
4+
* The seeder got an honesty fix in #2973/#2987 (partial failure →
5+
* success=false + non-zero exit). The migrate surface had the same class of
6+
* bug: a failed up()/down() printed "Error migrating to <version>." yet
7+
* exited 0, `db reset --force` exited 0 on a ServerNotRunning refusal, and
8+
* forget/pretend refusals exited 0 — all indistinguishable from success in
9+
* a `wheels migrate latest && ...` CI gate.
10+
*
11+
* These specs cover the CLI-side reporting-honesty seams: the failure
12+
* detection helpers and the `db reset --force` rethrow. Transaction/rollback
13+
* behaviour is already correct and is not exercised here.
14+
*
15+
* Module instantiation mirrors DbCommandSpec/MigrateCommandSpec — the CLI
16+
* test runner loads the module against a scaffolded temp project; server-
17+
* dependent paths throw "No running Wheels server detected" because the
18+
* temp project has no bound server, which is exactly what gap 2 relies on.
19+
*/
20+
component extends="wheels.wheelstest.system.BaseSpec" {
21+
22+
function beforeAll() {
23+
variables.testHelper = new cli.lucli.tests.TestHelper();
24+
variables.tempRoot = testHelper.scaffoldTempProject(expandPath("/"));
25+
26+
// Create vendor/wheels stub
27+
directoryCreate(tempRoot & "/vendor/wheels", true, true);
28+
29+
// scaffoldTempProject() copies the repo's lucee.json (which carries a
30+
// `port`) into the temp project. Strip the port so detectServerPort()
31+
// can never resolve a live server for this project — the gap-2 case
32+
// must deterministically take the ServerNotRunning refusal path
33+
// (requireProjectConfig=true refuses the common-port fallback), and we
34+
// must never accidentally POST a real `reset` at a server that happens
35+
// to be bound to the configured port.
36+
fileWrite(tempRoot & "/lucee.json", "{}");
37+
38+
variables.mod = new cli.lucli.Module(cwd = variables.tempRoot);
39+
}
40+
41+
function afterAll() {
42+
testHelper.cleanupTempProject(variables.tempRoot);
43+
}
44+
45+
function run() {
46+
47+
describe("migration failure → CLI exit code (##3081)", () => {
48+
49+
describe("$migrationOutputIndicatesFailure — swallowed-step detection (gap 1)", () => {
50+
51+
it("flags a failed up() step that migrateTo() folded into its return string", () => {
52+
var output = "Migrating from 0 up to 20260101000000." & chr(10)
53+
& "-------- 20260101000000_create_widgets --------" & chr(10)
54+
& "Error migrating to 20260101000000." & chr(10)
55+
& "[SQLITE_ERROR] SQL error or missing database (no such function: NOW)";
56+
expect(mod.$migrationOutputIndicatesFailure(output)).toBeTrue();
57+
});
58+
59+
it("flags an IrreversibleMigration down() failure", () => {
60+
var output = "Migrating from 20260101000000 down to 0." & chr(10)
61+
& "Error migrating to 20260101000000." & chr(10)
62+
& "Cannot reverse this migration (IrreversibleMigration).";
63+
expect(mod.$migrationOutputIndicatesFailure(output)).toBeTrue();
64+
});
65+
66+
it("does not flag normal successful migration output", () => {
67+
var output = "Migrating from 0 up to 20260101000000." & chr(10)
68+
& "-------- 20260101000000_create_widgets --------" & chr(10)
69+
& "CREATE TABLE widgets (id INTEGER PRIMARY KEY)";
70+
expect(mod.$migrationOutputIndicatesFailure(output)).toBeFalse();
71+
});
72+
73+
it("does not flag the no-op 'No pending migrations' message", () => {
74+
expect(
75+
mod.$migrationOutputIndicatesFailure("No pending migrations. Database is at version 20260101000000.")
76+
).toBeFalse();
77+
});
78+
79+
});
80+
81+
describe("$cliMigrationResponseFailed — bridge response honesty", () => {
82+
83+
it("treats success:true carrying a failed-step message as a failure (gap 1)", () => {
84+
expect(
85+
mod.$cliMigrationResponseFailed({
86+
success: true,
87+
message: "Error migrating to 20260101000000." & chr(10) & "[SQLITE_ERROR] no such function: NOW"
88+
})
89+
).toBeTrue();
90+
});
91+
92+
it("treats an explicit success:false refusal as a failure (gap 3)", () => {
93+
expect(
94+
mod.$cliMigrationResponseFailed({
95+
success: false,
96+
message: "Version 20260101000000 was not found in the tracking table."
97+
})
98+
).toBeTrue();
99+
});
100+
101+
it("treats a clean success response as not-failed", () => {
102+
expect(
103+
mod.$cliMigrationResponseFailed({
104+
success: true,
105+
message: "Migrating from 0 up to 20260101000000." & chr(10) & "CREATE TABLE widgets (...)"
106+
})
107+
).toBeFalse();
108+
});
109+
110+
});
111+
112+
describe("db reset --force refusal honesty (gap 2)", () => {
113+
114+
it("rethrows the ServerNotRunning refusal instead of swallowing it (exit non-zero)", () => {
115+
// No server is bound to the scaffolded temp project, so
116+
// runMigration("latest") -> $requireRunningServer throws.
117+
// The pre-fix dbReset catch printed red and returned ""
118+
// (exit 0); the fix rethrows so the refusal reaches $?.
119+
//
120+
// arg1=/arg2= exercises the callerArgs path through
121+
// structuredArgs() — the same mechanism DbCommandSpec's
122+
// throwing spec uses. The instance-level `__arguments`
123+
// stash is NOT reliable here: set externally it lands in
124+
// the component's `this` scope, but structuredArgs()'s
125+
// unscoped read resolves the variables scope in the
126+
// in-server suite, so db() would see zero args and print
127+
// usage help instead of dispatching reset.
128+
expect(() => mod.db(arg1 = "reset", arg2 = "--force"))
129+
.toThrow(type = "Wheels.ServerNotRunning");
130+
});
131+
132+
});
133+
134+
});
135+
136+
}
137+
138+
}

web/sites/guides/src/content/docs/v4-0-0/command-line-tools/wheels-commands/database.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,11 @@ Single-command health report for the migration tracking table. Lists orphan trac
5757

5858
##### `forget`
5959

60-
Deletes a stale tracking row: `wheels migrate forget <version> --yes`. Dry-run by default; `--yes` is required to mutate. Refuses if a matching local migration file exists, or if the version isn't in the tracking table.
60+
Deletes a stale tracking row: `wheels migrate forget <version> --yes`. Dry-run by default; `--yes` is required to mutate. Refuses if a matching local migration file exists, or if the version isn't in the tracking table. Refusals exit non-zero.
6161

6262
##### `pretend`
6363

64-
Records a version as applied without running its `up()`: `wheels migrate pretend <version> --yes`. Dry-run by default; `--yes` is required to mutate. Refuses if the version is already applied or has no matching file.
64+
Records a version as applied without running its `up()`: `wheels migrate pretend <version> --yes`. Dry-run by default; `--yes` is required to mutate. Refuses if the version is already applied or has no matching file. Refusals exit non-zero.
6565

6666
##### `rename-system-tables`
6767

@@ -73,7 +73,7 @@ Migrates legacy framework bookkeeping tables to their current `wheels_`-prefixed
7373
wheels migrate latest
7474
```
7575

76-
Prints `Running migration: latest...` in cyan, dispatches the run to the server, and reports completion with a message like `Migration latest completed.` in green. Migration errors are caught and reported as `Migration failed: <message>`.
76+
Prints `Running migration: latest...` in cyan, dispatches the run to the server, and reports completion with a message like `Migration latest completed.` in green. Migration errors are caught and reported as `Migration failed: <message>` — and the command exits non-zero, so a `wheels migrate latest && ...` CI gate does not proceed as if the schema moved.
7777

7878
<Aside type="note" title="Generating migration files">
7979
`wheels migrate` only applies migrations — it does not create them. To stamp out a new migration file, use `wheels generate migration` (blank) or `wheels generate property` (add-column). See [Code Generation](../code-generation/).
@@ -142,7 +142,7 @@ Runs pending migrations and reseeds the database in one shot. Destructive in the
142142
wheels db reset --force
143143
```
144144

145-
Prints `Running migrations...`, dispatches `wheels migrate latest`, then prints `Running seeds...` and dispatches `wheels seed`. Ends with `Database reset complete.` in green.
145+
Prints `Running migrations...`, dispatches `wheels migrate latest`, then prints `Running seeds...` and dispatches `wheels seed`. Ends with `Database reset complete.` in green. A migration or seed failure exits non-zero so the error is visible in scripts and CI.
146146

147147
##### `status`
148148

0 commit comments

Comments
 (0)