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", + ); + }); +});