Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/check-reports-commonjs-app-roots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@b4run/sdk": patch
"@b4run/core": patch
"@b4run/cli": patch
---

`b4 check` no longer reports a clean `0 routes discovered` for an app whose `package.json` lacks `"type": "module"`. Route discovery now fails with `B4_E1006` naming the app root's `package.json`, and a route `index.ts` with no recognisable export fails with `B4_E1007` naming the file, the exports it found, and, when the module was loaded as CommonJS, the nested `package.json` that caused it. Closes #685.
24 changes: 12 additions & 12 deletions apps/web/app/seo/lastmod.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@
"recordDigest": "de3c7087f46186b00f2c8a21562fd89e19c8a707b9c05f623689ed9676378f91"
},
"/docs/api/core": {
"lastModified": "2026-09-17T15:45:37.876Z",
"sourceDigest": "c4b9171efa71b2a792b2cd0101b92d43761afed4b183e82c6d1d0125bcfb78f4",
"recordDigest": "3d6b19fddfca682b11a02e1b8c736250f4982c3832e261bd754e61334678ce43"
"lastModified": "2026-09-17T16:43:25.354Z",
"sourceDigest": "64589cd04b4b2aa8599fdae20d6771f7de9e3f787702f83545284e5c00c302bd",
"recordDigest": "abe6df8a48f64554f477c849936cea71c0c0a9fd716e27107228672caea12e66"
},
"/docs/api/evals": {
"lastModified": "2026-09-07T19:58:55.550Z",
Expand Down Expand Up @@ -132,9 +132,9 @@
"recordDigest": "854d70653b7e7ae1a80889d317e0b5be8019a970d60e32882b07dd906cd3e951"
},
"/docs/cli": {
"lastModified": "2026-09-17T16:17:25.776Z",
"sourceDigest": "3e1ea49309f157f01e1a126bd7984f1255510b34a24abec0125faebe5e95f911",
"recordDigest": "baf61da754f77e59f48ce0b1ba99de75b7c14a2b76a98e29d1f3b800b266485c"
"lastModified": "2026-09-17T16:43:25.354Z",
"sourceDigest": "8c26e2dcd9ddc5dc03115db590e253826789f3c08ca9d8b706a4d1667b275c21",
"recordDigest": "25298aa3d065bd0004dedf0a17cac2c31a5e5e5eca5eae5267cf2031f57bb518"
},
"/docs/configuration": {
"lastModified": "2026-09-17T16:17:25.776Z",
Expand Down Expand Up @@ -187,9 +187,9 @@
"recordDigest": "812adb1c068febdd8beeb3c89c3053497bbb9d187502ab2fc060ff23ee5f79e8"
},
"/docs/errors": {
"lastModified": "2026-09-07T19:58:55.550Z",
"sourceDigest": "d82d328c760e71ae0bfbc56b0377a1f2e16cf463c9e15835ef08ddc15831869c",
"recordDigest": "b0a3fe1fa8861ce91a5c595a5558ce6c78e48fcc413beed8aec7666d3c19b7b3"
"lastModified": "2026-09-17T16:43:25.354Z",
"sourceDigest": "59caac19e5aabcba18d9e141e163e7b1de8baa68fd4f7fb5f92f08618afc3866",
"recordDigest": "b0b89eb53df127c4e988aac62981715c933c8caf128a58815c14c4d3e3c0c16d"
},
"/docs/evals": {
"lastModified": "2026-09-07T19:58:55.550Z",
Expand Down Expand Up @@ -392,9 +392,9 @@
"recordDigest": "e412c909c42b74ed38a4e11f33f3e33904c1013bfab00db4bc5d7d1f5ec0b5f3"
},
"/docs/upgrading": {
"lastModified": "2026-09-10T06:16:49.655Z",
"sourceDigest": "34bf05eee2c15f01efc5500e692094a3cdb33d9ff407d946f8621092cb1da675",
"recordDigest": "32de3eb9092aa7063e5a83488110de723f8f04fe1bbc195b2a027719fb961a98"
"lastModified": "2026-09-17T16:28:35.561Z",
"sourceDigest": "ed2da849d96865e9718540e111c028fe4f2b97a043d12b47727ce57b2db03239",
"recordDigest": "9453942d57af7613b10db2cf166e595595904c33c7770a680f33254740735352"
},
"/docs/workspace": {
"lastModified": "2026-09-07T19:58:55.550Z",
Expand Down
1 change: 1 addition & 0 deletions apps/web/content/docs/api/core.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ Importing `/node` registers the disk-backed `b4.config.ts` loader. The root rema
| `CreateWorkspaceFsOptions` | Configure workspace filesystem creation. |
| `createWorkspaceFs` | Create a workspace filesystem facade. |
| `B4ConfigLoader` | Define the configuration loader seam. |
| `B4AppError` | Describe an app-root or route-entry failure that carries a `B4_Exxxx` code. |
| `__clearB4ConfigCacheForTests` | Clear the config memo in tests. |
| `loadB4Config` | Load or read a memoized app configuration. |
| `registerConfigLoader` | Register a runtime-specific config loader. |
Expand Down
2 changes: 2 additions & 0 deletions apps/web/content/docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ b4 check
Internally, `b4 check` loads `b4.config.ts`, resolves the app root, runs `discoverRoutes`, and parses each route's tool definitions with `discoverToolDefinitions`. It surfaces:

- A `b4.config.ts` that fails to load.
- An app root `package.json` without `"type": "module"`, reported as `B4_E1006`. B4.run loads `b4.config.ts` and every route `index.ts` as ES modules; without the field Node compiles them as CommonJS, and a route exported with `export default agent(...)` is not recognised. This used to pass as `0 routes discovered`.
- Any route directory whose `index.ts` has no recognisable export, reported as `B4_E1007` with the file path and the exports it did find. When the module was loaded as CommonJS (a nested `package.json` without `"type": "module"`), the message names that file as the likely cause. A directory that is not a route belongs behind a `_` prefix, which keeps it out of discovery.
- Any discovered route whose `index.ts` exports more than one of `agent`, `workflow`, `graph`, `chain`.
- Any tool file that fails to parse.
- A stale build manifest: when `.b4/build/modules.mjs` exists (written by `b4 build`'s `node` target), its routes are compared against the routes on disk — a mismatch (for example a route renamed after the last build) fails with the missing/extra route ids and a prompt to re-run `b4 build`. `.b4/build/modules.edge.mjs` is checked the same way whenever `"hono"` is a configured target: it is a separate artifact with its own copy of every route's static imports, and an app that builds for `hono` **alone** emits no `modules.mjs` at all — so this pass used to have nothing to look at for exactly the deployment shape that cannot re-walk its route tree at runtime. A manifest that is simply absent is a no-op, and the edge manifest is skipped when `hono` is not a configured target, so a leftover file from an experiment is not an error.
Expand Down
2 changes: 2 additions & 0 deletions apps/web/content/docs/errors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ A code may omit a docs link and still be a valid, searchable identifier.
| `B4_E1003` | Unknown build target | [/docs/deployment](/docs/deployment) |
| `B4_E1004` | Invalid delegation policy | [/docs/subagents#delegation-policy](/docs/subagents#delegation-policy) |
| `B4_E1005` | Feature unsupported by the build target or runtime | [/docs/deployment](/docs/deployment) |
| `B4_E1006` | App root package.json is not an ES module | [/docs/cli#b4-check](/docs/cli#b4-check) |
| `B4_E1007` | Route entry has no recognisable export | [/docs/cli#b4-check](/docs/cli#b4-check) |
| `B4_E2001` | Sandbox unavailable | [/docs/sandbox#what-it-is--and-isnt](/docs/sandbox#what-it-is--and-isnt) |
| `B4_E2002` | Sandbox preflight failed | [/docs/sandbox#quickstart](/docs/sandbox#quickstart) |
| `B4_E3001` | Permission denied | [/docs/permissions](/docs/permissions) |
Expand Down
29 changes: 29 additions & 0 deletions apps/web/content/docs/upgrading.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,35 @@ The built-in SQLite and pgvector stores already implement all of this, and the
`runMemoryStoreConformance` kit in `@b4run/testing` covers it — run the kit
against a custom store to verify the contract.

## The app root must declare `"type": "module"`

`b4 check`, `dev`, `build`, `verify`, `routes`, and `typegen` now fail with
`B4_E1006` when the app root's `package.json` does not set `"type": "module"`,
where they used to run. Add the field:

```json
{
"type": "module"
}
```

Apps scaffolded by `create-b4-app` already have it. An app that never set it
was in one of two states, and both are fixed by the same line:

- **Routes exported with `export default agent(...)` were silently dropped.**
`b4 check` reported `0 routes discovered` and exited 0, because the route
modules were compiled as CommonJS and their default export arrived wrapped in
the ESM/CommonJS interop object.
- **Routes exported as `export const workflow` (or `graph`/`chain`) appeared to
work.** Node's CommonJS lexer hoists those into named exports, so discovery
found them — but `b4.config.ts` was loaded through the same interop wrapper,
so every setting on it was silently ignored and the defaults applied.

A route `index.ts` that exports nothing B4.run recognises is now also an error
(`B4_E1007`) naming the file, rather than a directory that is skipped in
silence. If such a directory is not a route, prefix its name with `_` to keep
it out of route discovery.

## Related

<RelatedCards items={[
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync } from "node:fs"
import { join } from "node:path"
import { pathToFileURL } from "node:url"

import type { B4Config, RouteManifest } from "@b4run/core"
import { B4AppError, type B4Config, type RouteManifest } from "@b4run/core"
import { discoverRoutes } from "@b4run/core/node"
import type { Command } from "commander"
import {
Expand Down Expand Up @@ -148,7 +148,14 @@ export async function runCheckCommand(options: CheckOptions, io: CommandIo): Pro
})
} catch (error) {
if (error instanceof CliError) throw error
throw new CliError(`Validation failed: ${formatErrorMessage(error)}`)
// Discovery errors that carry a registry code (app root not an ES module,
// route entry with no recognisable export) keep it so the `[B4_Exxxx]`
// footer renders; everything else stays a plain validation failure.
throw new CliError(
`Validation failed: ${formatErrorMessage(error)}`,
1,
error instanceof B4AppError ? { code: error.code } : {},
)
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/build-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ async function createFixtureApp(files: Readonly<Record<string, string>>) {

const appFiles = {
// @b4run/cli in dependencies → node target emits no runtime-dep warning.
"package.json": '{ "dependencies": { "@b4run/cli": "workspace:*" } }\n',
"package.json": '{ "type": "module", "dependencies": { "@b4run/cli": "workspace:*" } }\n',
"b4.config.ts": "export default {};\n",
...files,
}
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/test/build-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ async function createFixtureApp(files: Readonly<Record<string, string>>) {
// Every generated runtime import is declared, so selected targets emit no
// runtime-dependency warning.
"package.json": `${JSON.stringify({
type: "module",
dependencies: {
"@b4run/cli": "workspace:*",
"@b4run/postgres-storage": "workspace:*",
Expand Down Expand Up @@ -232,7 +233,7 @@ describe("b4 build — targets", () => {

test("warns when @b4run/cli is not a runtime dependency", async () => {
const appRoot = await createFixtureApp({
"package.json": '{ "devDependencies": { "@b4run/cli": "workspace:*" } }\n',
"package.json": '{ "type": "module", "devDependencies": { "@b4run/cli": "workspace:*" } }\n',
})

const { stderr } = await runBuild(appRoot)
Expand All @@ -241,7 +242,7 @@ describe("b4 build — targets", () => {

test("does not warn when @b4run/cli is a runtime dependency", async () => {
const appRoot = await createFixtureApp({
"package.json": '{ "dependencies": { "@b4run/cli": "workspace:*" } }\n',
"package.json": '{ "type": "module", "dependencies": { "@b4run/cli": "workspace:*" } }\n',
})

const { stderr } = await runBuild(appRoot)
Expand Down Expand Up @@ -325,7 +326,7 @@ describe("b4 check — build targets", () => {
test("vercel dependency notice names the vercel target", async () => {
const appRoot = await createFixtureApp({
"b4.config.ts": 'export default { build: { targets: ["vercel"] } };\n',
"package.json": '{ "dependencies": { "@b4run/cli": "workspace:*" } }\n',
"package.json": '{ "type": "module", "dependencies": { "@b4run/cli": "workspace:*" } }\n',
})

const stdout: string[] = []
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/test/chain-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe("chain route execution", () => {
test("executes a chain route with invoke", async () => {
appRoot = await mkdtemp(join(tmpdir(), "b4-chain-"))
await mkdir(join(appRoot, "src", "app", "hello"), { recursive: true })
await writeFile(join(appRoot, "package.json"), "{}\n")
await writeFile(join(appRoot, "package.json"), '{"type":"module"}\n')
await writeFile(join(appRoot, "b4.config.ts"), "export default {}")
await writeFile(
join(appRoot, "src", "app", "hello", "index.ts"),
Expand Down Expand Up @@ -47,7 +47,7 @@ export const chain = {
test("fails with clear error when chain entry has no invoke", async () => {
appRoot = await mkdtemp(join(tmpdir(), "b4-chain-"))
await mkdir(join(appRoot, "src", "app", "broken"), { recursive: true })
await writeFile(join(appRoot, "package.json"), "{}\n")
await writeFile(join(appRoot, "package.json"), '{"type":"module"}\n')
await writeFile(join(appRoot, "b4.config.ts"), "export default {}")
await writeFile(
join(appRoot, "src", "app", "broken", "index.ts"),
Expand Down
84 changes: 84 additions & 0 deletions packages/cli/test/check-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"
import { dirname, join, resolve } from "node:path"
import { afterEach, describe, expect, test } from "vitest"

const SDK_PATH = resolve(import.meta.dirname, "../../sdk")

import { run } from "../src/index.js"

const tempDirs: string[] = []
Expand Down Expand Up @@ -171,6 +173,88 @@ export const graph = { invoke: async () => ({}) }
expect(result.stdout).not.toContain("/empty")
})

test('fails with B4_E1006 when the app root package.json lacks "type": "module"', async () => {
const appRoot = await createFixtureApp({
"package.json": '{"name":"no-type"}\n',
"src/app/hello/index.ts": `export async function workflow() { return {} }
`,
})

const result = await invoke(["check", "--cwd", appRoot])

expect(result.exitCode).toBe(1)
expect(result.stdout).not.toContain("routes discovered")
expect(result.stderr).toContain("Validation failed")
expect(result.stderr).toContain(join(appRoot, "package.json"))
expect(result.stderr).toContain('"type": "module"')
expect(result.stderr).toContain("[B4_E1006]")
expect(result.stderr).toContain("https://b4.run/docs/cli#b4-check")
})

test("fails with B4_E1007 naming the file when a route index.ts has no recognisable export", async () => {
const appRoot = await createFixtureApp({
"src/app/hello/index.ts": `export async function workflow() { return {} }
`,
"src/app/util/index.ts": "export const helper = 1\n",
})

const result = await invoke(["check", "--cwd", appRoot])

expect(result.exitCode).toBe(1)
expect(result.stdout).not.toContain("routes discovered")
expect(result.stderr).toContain("Validation failed")
expect(result.stderr).toContain(join(appRoot, "src/app/util/index.ts"))
expect(result.stderr).toContain("helper")
expect(result.stderr).toContain("[B4_E1007]")
expect(result.stderr).toContain("https://b4.run/docs/cli#b4-check")
})

// Issue #685. Under vitest the route import goes through vite-node, which
// compiles TypeScript regardless of package.json "type", so the CommonJS
// interop shape the tsx loader produces is only reachable from the built CLI.
test("built b4 check diagnoses CommonJS route modules instead of reporting 0 routes", {
timeout: 30_000,
}, async () => {
const builtCli = await cliExecutable()
const agentRoute = `import { agent } from "@b4run/sdk"
export default agent({ model: "gpt-5-mini" } as any)
`

// The issue's repro: app root without "type": "module".
const noType = await createFixtureApp({
"package.json": '{"name":"issue-685"}\n',
"src/app/assistant/index.ts": agentRoute,
})
await mkdir(join(noType, "node_modules/@b4run"), { recursive: true })
await symlink(SDK_PATH, join(noType, "node_modules/@b4run/sdk"))

const noTypeResult = await executeCli(builtCli, ["check", "--cwd", noType])

expect(noTypeResult.code).toBe(1)
expect(noTypeResult.stdout).not.toContain("routes discovered")
expect(noTypeResult.stderr).toContain('"type": "module"')
expect(noTypeResult.stderr).toContain("[B4_E1006]")

// A nested package.json flips only that route's module format, so the app
// root check passes and the route-level diagnosis must name the cause.
const nested = await createFixtureApp({
"src/app/assistant/index.ts": agentRoute,
"src/app/assistant/package.json": '{"name":"nested"}\n',
})
await mkdir(join(nested, "node_modules/@b4run"), { recursive: true })
await symlink(SDK_PATH, join(nested, "node_modules/@b4run/sdk"))

const nestedResult = await executeCli(builtCli, ["check", "--cwd", nested])

expect(nestedResult.code).toBe(1)
expect(nestedResult.stdout).not.toContain("routes discovered")
expect(nestedResult.stderr).toContain(join(nested, "src/app/assistant/index.ts"))
expect(nestedResult.stderr).toContain("CommonJS")
expect(nestedResult.stderr).toContain(join(nested, "src/app/assistant/package.json"))
expect(nestedResult.stderr).toContain('"type": "module"')
expect(nestedResult.stderr).toContain("[B4_E1007]")
})

test("returns a nonzero exit code and a stable error prefix for invalid apps", async () => {
const appRoot = await mkdtemp(join(tmpdir(), "b4-cli-check-invalid-"))
tempDirs.push(appRoot)
Expand Down
Loading
Loading