From 4b64a2eb280014da6b002642f4b92cefda1b9847 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 13:18:20 +0000 Subject: [PATCH] fix(cli): say which build output is missing, not just "no index.html" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A build that emits no Workers artifact takes the no-worker arm of the deployments lane, so `readIndexHtml()` is where a full-stack build that failed to emit its server lands — not only a genuinely static one. It reported every such case with one message, "No index.html found in "" — a static site needs one at the output directory root", which names a site type the caller never chose and does not say which of several very different conditions actually happened. Split the diagnosis four ways: no output directory configured and no artifact emitted, the directory missing, the directory empty, and the directory populated but with no entry point at its root. The last one names any index.html found deeper in the output, which is the signature of a client/server split build whose artifact was never emitted. The first two also restore the two guard rails the legacy tar.gz upload has in `deploySite()` and this lane had dropped. Diagnosis only — same throw, same `INVALID_INPUT` code (platform-side publish alerting keys on it), and the hints ride the `--json` envelope, which is the publish sandbox's only view of a failed deploy. No routing, transport or success-path behavior changes on either arm. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y9GRcXmFAabLGUZkyRyaJd --- docs/deployments.md | 19 ++- packages/cli/src/core/site/deployment.ts | 92 +++++++++++++- .../site_deploy_output_diagnostics.spec.ts | 118 ++++++++++++++++++ 3 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 packages/cli/tests/cli/site_deploy_output_diagnostics.spec.ts diff --git a/docs/deployments.md b/docs/deployments.md index f01ab6f1..309f0598 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -1,6 +1,6 @@ # Deployments -**Keywords:** deployments, full-stack, Cloudflare Workers, wrangler, no_bundle, asset manifest, hash, git hash, commit, buckets, presigned, S3, upload session, finalize, .assetsignore, negation, concurrency, .wrangler/deploy/config.json, static site, BASE44_DEPLOYMENTS_API, env gate, target +**Keywords:** deployments, full-stack, Cloudflare Workers, wrangler, no_bundle, asset manifest, hash, git hash, commit, buckets, presigned, S3, upload session, finalize, .assetsignore, negation, concurrency, .wrangler/deploy/config.json, static site, BASE44_DEPLOYMENTS_API, env gate, target, index.html, entry point, output directory, diagnostics Deployments ship an app's built output addressed by the commit that produced it. This is a transport of the site module, not a module of its own, so it lives directly in `src/core/site/`: `deployment.ts` (the flow), `wrangler-config.ts` (artifact detection), `modules.ts` (worker module collection), `manifest.ts` (asset walk + hashing), `upload.ts` (bucket and presigned uploads), `git-hash.ts` (the commit address), with the requests and responses in the shared `api.ts` / `schema.ts` next to the legacy tar.gz upload. @@ -75,9 +75,24 @@ On the lane with no worker, the output directory becomes the asset manifest (ind With the gate off, every `site deploy` takes the legacy tar.gz path unchanged — including a full-stack project, whose worker is then not shipped at all. +### When there is no index.html to finalize with + +A build that emitted no artifact takes the no-worker arm, so `readIndexHtml()` is where a full-stack build that failed to emit its worker lands — not just a genuinely static one. `missingIndexHtmlError()` therefore separates the four ways the entry point can be absent, because one message for all of them says nothing about which happened: + +| condition | message | +|---|---| +| no worker **and** no `site.outputDirectory` | "No site output to deploy" | +| output directory missing | "Output directory does not exist" | +| output directory empty | "No files found in output directory" | +| populated, no root index.html | "No index.html at the root", naming any `**/index.html` found deeper | + +The last one is the interesting case: an `index.html` one level down is the signature of a client/server split build whose `.wrangler/deploy/config.json` was never emitted, so the error names the paths it did find and says the artifact was missing. The first two restore the guard rails the legacy tar.gz upload has (`deploySite()`) and this lane had dropped. + +All four are diagnosis only — same throw, same `INVALID_INPUT` code, and the hints ride the `--json` envelope, which is the platform publish sandbox's only view of a failed deploy. **Do not change the code here**: platform-side publish alerting keys on `cli_error_code`. + ## Testing -`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` selects the arm: `{type: "cf", ...}`, `{type: "s3", ...}` or `null`), `mockAssetUpload` (serves a Cloudflare-style `POST /cf-assets/upload` target, captures the Authorization header, `?base64=true` query and multipart fields in `assetUploadRequests`, responds 201 with the completion jwt), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures multipart fields in `finalizeRequests` and query strings in `finalizeQueries`). Fixtures: `tests/fixtures/fullstack-project/` (redirect file + `build/server` worker + `build/client` assets with `.assetsignore`) and `tests/fixtures/with-site/` (static output dir) — not git repos, so specs pass `--git-hash`. Unit tests live in `tests/core/site-*.spec.ts`. +`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` selects the arm: `{type: "cf", ...}`, `{type: "s3", ...}` or `null`), `mockAssetUpload` (serves a Cloudflare-style `POST /cf-assets/upload` target, captures the Authorization header, `?base64=true` query and multipart fields in `assetUploadRequests`, responds 201 with the completion jwt), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures multipart fields in `finalizeRequests` and query strings in `finalizeQueries`). Fixtures: `tests/fixtures/fullstack-project/` (redirect file + `build/server` worker + `build/client` assets with `.assetsignore`) and `tests/fixtures/with-site/` (static output dir) — not git repos, so specs pass `--git-hash`. Unit tests live in `tests/core/site-*.spec.ts`. The no-entry-point diagnostics have their own spec, `tests/cli/site_deploy_output_diagnostics.spec.ts`, which mutates the copied `with-site` fixture (nest the index.html, empty the directory, remove it) and asserts each case fails before the create call. ## Rules (Deployments-Specific) diff --git a/packages/cli/src/core/site/deployment.ts b/packages/cli/src/core/site/deployment.ts index a73e3104..d785767c 100644 --- a/packages/cli/src/core/site/deployment.ts +++ b/packages/cli/src/core/site/deployment.ts @@ -149,13 +149,99 @@ async function readIndexHtml( assets: AssetManifestResult, ): Promise { if (!assetsDir || !assets.manifest["/index.html"]) { - throw new InvalidInputError( - `No index.html found in "${assetsDir ?? "the site output directory"}" — a static site needs one at the output directory root.`, - ); + throw await missingIndexHtmlError(assetsDir, assets); } return new Uint8Array(await readFile(join(assetsDir, "index.html"))); } +/** Same wording the legacy tar.gz upload uses for an unbuilt project. */ +const BUILD_FIRST_HINT = { + message: + "Run 'base44 build' first (it injects your app id; a bare 'npm run build' does not)", +} as const; + +/** + * The redirect file as it appears in copy, spelled out rather than taken from + * `WRANGLER_REDIRECT_PATH` in `wrangler-config.ts`: that constant is built with + * `join()` for touching the file, so it would read with backslashes on Windows. + */ +const REDIRECT_FILE_IN_COPY = ".wrangler/deploy/config.json"; + +/** + * Says what the build did not emit without naming the worker — user-facing copy + * says "site" and does not make the distinction (see docs/deployments.md). + */ +const NO_ARTIFACT_HINT = { + message: `A build that emits ${REDIRECT_FILE_IN_COPY} deploys its server too; without one, only the files in the output directory are deployed.`, +} as const; + +/** + * Why there is no index.html to finalize with. One message used to cover every + * one of these — a missing output directory, an empty one, and one whose entry + * point sits a level down all read as "No index.html found ... a static site + * needs one", which says nothing about which of the three happened and asserts + * a site type the caller never chose. The first two also restore the guard + * rails the legacy tar.gz upload has and this lane dropped. + * + * Diagnosis only: the throw itself, and the `INVALID_INPUT` code the platform + * keys its publish alerting on, are unchanged. + */ +async function missingIndexHtmlError( + assetsDir: string | null, + assets: AssetManifestResult, +): Promise { + if (!assetsDir) { + return new InvalidInputError( + `No site output to deploy: this build emitted no ${REDIRECT_FILE_IN_COPY}, and the project config sets no 'site.outputDirectory' to fall back to.`, + { + hints: [ + { + message: + 'Add \'site.outputDirectory\' to your config.jsonc (e.g., "site": { "outputDirectory": "dist" })', + }, + ], + }, + ); + } + + if (!(await pathExists(assetsDir))) { + return new InvalidInputError( + `Output directory does not exist: ${assetsDir}. Make sure to build your project first.`, + { hints: [BUILD_FIRST_HINT] }, + ); + } + + const paths = Object.keys(assets.manifest); + if (paths.length === 0) { + return new InvalidInputError( + `No files found in output directory: ${assetsDir}. Make sure to build your project first.`, + { hints: [BUILD_FIRST_HINT] }, + ); + } + + // Populated, but the entry point is not where finalize reads it from. An + // index.html one level down is the signature of a build that split its + // output client/server without emitting the artifact that would have shipped + // the server, so name the ones we found rather than leave it to be guessed. + const nested = paths.filter((path) => path.endsWith("/index.html")).sort(); + + return new InvalidInputError( + `No index.html at the root of "${assetsDir}" — the build emitted ${paths.length} ${paths.length === 1 ? "file" : "files"} there, none of them an entry point.`, + { + hints: [ + ...(nested.length > 0 + ? [ + { + message: `Found an index.html deeper in the output: ${nested.join(", ")} — point 'site.outputDirectory' at that directory, or have the build emit an entry point at the root.`, + }, + ] + : []), + NO_ARTIFACT_HINT, + ], + }, + ); +} + /** * The subset of the wrangler assets config the deployments API accepts. The * unsupported fields would change runtime behavior if dropped silently, so each diff --git a/packages/cli/tests/cli/site_deploy_output_diagnostics.spec.ts b/packages/cli/tests/cli/site_deploy_output_diagnostics.spec.ts new file mode 100644 index 00000000..c079445c --- /dev/null +++ b/packages/cli/tests/cli/site_deploy_output_diagnostics.spec.ts @@ -0,0 +1,118 @@ +import { mkdir, rename, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +/** The commit the fixture "build" came from. */ +const GIT_HASH = "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c"; + +/** + * What the deploy says when the build left no index.html to finalize with. + * + * These all used to read "No index.html found ... a static site needs one at + * the output directory root" — one message for a missing output directory, an + * empty one, and one whose entry point sits a level down, which is what sent a + * production full-stack publish failure looking for a routing bug that was not + * there. Every case still fails, still fails before the create call, and still + * carries `INVALID_INPUT`. + */ +describe("site deploy diagnostics when the build output has no root index.html", () => { + const t = setupCLITests(); + + /** The output directory `with-site` configures, in the copied fixture. */ + function siteOutput(): string { + return join(t.getTempDir(), "project", "site-output"); + } + + it("names the nested entry point when the build split its output", async () => { + // Given a build that put its entry point a level down, as a client/server + // split does, and emitted no artifact to ship a server with + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); + await mkdir(join(siteOutput(), "client"), { recursive: true }); + await rename( + join(siteOutput(), "index.html"), + join(siteOutput(), "client", "index.html"), + ); + + // When + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); + + // Then + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No index.html at the root"); + t.expectResult(result).toContain("3 files there"); + t.expectResult(result).toContain("/client/index.html"); + t.expectResult(result).toContain(".wrangler/deploy/config.json"); + // Resolved before the create call, so nothing left the machine. + expect(t.api.deploymentCreateRequests).toHaveLength(0); + }); + + it("distinguishes an output directory that was never built", async () => { + // Given + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); + await rm(siteOutput(), { recursive: true, force: true }); + + // When + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); + + // Then + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Output directory does not exist"); + t.expectResult(result).toNotContain("No index.html"); + expect(t.api.deploymentCreateRequests).toHaveLength(0); + }); + + it("distinguishes an output directory a build left empty", async () => { + // Given + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); + await rm(siteOutput(), { recursive: true, force: true }); + await mkdir(siteOutput(), { recursive: true }); + + // When + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); + + // Then + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No files found in output directory"); + t.expectResult(result).toNotContain("No index.html"); + expect(t.api.deploymentCreateRequests).toHaveLength(0); + }); + + it("keeps the INVALID_INPUT code and carries the hints under --json", async () => { + // Given the shape the platform's publish sandbox runs, whose alerting keys + // on the code and whose only view of the failure is this document + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); + await mkdir(join(siteOutput(), "client"), { recursive: true }); + await rename( + join(siteOutput(), "index.html"), + join(siteOutput(), "client", "index.html"), + ); + + // When + const result = await t.run( + "site", + "deploy", + "-y", + "--git-hash", + GIT_HASH, + "--json", + ); + + // Then + t.expectResult(result).toFail(); + const envelope = JSON.parse(result.stdout) as { + code: string; + error: string; + hints?: { message: string }[]; + }; + expect(envelope.code).toBe("INVALID_INPUT"); + expect(envelope.error).toContain("No index.html at the root"); + expect(envelope.hints?.map((hint) => hint.message).join("\n")).toContain( + "/client/index.html", + ); + }); +});