diff --git a/cog-build-deploy/README.md b/cog-build-deploy/README.md index b0e1b80e..f7a24fc4 100644 --- a/cog-build-deploy/README.md +++ b/cog-build-deploy/README.md @@ -21,12 +21,15 @@ Every input from `/cog-build/` is inherited unchanged. New on top: | Name | Type | Default | Description | |---|---|---|---| | `deploy_to_cloudflare` | `bool` | `True` | Run `wrangler deploy --temporary` after the build. Set `False` to skip the deploy step entirely. | -| `wrangler_project_path` | `str` | `""` | Path inside the workspace to the directory containing a wrangler config. Default empty = auto-detect the shallowest `wrangler.toml` / `wrangler.json` / `wrangler.jsonc` under `$PASCLAW_HOME/workspace/`. Both `src/my-worker` (a dir) and `src/my-worker/wrangler.jsonc` (the file) work. Absolute paths are stripped so the operator can't escape the workspace. | +| `wrangler_project_path` | `str` | `""` | **Not required.** Path inside the workspace to the directory containing a wrangler config. Default empty = auto-detect the shallowest `wrangler.toml` / `wrangler.json` / `wrangler.jsonc` under `$PASCLAW_HOME/workspace/`. Both `src/my-worker` (a dir) and `src/my-worker/wrangler.jsonc` (the file) work. Absolute paths are stripped so the operator can't escape the workspace. | +| `install_deps_before_deploy` | `bool` | `True` | Run `npm install` (and `npm run build` when the project's `package.json` declares a `build` script) in the wrangler project dir before deploying. **Required for any project with dependencies or a bundler step** — `wrangler deploy` bundles the entry point but does *not* install `node_modules` or run build scripts, so without this a dep-using Worker fails to bundle (`Could not resolve `) or deploys broken and crashes at runtime. Set `False` only for a hand-written, zero-dependency single-file Worker. | The auto-detect path means **no agent-side instructions are needed** — if the build produced any wrangler config (TOML / JSON / JSONC — Cloudflare recommends `.jsonc` for new projects), it'll get found and deployed. If multiple exist (rare), set `wrangler_project_path` explicitly to disambiguate. **Plan-only predictions never deploy.** When `mode="plan"` is used the build step is skipped, so the deploy block is also skipped even with `deploy_to_cloudflare=True` — a planning-only run can't accidentally publish stale code from a `workspace_in` that happened to contain a wrangler config. Slots `[2]` / `[3]` carry `(no deployment attempted -- plan-only mode)` in that case. +**Dependencies + build step.** `wrangler deploy` bundles the entry point with esbuild but doesn't install `node_modules` or run a project's build script — so a project that imports an npm package, or serves a built `dist/` (Vite, Workers Sites), would otherwise fail to bundle or deploy a broken Worker that crashes at runtime. With `install_deps_before_deploy=True` (the default) the cog runs `npm install` (and `npm run build` if a `build` script is declared) in the project dir first. This was validated against a real Hono + TypeScript Worker: raw deploy fails `Could not resolve "hono"`; install → build → deploy serves both `/` and `/api/hello` live. `node_modules` / `dist` are created in the live workspace *after* `pasclaw build` already wrote `workspace_out.zip`, so they don't bloat the returned archive. + ## Outputs A list of **four** file URLs (was two on `/cog-build/`): @@ -49,6 +52,8 @@ Slots `[2]` and `[3]` always exist — empty fields would silently disappear on | `(no deployment attempted)` | `deploy_to_cloudflare=False`. | | `(no deployment attempted -- plan-only mode)` | `mode="plan"` -- the build step ran no agent loop, so the deploy step is skipped too even with the toggle on. | | `(no wrangler.toml in workspace -- nothing to deploy)` | Agent didn't produce a wrangler config (TOML / JSON / JSONC); deploy was skipped automatically. | +| `(deploy failed: npm install exit N)\n` | `npm install` failed in the project dir (bad/unreachable dependency, etc.). Deploy not attempted. | +| `(deploy failed: npm run build exit N)\n` | The project's `build` script failed. Deploy not attempted. | | `(deploy failed: wrangler exit N)\n` | wrangler returned non-zero. Workspace + reply still come back, the prediction itself doesn't fail. | | `(deploy failed: wrangler timed out after 5 min)` | Deploy hit the 5-minute timeout. | | `(deploy failed: wrangler CLI not found -- rebuild the cog image)` | The image was built without wrangler. Check `cog.yaml`. | diff --git a/cog-build-deploy/predict.py b/cog-build-deploy/predict.py index f869cc9e..ca4cb725 100644 --- a/cog-build-deploy/predict.py +++ b/cog-build-deploy/predict.py @@ -376,6 +376,19 @@ def predict( "specific one deployed.", default="", ), + install_deps_before_deploy: bool = Input( + description="Before `wrangler deploy --temporary`, run " + "`npm install` (and `npm run build` when the project's " + "package.json declares a build script) in the wrangler " + "project dir. Required for any project with dependencies " + "or a bundler step (TypeScript + Vite, Hono, etc.) -- " + "wrangler bundles the entry point but does NOT install " + "node_modules or run build scripts, so without this a " + "dep-using Worker fails to bundle / deploys broken and " + "crashes at runtime. Set False only for a hand-written, " + "zero-dependency single-file Worker that needs no build.", + default=True, + ), ) -> list[Path]: """ Run `pasclaw build` against the unzipped workspace, then ship the @@ -791,7 +804,8 @@ def invoke_pasclaw(subcmd, extra_args, in_zip_arg, out_zip_arg, claim_url = "(no deployment attempted)" if deploy_to_cloudflare and do_build: deployed_url, claim_url = self._deploy_to_cloudflare( - home_dir, wrangler_project_path, scratch + home_dir, wrangler_project_path, scratch, + install_deps=install_deps_before_deploy, ) elif deploy_to_cloudflare and not do_build: deployed_url = "(no deployment attempted -- plan-only mode)" @@ -906,7 +920,8 @@ def _extract_urls(self, blob): claim = u return deployed, claim - def _deploy_to_cloudflare(self, home_dir, operator_override, scratch): + def _deploy_to_cloudflare(self, home_dir, operator_override, scratch, + install_deps=True): """Run `wrangler deploy --temporary` and parse the URLs out of stdout. Runs wrangler with a PER-PREDICTION HOME so the temporary @@ -967,6 +982,76 @@ def _deploy_to_cloudflare(self, home_dir, operator_override, scratch): ): env.pop(k, None) + # ---- npm install + build before deploy ---------------------- + # `wrangler deploy` auto-bundles the entry point with esbuild, + # but it does NOT install dependencies or run a project's build + # script. Anything beyond a zero-dependency single-file Worker + # therefore fails: a TS/Vite project that imports an npm package + # dies at bundle time with "Could not resolve " (no + # node_modules), and a Workers-Sites project that serves a built + # dist/ dir deploys an empty site and the Worker throws at + # runtime ("Worker crashed" when you hit the URL). So when the + # project has a package.json we run `npm install` and, if a + # `build` script exists, `npm run build` first. + # + # This runs the agent-authored package.json's install/build + # scripts (arbitrary code), but the build step already ran + # arbitrary agent tool calls and the temp account + container + # are throwaway -- consistent with the existing trust model. + # node_modules / dist land in the live workspace AFTER pasclaw + # build already wrote workspace_out.zip, so they don't bloat + # the returned archive. The operator can set install_deps=False + # to skip (e.g. a hand-written single-file Worker needing no + # deps). Codex follow-up after a real Vite-project deploy + # surfaced the "Worker crashed" gap. + pkg_json = os.path.join(proj, "package.json") + if install_deps and os.path.isfile(pkg_json): + print(f"deploy: package.json found in {proj}; running npm install") + try: + ires = subprocess.run( + ["npm", "install", "--no-audit", "--no-fund"], + cwd=proj, capture_output=True, text=True, + timeout=600, env=env, # installs can be slow + ) + except subprocess.TimeoutExpired: + return ("(deploy failed: npm install timed out after 10 min)", + "(deploy failed: npm install timed out after 10 min)") + except FileNotFoundError: + return ("(deploy failed: npm not found -- rebuild the cog image)", + "(deploy failed: npm not found -- rebuild the cog image)") + if ires.returncode != 0: + tail = ((ires.stdout or "") + (ires.stderr or "")).strip().splitlines()[-8:] + msg = "(deploy failed: npm install exit %d)\n%s" % ( + ires.returncode, "\n".join(tail)) + return (msg, msg) + + # Run `npm run build` only when the package declares a build + # script -- otherwise npm errors "missing script: build". + has_build = False + try: + import json as _json + with open(pkg_json) as f: + has_build = bool(_json.load(f).get("scripts", {}).get("build")) + except Exception as e: + print(f"deploy: could not parse package.json for a build " + f"script ({e}); skipping npm run build") + if has_build: + print("deploy: running npm run build") + try: + bres = subprocess.run( + ["npm", "run", "build"], + cwd=proj, capture_output=True, text=True, + timeout=600, env=env, + ) + except subprocess.TimeoutExpired: + return ("(deploy failed: npm run build timed out after 10 min)", + "(deploy failed: npm run build timed out after 10 min)") + if bres.returncode != 0: + tail = ((bres.stdout or "") + (bres.stderr or "")).strip().splitlines()[-8:] + msg = "(deploy failed: npm run build exit %d)\n%s" % ( + bres.returncode, "\n".join(tail)) + return (msg, msg) + print(f"deploy: running `wrangler deploy --temporary` in {proj} " f"(HOME={wrangler_home})") try: