diff --git a/.claude/rules/examples.md b/.claude/rules/examples.md index f889dd7..38e34d9 100644 --- a/.claude/rules/examples.md +++ b/.claude/rules/examples.md @@ -44,12 +44,12 @@ To pick: for a worker that serves a page or fronts an external API, start from accepted by `wdl init`. The CLI sanitizes this value only for Wrangler's local bundling check; the original name is deployed to WDL. 2. **Copy the example tree** into the new directory, preserving the layout - (`src/`, `public/`, `migrations/` if present, etc.). Don't add subdirectories - or top-level files the example doesn't have. + (`src/`, `public/`, `migrations/` if present, etc.). Add only the project + support files described below. 3. **Rewrite `name`** in: - `package.json` → `"name": ""` - `wrangler.jsonc` or `wrangler.toml` → top-level `name` field. - - **Don't** rewrite anything else automatically. Compatibility date, binding + - **Don't** rewrite feature config automatically. Compatibility date, binding ids, vars, etc. stay as the example sets them; the user edits what they need. 4. **Write `.gitignore`** at the project root with: @@ -65,14 +65,23 @@ To pick: for a worker that serves a page or fronts an external API, start from .env .env.* !.env.example + .dev.vars* ``` Examples don't ship one because they're committed to wdl-cli's repo — every new project does. -5. **Customize `src/`** to do what the user asked. The example's handler is a +5. **Complete the project scaffold.** Keep the example's Wrangler v4 dependency, + add `@wdl-dev/cli` as a dev dependency, add `deploy` (`wdl deploy .` with + `--ns ` only when provided) and `dry-run` + (`wrangler deploy --dry-run --outdir=.deploy-dist --env-file=.wdl-empty.env`) + scripts, create an empty `.wdl-empty.env`, and copy `templates/AGENTS.md` + into the new project. Also write `CLAUDE.md` with `See AGENTS.md.` on one + line, matching `wdl init`. These files are generated by `wdl init` but are + not present in every example tree. +6. **Customize `src/`** to do what the user asked. The example's handler is a starting point, not a final implementation; replace it. -6. **Print next steps** to the user: +7. **Print next steps** to the user: ``` cd npm install @@ -110,7 +119,7 @@ To pick: for a worker that serves a page or fronts an external API, start from ## Deploy ```bash -npx wrangler deploy --dry-run --outdir=.deploy-dist # bundle check +npm run dry-run # bundle check wdl deploy . --env preview --ns # env override preview wdl deploy . --env production --ns # env override prod ``` diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index 10a3626..cc1c69a 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -1,6 +1,6 @@ --- name: wdl-deploy -description: Deploy and manage Cloudflare Workers-style projects on the WDL platform via the `wdl` CLI (init, deploy, config explain, whoami, doctor, tail, secret, token, workers, delete, d1, r2, ai, workflows). Trigger when the user asks to scaffold or deploy a Worker, inspect resolved CLI configuration, identify the active control token/principal, manage the local WDL token store, run diagnostics, tail live logs, configure KV / Queues / Durable Objects / Workflows / AI bindings, manage D1 / R2 / AI providers / secrets through `wdl`, or troubleshoot wdl CLI output. Works with `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` projects pinned to wrangler@^4. +description: Deploy and manage Cloudflare Workers-style projects on the WDL platform via the `wdl` CLI (init, deploy, config explain, whoami, doctor, tail, secret, token, workers, delete, d1, r2, ai, workflows). Trigger when the user asks to scaffold or deploy a Worker, inspect resolved CLI configuration, identify the active control token/principal, manage the local WDL token store, run diagnostics, tail live logs, configure KV / Queues / Durable Objects / Workflows / AI bindings, manage D1 / R2 / AI providers / secrets through `wdl`, or troubleshoot wdl CLI output. Works with `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` projects using Wrangler `>=4.27.0 <5.0.0`. --- # WDL CLI deploy skill @@ -53,9 +53,11 @@ authoritative, and agent-facing references use the English set. New Wrangler configs should use `compatibility_date = "2026-06-17"` unless a project feature requires a newer target or the operator gives a different -target. Control rejects explicit dates before `2026-04-01`, invalid or future -dates, dates newer than the bundled workerd supports, upstream experimental -enable flags, `legacy_error_serialization`, and +target. The selected Wrangler needs `>=4.27.0 <5.0.0` for `--env-file`; an older +project-local install takes precedence over the CLI's bundled release and fails +even during version probing. Control rejects explicit dates before `2026-04-01`, +invalid or future dates, dates newer than the bundled workerd supports, upstream +experimental enable flags, `legacy_error_serialization`, and `allow_irrevocable_stub_storage`. WDL follows Wrangler config priority (`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`). Both JSON filenames use Wrangler's JSONC syntax, including comments and trailing commas. @@ -65,44 +67,48 @@ The CLI still fails fast for cheap local cases such as Python Workers modules, unmapped top-level or selected-env Wrangler runtime/deploy keys (`[site]`, `pages_build_output_dir`, `observability`, `limits`, `placement`, etc.), and ambiguous runtime `env` name collisions between `[vars]`, explicit bindings, and -the implicit `ASSETS` binding. For an operator-enabled routed Worker, explicit -`workers_dev = false` keeps its pattern routes active while disabling the -default platform-domain URL; it requires at least one `route` / `routes` pattern -and is not inferred. The deploy summary prints every active route-pattern URL -hint, preserving the trailing `*` on prefix patterns, and includes the -platform-domain URL only while it is enabled. Cloudflare's separate -`preview_urls` field is unsupported and rejected by the CLI. WDL consumes -`[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, +the implicit `ASSETS` binding. Custom module `rules` are rejected because WDL +cannot recover their types from Wrangler's output. For an operator-enabled +routed Worker, explicit `workers_dev = false` keeps its string pattern routes +active while disabling the default platform-domain URL; it requires at least one +`route` / `routes` pattern and is not inferred. The deploy summary prints every +active route-pattern URL hint, preserving the trailing `*` on prefix patterns, +and includes the platform-domain URL only while it is enabled. Cloudflare's +separate `preview_urls` field is unsupported and rejected by the CLI. WDL +consumes `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, `[[services]].ns`, and `[wdl]` itself and removes those WDL extensions from Wrangler's temporary bundle config. `[ai]` is standard Wrangler configuration and stays in that config for Wrangler validation. If a selected named -environment omits its own `ai`, the CLI warns that the top-level binding is not -inherited; WDL independently maps its `binding` into the WDL manifest. Other -fields retain their existing Wrangler passthrough behavior. `[[connect]]` TCP -listeners are unsupported and rejected before bundling. Specific nested fields -that WDL cannot represent are rejected rather than silently dropped, including -Cloudflare Artifacts `triggers.events` subscriptions and R2 -`local_dev.experimental_s3_credentials`. `[[workflows]]` supports only `name`, -`binding`, and `class_name`; `script_name` and all other fields, including -`schedules`, `limits`, `default_retention`, and `concurrency`, are rejected -before bundling. Use per-instance `create()` retention instead of -`[[workflows]].default_retention`. `[wdl] session_policy` accepts `preserve` or -`restart`. The default `preserve` leaves loaded Durable Object facets on the -version that built them until the host actor restarts or the facet is deleted, -and keeps established WebSockets draining while their backend stays healthy. -`restart` closes the worker's open WebSockets with code `1012` at promotion and -retires stale facets on their next dispatch, preserving SQLite state. Wrangler's -object-shaped declarative `exports` config is unsupported. The dry-run child -hides Wrangler's banner (and its normal update check) and disables anonymous -telemetry and automatic agent skills installation, updates, and prompts. -Bundling keeps stdin closed even with `--verbose`, which still forwards -stdout/stderr. Wrangler may still write local metrics state and debug logs, and -consult the configured npm registry when reporting an unknown configuration -field; project build hooks retain their normal network access. For -`[[services]]` and `[[exports]]`, read `docs/deploy.md`: tenant JSRPC may -delegate service or Durable Object class stubs as opaque capabilities, but the -receiver cannot rewrite their host-authored caller properties. Keep delegated -stubs in memory; long-term irrevocable stub storage is unsupported. +environment omits top-level `[ai]`, `[[exports]]`, or `[[platform_bindings]]`, +the CLI warns that the binding is not inherited; WDL independently maps `[ai]`'s +`binding` into the WDL manifest. Other fields retain their existing Wrangler +passthrough behavior. `[[connect]]` TCP listeners are unsupported and rejected +before bundling. Specific nested fields that WDL cannot represent are rejected +rather than silently dropped, including Cloudflare Artifacts `triggers.events` +subscriptions and R2 `local_dev.experimental_s3_credentials`, queue consumer +types other than `worker`, unmapped queue/service/DO entry fields, route +objects, and unsupported `[assets]` options. The implicit asset binding is named +`ASSETS`. `[[workflows]]` supports only `name`, `binding`, and `class_name`; +`script_name` and all other fields, including `schedules`, `limits`, +`default_retention`, and `concurrency`, are rejected before bundling. Use +per-instance `create()` retention instead of `[[workflows]].default_retention`. +`[wdl] session_policy` accepts `preserve` or `restart`. The default `preserve` +leaves loaded Durable Object facets on the version that built them until the +host actor restarts or the facet is deleted, and keeps established WebSockets +draining while their backend stays healthy. `restart` closes the worker's open +WebSockets with code `1012` at promotion and retires stale facets on their next +dispatch, preserving SQLite state. Wrangler's object-shaped declarative +`exports` config is unsupported. The dry-run child hides Wrangler's banner (and +its normal update check) and disables anonymous telemetry and automatic agent +skills installation, updates, and prompts. Bundling keeps stdin closed even with +`--verbose`, which still forwards stdout/stderr. Wrangler may still write local +metrics state and debug logs, and consult the configured npm registry when +reporting an unknown configuration field; project build hooks retain their +normal network access. For `[[services]]` and `[[exports]]`, read +`docs/deploy.md`: tenant JSRPC may delegate service or Durable Object class +stubs as opaque capabilities, but the receiver cannot rewrite their +host-authored caller properties. Keep delegated stubs in memory; long-term +irrevocable stub storage is unsupported. If a command reports `Missing namespace`, supply the intended tenant with `--ns ` or `WDL_NS` before retrying. @@ -114,18 +120,30 @@ selects HTTP on any host. Every HTTP `.local` target emits the plaintext-token warning. Never recommend setting `CONTROL_CONNECT_HOST` outside local development: it -overrides the TCP target the admin token connects to (Host header + TLS SNI -still track `CONTROL_URL`), and a stale value in a CI or production shell could -route the token to an unintended host. A URL-form override uses its scheme only -to choose the default TCP port; transport still follows `CONTROL_URL`. GUIDE -covers the details. Local deploy output also derives the public Worker scheme -and port from `CONTROL_URL`, never `CONTROL_CONNECT_HOST`. +overrides the TCP target the admin token connects to (Host and TLS certificate +identity still track `CONTROL_URL`; IP authorities omit SNI), and a stale value +in a CI or production shell could route the token to an unintended host. A +URL-form override uses its scheme only to choose the default TCP port; transport +still follows `CONTROL_URL`. GUIDE covers the details. Local deploy output also +derives the public Worker scheme and port from `CONTROL_URL`, never +`CONTROL_CONNECT_HOST`. An override in a project `.env` is ignored unless the +effective token and `CONTROL_URL` also come from that same `.env`; use a shell +override when testing an endpoint supplied by a flag, shell, or token store. `wdl deploy` runs the project's Wrangler dry-run and build hooks as the user, so -they can read the on-disk token store (`~/.config/wdl/credentials`); only deploy -trusted projects. For a less-trusted or third-party project, recommend +they can read project `.env` and the on-disk token store +(`~/.config/wdl/credentials`). The CLI prevents Wrangler's automatic `.env` +reload into the child environment, but it cannot sandbox project code; only +deploy trusted projects. For a less-trusted or third-party project, recommend `--no-token-store` (or `WDL_TOKEN_STORE=off`) with an ephemeral `--token` / -`--control-url`, rather than relying on the global store. +`--control-url`, rather than relying on the global store. `wdl doctor` also +executes the project's local Wrangler `--version`; run it only when that local +tool is trusted. + +For a project generated by `wdl init`, use `npm run dry-run` for a local bundle +check. Its empty `.wdl-empty.env` prevents Wrangler from injecting the project +`.env` into build hooks; direct Wrangler dry-runs need the same explicit empty +`--env-file`. Build hooks can still read project files themselves. `wdl ai`, `wdl secret`, and `wdl token` redact invalid argument details. When a string option precedes the complete subcommand path and its separate value is diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c190b8f..9adf12c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,8 @@ jobs: dist-tag: ${{ steps.version.outputs.dist-tag }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: @@ -59,6 +61,8 @@ jobs: id-token: write # OIDC: trusted-publishing auth + provenance attestation steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: @@ -87,6 +91,8 @@ jobs: packages: write steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: @@ -107,6 +113,8 @@ jobs: contents: write steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index a490e03..c322619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ ## Unreleased +### Changed + +- Reject custom Wrangler module rules, unmapped queue, service, Durable Object, + and asset fields, and route objects instead of silently dropping their + effects. +- Keep WDL `[[exports]]` and `[[platform_bindings]]` scoped to the selected + environment and warn when they are omitted there; let env-level `route` and + `routes` replace each other. +- Project-local Wrangler must support `--env-file` (`>=4.27.0 <5.0.0`) for + version probing and bundling; older v4 installations take precedence over the + bundled release. + +### Fixed + +- Treat bundled `.sql` modules as text, reconnect idle or transiently failed + Tail streams while stopping on permanent `ctx_unavailable`, and show partial + D1 migration progress after an apply error. +- Keep asset directories out of Wrangler dry-run so excluded files cannot block + bundling before the CLI applies its asset ignore rules. +- Show retained Durable Object storage in worker deletion output and avoid + forged human-output lines in D1, R2, and doctor summaries. +- Correct `--json` help text, optional `--yes` usage, and generated project + exclusions for `.dev.vars*` files. + +### Security + +- Stop Wrangler from automatically loading the project's `.env` into its build + environment, including generated `npm run dry-run` checks; exclude `.env*`, + `.dev.vars*`, and `.wdl-empty.env` from assets by default, and prevent a + project `.env` from redirecting a higher-priority Control URL via + `CONTROL_CONNECT_HOST`. +- Verify HTTPS certificates against the Control URL's IP authority even when + `CONTROL_CONNECT_HOST` overrides the socket destination. +- Avoid persisting checkout credentials in release jobs. + ## 1.9.0 ### Changed diff --git a/GUIDE-zh.md b/GUIDE-zh.md index 96a4fa3..a180359 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -25,7 +25,7 @@ https://.// 前置条件: -- Worker 项目使用 Wrangler v4(`wrangler@^4`);CLI 的打包步骤已不再支持 v3。 +- 选中的 Wrangler 版本必须在 `>=4.27.0 <5.0.0` 范围内,以支持 `--env-file`。项目本地版本优先于 CLI 自带的已测试 v4 版本;旧的本地 v4 甚至会在版本探测时失败。 - 本机使用 Node.js 22 或更新版本,与 CLI 运行时和 Wrangler v4 基线一致。 - 如果 Worker 项目有依赖,部署前先在 Worker 项目目录执行 `npm install`。 @@ -89,16 +89,20 @@ CLI 只会从 `.env` 读取 WDL 平台变量:`ADMIN_TOKEN`、`CONTROL_URL`、` 如果命令报告 `Missing namespace`,请传入 `--ns ` 或设置 `WDL_NS` 后重试。 -`CONTROL_CONNECT_HOST` 是本地开发 / 调试用的覆盖开关:它改变请求实际连接的 TCP 目标,而 HTTP Host header 和 TLS SNI 仍跟随 `CONTROL_URL`(所以 HTTPS 下控制面证书仍会拒绝被重定向的连接;纯 http 没有这层保护)。只在本地开发用 —— 不要在 CI 或生产 shell 中持久设置,残留值可能把 admin token 路由到非预期目标。覆盖值写成 URL 时,scheme 只决定默认 TCP 端口(`http` 为 80,`https` 为 443);请求使用 HTTP 还是 HTTPS、Host 和 SNI 仍由 `CONTROL_URL` 决定。 +`CONTROL_CONNECT_HOST` 是本地开发 / 调试用的覆盖开关:它改变请求实际连接的 TCP 目标,而 HTTP Host header 和 TLS 证书校验目标仍跟随 `CONTROL_URL`。DNS 域名会发送 SNI;IP 地址不发送 SNI,但证书仍按该 IP 校验。纯 HTTP 没有证书保护。只在本地开发用 —— 不要在 CI 或生产 shell 中持久设置,残留值可能把 admin token 路由到非预期目标。覆盖值写成 URL 时,scheme 只决定默认 TCP 端口(`http` 为 80,`https` 为 443);请求使用 HTTP 还是 HTTPS、Host 仍由 `CONTROL_URL` 决定。 + +项目 `.env` 提供的 `CONTROL_CONNECT_HOST` 只有在有效 token 和 `CONTROL_URL` 都来自同一份 `.env` 时才会生效,否则会被忽略。调试 shell、flag 或 token store 提供的端点时,请在 shell 中设置这个覆盖值。 推荐的做法是把这些凭证放进托管存储,而不是 shell export 或项目 `.env`:`wdl token set --ns --control-url ` 用隐藏输入读取 token、调 `/whoami` 校验后按 namespace 存入 `~/.config/wdl/credentials`(不进 shell 历史、也不落在项目文件里)。存储是优先级最低的层——命令行标志、shell env、项目 `.env` 仍然胜出——`wdl token list` / `wdl token rm` 管理它。第一个存入的 namespace 成为默认(一行 base `WDL_NS`,和项目 `.env` 一样),命令不带 `--ns` 也能跑;`wdl token use ` 切换默认。CLI 会拒绝 symlink / 非普通文件形式的 credentials 路径;在 POSIX 上,如果 store 文件不属于当前用户、store 目录可被 group/other 写或文件能被 group/other 访问,也会拒绝读取。详见 [token-zh.md](./docs/token-zh.md)。 `wdl ai`、`wdl secret` 和 `wdl token` 都可能接收凭据,因此会脱敏无效参数的细节。如果完整子命令路径前的 string option 使用分离式值,而该值本身也是命令词,请把子命令放到前面,或改用 `--flag=value` 消歧。例如写 `wdl secret list --worker put` 或 `wdl secret --worker=put list`,不要写 `wdl secret --worker put list`。 -`wdl deploy` 在上传前会以你的 OS 用户身份运行项目本地的 Wrangler dry-run 和 build 钩子,这些代码能读到磁盘上的 store(env scrub 只把 WDL 变量挡在 Wrangler 子进程的环境外,挡不住文件),所以只部署你信任的项目。`--no-token-store`(或 `WDL_TOKEN_STORE=off`)让 CLI 只从 flag / shell / `.env` 解析凭据、完全不读 store —— 这是给不太信任的项目或 CI 用的解析 opt-out,不是对文件本身的保护。 +`wdl deploy` 在上传前会以你的 OS 用户身份运行项目本地的 Wrangler dry-run 和 build 钩子。CLI 传入空 env-file,避免 Wrangler 把项目 `.env` 重新载入子进程环境;但 build 钩子仍能直接读取 `.env`、`.dev.vars` 和磁盘上的 token store,所以只部署你信任的项目。`--no-token-store`(或 `WDL_TOKEN_STORE=off`)让 CLI 只从 flag / shell / `.env` 解析凭据、完全不读 store —— 这是给不太信任的项目或 CI 用的解析 opt-out,不是对文件本身的保护。 用 `wdl config explain` 查看最终 namespace、control URL、脱敏 token 以及每个值的来源。如果解析需要读取 token store,而该次读取发现 store 损坏、无法读取或未通过安全检查,这个诊断命令仍会排除 store 后成功退出,展示剩余 flag / shell / `.env` 来源,并在人类可读的 `tokenStore` block 或 JSON `tokenStore.error` 中报告故障;实际操作命令需要该 store 时仍会 fail closed。如果更高优先级来源已经覆盖 namespace、control URL 和 token,CLI 不会读取或诊断 store。用 `wdl whoami` 调 control-plane `/whoami`,查看当前 authenticated principal、token id、platform version、最低支持 CLI version 和 URL hints。用 `wdl doctor` 做本地可用性检查,包括 Node.js、wdl-cli、Wrangler、配置文件是否存在、凭据是否能解析,以及 `/whoami` 是否可达;在 CI 里可加 `--strict`,命令仍会打印检查结果,但只要任一检查失败就以非零退出。当 control plane 暴露 `/whoami` 时,`doctor` 可以发现 token 是否有效、principal namespace、platform version 和 CLI compatibility;更细的 capability 检查仍需要额外的 control endpoint。运维方没有配置公开 platform domain 时,namespace URL 可能显示为 `(unavailable)`;认证和其它 `/whoami` 字段仍然有效。 +在项目目录运行 `wdl doctor` 会以当前 OS 用户身份执行该项目 Wrangler 的 `--version`。与 `wdl deploy` 一样,只在信任项目本地工具时运行。 + ## 脚手架新 Worker `wdl init` 是新建 WDL Worker 项目的默认脚手架: @@ -111,9 +115,9 @@ npm install 它会写入: -- `package.json` —— 传了 `--ns` 时 `npm run deploy` 会把它烤进去,否则就是 `wdl deploy .`(namespace 在部署期解析),另有 `npm run dry-run` 本地打包检查;devDependencies 固定 `wrangler@^4` 和 `@wdl-dev/cli`。 +- `package.json` —— 传了 `--ns` 时 `npm run deploy` 会把它烤进去,否则就是 `wdl deploy .`(namespace 在部署期解析),另有使用空 `.wdl-empty.env` 的 `npm run dry-run` 本地打包检查;devDependencies 固定 CLI 已测试的 Wrangler v4 版本和 `@wdl-dev/cli`。 - `wrangler.jsonc` —— 顶层 `name` 是 worker 名(默认等于目录名,可用 `--worker ` 覆盖)。 -- `src/index.js`、`.gitignore`,以及 `AGENTS.md`/`CLAUDE.md`,方便 AI 代理找到 `node_modules/@wdl-dev/cli/docs/` 下的分主题文档。 +- `src/index.js`、`.gitignore`、空 `.wdl-empty.env`,以及 `AGENTS.md`/`CLAUDE.md`,方便 AI 代理找到 `node_modules/@wdl-dev/cli/docs/` 下的分主题文档。 `wdl init . --ns acme` 可以在当前(空)目录原地脚手架。目录名须以字母开头,只能包含字母、数字和连字符。 @@ -211,6 +215,8 @@ wdl tail hello --max-reconnects 0 # 不限制自动重连次数 `wdl tail` 是 best-effort 实时调试工具,不是审计历史。高流量 worker 或终端连接消费太慢时,可能跳过中间事件。control 侧过大的 console 或 exception 事件会整条丢弃,并以较小的 warning 事件报告,而不是截断后输出;作为独立的客户端防线,如果超大 SSE event 拼接后的 data 超过 4 MiB,CLI 会终止当前 tail 会话。事故复盘和完整 payload 请使用管理方提供的常规日志平台。 +control 返回临时性的 502/503/504,或 SSE 流连续 30 秒没有任何数据时,CLI 会按既有退避策略重连(control 正常情况下每 5 秒发送心跳);永久性的 `503 ctx_unavailable` 和其它 HTTP 错误会终止会话。 + control 可能主动回收长时间运行的 tail 会话:客户端约 15s 不读会收到 `session_idle`,会话达到运维方配置的最大时长(默认 15 分钟)会收到 `session_expired`。CLI 会打印 warning 并自动重连;如果反复出现,通常说明终端或外层 wrapper 没有及时消费输出。 普通格式化输出会把 worker 名前缀拼到 fetch path 上:worker 内部看到的 `/` 会显示成 `//`,便于和浏览器访问路径对应。`--raw` 保留原始JSON payload。 @@ -236,7 +242,7 @@ https://.// 如果管理方已明确为你的 namespace 开通自定义 routing,会同时给出允许使用的 host 和 route pattern。普通 tenant 示例和首次部署不要配置 `route` / `routes`。如果 custom-host promote 因 host 已被占用而失败,请联系管理方;同一个 namespace 内的多个 Worker 仍可以在已开通的形态下按路径拆分流量。 -至少有一条 route pattern 的 Worker 可以设置 `workers_dev = false`,在保持 pattern route active 的同时关闭默认 WDL platform-domain URL。WDL 要求显式 opt out;仅声明 `route` / `routes` 不会关闭 platform URL。Deploy 摘要会输出每条 active route-pattern URL hint,并在 prefix pattern 上保留尾部 `*`,而且只在 platform-domain URL 启用时输出它。 +至少有一条 route pattern 的 Worker 可以设置 `workers_dev = false`,在保持 pattern route active 的同时关闭默认 WDL platform-domain URL。`route` / `routes` 只接受字符串 pattern,不支持 route object。WDL 要求显式 opt out;仅声明 `route` / `routes` 不会关闭 platform URL。Deploy 摘要会输出每条 active route-pattern URL hint,并在 prefix pattern 上保留尾部 `*`,而且只在 platform-domain URL 启用时输出它。 Cloudflare 用 `workers_dev` 控制 Worker 的 `*.workers.dev` route;版本化 preview URL 由独立的 `preview_urls` 控制,后者默认跟随 `workers_dev`。WDL 则把 `workers_dev` 映射到 namespace 的普通服务路径 `.//`,所以迁移 `wrangler.toml` 时要重新确认。WDL 还要求至少有一条 route pattern 才能显式 opt out,不会仅因声明了 route 就自动关闭该路径。WDL 不支持 `preview_urls`,CLI 会拒绝该字段。 @@ -268,7 +274,9 @@ Wrangler 能打包、但 WDL 不能运行的形状由 control plane 作为 canon | Analytics Engine | 暂不支持,部署时会拒绝 | | 其他未映射的 Wrangler 绑定/配置/策略段(例如 `vectorize`、`hyperdrive`、`agent_memory`、`websearch`、`media`、`stream`、`ratelimits`、`vpc_services`、`cloudchamber`、`containers`、`wasm_modules`、`[site]`、`limits`、`placement`、`observability`、`pages_build_output_dir`) | 不支持;部署时显式报错,不会静默丢弃绑定/配置。CLI 报错会点名被拒字段;内部拒绝列表跟随打包的 Wrangler schema,这里不复刻完整清单 | -WDL 会自行消费 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns` 和 `[wdl]`,并从传给 Wrangler bundler 的临时配置中移除这些 WDL 扩展。`[ai]` 是 Wrangler 标准配置,会保留在临时配置中供 Wrangler 校验;如果选中的 named environment 没有自己的 `ai`,CLI 会提示顶层 binding 不会继承。WDL 另行只接受其中的 `binding` 字段,并把该声明映射到 WDL manifest。其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。 +WDL 会自行消费 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns` 和 `[wdl]`,并从传给 Wrangler bundler 的临时配置中移除这些 WDL 扩展。`[ai]` 是 Wrangler 标准配置,会保留在临时配置中供 Wrangler 校验;如果选中的 named environment 漏掉顶层 `[ai]`、`[[exports]]` 或 `[[platform_bindings]]`,CLI 会提示这些 binding 不会继承。WDL 另行只接受 `[ai]` 的 `binding` 字段,并把该声明映射到 WDL manifest。其它字段保持既有的 Wrangler 透传行为;但自定义 module `rules` 的类型无法从 Wrangler bundle output 恢复,因此 CLI 会拒绝。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。 + +CLI 也会拒绝非 `worker` 的 queue consumer 类型、queue / service / Durable Object binding entry 中无法映射的字段、route object,以及 `html_handling`、`not_found_handling` 等不支持的 `[assets]` 选项。隐式 asset binding 的名称固定为 `ASSETS`;其它 `assets.binding` 会被拒绝。 `[[connect]]` TCP listener 没有对应的 WDL runtime 映射,顶层和所选 environment 内的声明都会在打包前被拒绝。 @@ -276,7 +284,7 @@ Cron triggers 和 queue consumers 是运行时 dispatch 能力。除非管理方 R2 custom metadata key 读取时会按 HTTP header 语义归一成小写。R2 object head 会暴露 HTTP metadata 和 custom metadata,所以鉴权上与读取 object body 同级,不开放给 observer 角色。R2 支持条件请求、range GET 和 `list({ include: [...] })` metadata hydration。 `list({ include })` 会在并发上限内额外发起 HEAD;只有列表结果确实需要 metadata 时再打开。 -删除 Worker 不会删除 R2 数据。可以用 `wdl r2 buckets list` 和 `wdl r2 objects list ` 查看 namespace 内的 R2 数据;用 `wdl r2 objects head ` / `wdl r2 objects get ` 查看单个对象;用 `wdl r2 objects delete --yes` 显式删除单个对象。`wdl r2 buckets list` 是从已有对象 prefix 推出来的,所以已声明的 bucket 要到第一次 PUT 后才会出现。object delete 是幂等的单次 S3 DELETE,不做 retry,也不报告对象此前是否存在。对象不存在时,`HEAD` 遵循 HTTP 语义返回空 404; `wdl r2 objects head` 会显示状态码,不会有 JSON 错误体可解析。 +删除 Worker 不会删除 R2 数据。可以用 `wdl r2 buckets list` 和 `wdl r2 objects list ` 查看 namespace 内的 R2 数据;用 `wdl r2 objects head ` / `wdl r2 objects get ` 查看单个对象;用 `wdl r2 objects delete ` 显式删除单个对象,默认会提示确认。`wdl r2 buckets list` 是从已有对象 prefix 推出来的,所以已声明的 bucket 要到第一次 PUT 后才会出现。object delete 是幂等的单次 S3 DELETE,不做 retry,也不报告对象此前是否存在。对象不存在时,`HEAD` 遵循 HTTP 语义返回空 404; `wdl r2 objects head` 会显示状态码,不会有 JSON 错误体可解析。 `wdl r2 objects get` 会写出原始 object bytes。需要 stream bytes 时请 pipe 或重定向 stdout;在交互终端中请使用 `--out `。 @@ -284,7 +292,7 @@ R2 object key 可以包含开头、结尾或连续的 `/` 分隔符;CLI 会保 ### 环境覆盖 -如果 Wrangler 配置里有 `[env.]`,必须通过 `--env ` 或 `CLOUDFLARE_ENV` 显式选择;CLI 不会自动挑一个默认环境。和 Cloudflare Workers / Wrangler 不同,WDL 不会把环境名追加到 worker / script 名后面:`wdl deploy . --env preview` 仍然更新顶层 `name` 指定的 worker。`vars` 和大部分 bindings 仍是 env-scoped / non-inheritable:选中 env 后,顶层 `[vars]`、KV、D1、R2、AI、queues、services、workflows 都不会自动进入该 env;如果选中的 env 漏掉顶层 `[ai]` binding,deploy 会明确提示。策略类配置则会继承:`workers_dev`、`route` / `routes` 和 `[wdl]` 在 env 没有自己声明时继续生效。需要同时跑 staging / production 时,默认用不同 namespace 区分,除非管理方另有约定。 +如果 Wrangler 配置里有 `[env.]`,必须通过 `--env ` 或 `CLOUDFLARE_ENV` 显式选择;CLI 不会自动挑一个默认环境。和 Cloudflare Workers / Wrangler 不同,WDL 不会把环境名追加到 worker / script 名后面:`wdl deploy . --env preview` 仍然更新顶层 `name` 指定的 worker。`vars` 和大部分 bindings 仍是 env-scoped / non-inheritable:选中 env 后,顶层 `[vars]`、KV、D1、R2、AI、queues、services、workflows、`[[exports]]` 和 `[[platform_bindings]]` 都不会自动进入该 env;如果选中的 env 漏掉顶层 `[ai]`、`[[exports]]` 或 `[[platform_bindings]]`,deploy 会明确提示。策略类配置则会继承:`workers_dev`、`route` / `routes` 和 `[wdl]` 在 env 没有自己声明时继续生效;env 中的 `route` 或 `routes` 会替换顶层另一种写法。需要同时跑 staging / production 时,默认用不同 namespace 区分,除非管理方另有约定。 ### KV @@ -363,7 +371,7 @@ wdl r2 buckets list wdl r2 objects list uploads --prefix images/ wdl r2 objects head uploads images/logo.png wdl r2 objects get uploads images/logo.png --out logo.png -wdl r2 objects delete uploads images/logo.png --yes +wdl r2 objects delete uploads images/logo.png ``` `--out` 接受项目目录外的显式文件系统路径。当前沿用普通覆盖语义:已有文件会被替换,symlink 会跟随到目标。下载前请核对目标路径。 @@ -451,6 +459,8 @@ wdl deploy . Migration 是 forward-only。WDL 使用 migration 文件名作为 migration id,已经 apply 的 migration 文件不应重命名或修改;重命名会被视为一条新的 migration。平台不提供自动 down/rollback workflow。若 Worker 版本可能 rollback,migration 应按 expand/contract 方式编写。 +Apply 报错不代表整批都未执行:前面的 migration 可能已经完成。CLI 会在有数据时显示 control 返回的 applied/skipped ID;重试前先运行 `wdl d1 migrations status `。 + 以 `_cf_` 开头的 SQLite object name 是 workerd 保留名,大小写不敏感。不要创建或 `RENAME TO` 到 `_cf_*` 形式的 D1 table、index、trigger 或 view;包含这类 DDL 的 migration 在新数据库上可能失败。已经 apply 的 migration 文件不要回改;需要修正时新增 forward migration,把应用数据迁到非保留名称。 常用命令: @@ -550,8 +560,8 @@ wdl workflows instances api orders [--limit ] [--cursor ] wdl workflows status api orders order-123 --include-steps wdl workflows pause api orders order-123 wdl workflows resume api orders order-123 -wdl workflows restart api orders order-123 --yes -wdl workflows terminate api orders order-123 --yes +wdl workflows restart api orders order-123 +wdl workflows terminate api orders order-123 ``` `--limit` 和 `--step-limit` 接受 1..1000 的整数,超出范围时会在请求 Control 前本地拒绝。`--step-limit` 只能和 `--include-steps` 一起使用。 @@ -723,9 +733,9 @@ return Response.redirect(logoUrl); 这里只支持 `assets.directory` 这类“Worker 返回资源 URL,浏览器直接取静态资源”的模式。Cloudflare Workers Assets 的 `run_worker_first` 拦截模式尚未实现;即使配置了也不会生效。如果静态文件必须经过 Worker 鉴权或改写,请把文件打进 Worker bundle,由 Worker 自己返回。 -发送给 control 的 deploy manifest JSON 最大 32 MiB。Assets 会在部署时以 base64(约 4/3 膨胀)嵌进这个 JSON 请求,所以大量静态文件可能先撞到 control request cap,而不是运行时限制。CLI 另外在打包前预检:单文件最大 25 MiB、总量最大 100 MiB。大体积或频繁变化的文件应使用 R2。 +发送给 control 的 deploy manifest JSON 最大 32 MiB。Assets 会在部署时以 base64(约 4/3 膨胀)嵌进这个 JSON 请求,所以大量静态文件可能先撞到 control request cap,而不是运行时限制。CLI 另外在上传前预检:单文件最大 25 MiB、总量最大 100 MiB。大体积或频繁变化的文件应使用 R2。 -CLI 默认跳过 assets 目录里的 `.git/`、`node_modules/`、`.DS_Store`、 `.wrangler/`、`.deploy-dist/`、`.wrangler.wdl-tmp*.json`、`.env`/`.env.*`,不作为静态资源上传;deploy 会输出一行 note 列出被跳过的条目。需要排除更多文件(或用 `!pattern` 行刻意取回某个默认排除项)时,在 assets 目录放一个 gitignore 语法的 `.assetsignore` 文件——与 Cloudflare Workers Assets 同一机制。 `.assetsignore` 本身默认也不会上传。 +CLI 默认跳过 assets 目录里的 `.git/`、`node_modules/`、`.DS_Store`、 `.wrangler/`、`.deploy-dist/`、`.wrangler.wdl-tmp*.json`、`.env*`、`.dev.vars*` 和 `.wdl-empty.env`,不作为静态资源上传;deploy 会输出一行 note 列出被跳过的条目。需要排除更多文件(或用 `!pattern` 行刻意取回某个默认排除项)时,在 assets 目录放一个 gitignore 语法的 `.assetsignore` 文件——与 Cloudflare Workers Assets 同一机制。CLI 会在打包后自行收集 assets;Wrangler dry-run 不会按 assets 扫描该目录。`.assetsignore` 本身默认也不会上传。 ### Service bindings @@ -894,7 +904,7 @@ wdl delete worker hello --dry-run wdl delete worker hello ``` -`wdl delete worker` 同样默认要求确认。建议先用 `--dry-run` 预览受影响的线上版本、保留版本、路由、worker secrets、workflow definitions、queue consumers 和资产清理。即使没有 deployed version,`wdl workers` 也会用 `workflow-defs=yes` 显示仍有 workflow definitions 的 entry;旧 control 未上报该字段时,CLI 显示 `workflow-defs=unknown`,这不表示没有 workflow definitions。自动化脚本里只有在已有独立安全检查后,才建议传 `--yes`。 +`wdl delete worker` 同样默认要求确认。建议先用 `--dry-run` 预览受影响的线上版本、保留版本、路由、worker secrets、workflow definitions、queue consumers 和资产清理。即使没有 deployed version,`wdl workers` 也会用 `workflow-defs=yes` 显示仍有 workflow definitions 的 entry;旧 control 未上报该字段时,CLI 显示 `workflow-defs=unknown`,这不表示没有 workflow definitions。自动化脚本里只有在已有独立安全检查后,才建议传 `--yes`。如果 control 返回相关字段,删除输出还会显示 Durable Object 存储是否保留及受影响的对象数量。 确认后删除 D1 数据库: @@ -913,7 +923,7 @@ wdl tail hello | 现象 | 可能原因 | 检查方式 | | --- | --- | --- | | `Missing admin token` | 没有提供 tenant token | 运行 `wdl token set --ns --control-url `(推荐),或设 `ADMIN_TOKEN` / 传 `--token` | -| `wrangler build failed` | Wrangler 无法打包 Worker 项目 | 在 Worker 项目目录执行 `npx wrangler deploy --dry-run`,先修本地构建或配置错误 | +| `wrangler build failed` | Wrangler 无法打包 Worker 项目 | 初始化项目运行 `npm run dry-run`;直接跑 Wrangler 时需提供空的 `--env-file`,避免加载项目 `.env` | | deploy 成功但 promote 失败 | route、自定义 host 或 binding 在 promote 阶段校验失败 | 确认自定义 host 已为你的 namespace 开通,service binding 目标存在 | | Worker URL 返回 404 | URL 形态或 worker name 不对 | 使用 `https://.//`,不要漏掉 worker name 这一段路径 | | Worker URL 返回 `502 runtime_error` | Worker `fetch()` handler 在产生响应前抛错 | 用 `wdl tail ` 和请求日志排查;异常细节不会复制到客户端响应体 | diff --git a/GUIDE.md b/GUIDE.md index 1c396e9..2ce2f81 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -31,8 +31,9 @@ https://.// Prerequisites: -- Wrangler v4 (`wrangler@^4`) in the Worker project; v3 is no longer supported - by the CLI's bundling step. +- The selected Wrangler must be `>=4.27.0 <5.0.0` for `--env-file`. A + project-local installation takes precedence over the CLI's bundled, tested v4 + release; older local v4 releases fail even during version probing. - Node.js 22 or newer, matching the CLI runtime and Wrangler v4 baseline. - `npm install` inside the Worker project before deploying if the Worker has dependencies. @@ -127,13 +128,17 @@ If a command reports `Missing namespace`, pass `--ns ` or set `WDL_NS` before retrying. `CONTROL_CONNECT_HOST` is a local-dev / debug override: it changes the TCP -target the request connects to while the HTTP Host header and TLS SNI keep -tracking `CONTROL_URL` (so over HTTPS the control plane's certificate still -rejects a redirected connection; plain http has no such check). Use it only for -local development — never set it persistently in a CI or production shell, where -a stale value could route the admin token to an unintended target. When the -override is a URL, its scheme only selects the default TCP port (`http` uses 80; -`https` uses 443); request transport, Host, and SNI still follow `CONTROL_URL`. +target the request connects to while the HTTP Host header and TLS certificate +identity keep tracking `CONTROL_URL`. DNS authorities use SNI; IP authorities +omit SNI but still validate the certificate against that IP. Plain HTTP has no +certificate check. Use the override only for local development — never set it +persistently in a CI or production shell, where a stale value could route the +admin token to an unintended target. When the override is a URL, its scheme only +selects the default TCP port (`http` uses 80; `https` uses 443); request +transport and Host still follow `CONTROL_URL`. An override from a project `.env` +is ignored unless the effective token and `CONTROL_URL` both came from that same +`.env`; set the override in your shell for local debugging with a shell, flag, +or stored endpoint. The recommended setup keeps these credentials in a managed store rather than a shell export or a project `.env`: `wdl token set --ns --control-url ` @@ -156,12 +161,13 @@ subcommand first or use `--flag=value`; for example, write `wdl secret --worker put list`. `wdl deploy` runs the project's local Wrangler dry-run and build hooks as your -OS user before uploading, and that code can read the on-disk store (the env -scrub keeps WDL variables out of the Wrangler child's environment, not out of -the file), so only deploy projects you trust. `--no-token-store` (or -`WDL_TOKEN_STORE=off`) resolves credentials from flags / shell / `.env` only and -never reads the store — a resolution opt-out for less-trusted projects or CI, -not protection for the file itself. +OS user before uploading. The CLI passes an empty env-file so Wrangler does not +reload project `.env` values into the child environment, but build hooks can +still read `.env`, `.dev.vars`, and the on-disk token store directly. Only +deploy projects you trust. `--no-token-store` (or `WDL_TOKEN_STORE=off`) +resolves credentials from flags / shell / `.env` only and never reads the store +— a resolution opt-out for less-trusted projects or CI, not protection for the +file itself. Use `wdl config explain` to inspect the final namespace, control URL, masked token, and where each value came from. If resolution needs the token store and @@ -182,6 +188,10 @@ capability checks still require additional control endpoints. The namespace URL can be `(unavailable)` when the operator has not configured a public platform domain; authentication and other `/whoami` fields still work. +In a project directory, `wdl doctor` executes that project's Wrangler +`--version` as your OS user. Run it only in projects you trust, just like +`wdl deploy`. + ## Scaffolding a New Worker `wdl init` is the default scaffold for new WDL Worker projects: @@ -196,12 +206,14 @@ It writes: - `package.json` — `npm run deploy` with `--ns` baked in when you pass it (otherwise just `wdl deploy .`, with the namespace resolved at deploy time), - plus an `npm run dry-run` local bundle check; pins `wrangler@^4` and - `@wdl-dev/cli` as devDependencies. + plus an `npm run dry-run` local bundle check that uses a generated empty + `.wdl-empty.env`; pins the CLI-tested Wrangler v4 release and `@wdl-dev/cli` + as devDependencies. - `wrangler.jsonc` — top-level `name` is the worker name (defaults to the directory name; override with `--worker `). -- `src/index.js`, `.gitignore`, and `AGENTS.md`/`CLAUDE.md` so AI agents can - find the per-feature docs under `node_modules/@wdl-dev/cli/docs/`. +- `src/index.js`, `.gitignore`, `.wdl-empty.env`, and `AGENTS.md`/`CLAUDE.md` so + AI agents can find the per-feature docs under + `node_modules/@wdl-dev/cli/docs/`. Use `wdl init . --ns acme` to scaffold into the current (empty) directory. The directory name must start with a letter and contain only letters, digits, and @@ -329,6 +341,12 @@ short network reconnects, while multi-worker sessions may miss events during reconnect. For critical debugging, open a dedicated `wdl tail ` session and trigger the request after the tail is connected. +Formatted fetch paths include the worker-name prefix: a Worker-internal `/` +appears as `//`; `--raw` keeps the original event payload. Scheduled and +queue start/finish events include outcome and duration, but `console.*` inside +those handlers is not included in this tail stream. Restarting the CLI starts a +new live session unless `--since` is supplied. + The tail stream is best-effort live debugging, not audit history. Under high traffic or a slow terminal connection, some middle events can be skipped. Control-side oversized console or exception events are dropped whole and @@ -337,6 +355,11 @@ CLI terminates the tail session if an oversized SSE event's assembled data exceeds 4 MiB. Use the normal log platform your operator provides for incident reconstruction and full payloads. +The CLI reconnects after a transient 502/503/504 control response or 30 seconds +without stream data (the control normally sends a heartbeat every 5 seconds). +The permanent `503 ctx_unavailable` error and other HTTP errors stop the +session. + Control may close long-running tail sessions when the client stops reading (`session_idle`, about 15s) or when the session reaches its maximum lifetime (`session_expired`, operator default 15 minutes). The CLI prints the warning and @@ -374,8 +397,9 @@ for you. A Worker with at least one route pattern may set `workers_dev = false` to disable its default WDL platform-domain URL while keeping its pattern routes -active. WDL requires this explicit opt-out; declaring `route` / `routes` alone -does not disable the platform URL. The deploy summary prints every active +active. Use string patterns for `route` / `routes`; route objects are +unsupported. WDL requires this explicit opt-out; declaring `route` / `routes` +alone does not disable the platform URL. The deploy summary prints every active route-pattern URL hint, preserving the trailing `*` on prefix patterns, and prints the platform-domain URL only while it is enabled. @@ -426,11 +450,19 @@ WDL consumes `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, `[[services]].ns`, and `[wdl]` itself and removes those WDL extensions from the temporary config passed to the Wrangler bundler. `[ai]` is standard Wrangler configuration and stays in that temporary config for Wrangler validation. When a -selected named environment omits its own `ai`, the CLI warns that the top-level -binding is not inherited; WDL independently accepts only its `binding` field and -maps that declaration into the WDL manifest. Other fields retain their existing -Wrangler passthrough behavior. Wrangler's object-shaped declarative `exports` -configuration is not supported by WDL. +selected named environment omits top-level `[ai]`, `[[exports]]`, or +`[[platform_bindings]]`, the CLI warns that the binding is not inherited; WDL +independently accepts only `[ai]`'s `binding` field and maps that declaration +into the WDL manifest. Other fields retain their existing Wrangler passthrough +behavior, except custom module `rules`: the CLI cannot recover their types from +Wrangler's bundle output and rejects them. Wrangler's object-shaped declarative +`exports` configuration is not supported by WDL. + +The CLI rejects queue consumer types other than `worker`, unmapped fields in +queue, service, and Durable Object binding entries, route objects, and +unsupported `[assets]` options such as `html_handling` and `not_found_handling`. +WDL's implicit asset binding is named `ASSETS`; another `assets.binding` is +rejected. `[[connect]]` TCP listeners have no WDL runtime mapping and are rejected before bundling, both at the top level and in the selected environment. @@ -452,11 +484,11 @@ results need metadata. R2 data is not deleted when a Worker is deleted. Use `wdl r2 buckets list` and `wdl r2 objects list ` to inspect namespace R2 data, `wdl r2 objects head ` / `wdl r2 objects get ` to -inspect one object, and `wdl r2 objects delete --yes` to -explicitly remove one object. `wdl r2 buckets list` is derived from existing -object prefixes, so a declared bucket appears only after its first PUT. Object -delete is a single idempotent S3 DELETE, is not retried, and does not report -whether the object previously existed. Missing-object `HEAD` follows HTTP +inspect one object, and `wdl r2 objects delete ` to explicitly +remove one object after confirmation. `wdl r2 buckets list` is derived from +existing object prefixes, so a declared bucket appears only after its first PUT. +Object delete is a single idempotent S3 DELETE, is not retried, and does not +report whether the object previously existed. Missing-object `HEAD` follows HTTP semantics and returns an empty 404; `wdl r2 objects head` reports the status rather than a JSON error body. @@ -475,11 +507,13 @@ choose a default environment. Unlike Cloudflare Workers / Wrangler, WDL does not append the environment name to the worker / script name: `wdl deploy . --env preview` still updates the top-level `name`. `vars` and most bindings remain env-scoped and non-inheritable: selecting an env does not carry -top-level `[vars]`, KV, D1, R2, AI, queues, services, or workflows into that -env. Deploy warns when a top-level `[ai]` binding is omitted from the selected +top-level `[vars]`, KV, D1, R2, AI, queues, services, workflows, `[[exports]]`, +or `[[platform_bindings]]` into that env. Deploy warns when top-level `[ai]`, +`[[exports]]`, or `[[platform_bindings]]` is omitted from the selected environment. Policies do inherit: `workers_dev`, `route` / `routes`, and `[wdl]` -keep applying unless the env declares its own. For staging and production side -by side, use separate namespaces unless your operator tells you otherwise. +keep applying unless the env declares its own. An env-level `route` or `routes` +replaces the other top-level form. For staging and production side by side, use +separate namespaces unless your operator tells you otherwise. ### KV @@ -580,7 +614,7 @@ wdl r2 buckets list wdl r2 objects list uploads --prefix images/ wdl r2 objects head uploads images/logo.png wdl r2 objects get uploads images/logo.png --out logo.png -wdl r2 objects delete uploads images/logo.png --yes +wdl r2 objects delete uploads images/logo.png ``` `--out` accepts an explicit filesystem path outside the project. It currently @@ -716,7 +750,9 @@ Migrations are forward-only. WDL uses the migration filename as the migration id, so already-applied migration files should not be renamed or edited; a rename is treated as a new migration. There is no automatic down/rollback workflow, so write migrations in an expand/contract style when a Worker version rollback may -happen. +happen. An apply error does not mean the whole batch failed: earlier migrations +may already be applied. The CLI shows control-reported applied/skipped IDs when +available; run `wdl d1 migrations status ` before retrying. SQLite object names starting with `_cf_` are reserved by workerd, case-insensitively. Avoid creating or renaming D1 tables, indexes, triggers, or @@ -861,8 +897,8 @@ wdl workflows instances api orders [--limit ] [--cursor ] wdl workflows status api orders order-123 --include-steps wdl workflows pause api orders order-123 wdl workflows resume api orders order-123 -wdl workflows restart api orders order-123 --yes -wdl workflows terminate api orders order-123 --yes +wdl workflows restart api orders order-123 +wdl workflows terminate api orders order-123 ``` `--limit` and `--step-limit` accept integers from 1 through 1000 and are @@ -1108,15 +1144,17 @@ The deploy manifest sent to control is capped at 32 MiB. Assets are embedded in that JSON request during deploy (base64, ~4/3 inflation), so a large asset set can hit the control request cap before runtime limits. The CLI additionally pre-checks each asset file against a 25 MiB per-file cap and 100 MiB total cap -before bundling. Use R2 for bulk or frequently changing files. +before upload. Use R2 for bulk or frequently changing files. By default the CLI skips `.git/`, `node_modules/`, `.DS_Store`, `.wrangler/`, -`.deploy-dist/`, `.wrangler.wdl-tmp*.json`, and `.env`/`.env.*` in the assets -tree; deploy prints a note listing what was skipped. To exclude more files (or -deliberately re-include one of the defaults with a `!pattern` line), add a -`.assetsignore` file with gitignore-style patterns to the assets directory — the -same mechanism Cloudflare Workers Assets uses. The `.assetsignore` file itself -is also skipped by default. +`.deploy-dist/`, `.wrangler.wdl-tmp*.json`, `.env*`, `.dev.vars*`, and +`.wdl-empty.env` in the assets tree; deploy prints a note listing what was +skipped. To exclude more files (or deliberately re-include one of the defaults +with a `!pattern` line), add a `.assetsignore` file with gitignore-style +patterns to the assets directory — the same mechanism Cloudflare Workers Assets +uses. The CLI collects assets after bundling; Wrangler's dry-run does not scan +the directory as assets. The `.assetsignore` file itself is also skipped by +default. ### Service Bindings @@ -1333,7 +1371,9 @@ secrets, workflow definitions, queue consumers, and asset cleanup. `wdl workers` reports `workflow-defs=yes` even for entries that have no deployed version. When an older control does not report this field, the CLI displays `workflow-defs=unknown`; that does not mean no definitions exist. In automation, -pass `--yes` only after a separate safety check. +pass `--yes` only after a separate safety check. When control reports it, delete +output also shows Durable Object storage retention and the number of affected +objects. Delete a D1 database after confirming: @@ -1352,7 +1392,7 @@ wdl tail hello | Symptom | Likely cause | What to check | | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `Missing admin token` | No tenant token was provided | Run `wdl token set --ns --control-url ` (recommended), set `ADMIN_TOKEN`, or pass `--token` | -| `wrangler build failed` | Wrangler could not bundle the Worker project | Run `npx wrangler deploy --dry-run` inside the Worker project and fix local build/config errors | +| `wrangler build failed` | Wrangler could not bundle the Worker project | Run `npm run dry-run` in an initialized project; direct Wrangler dry-runs need an empty `--env-file` to avoid loading project `.env` | | Deploy succeeds but promote fails | Route, custom host, or binding validation failed at promotion time | Check that custom hosts are enabled for your namespace and service-binding targets exist | | Worker URL returns 404 | URL shape or worker name is wrong | Use `https://.//`; include the worker name path segment | | Worker URL returns `502 runtime_error` | The Worker `fetch()` handler threw before producing a response | Use `wdl tail ` and request logs; exception details are intentionally not copied into the client response body | diff --git a/README-zh.md b/README-zh.md index 11920d2..4caabad 100644 --- a/README-zh.md +++ b/README-zh.md @@ -10,7 +10,7 @@ **WDL 与 Cloudflare, Inc. 没有关联、背书或赞助关系。Cloudflare、Cloudflare Workers、Wrangler 和 workerd 是 Cloudflare, Inc. 的商标或注册商标。** -- 你写的就是标准 module worker(`export default { fetch }`),配普通的 `wrangler.json` / `wrangler.jsonc` / `wrangler.toml`,pin 在 `wrangler@^4`。 +- 你写的就是标准 module worker(`export default { fetch }`),配普通的 `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` 和 Wrangler `>=4.27.0 <5.0.0`。 - `wdl deploy` 只用 `wrangler deploy --dry-run` 做**本地打包**——不会向 Cloudflare 发送任何东西。在 WDL 平台上不要用 `wrangler deploy` 发布,真实发布走 `wdl deploy`。 - Worker 默认通过平台域名上带路径前缀的 URL 提供服务: diff --git a/README.md b/README.md index fc2cec7..a88cf05 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ Cloudflare, Cloudflare Workers, Wrangler, and workerd are trademarks or registered trademarks of Cloudflare, Inc.** - You write standard module workers (`export default { fetch }`) with a normal - `wrangler.json` / `wrangler.jsonc` / `wrangler.toml`, pinned to `wrangler@^4`. + `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` and Wrangler + `>=4.27.0 <5.0.0`. - `wdl deploy` runs `wrangler deploy --dry-run` **for local bundling only** — nothing is ever sent to Cloudflare. Do not use `wrangler deploy` against a WDL platform; releases go through `wdl deploy`. diff --git a/commands/d1.js b/commands/d1.js index c608449..9a553ac 100644 --- a/commands/d1.js +++ b/commands/d1.js @@ -19,15 +19,17 @@ import { defineCommand } from "../lib/command.js"; import { CliError, defineCliOption, + formatHttpError, formatHelp, isMain, isPathInside, missingNamespaceError, optionHelp, + readJsonOrFail, unexpectedArgument, } from "../lib/common.js"; import { confirmAction } from "../lib/stdin.js"; -import { escapeTerminalText, formatDiagnosticValue, writeResult } from "../lib/output.js"; +import { escapeTerminalText, formatDiagnosticValue, writeJsonOr, writeResult, writeStatusLine } from "../lib/output.js"; const D1_EXECUTE_MODES = ["all", "raw", "run", "exec"]; @@ -118,12 +120,9 @@ async function runD1({ values, positionals, context }) { "create d1 database" ) ); - writeResult( - values.json === true, - body, - () => [`OK ${body.namespace}/${body.databaseId} created name=${body.databaseName || "-"}`], - stdout - ); + if (!writeJsonOr(values.json === true, body, stdout)) { + writeStatusLine(stdout, `OK ${body.namespace}/${body.databaseId} created name=${body.databaseName || "-"}`); + } return; } @@ -149,7 +148,9 @@ async function runD1({ values, positionals, context }) { "delete d1 database" ) ); - writeResult(values.json === true, body, () => [`OK ${body.namespace}/${body.databaseId} deleted`], stdout); + if (!writeJsonOr(values.json === true, body, stdout)) { + writeStatusLine(stdout, `OK ${body.namespace}/${body.databaseId} deleted`); + } return; } @@ -239,23 +240,51 @@ async function runMigrationsCommand({ action, databaseRef, context }) { if (action === "apply") { const migrations = loadLocalMigrations({ values, env, cwd, databaseRef, warn }); - const body = /** @type {Parameters[0]} */ ( - await context.fetchJson( - `${migrationsBase}/apply`, - { - method: "POST", - headers, - body: JSON.stringify({ migrations }), - timeoutMs: LONG_CONTROL_TIMEOUT_MS, - }, - "apply d1 migrations" - ) - ); + const label = "apply d1 migrations"; + const res = await context.controlFetch(`${migrationsBase}/apply`, { + method: "POST", + headers, + body: JSON.stringify({ migrations }), + timeoutMs: LONG_CONTROL_TIMEOUT_MS, + env, + }); + if (!res.ok) { + const text = await res.text(); + throw new CliError( + `${label} failed: ${formatHttpError(res.status, text, res.headers)}${formatD1ApplyProgress(text)}` + ); + } + const body = /** @type {Parameters[0]} */ (await readJsonOrFail(res, label)); writeResult(values.json === true, body, () => formatD1MigrationApply(body), stdout); return; } } +/** @param {string} text */ +function formatD1ApplyProgress(text) { + /** @type {unknown} */ + let parsed; + try { + parsed = JSON.parse(text); + } catch { + return ""; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return ""; + const body = /** @type {Record} */ (parsed); + /** @type {string[]} */ + const parts = []; + for (const key of ["applied", "skipped"]) { + const entries = body[key]; + if (!Array.isArray(entries) || entries.length === 0) continue; + const ids = entries.slice(0, 5).map((entry) => { + const id = entry && typeof entry === "object" ? /** @type {{ id?: unknown }} */ (entry).id : undefined; + return typeof id === "string" ? escapeTerminalText(id) : "?"; + }); + parts.push(`${key} before failure=${ids.join(",")}${entries.length > 5 ? `,+${entries.length - 5} more` : ""}`); + } + return parts.length ? `; ${parts.join("; ")}` : ""; +} + /** * @param {import("../lib/d1-files.js").MigrationFile[]} migrations * @returns {string} diff --git a/commands/doctor.js b/commands/doctor.js index 5bf8753..82d6fb4 100644 --- a/commands/doctor.js +++ b/commands/doctor.js @@ -4,7 +4,7 @@ import path from "node:path"; import { defineCommand } from "../lib/command.js"; import { CliError, defineCliOption, formatHelp, isMain, isNonEmptyString, optionHelp } from "../lib/common.js"; import { warnIfInsecureControlUrl } from "../lib/credentials.js"; -import { writeResult } from "../lib/output.js"; +import { escapeTerminalText, writeResult } from "../lib/output.js"; import { readTokenStore, tokenStorePath } from "../lib/token-store.js"; import { resolveDiagnosticConfigState } from "../lib/config-state.js"; import { CLI_ROOT, currentCliVersion, readCliPackageJson } from "../lib/package-info.js"; @@ -74,7 +74,7 @@ async function runDoctor({ values, positionals, context: baseContext }) { checks.push(...remote.checks); const body = { checks, whoami: remote.whoami, whoamiError: remote.error }; - writeResult(Boolean(values.json), body, () => formatDoctor(checks), context.stdout); + writeResult(Boolean(values.json), body, () => formatDoctor(checks, remote.checks), context.stdout); if (values.strict === true && checks.some((item) => !item.ok)) { throw new CliError("doctor checks failed"); } @@ -315,12 +315,15 @@ function check({ ok, label, detail = "" }) { return { ok, label, detail }; } -/** @param {DoctorCheck[]} checks */ -function formatDoctor(checks) { +/** @param {DoctorCheck[]} checks @param {DoctorCheck[]} remoteChecks */ +function formatDoctor(checks, remoteChecks) { + const remoteRows = new Set(remoteChecks); return checks.map((item) => { - const line = `${item.ok ? "✓" : "✗"} ${item.label}`; + const label = remoteRows.has(item) ? escapeTerminalText(item.label) : item.label; + const line = `${item.ok ? "✓" : "✗"} ${label}`; if (!item.detail) return line; - const detail = item.detail + const detailText = remoteRows.has(item) ? escapeTerminalText(item.detail) : item.detail; + const detail = detailText .split("\n") .map((detailLine) => ` ${detailLine}`) .join("\n"); diff --git a/commands/init.js b/commands/init.js index 4471e97..052b6a5 100644 --- a/commands/init.js +++ b/commands/init.js @@ -206,7 +206,7 @@ async function writeStarter(targetDir, { packageName, workerName, ns }) { type: "module", scripts: { deploy: ns ? `wdl deploy . --ns ${ns}` : "wdl deploy .", - "dry-run": "wrangler deploy --dry-run --outdir=.deploy-dist", + "dry-run": "wrangler deploy --dry-run --outdir=.deploy-dist --env-file=.wdl-empty.env", }, devDependencies: { wrangler: wranglerDep, @@ -243,6 +243,7 @@ ${WRANGLER_WDL_TMP_PREFIX}*.json .env .env.* !.env.example +.dev.vars* `; await fs.mkdir(path.join(targetDir, "src"), { recursive: true }); @@ -251,6 +252,7 @@ ${WRANGLER_WDL_TMP_PREFIX}*.json fs.writeFile(path.join(targetDir, "wrangler.jsonc"), wranglerJsonc), fs.writeFile(path.join(targetDir, "src", "index.js"), indexJs), fs.writeFile(path.join(targetDir, ".gitignore"), gitignore), + fs.writeFile(path.join(targetDir, ".wdl-empty.env"), ""), ]); } diff --git a/commands/r2.js b/commands/r2.js index d382005..e056d45 100644 --- a/commands/r2.js +++ b/commands/r2.js @@ -15,7 +15,7 @@ import { unexpectedArgument, } from "../lib/common.js"; import { confirmAction } from "../lib/stdin.js"; -import { escapeTerminalText, writeResult, writeStatusLine } from "../lib/output.js"; +import { escapeTerminalText, writeJsonOr, writeResult, writeStatusLine } from "../lib/output.js"; import { formatBucketList, formatObjectHead, formatObjectList } from "../lib/r2-format.js"; const R2_OPTIONS = [ @@ -178,7 +178,9 @@ async function runR2({ values, positionals, context: baseContext }) { "delete R2 object" ) ); - writeResult(values.json === true, body, () => [`OK ${body.namespace}/${body.bucket}/${body.key} deleted`], stdout); + if (!writeJsonOr(values.json === true, body, stdout)) { + writeStatusLine(stdout, `OK ${body.namespace}/${body.bucket}/${body.key} deleted`); + } return; } @@ -337,7 +339,7 @@ function usageText() { "wdl r2 objects list [--prefix ] [--delimiter ] [options]", "wdl r2 objects head [options]", "wdl r2 objects get [--out ] [options]", - "wdl r2 objects delete --yes [options]", + "wdl r2 objects delete [--yes] [options]", ], description: "Inspect and delete namespace-scoped R2 virtual bucket data.", options: optionHelp(R2_OPTIONS), diff --git a/commands/tail.js b/commands/tail.js index f12da1b..697cf4b 100644 --- a/commands/tail.js +++ b/commands/tail.js @@ -25,9 +25,12 @@ const RECONNECT_STABLE_MS = 30_000; // cap-stuck attempts. `--max-reconnects 0` disables the cap. const DEFAULT_MAX_RECONNECTS_AT_CAP = 10; const TAIL_CONNECT_TIMEOUT_MS = 30_000; +const TAIL_IDLE_TIMEOUT_MS = 30_000; const TAIL_ERROR_BODY_MAX_BYTES = 64 * 1024; +const RETRYABLE_TAIL_STATUSES = new Set([502, 503, 504]); export const SSE_MAX_LINE_CHARS = 1024 * 1024; export const SSE_MAX_EVENT_BYTES = 4 * 1024 * 1024; +class TailIdleError extends Error {} // Socket-shutdown error shapes we tolerate as "our own abort". // Anything else (e.g. a 5xx racing the abort) bubbles to the user. const ABORT_TOLERATED_ERRORS = new Set(["ECONNRESET", "ECONNABORTED", "EPIPE", "ABORT_ERR"]); @@ -132,7 +135,7 @@ export const meta = command.meta; /** * The result of one SSE connection lifecycle: empty on a clean end, or * `{ fatal }` carrying an error detail to surface and stop reconnecting. - * @typedef {{ fatal?: string, serverRecycle?: boolean }} StreamResult + * @typedef {{ fatal?: string, retryableStatus?: number, retryableDetail?: string, serverRecycle?: boolean }} StreamResult */ /** @@ -223,6 +226,7 @@ async function runTail({ values, positionals, context: baseContext }) { let result; let transportErr = null; let connectedAt = null; + let lastActivityAt = null; try { const hasResumeCursor = lastEventId !== null; result = await streamSse({ @@ -241,6 +245,9 @@ async function runTail({ values, positionals, context: baseContext }) { connectedAt = now(); stderr(attempts === 0 ? "tail connected; waiting for events…" : "tail reconnected; waiting for events…"); }, + onActivity: () => { + lastActivityAt = now(); + }, }); } catch (err) { if (err instanceof CliError) throw err; @@ -250,19 +257,26 @@ async function runTail({ values, positionals, context: baseContext }) { attempts += 1; if (ctrl.signal.aborted) break; - // Ended without a fatal error — server closed cleanly. For a 4xx / - // 5xx response with a JSON error body, surface it and exit instead - // of looping (the request would just keep failing). + // Ended without a fatal error — server closed cleanly. Most HTTP + // errors remain fatal; transient gateway/control failures reconnect. if (result?.fatal) { throw new CliError(result.fatal); } + const retryableFailure = result?.retryableStatus + ? `HTTP ${result.retryableStatus}${result.retryableDetail ? ` ${result.retryableDetail}` : ""}` + : null; + if (retryableFailure) stderr(`tail control returned ${retryableFailure}; will reconnect`); if (result?.serverRecycle) { backoff = RECONNECT_INITIAL_MS; consecutiveAtCap = 0; } const connectedAtMs = connectedAt; const connectionAgeMs = typeof connectedAtMs === "number" ? now() - connectedAtMs : 0; - const stableConnection = connectionAgeMs >= RECONNECT_STABLE_MS; + const activeAgeMs = + typeof connectedAtMs === "number" && typeof lastActivityAt === "number" ? lastActivityAt - connectedAtMs : 0; + // An initial tail-open frame does not make a 30-second silent stream stable. + const stableConnection = + (transportErr instanceof TailIdleError ? activeAgeMs : connectionAgeMs) >= RECONNECT_STABLE_MS; if (stableConnection) { backoff = RECONNECT_INITIAL_MS; consecutiveAtCap = 0; @@ -280,7 +294,8 @@ async function runTail({ values, positionals, context: baseContext }) { throw new CliError( `tail: gave up after ${consecutiveAtCap} consecutive reconnects ` + `failed at the ${RECONNECT_MAX_MS}ms backoff cap ` + - `(override with --max-reconnects N, or 0 to disable)` + `(override with --max-reconnects N, or 0 to disable)` + + (retryableFailure ? `; last control error: ${retryableFailure}` : "") ); } } @@ -334,12 +349,19 @@ function sleep(ms, signal) { * transport: import("../lib/control-fetch.js").ControlTransport | null, * onEvent: (event: SseEvent) => "server-recycle" | void, * onConnected?: () => void, + * onActivity?: () => void, * }} arg * @returns {Promise} */ -function streamSse({ url, headers, signal, env, transport, onEvent, onConnected }) { +function streamSse({ url, headers, signal, env, transport, onEvent, onConnected, onActivity }) { /** @type {(() => void) | null} */ let onAbort = null; + /** @type {ReturnType | null} */ + let idleTimer = null; + const clearIdleTimer = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = null; + }; /** @type {Promise} */ const promise = new Promise((resolve, reject) => { const u = new URL(url); @@ -354,6 +376,14 @@ function streamSse({ url, headers, signal, env, transport, onEvent, onConnected if (connectTimer) clearTimeout(connectTimer); connectTimer = null; }; + const resetIdleTimer = () => { + clearIdleTimer(); + idleTimer = setTimeout(() => { + reject(new TailIdleError(`tail stream idle for ${TAIL_IDLE_TIMEOUT_MS}ms`)); + req.destroy(); + }, TAIL_IDLE_TIMEOUT_MS); + idleTimer.unref?.(); + }; let serverRecycle = false; /** @type {import("../lib/control-fetch.js").ControlClientRequest} */ @@ -361,6 +391,11 @@ function streamSse({ url, headers, signal, env, transport, onEvent, onConnected try { req = lib.request(reqOpts, (/** @type {import("node:http").IncomingMessage} */ res) => { clearConnectTimer(); + res.on("data", resetIdleTimer); + res.on("end", clearIdleTimer); + res.on("error", clearIdleTimer); + res.on("close", clearIdleTimer); + resetIdleTimer(); const status = res.statusCode || 0; /** @param {unknown} err */ const onResponseError = (err) => { @@ -378,15 +413,26 @@ function streamSse({ url, headers, signal, env, transport, onEvent, onConnected }); res.on("end", () => { let detail; + let code = null; try { - const body = /** @type {{ message?: string, error?: string }} */ ( + const body = /** @type {{ message?: unknown, error?: unknown } | null} */ ( JSON.parse(Buffer.concat(chunks).toString("utf8")) ); - detail = escapeTerminalText(body.message || body.error || `HTTP ${status}`); + code = typeof body?.error === "string" ? body.error : null; + const message = typeof body?.message === "string" ? body.message : null; + detail = escapeTerminalText( + code && message ? `${code}: ${message}` : message || code || `HTTP ${status}` + ); } catch { detail = `HTTP ${status}`; } - resolve({ fatal: detail }); + if (status === 503 && code === "ctx_unavailable") { + resolve({ fatal: `HTTP ${status} ${detail}` }); + } else if (RETRYABLE_TAIL_STATUSES.has(status)) { + resolve({ retryableStatus: status, retryableDetail: detail === `HTTP ${status}` ? undefined : detail }); + } else { + resolve({ fatal: detail }); + } }); return; } @@ -396,6 +442,7 @@ function streamSse({ url, headers, signal, env, transport, onEvent, onConnected }); res.setEncoding("utf8"); res.on("data", (/** @type {string} */ chunk) => { + onActivity?.(); try { parser.push(chunk); } catch (err) { @@ -418,11 +465,13 @@ function streamSse({ url, headers, signal, env, transport, onEvent, onConnected } req.on("error", (/** @type {unknown} */ err) => { clearConnectTimer(); + clearIdleTimer(); if (signal?.aborted && isExpectedAbortError(err)) return resolve({}); reject(err); }); onAbort = () => { clearConnectTimer(); + clearIdleTimer(); req.destroy(tailAbortError()); }; if (signal) { @@ -441,6 +490,7 @@ function streamSse({ url, headers, signal, env, transport, onEvent, onConnected // flapping reconnect loop doesn't accumulate one closure per attempt. return signal ? promise.finally(() => { + clearIdleTimer(); if (onAbort) signal.removeEventListener("abort", onAbort); }) : promise; diff --git a/commands/workflows.js b/commands/workflows.js index def3030..c96f5bc 100644 --- a/commands/workflows.js +++ b/commands/workflows.js @@ -195,8 +195,8 @@ function usageText() { "wdl workflows status [--include-steps] [--step-limit ] [options]", "wdl workflows pause [options]", "wdl workflows resume [options]", - "wdl workflows restart --yes [options]", - "wdl workflows terminate --yes [options]", + "wdl workflows restart [--yes] [options]", + "wdl workflows terminate [--yes] [options]", ], description: "Inspect and control WDL Workflow instances.", options: optionHelp(WORKFLOW_OPTIONS), diff --git a/docs/assets-zh.md b/docs/assets-zh.md index 5c32b00..da560e9 100644 --- a/docs/assets-zh.md +++ b/docs/assets-zh.md @@ -33,9 +33,9 @@ directory = "./public" 目录路径相对于 wrangler 配置文件。目录下的文件原样上传;子目录在 CDN URL 中保留结构。 -Deploy manifest JSON 最大 32 MiB。Assets 会在部署时以 base64(约 4/3 膨胀)嵌进这个 JSON 请求,所以大文件集合可能先撞到 control request cap。CLI 另外在打包前预检:单文件最大 25 MiB、总量最大 100 MiB。大体积、运行时上传或频繁变化的文件用 R2 —— 见 [r2-zh.md](./r2-zh.md)。 +Deploy manifest JSON 最大 32 MiB。Assets 会在部署时以 base64(约 4/3 膨胀)嵌进这个 JSON 请求,所以大文件集合可能先撞到 control request cap。CLI 另外在上传前预检:单文件最大 25 MiB、总量最大 100 MiB。大体积、运行时上传或频繁变化的文件用 R2 —— 见 [r2-zh.md](./r2-zh.md)。 -CLI 默认不会上传 assets 目录里的 `.git/`、`node_modules/`、`.DS_Store`、`.wrangler/`、`.deploy-dist/`、`.wrangler.wdl-tmp*.json`、`.env`/`.env.*`;deploy 会输出一行 note 列出被跳过的条目。要排除更多文件,在 assets 目录放一个 gitignore 语法的 `.assetsignore`(支持 `!pattern` 反向规则,可刻意取回某个默认排除项)——与 Cloudflare Workers Assets 同一机制。`.assetsignore` 本身默认也不会上传。 +CLI 默认不会上传 assets 目录里的 `.git/`、`node_modules/`、`.DS_Store`、`.wrangler/`、`.deploy-dist/`、`.wrangler.wdl-tmp*.json`、`.env*`、`.dev.vars*` 和 `.wdl-empty.env`;deploy 会输出一行 note 列出被跳过的条目。要排除更多文件,在 assets 目录放一个 gitignore 语法的 `.assetsignore`(支持 `!pattern` 反向规则,可刻意取回某个默认排除项)——与 Cloudflare Workers Assets 同一机制。CLI 会在打包后自行收集 assets,Wrangler dry-run 不会按 assets 扫描该目录。`.assetsignore` 本身默认也不会上传。 ## Worker 端使用 @@ -76,6 +76,7 @@ public/ - ❌ 把运行时变化的文件或大体积文件集合放 assets。Assets 每次部署不可变,且受 deploy manifest 32 MiB 上限约束。用 R2 —— 见 [r2-zh.md](./r2-zh.md)。 - ❌ 加 `assets.run_worker_first`。会被静默忽略。 +- ❌ 把 `assets.binding` 改成 `ASSETS` 以外的名字,或设置仅 Cloudflare 支持的 `html_handling` / `not_found_handling`。CLI 会拒绝,避免 Worker 带着不同的资源行为部署。 - ❌ 在源码里硬编码 CDN 主机。永远走 `await env.ASSETS.url(...)`。 - ❌ 把构建产物提交到 git。在部署时生成。 diff --git a/docs/assets.md b/docs/assets.md index 226613b..fed77b2 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -42,18 +42,19 @@ URL. The deploy manifest JSON is capped at 32 MiB. Assets are embedded into that JSON request as base64 (~4/3 inflation) during deploy, so a large asset set can hit -the control request cap first. The CLI additionally pre-checks before bundling: -25 MiB per file, 100 MiB total. Use R2 for bulk, runtime-uploaded, or frequently +the control request cap first. The CLI additionally pre-checks before upload: 25 +MiB per file, 100 MiB total. Use R2 for bulk, runtime-uploaded, or frequently changing files — see [r2.md](./r2.md). By default the CLI does not upload `.git/`, `node_modules/`, `.DS_Store`, -`.wrangler/`, `.deploy-dist/`, `.wrangler.wdl-tmp*.json`, or `.env`/`.env.*` -from the assets directory; deploy prints a one-line note listing what was -skipped. To exclude more files, add a gitignore-syntax `.assetsignore` file to -the assets directory (`!pattern` negation rules are supported, so you can -deliberately re-include one of the defaults) — the same mechanism Cloudflare -Workers Assets uses. The `.assetsignore` file itself is also not uploaded by -default. +`.wrangler/`, `.deploy-dist/`, `.wrangler.wdl-tmp*.json`, `.env*`, `.dev.vars*`, +or `.wdl-empty.env` from the assets directory; deploy prints a one-line note +listing what was skipped. To exclude more files, add a gitignore-syntax +`.assetsignore` file to the assets directory (`!pattern` negation rules are +supported, so you can deliberately re-include one of the defaults) — the same +mechanism Cloudflare Workers Assets uses. The CLI collects assets after +bundling, so Wrangler's dry-run does not scan the directory as assets. The +`.assetsignore` file itself is also not uploaded by default. ## Worker-side usage @@ -105,6 +106,9 @@ frontend build commands automatically. are immutable per deploy and bounded by the 32 MiB deploy manifest cap. Use R2 — see [r2.md](./r2.md). - ❌ Adding `assets.run_worker_first`. It is silently ignored. +- ❌ Renaming `assets.binding` from `ASSETS`, or setting Cloudflare-only + `html_handling` / `not_found_handling`. The CLI rejects these instead of + deploying a Worker with different asset behavior. - ❌ Hardcoding the CDN host in source. Always go through `await env.ASSETS.url(...)`. - ❌ Committing build output to git. Generate it at deploy time. diff --git a/docs/d1-zh.md b/docs/d1-zh.md index c108d7d..1855806 100644 --- a/docs/d1-zh.md +++ b/docs/d1-zh.md @@ -59,6 +59,8 @@ wdl d1 migrations status main # 查看待应用 wdl d1 migrations apply main # 单向,不能回滚 ``` +如果 apply 在先前迁移已完成后失败,CLI 会在错误中附上 control 返回的 applied/skipped ID。重试前先查 `migrations status`;一次失败不代表整批都未执行。 + `migrations_dir` 和显式 `--dir` 都必须留在项目根目录内。 迁移一旦应用就不可改。**绝对不要**重命名或修改已应用的文件 —— CLI 通过文件名追踪,重命名会被当作全新的迁移再执行一次。 diff --git a/docs/d1.md b/docs/d1.md index 867a7df..17595a2 100644 --- a/docs/d1.md +++ b/docs/d1.md @@ -65,6 +65,10 @@ wdl d1 migrations status main # see what is pending wdl d1 migrations apply main # forward-only, no rollback ``` +If apply fails after earlier migrations completed, the CLI includes the +control-reported applied/skipped IDs in its error. Check `migrations status` +before retrying; a failed batch is not necessarily all-or-nothing. + Both `migrations_dir` and an explicit `--dir` must stay inside the project root. Once applied, a migration is immutable. **Never** rename or edit an diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index 270575b..bcf67e8 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -6,8 +6,12 @@ wrangler 解析顺序是 `WDL_WRANGLER_BIN`、Worker 项目本地 wrangler、CLI 包本地 wrangler、最后是 `PATH`。默认不会临时 `npx --yes wrangler@^4` 拉包;只有设置 `WDL_ALLOW_NPX_WRANGLER=1` 时才允许这个 fallback。 +选中的 Wrangler 版本必须在 `>=4.27.0 <5.0.0` 范围内:WDL 在版本探测和 dry-run 打包时都会传入 `--env-file`。旧的项目本地版本优先于 CLI 自带版本;请升级该项目依赖。 + WDL 会隐藏这个 dry-run 子进程的 Wrangler banner(因此跳过常规 banner 更新检查),关闭匿名遥测及自动 agent skills 安装、更新和提示。即使使用 `--verbose`,打包时 stdin 也保持关闭,但仍透传 stdout/stderr。Wrangler 仍可能写入本地 metrics 状态和调试日志,并在报告未知配置字段时访问已配置的 npm registry;项目 build hook 仍保留正常的网络访问能力。 +`wdl init` 生成项目的 `npm run dry-run` 会给 Wrangler 指定空 `.wdl-empty.env`,避免把项目 `.env` 注入 build hook。直接执行 `wrangler deploy --dry-run` 则会加载项目 `.env`,除非也传入空的 `--env-file`;两种方式都不能阻止 build hook 自行读取项目文件。 + ## CLI 调用形式 按以下顺序选一种: @@ -38,10 +42,12 @@ CLI 需要三个值: 优先级:`CLI 标志 > shell env > .env 中 [] 段 > .env 基础段 > wdl token store`。都没有提供时命令直接报错——没有内置默认值。 -**不可信项目:** `wdl deploy` 在上传前会以你的 OS 用户身份运行项目本地的 Wrangler dry-run 和 build 钩子,这些代码能读到磁盘上的 token store(凭证 scrub 只把 WDL 变量挡在 Wrangler 子进程环境外,挡不住文件)。只部署你信任的项目。对不可信 / 第三方项目,用临时的 `--token` / `--control-url` 加 `--no-token-store`(或 `WDL_TOKEN_STORE=off`)让 CLI 不读 store —— 而且根本别留全局 store,因为这个 flag 只是不**读**文件,挡不住文件本身在磁盘上。详见 [token-zh.md](./token-zh.md)。 +**不可信项目:** `wdl deploy` 在上传前会以你的 OS 用户身份运行项目本地的 Wrangler dry-run 和 build 钩子。CLI 会阻止 Wrangler 把项目 `.env` 重新载入子进程环境,但项目代码仍能直接读取该文件和磁盘上的 token store。只部署你信任的项目。对不可信 / 第三方项目,用临时的 `--token` / `--control-url` 加 `--no-token-store`(或 `WDL_TOKEN_STORE=off`)让 CLI 不读 store —— 而且根本别留全局 store,因为这个 flag 只是不**读**文件,挡不住文件本身在磁盘上。详见 [token-zh.md](./token-zh.md)。 不确定最终取了哪个值时,运行 `wdl config explain`;要确认 token 实际连到哪个 control、principal、platform version 和 URL hints,运行 `wdl whoami`;本机与远端基础排查运行 `wdl doctor`。当 control 支持 `/whoami` 时,`doctor` 会验证远端 token、principal namespace、platform version 和 CLI compatibility。CI 里需要失败即挡住后续步骤时,用 `wdl doctor --strict`。如果运维方没有配置公开 platform domain,namespace URL 可能显示为 `(unavailable)`;这不代表认证失败。 +`wdl doctor` 还会在项目安装了本地 Wrangler 时运行其 `wrangler --version`;只在信任项目本地工具时运行。 + 运行时密钥(与 `ADMIN_TOKEN` 不同)见 [secrets-zh.md](./secrets-zh.md)。 ## Worker URL 形态 @@ -52,7 +58,7 @@ https://.// Worker 看到的路径是**剥掉 `/` 之后的路径**。除非运维方明确启用,租户没有自定义路由能力;首次配置不要加 `route` / `routes`。 -运维方启用自定义路由后,至少有一条 route pattern 的 Worker 可以设置 `workers_dev = false`。Custom routes 会继续生效,但上面的 platform-domain URL 会返回 404。Deploy 摘要会输出每条 active route-pattern URL hint,并在 prefix pattern 上保留尾部 `*`,而且只在 platform-domain URL 启用时输出它。WDL 不会仅因配置了 `route` / `routes` 就推断为 opt-out。 +运维方启用自定义路由后,至少有一条 route pattern 的 Worker 可以设置 `workers_dev = false`。`route` / `routes` 只接受字符串 pattern,不支持 route object。Custom routes 会继续生效,但上面的 platform-domain URL 会返回 404。Deploy 摘要会输出每条 active route-pattern URL hint,并在 prefix pattern 上保留尾部 `*`,而且只在 platform-domain URL 启用时输出它。WDL 不会仅因配置了 `route` / `routes` 就推断为 opt-out。 Cloudflare 用 `workers_dev` 控制 Worker 的 `*.workers.dev` route;版本化 preview URL 由独立的 `preview_urls` 控制,后者默认跟随 `workers_dev`。WDL 则把该开关映射到上面的普通 platform-domain 服务路径,所以迁移 `wrangler.toml` 时要重新确认。WDL 不支持 `preview_urls`,CLI 会拒绝该字段。 @@ -75,7 +81,7 @@ Cloudflare 用 `workers_dev` 控制 Worker 的 `*.workers.dev` route;版本化 1. **解析 CLI 调用形式**(上文)。 2. **解析凭证** —— 可信项目优先用 `.env` 或 `wdl token` store,不要内联环境变量;不可信 / 第三方项目改用临时 `--token` / `--control-url` 加 `--no-token-store`(见上方凭证段 —— deploy 会以你的身份运行项目代码)。 -3. **wrangler 版本检查。** 打包步骤需要 `wrangler@^4`。如果项目 pin 了 v3,停下,告诉用户 —— 不要默默升级。 +3. **wrangler 版本检查。** 打包步骤需要支持 `--env-file` 的 Wrangler `>=4.27.0 <5.0.0`。如果项目 pin 了旧版本,停下,告诉用户 —— 不要默默升级。 4. **安装 worker 依赖**(在 worker 目录下 `npm install`),如果 `node_modules` 不存在。 5. **预创建持久化绑定。** 读 wrangler 配置: - `[[d1_databases]]` → 对每个 `database_name`,先 `wdl d1 list` 检查;缺的用 `wdl d1 create ` 创建。见 [d1-zh.md](./d1-zh.md)。 @@ -125,7 +131,7 @@ wdl deploy . --env production **支持:** `name`、`main`、`compatibility_date` / `compatibility_flags`、`[vars]`、`[[kv_namespaces]]`、`[[d1_databases]]`、`[[durable_objects.bindings]]`、`[[workflows]]`、`[[r2_buckets]]`、`[ai]`、`[assets] directory`、`[triggers] crons`、`[[triggers.schedules]]`(带 timezone,平台扩展)、`[[queues.producers]]` / `[[queues.consumers]]`、`[[services]]`、`[[platform_bindings]]`、`[[exports]]`、`route` / `routes`、`workers_dev`、`[wdl] session_policy`、`[env.]`。 -WDL 会自行消费 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns` 和 `[wdl]`,并从传给 Wrangler bundler 的临时配置中移除这些 WDL 扩展。`[ai]` 是 Wrangler 标准配置,会保留在临时配置中供 Wrangler 校验;如果选中的 named environment 没有自己的 `ai`,CLI 会提示顶层 binding 不会继承。WDL 另行只接受其中的 `binding` 字段,并把该声明映射到 WDL manifest。其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。`[wdl] session_policy` 见上面的会话策略一节。 +WDL 会自行消费 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns` 和 `[wdl]`,并从传给 Wrangler bundler 的临时配置中移除这些 WDL 扩展。`[ai]` 是 Wrangler 标准配置,会保留在临时配置中供 Wrangler 校验;如果选中的 named environment 漏掉顶层 `[ai]`、`[[exports]]` 或 `[[platform_bindings]]`,CLI 会提示这些 binding 不会继承。WDL 另行只接受 `[ai]` 的 `binding` 字段,并把该声明映射到 WDL manifest。其它字段保持既有的 Wrangler 透传行为,但自定义 module `rules` 无法从 Wrangler bundle output 恢复类型,CLI 会拒绝。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。`[wdl] session_policy` 见上面的会话策略一节。 WDL 还会拒绝 `[[connect]]` TCP listener、Cloudflare Artifacts `triggers.events` subscription 和 R2 `local_dev.experimental_s3_credentials`;它们都没有对应的 WDL deploy manifest 或 runtime 映射。 @@ -135,22 +141,28 @@ Tenant JSRPC 可以序列化 `Blob` value,并把 service 或 Durable Object cl **不支持(部署失败):** Analytics Engine。Durable Objects 仅支持同 worker class;`script_name`、rename/delete migration 暂未实现。WDL Workflows 仅支持当前 Worker 内定义的 workflow class,不是完整 Cloudflare Workflows parity;`script_name`、跨 worker workflow、跨 worker callback、service-binding callback 和 Cloudflare source-AST visualizer 暂不支持。`route` / `routes` 仅在运维方启用时支持。Python Workers modules、不支持的 workerd compatibility flags 和 WDL 保留注入模块名会在部署时被拒绝:CLI 会对本地 `.py` module fail-fast,workerd compatibility 与 bundle-shape policy 由 control plane canonical 判断。WDL 会忽略、且无法映射进 manifest 的顶层或所选 env Wrangler runtime/deploy 配置字段和 section 也会由 CLI 直接拒绝,包括 legacy `[site]` Workers Sites、`pages_build_output_dir`、`observability`、`limits`、`placement`,以及错误信息点名的其它 unsupported binding/config field 或 section。`assets.run_worker_first` 会被静默忽略。 +CLI 也会拒绝非 `worker` 的 queue consumer 类型、queue / service / Durable Object binding entry 中无法映射的字段、route object,以及 `html_handling`、`not_found_handling` 等不支持的 `[assets]` 选项。隐式 asset binding 固定名为 `ASSETS`;其它 `assets.binding` 名称会被拒绝。默认 asset 排除列表包含 `.env*`、`.dev.vars*` 和 `.wdl-empty.env`。 + WDL 的 `[[workflows]]` 只支持 `name`、`binding` 和 `class_name`。CLI 会在打包前拒绝 `script_name` 及其它所有字段,包括 `schedules`、`limits`、`default_retention` 和 `concurrency`。保留时间请通过单个 instance 的 `create()` retention 设置,不要使用 Wrangler 的 `default_retention`。 Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在可路由的 tenant Worker 上。通过 `[[platform_bindings]]` 选择的 Worker 是冷加载的平台能力,不是 public/runtime dispatch 目标,不能声明 cron triggers 或 queue consumers。 ## 破坏性命令 -`wdl delete worker`、`wdl delete version`、`wdl d1 delete`、`wdl secret delete` 和 `wdl ai providers delete` 默认会提示确认。如果有 `--dry-run`,先跑一遍;否则先做只读检查。删除 AI provider 前,先运行 `wdl config explain` 确认最终解析出的 namespace,再用 `wdl ai providers get --ns ` 查看目标,并在删除时传入同一个显式 `--ns`;删除 provider 会同时删除其 metadata 和 credential。只有与用户确认后才能加 `--yes`;**不要**主动加。 +`wdl delete worker`、`wdl delete version`、`wdl d1 delete`、`wdl secret delete`、`wdl r2 objects delete`、`wdl workflows restart`、`wdl workflows terminate` 和 `wdl ai providers delete` 默认会提示确认。如果有 `--dry-run`,先跑一遍;否则先做只读检查。删除 AI provider 前,先运行 `wdl config explain` 确认最终解析出的 namespace,再用 `wdl ai providers get --ns ` 查看目标,并在删除时传入同一个显式 `--ns`;删除 provider 会同时删除其 metadata 和 credential。只有与用户确认后才能加 `--yes`;**不要**主动加。 `wdl delete version` 没有 dry-run endpoint:请先检查保留版本。CLI 会拒绝 `--dry-run`,不会静默执行删除。 `wdl workers` 会显示 `workflow-defs=yes` 或 `workflow-defs=no`;`unknown` 表示旧 control 没有返回该字段,不表示没有 workflow definitions。即使 blocker 使 `wouldDelete=no`,worker delete dry-run 仍会报告 secret 和 workflow-definition 是否存在。 +如果 control 返回相关字段,worker delete 输出还会报告 Durable Object storage 是否保留及受影响的 object 数量。 + 删除 worker **不会**删除 R2 数据 —— 见 [r2-zh.md](./r2-zh.md)。 ## 常见错误 +Tail 收到临时性的 502/503/504,或连续 30 秒没有收到任何 stream bytes(含心跳)时会自动重连;永久性的 `503 ctx_unavailable` 和其它 HTTP 错误仍是致命错误。 + | 现象 | 原因 / 修复 | | --- | --- | | `wdl: command not found` | CLI 不在 PATH。在 wdl-cli 仓库内用 `node /bin/wdl.js`;其他情况执行 `npm i -g @wdl-dev/cli`。 | @@ -165,7 +177,7 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 | `worker_env_too_large` | 减少 `[vars]`、secrets 或 binding metadata;如果错误点名 retained version,redeploy/delete 该版本。 | | `worker_code_too_large` | 减少生成的 Worker code 大小,或拆分 worker。 | | `worker_code_invalid` | 按 control plane 返回的原因修正 Worker bundle 形状,包括 WDL 保留注入模块名。 | -| `wrangler build failed` | 在项目里跑 `npx wrangler deploy --dry-run` 然后在那边修。 | +| `wrangler build failed` | 在初始化的项目里运行 `npm run dry-run`,再修本地构建或配置错误。直接跑 Wrangler 时需提供空的 `--env-file`,避免加载项目 `.env`。 | | `the promotion outcome is unknown` | promote 遇到 timeout、传输失败、3xx/5xx 或未确认的 2xx。再次部署前先用 `wdl workers` 确认 active version。 | | `control rejected the promotion` | control 拒绝了这个 version——常见于自定义 host 或 service binding 目标校验失败。按它报告的原因修复后重新部署。 | | `control did not confirm session_policy = restart` | control 版本早于 `[wdl] session_policy`;version 已上传并被保留,但没有 promote。先升级 control 再重新部署。 | diff --git a/docs/deploy.md b/docs/deploy.md index ba751ca..666f28f 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -10,7 +10,10 @@ Do **not** use `wrangler deploy` on this platform — only `wdl deploy`. Wrangler resolution order is `WDL_WRANGLER_BIN`, the Worker project's local wrangler, the CLI package's local wrangler, then `PATH`. By default there is no transient `npx --yes wrangler@^4` fetch; that fallback is allowed only when -`WDL_ALLOW_NPX_WRANGLER=1` is set. +`WDL_ALLOW_NPX_WRANGLER=1` is set. The selected Wrangler must be +`>=4.27.0 <5.0.0`: WDL passes `--env-file` during both version probing and +dry-run bundling. An older project-local install takes precedence over the CLI's +bundled Wrangler; update that project dependency. WDL hides Wrangler's banner (which skips the normal banner update check) and disables anonymous telemetry and automatic agent skills installation, updates, @@ -20,6 +23,12 @@ metrics state and debug logs, and consult the configured npm registry when reporting an unknown configuration field. Project build hooks retain their normal network access. +In projects generated by `wdl init`, `npm run dry-run` points Wrangler at the +generated empty `.wdl-empty.env` so it does not inject project `.env` values +into build hooks. A direct `wrangler deploy --dry-run` does load project `.env` +unless you also pass an empty `--env-file`; neither form prevents build hooks +from reading project files themselves. + ## CLI invocation forms Pick one in this order: @@ -75,9 +84,9 @@ Precedence: If none supplies a value, the command fails — there is no built-in default. **Untrusted projects:** `wdl deploy` runs the project's local Wrangler dry-run -and build hooks as your OS user, so that code can read the on-disk token store -(the credential scrub only keeps WDL variables out of the Wrangler child's -environment, not out of the file). Only deploy projects you trust. For an +and build hooks as your OS user. The CLI prevents Wrangler from reloading the +project `.env` into its child environment, but project code can still read that +file and the on-disk token store. Only deploy projects you trust. For an untrusted or third-party project, pass an ephemeral `--token` / `--control-url` plus `--no-token-store` (or `WDL_TOKEN_STORE=off`) so the CLI ignores the store — and don't keep a global store at all, since the flag opts out of _reading_ the @@ -90,7 +99,9 @@ When the control plane supports `/whoami`, `doctor` verifies the remote token, principal namespace, platform version, and CLI compatibility. Use `wdl doctor --strict` in CI when a failed check should make the job fail. The namespace URL may be `(unavailable)` when the operator has not configured a -public platform domain; that does not mean authentication failed. +public platform domain; that does not mean authentication failed. `wdl doctor` +also runs the project's local `wrangler --version` when one is installed, so run +it only in projects whose local tools you trust. For runtime secrets (distinct from `ADMIN_TOKEN`), see [secrets.md](./secrets.md). @@ -106,8 +117,9 @@ have no custom routing capability unless the operator explicitly enables it; do not add `route` / `routes` in a first-time setup. When an operator has enabled custom routing, a Worker with at least one route -pattern may set `workers_dev = false`. Its custom routes remain active, but the -platform-domain URL above returns 404. The deploy summary prints each active +pattern may set `workers_dev = false`. Use string patterns for `route` / +`routes`; route objects are unsupported. Its custom routes remain active, but +the platform-domain URL above returns 404. The deploy summary prints each active route-pattern URL hint, preserving the trailing `*` on prefix patterns, and prints the platform-domain URL only while it is enabled. WDL does not infer this opt-out merely because `route` / `routes` is present. @@ -147,8 +159,9 @@ use. third-party project, use an ephemeral `--token` / `--control-url` with `--no-token-store` instead (see Credentials above — deploy runs project code as you). -3. **Wrangler version check.** The bundling step requires `wrangler@^4`. If the - project pins v3, stop and tell the user — do not silently upgrade. +3. **Wrangler version check.** The bundling step needs Wrangler + `>=4.27.0 <5.0.0` for `--env-file`. If the project pins an older release, + stop and tell the user — do not silently upgrade. 4. **Install worker dependencies** (`npm install` in the worker directory) if `node_modules` is missing. 5. **Pre-create persistent bindings.** Read the wrangler config: @@ -253,12 +266,14 @@ WDL consumes `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, `[[services]].ns`, and `[wdl]` itself and removes those WDL extensions from the temporary config passed to the Wrangler bundler. `[ai]` is standard Wrangler configuration and stays in that temporary config for Wrangler validation. When a -selected named environment omits its own `ai`, the CLI warns that the top-level -binding is not inherited; WDL independently accepts only its `binding` field and -maps that declaration into the WDL manifest. Other fields retain their existing -Wrangler passthrough behavior. Wrangler's object-shaped declarative `exports` -configuration is not supported by WDL. `[wdl] session_policy` has its own -section above. +selected named environment omits top-level `[ai]`, `[[exports]]`, or +`[[platform_bindings]]`, the CLI warns that the binding is not inherited; WDL +independently accepts only `[ai]`'s `binding` field and maps that declaration +into the WDL manifest. Other fields retain their existing Wrangler passthrough +behavior, except custom module `rules`: the CLI cannot recover their types from +Wrangler's bundle output and rejects them. Wrangler's object-shaped declarative +`exports` configuration is not supported by WDL. `[wdl] session_policy` has its +own section above. WDL also rejects `[[connect]]` TCP listeners, Cloudflare Artifacts `triggers.events` subscriptions, and R2 `local_dev.experimental_s3_credentials`: @@ -287,6 +302,13 @@ legacy `[site]` Workers Sites, `pages_build_output_dir`, `observability`, `limits`, `placement`, and other unsupported binding/config fields or sections named in the error. `assets.run_worker_first` is silently ignored. +The CLI also rejects queue consumer types other than `worker`, unmapped fields +in queue, service, and Durable Object binding entries, route objects, and +unsupported `[assets]` options such as `html_handling` and `not_found_handling`. +WDL's implicit asset binding is named `ASSETS`; another `assets.binding` name is +rejected. The default asset exclusions include `.env*`, `.dev.vars*`, and +`.wdl-empty.env`. + WDL supports only `name`, `binding`, and `class_name` in `[[workflows]]`. The CLI rejects `script_name` and all other fields, including `schedules`, `limits`, `default_retention`, and `concurrency`, before bundling. Use per-instance @@ -301,7 +323,8 @@ consumers. ## Destructive commands `wdl delete worker`, `wdl delete version`, `wdl d1 delete`, `wdl secret delete`, -and `wdl ai providers delete` prompt for confirmation by default. If `--dry-run` +`wdl r2 objects delete`, `wdl workflows restart`, `wdl workflows terminate`, and +`wdl ai providers delete` prompt for confirmation by default. If `--dry-run` exists, run it first; otherwise do a read-only check. Before deleting an AI provider, run `wdl config explain` to confirm the resolved namespace, inspect the target with `wdl ai providers get --ns `, and use the @@ -315,37 +338,42 @@ first. The CLI rejects `--dry-run` rather than silently performing the delete. `wdl workers` reports `workflow-defs=yes` or `workflow-defs=no`; `unknown` means an older control omitted the field, not that no definitions exist. Worker delete dry-runs report secret and workflow-definition presence even when a blocker -makes `wouldDelete=no`. +makes `wouldDelete=no`. When available, worker delete output also reports +whether Durable Object storage is retained and how many objects are affected. Deleting a worker does **not** delete R2 data — see [r2.md](./r2.md). ## Common errors -| Symptom | Cause / fix | -| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `wdl: command not found` | The CLI is not on PATH. Inside the wdl-cli repo use `node /bin/wdl.js`; otherwise run `npm i -g @wdl-dev/cli`. | -| `Missing admin token` | No token resolved. Run `wdl token set --ns --control-url ` (recommended), or set `ADMIN_TOKEN` / pass `--token` / use the `[]` section of `.env`. | -| `401 unknown_token: unauthorized` | The token is invalid for this control plane / namespace. Re-check `ADMIN_TOKEN`. | -| `[vars] must be an object` | Use a `[vars]` table/object; arrays are invalid. | -| `[vars] : only string/number/boolean values are supported` | Remove nested values; move sensitive strings to a secret. | -| `binding name collision: ` | `[vars]`, explicit bindings, or the implicit `ASSETS` binding reused a runtime env name. Rename one of them. | -| `experimental_compat_flag_unsupported` | Remove the experimental workerd compatibility flag. | -| `compatibility_flag_unsupported` | Remove the unsupported compatibility flag named by control. | -| `python_workers_unsupported` | Python Workers are not supported by WDL; remove Python Worker modules. The CLI also fails fast on local `.py` modules. | -| `worker_env_too_large` | Reduce `[vars]`, secrets, or binding metadata; redeploy/delete any retained version named in the error. | -| `worker_code_too_large` | Reduce generated Worker code size or split the worker. | -| `worker_code_invalid` | Fix the Worker bundle shape reported by the control plane, including WDL-reserved injected module names. | -| `wrangler build failed` | Run `npx wrangler deploy --dry-run` inside the project and fix it there. | -| `the promotion outcome is unknown` | A timeout, transport failure, 3xx/5xx or unconfirmed 2xx answered the promote. Check the active version with `wdl workers` before deploying again. | -| `control rejected the promotion` | Control refused this version — often a custom host or service-binding target that failed validation. Fix what it reported, then deploy again. | -| `control did not confirm session_policy = restart` | The control plane predates `[wdl] session_policy`; the version was uploaded and retained but not promoted. Upgrade control, then deploy again. | -| `control promoted the worker without confirming its restart session policy` | The version is live but its sessions may not have restarted. Reconnect clients that must run it, or deploy again once control confirms the policy. | -| Worker URL returns 404 | The URL is missing the `/` segment. | -| `wdl tail` has no history | Tail is live-only; open `wdl tail ` before triggering the request. | -| `tail SSE event exceeded 4194304 bytes` | One assembled SSE event exceeded the CLI's 4 MiB UTF-8 data cap, so the current tail session terminated. Reduce/fix the upstream event before reconnecting. | -| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream; the CLI reconnects automatically unless the reconnect cap is reached. | -| Namespace secret did not take effect | NS-level secrets do not force-bump workers; redeploy once or use a worker-level secret. | -| Service binding still hits the old target | Bindings are pinned at caller deploy time; redeploy the caller. | +| Symptom | Cause / fix | +| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `wdl: command not found` | The CLI is not on PATH. Inside the wdl-cli repo use `node /bin/wdl.js`; otherwise run `npm i -g @wdl-dev/cli`. | +| `Missing admin token` | No token resolved. Run `wdl token set --ns --control-url ` (recommended), or set `ADMIN_TOKEN` / pass `--token` / use the `[]` section of `.env`. | +| `401 unknown_token: unauthorized` | The token is invalid for this control plane / namespace. Re-check `ADMIN_TOKEN`. | +| `[vars] must be an object` | Use a `[vars]` table/object; arrays are invalid. | +| `[vars] : only string/number/boolean values are supported` | Remove nested values; move sensitive strings to a secret. | +| `binding name collision: ` | `[vars]`, explicit bindings, or the implicit `ASSETS` binding reused a runtime env name. Rename one of them. | +| `experimental_compat_flag_unsupported` | Remove the experimental workerd compatibility flag. | +| `compatibility_flag_unsupported` | Remove the unsupported compatibility flag named by control. | +| `python_workers_unsupported` | Python Workers are not supported by WDL; remove Python Worker modules. The CLI also fails fast on local `.py` modules. | +| `worker_env_too_large` | Reduce `[vars]`, secrets, or binding metadata; redeploy/delete any retained version named in the error. | +| `worker_code_too_large` | Reduce generated Worker code size or split the worker. | +| `worker_code_invalid` | Fix the Worker bundle shape reported by the control plane, including WDL-reserved injected module names. | +| `wrangler build failed` | In an initialized project, run `npm run dry-run` and fix the local build/config error. Direct Wrangler dry-runs need an empty `--env-file` to avoid loading project `.env`. | +| `the promotion outcome is unknown` | A timeout, transport failure, 3xx/5xx or unconfirmed 2xx answered the promote. Check the active version with `wdl workers` before deploying again. | +| `control rejected the promotion` | Control refused this version — often a custom host or service-binding target that failed validation. Fix what it reported, then deploy again. | +| `control did not confirm session_policy = restart` | The control plane predates `[wdl] session_policy`; the version was uploaded and retained but not promoted. Upgrade control, then deploy again. | +| `control promoted the worker without confirming its restart session policy` | The version is live but its sessions may not have restarted. Reconnect clients that must run it, or deploy again once control confirms the policy. | +| Worker URL returns 404 | The URL is missing the `/` segment. | +| `wdl tail` has no history | Tail is live-only; open `wdl tail ` before triggering the request. | +| `tail SSE event exceeded 4194304 bytes` | One assembled SSE event exceeded the CLI's 4 MiB UTF-8 data cap, so the current tail session terminated. Reduce/fix the upstream event before reconnecting. | +| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream; the CLI reconnects automatically unless the reconnect cap is reached. | +| Namespace secret did not take effect | NS-level secrets do not force-bump workers; redeploy once or use a worker-level secret. | +| Service binding still hits the old target | Bindings are pinned at caller deploy time; redeploy the caller. | + +The CLI also reconnects after a transient 502/503/504 tail response or 30 +seconds with no stream bytes (including heartbeats). The permanent +`503 ctx_unavailable` error and other HTTP errors remain fatal. ## Anti-patterns diff --git a/docs/env-overrides-zh.md b/docs/env-overrides-zh.md index 933819c..4d26b79 100644 --- a/docs/env-overrides-zh.md +++ b/docs/env-overrides-zh.md @@ -75,7 +75,7 @@ wdl deploy . --env production Cloudflare Workers / Wrangler 的 `--env preview` 通常会发布带环境后缀的 worker / script 名。WDL 不会这样做:`wdl deploy . --env preview` 和 `wdl deploy . --env production` 都更新顶层 `name` 指定的同一个 worker。要部署两个独立 worker,请用两个不同的顶层 `name`、两个目录,或两个 namespace。 -`vars` 和大部分 bindings 仍按 Wrangler 的 non-inheritable 心智模型处理:选中 `[env.]` 后,顶层 `[vars]`、KV、D1、R2、queues、services、workflows、AI 等不会自动继承到该 env。需要某个 runtime env 变量或 binding 时,要在对应的 `[env.]` 里重新声明;如果选中的 env 没有重新声明顶层 `[ai]` binding,deploy 会明确提示。 +`vars` 和大部分 bindings 仍按 Wrangler 的 non-inheritable 心智模型处理:选中 `[env.]` 后,顶层 `[vars]`、KV、D1、R2、queues、services、workflows、AI、`[[exports]]` 和 `[[platform_bindings]]` 不会自动继承到该 env。需要某个 runtime env 变量或 binding 时,要在对应的 `[env.]` 里重新声明;如果选中的 env 没有重新声明顶层 `[ai]`、`[[exports]]` 或 `[[platform_bindings]]`,deploy 会明确提示。 按上面的例子: @@ -95,8 +95,8 @@ Cloudflare Workers / Wrangler 的 `--env preview` 通常会发布带环境后缀 `[env.]` 可以覆盖多类配置,但继承规则不同: -- Non-inheritable:`[env.].vars`、`[[env..kv_namespaces]]`、`[[env..d1_databases]]`、`[[env..r2_buckets]]`、`[[env..queues.*]]`、`[[env..services]]`、`[[env..workflows]]`、`[env..ai]` 等。选中 env 后,顶层同类配置不会回退进来。 -- Inheritable:`main`、`compatibility_date` / `compatibility_flags`、`route` / `routes`、`workers_dev`、`[wdl]`、`[[migrations]]`、`[assets]`、`[triggers]` 等。env 里没写时继续使用顶层值;env 里写了则覆盖顶层值。 +- Non-inheritable:`[env.].vars`、`[[env..kv_namespaces]]`、`[[env..d1_databases]]`、`[[env..r2_buckets]]`、`[[env..queues.*]]`、`[[env..services]]`、`[[env..workflows]]`、`[[env..exports]]`、`[[env..platform_bindings]]`、`[env..ai]` 等。选中 env 后,顶层同类配置不会回退进来。 +- Inheritable:`main`、`compatibility_date` / `compatibility_flags`、`route` / `routes`、`workers_dev`、`[wdl]`、`[[migrations]]`、`[assets]`、`[triggers]` 等。env 里没写时继续使用顶层值;env 里写了则覆盖顶层值。env 中的 `route` 或 `routes` 会替换顶层另一种写法。 因此,共享的 `vars` 或 binding 不能只放顶层后期待所有 env 自动继承;每个 env 都需要声明自己要用的 runtime vars 和 bindings。共享的 DO migrations、assets / cron 等可放顶层,只在差异 env 下覆盖。 diff --git a/docs/env-overrides.md b/docs/env-overrides.md index dfa1465..f79470e 100644 --- a/docs/env-overrides.md +++ b/docs/env-overrides.md @@ -89,9 +89,10 @@ use two different top-level `name` values, two directories, or two namespaces. `vars` and most bindings still follow Wrangler's non-inheritable mental model: once `[env.]` is selected, top-level `[vars]`, KV, D1, R2, queues, -services, workflows, AI, etc. do not inherit into that env automatically. When a -runtime env var or binding is needed, redeclare it inside the matching -`[env.]`. Deploy emits a warning when a top-level `[ai]` binding is not +services, workflows, AI, `[[exports]]`, and `[[platform_bindings]]` do not +inherit into that env automatically. When a runtime env var or binding is +needed, redeclare it inside the matching `[env.]`. Deploy emits a warning +when top-level `[ai]`, `[[exports]]`, or `[[platform_bindings]]` is not redeclared in the selected environment. With the example above: @@ -126,12 +127,14 @@ differ: - Non-inheritable: `[env.].vars`, `[[env..kv_namespaces]]`, `[[env..d1_databases]]`, `[[env..r2_buckets]]`, `[[env..queues.*]]`, `[[env..services]]`, - `[[env..workflows]]`, `[env..ai]`, etc. Once an env is selected, - top-level config of the same kind does not fall back in. + `[[env..workflows]]`, `[env..ai]`, `[[env..exports]]`, + `[[env..platform_bindings]]`, etc. Once an env is selected, top-level + config of the same kind does not fall back in. - Inheritable: `main`, `compatibility_date` / `compatibility_flags`, `route` / `routes`, `workers_dev`, `[wdl]`, `[[migrations]]`, `[assets]`, `[triggers]`, etc. When the env does not set them, the top-level value keeps applying; when - the env sets them, it overrides the top-level value. + the env sets them, it overrides the top-level value. An env-level `route` or + `routes` replaces the other top-level form. So shared `vars` or bindings cannot live only at the top level in the expectation that every env inherits them; each env must declare the runtime vars diff --git a/docs/queues-zh.md b/docs/queues-zh.md index 7b1efbe..87e5449 100644 --- a/docs/queues-zh.md +++ b/docs/queues-zh.md @@ -55,6 +55,7 @@ export default { - Dead-letter queue 是有界的诊断通道(默认约 1 万条,近似裁剪)——应及时排空,不要当作持久归档使用。 - CLI 会转发通过基础整数 delay 解析的 `max_batch_timeout` 以兼容配置;WDL control 负责执行更严格的 Cloudflare 兼容 0..60 秒范围。当前不要依赖它做完整的等待聚合,实际 dispatch 主要由 `max_batch_size` 和平台调度节奏截断。 - `max_concurrency` 当前不支持,部署时会被拒绝。 +- 仅支持 `worker` 类型的 queue consumer;HTTP-pull consumer 以及 `[queues]`、producer、consumer entry 中无法映射的字段会在打包前被拒绝,而不是静默忽略。 - Queue consumer 是 runtime dispatch 目标,应声明在可路由的 tenant Worker 上,不要声明在 platform binding target Worker 上。 ## 端到端示例 diff --git a/docs/queues.md b/docs/queues.md index de4e108..2429144 100644 --- a/docs/queues.md +++ b/docs/queues.md @@ -72,6 +72,9 @@ and leave it to the platform's `max_retries` and `dead_letter_queue` handling. wait-based aggregation yet; actual dispatch is mostly cut off by `max_batch_size` and the platform's scheduling cadence. - `max_concurrency` is not supported and is rejected at deploy time. +- Only `worker` queue consumers are supported. HTTP-pull consumers and unmapped + fields in `[queues]`, producer, or consumer entries are rejected before + bundling rather than silently ignored. - Queue consumers are runtime dispatch targets; declare them on routeable tenant Workers, not on platform binding target Workers. diff --git a/docs/r2-zh.md b/docs/r2-zh.md index 716c926..2ecb154 100644 --- a/docs/r2-zh.md +++ b/docs/r2-zh.md @@ -101,7 +101,7 @@ wdl r2 buckets list wdl r2 objects list [--prefix

] [--delimiter ] [--limit ] [--cursor ] wdl r2 objects head # 只看 metadata,不下载 body wdl r2 objects get --out file # 下载 -wdl r2 objects delete --yes # 破坏性 —— 先确认 +wdl r2 objects delete [--yes] # 默认提示确认 ``` `wdl r2 objects get` 会写出原始 object bytes。需要 stream bytes 时请 pipe 或重定向 stdout;在交互终端中请使用 `--out `。 diff --git a/docs/r2.md b/docs/r2.md index 2482ddb..21858ce 100644 --- a/docs/r2.md +++ b/docs/r2.md @@ -121,7 +121,7 @@ wdl r2 buckets list wdl r2 objects list [--prefix

] [--delimiter ] [--limit ] [--cursor ] wdl r2 objects head # metadata only, no body download wdl r2 objects get --out file # download -wdl r2 objects delete --yes # destructive — confirm first +wdl r2 objects delete [--yes] # prompts by default ``` `wdl r2 objects get` writes raw object bytes. Pipe or redirect stdout when you diff --git a/docs/token-zh.md b/docs/token-zh.md index c6b1e4e..930f4ba 100644 --- a/docs/token-zh.md +++ b/docs/token-zh.md @@ -69,6 +69,8 @@ CLI 标志 > shell/CI env > 项目 ./.env > 全局 token 存储 > 未配置( 通过上方路径和权限检查的存储才被视为**可信**:token 和端点同源,存放在受保护的用户级配置目录中。项目 `.env` **不可信**:若一个 `.env` 提供了 control 端点却没同时提供 token,该端点仍会被丢弃——这样不可信的项目目录永远无法把你存的 token 重定向到它指定的主机。 +同理,项目 `.env` 提供的 `CONTROL_CONNECT_HOST` 只有在有效 token 和 `CONTROL_URL` 也来自同一份 `.env` 时才会生效,否则会被忽略。使用 flag、shell 或 token store 提供的端点做本地调试时,请在 shell 中设置连接覆盖值。 + ## 安全:deploy 会以你的身份运行项目代码 `wdl deploy` 在上传前会**以你的 OS 用户身份**运行项目本地的 Wrangler dry-run 以及任何 build 命令 / 依赖钩子。把 `ADMIN_TOKEN` 和控制面变量从子进程**环境**里 scrub 掉,只挡住了*环境*这条路 —— 它**不是沙箱**。磁盘上的 `~/.config/wdl/credentials` 仍被这些代码读到,就和 `~/.aws/credentials`、`~/.npmrc` 一样。所以恶意项目能读它;又因为 store 可能存着**多个 namespace** 的 token,一次不可信的 deploy 就能偷走与该项目无关的 namespace 的 token。 diff --git a/docs/token.md b/docs/token.md index f9bc7c9..9b89053 100644 --- a/docs/token.md +++ b/docs/token.md @@ -110,7 +110,10 @@ A store that passes the path and permission checks above is trusted: its token and endpoint are same-source in a protected per-user config location. A project `.env` is not: a `.env` that supplies a control endpoint without also supplying the token is still dropped, so an untrusted project directory can never redirect -your stored token to a host it chose. +your stored token to a host it chose. Likewise, `.env`-supplied +`CONTROL_CONNECT_HOST` is ignored unless the effective token and `CONTROL_URL` +also come from that same `.env`. Set a local-debug connection override in your +shell when using a flag, shell, or stored endpoint. ## Security: deploy runs project code as you diff --git a/docs/workflows-zh.md b/docs/workflows-zh.md index 38133ef..bcdc816 100644 --- a/docs/workflows-zh.md +++ b/docs/workflows-zh.md @@ -40,8 +40,8 @@ wdl workflows instances [--limit ] [--cursor ] wdl workflows status --include-steps [--step-limit ] wdl workflows pause wdl workflows resume -wdl workflows restart --yes -wdl workflows terminate --yes +wdl workflows restart [--yes] +wdl workflows terminate [--yes] ``` `--limit` 和 `--step-limit` 接受 1..1000 的整数;超出范围时 CLI 会在请求 Control 前拒绝。`--step-limit` 只能和 `--include-steps` 一起使用。 diff --git a/docs/workflows.md b/docs/workflows.md index 2f3f543..ccc082f 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -72,8 +72,8 @@ wdl workflows instances [--limit ] [--cursor ] wdl workflows status --include-steps [--step-limit ] wdl workflows pause wdl workflows resume -wdl workflows restart --yes -wdl workflows terminate --yes +wdl workflows restart [--yes] +wdl workflows terminate [--yes] ``` `--limit` and `--step-limit` accept integers from 1 through 1000. The CLI diff --git a/examples/inspection-demo/README.md b/examples/inspection-demo/README.md index c206298..49c4691 100644 --- a/examples/inspection-demo/README.md +++ b/examples/inspection-demo/README.md @@ -55,7 +55,7 @@ objects explicitly: wdl r2 buckets list wdl r2 objects list inspection-images --prefix inspections/ wdl r2 objects head inspection-images -wdl r2 objects delete inspection-images --yes +wdl r2 objects delete inspection-images ``` The D1 database is named `inspection-main`; delete it only when the demo data is diff --git a/lib/bundle-modules.js b/lib/bundle-modules.js index 98293d5..ed539a4 100644 --- a/lib/bundle-modules.js +++ b/lib/bundle-modules.js @@ -1,6 +1,6 @@ import path from "node:path"; -const TEXT_EXTS = new Set([".txt", ".css", ".html", ".htm", ".svg"]); +const TEXT_EXTS = new Set([".txt", ".css", ".html", ".htm", ".svg", ".sql"]); /** @param {string} filePath */ export function inferType(filePath) { diff --git a/lib/common.js b/lib/common.js index 852a367..bb442f0 100644 --- a/lib/common.js +++ b/lib/common.js @@ -193,7 +193,7 @@ const OPTION_DEFS = { "Control URL (env: CONTROL_URL)." ), token: defineCliOption("token", { type: "string" }, "--token ", "Admin token (env: ADMIN_TOKEN)."), - json: defineCliOption("json", { type: "boolean" }, "--json", "Print the raw control response."), + json: defineCliOption("json", { type: "boolean" }, "--json", "Print JSON output."), yes: defineCliOption("yes", { type: "boolean" }, "--yes", "Confirm destructive actions."), noTokenStore: defineCliOption( "no-token-store", diff --git a/lib/control-fetch.js b/lib/control-fetch.js index f3e346d..7b64e42 100644 --- a/lib/control-fetch.js +++ b/lib/control-fetch.js @@ -9,6 +9,7 @@ import http from "node:http"; import https from "node:https"; import { isIP } from "node:net"; import { Transform } from "node:stream"; +import { checkServerIdentity } from "node:tls"; import { CliError } from "./common.js"; import { bareUrlHostname, parseControlConnectHost } from "./control-connect-host.js"; import { escapeTerminalText, formatDiagnosticValue } from "./output.js"; @@ -230,9 +231,8 @@ export function controlRequestError(err) { // Shared socket/header options for control-plane requests (also used by // `wdl tail`'s SSE connection, so transport fixes land in one place). -// CONTROL_CONNECT_HOST overrides the TCP target while the Host header and -// SNI keep tracking the URL authority — the ALB's cert is issued for the -// admin host. +// CONTROL_CONNECT_HOST overrides the TCP target while Host and TLS certificate +// identity track the URL authority. SNI is omitted for an IP authority. /** * @param {URL} u * @param {NodeJS.ProcessEnv} [env] @@ -254,7 +254,10 @@ export function controlRequestOptions(u, env = process.env) { }, agent: false, }; - if (isHttps && isIP(tlsServerName) === 0) opts.servername = tlsServerName; + if (isHttps) { + if (isIP(tlsServerName) === 0) opts.servername = tlsServerName; + else opts.checkServerIdentity = (_host, certificate) => checkServerIdentity(tlsServerName, certificate); + } return opts; } diff --git a/lib/credentials.js b/lib/credentials.js index 27e8d5e..cd599d5 100644 --- a/lib/credentials.js +++ b/lib/credentials.js @@ -379,7 +379,7 @@ export function loadCliControlEnv( // Drop untrusted project-.env endpoints BEFORE filling from the global store, // so a dropped endpoint's slot is filled by the trusted store rather than // staying shadowed by what the guard just removed. - guardCrossOriginControlEnv(env, loaded, tokenFromFlag, onCrossOrigin); + guardCrossOriginControlEnv(env, loaded, tokenFromFlag, controlUrlFromFlag, onCrossOrigin); // The store is trusted (you wrote it via `wdl token`, token + endpoint // same-source) and not itself subject to the cross-origin guard, so it fills // the gaps left by flags / shell / project .env / the guard — but only for a @@ -451,15 +451,27 @@ function fillFromTokenStore(env, ns, namespaces, onLoad, covered = {}) { * @param {NodeJS.ProcessEnv} env * @param {Set} loadedFromDotenv * @param {boolean} tokenFromFlag + * @param {boolean} controlUrlFromFlag * @param {(line: string) => void} onCrossOrigin */ -function guardCrossOriginControlEnv(env, loadedFromDotenv, tokenFromFlag, onCrossOrigin) { +function guardCrossOriginControlEnv(env, loadedFromDotenv, tokenFromFlag, controlUrlFromFlag, onCrossOrigin) { // A NON-EMPTY .env token, not merely a loaded `ADMIN_TOKEN=` key: an empty // placeholder would otherwise mark the .env endpoint same-source while the // real token gets gap-filled from the global store afterwards — letting an // untrusted .env redirect a STORED token to a host it chose. const tokenIsFromDotenv = loadedFromDotenv.has("ADMIN_TOKEN") && isNonEmptyString(env.ADMIN_TOKEN) && !tokenFromFlag; - if (tokenIsFromDotenv) return; + if (tokenIsFromDotenv) { + if ( + loadedFromDotenv.has("CONTROL_CONNECT_HOST") && + (!loadedFromDotenv.has("CONTROL_URL") || !isNonEmptyString(env.CONTROL_URL) || controlUrlFromFlag) + ) { + delete env.CONTROL_CONNECT_HOST; + onCrossOrigin( + "warning: ignoring CONTROL_CONNECT_HOST from .env because the effective CONTROL_URL is not from the same .env." + ); + } + return; + } for (const key of CONTROL_ENDPOINT_KEYS) { if (!loadedFromDotenv.has(key)) continue; delete env[key]; diff --git a/lib/d1-format.js b/lib/d1-format.js index 6931843..36179da 100644 --- a/lib/d1-format.js +++ b/lib/d1-format.js @@ -1,3 +1,5 @@ +import { escapeTerminalText } from "./output.js"; + /** * @typedef {object} D1Database * @property {string} [databaseId] @@ -21,7 +23,9 @@ export function formatD1List(body) { const databases = Array.isArray(body.databases) ? body.databases : []; if (databases.length === 0) return ["(no d1 databases)"]; - return databases.map((db) => `${db.databaseId}\tname=${db.databaseName || "-"}\tcreated=${db.createdAt || "-"}`); + return databases.map( + (db) => `${cell(db.databaseId)}\tname=${cell(db.databaseName || "-")}\tcreated=${cell(db.createdAt || "-")}` + ); } /** @@ -40,7 +44,8 @@ export function formatD1MigrationList(body) { const migrations = Array.isArray(body.migrations) ? body.migrations : []; if (migrations.length === 0) return ["(no d1 migrations applied)"]; return migrations.map( - (migration) => `${migration.id}\tapplied=${migration.appliedAt || "-"}\tchecksum=${migration.checksum || "-"}` + (migration) => + `${cell(migration.id)}\tapplied=${cell(migration.appliedAt || "-")}\tchecksum=${cell(migration.checksum || "-")}` ); } @@ -52,7 +57,7 @@ export function formatD1MigrationStatus(body) { const migrations = Array.isArray(body.migrations) ? body.migrations : []; if (migrations.length === 0) return ["(no local migrations)"]; return migrations.map( - (migration) => `${migration.id}\tstate=${migration.state}\tapplied=${migration.appliedAt || "-"}` + (migration) => `${cell(migration.id)}\tstate=${cell(migration.state)}\tapplied=${cell(migration.appliedAt || "-")}` ); } @@ -65,7 +70,9 @@ export function formatD1MigrationApply(body) { const skipped = Array.isArray(body.skipped) ? body.skipped : []; if (applied.length === 0 && skipped.length === 0) return ["(no migrations applied)"]; return [ - ...applied.map((migration) => `Applied ${migration.id}\tstatements=${migration.statementCount ?? "-"}`), - ...skipped.map((migration) => `Skipped ${migration.id}\talready applied`), + ...applied.map((migration) => `Applied ${cell(migration.id)}\tstatements=${cell(migration.statementCount ?? "-")}`), + ...skipped.map((migration) => `Skipped ${cell(migration.id)}\talready applied`), ]; } +/** @param {unknown} value */ +const cell = (value) => escapeTerminalText(String(value)); diff --git a/lib/delete-format.js b/lib/delete-format.js index cbc44dd..6a36ae6 100644 --- a/lib/delete-format.js +++ b/lib/delete-format.js @@ -54,6 +54,7 @@ const ASSET_WARNING_KEYS = ["code", "message", "path", "key", "prefix", "reason" * @property {number} [queueConsumersRemoved] * @property {DeleteBlocker[]} [blockers] * @property {DeleteWorkflowBlocker} [workflowBlocker] + * @property {{ storageRetention?: { retained?: boolean, objects?: number } }} [durableObjects] * @property {DeleteAssetsSummary} [assets] */ @@ -74,7 +75,9 @@ export function formatVersionDelete(body) { export function formatWorkerDelete(body) { if (body.dryRun) return formatDryRun(body); if (!body.deleted) { - return [`(${field(body.namespace)}/${field(body.name)} had no worker-owned state)`]; + const lines = [`(${field(body.namespace)}/${field(body.name)} had no worker-owned state)`]; + appendDoStorageRetention(lines, body.durableObjects); + return lines; } const versions = @@ -89,6 +92,7 @@ export function formatWorkerDelete(body) { if (Number.isFinite(body.queueConsumersRemoved) && Number(body.queueConsumersRemoved) > 0) { lines.push(` queue consumers removed: ${body.queueConsumersRemoved}`); } + appendDoStorageRetention(lines, body.durableObjects); appendAssetsSummary(lines, body.assets); return lines; } @@ -115,11 +119,25 @@ function formatDryRun(body) { if (Number.isFinite(body.queueConsumersRemoved) && Number(body.queueConsumersRemoved) > 0) { lines.push(` queue consumers removed: ${body.queueConsumersRemoved}`); } + appendDoStorageRetention(lines, body.durableObjects); appendBlockers(lines, body.blockers); appendWorkflowBlocker(lines, body.workflowBlocker); return lines; } +/** + * @param {string[]} lines + * @param {WorkerDeleteBody["durableObjects"]} durableObjects + */ +function appendDoStorageRetention(lines, durableObjects) { + const retention = durableObjects?.storageRetention; + if (!retention) return; + const validObjects = Number.isSafeInteger(retention.objects) && Number(retention.objects) >= 0; + const objects = validObjects ? retention.objects : "-"; + if (retention.retained !== true && (!validObjects || Number(objects) === 0)) return; + lines.push(` Durable Object storage retained=${retention.retained === true ? "yes" : "no"} objects=${objects}`); +} + /** * @param {string[]} lines * @param {DeleteAssetsSummary | undefined} assets diff --git a/lib/r2-format.js b/lib/r2-format.js index ff781e9..6bae3de 100644 --- a/lib/r2-format.js +++ b/lib/r2-format.js @@ -1,4 +1,8 @@ // Human-readable rendering for `wdl r2`. (Response-header parsing lives in r2.js.) +import { escapeTerminalText } from "./output.js"; + +/** @param {unknown} value */ +const cell = (value) => escapeTerminalText(String(value)); /** * @typedef {object} R2Bucket @@ -18,9 +22,9 @@ * @returns {string[]} */ export function formatBucketList(body) { - const lines = [`R2 buckets in ${body.namespace}:`]; - for (const bucket of body.buckets || []) lines.push(` ${bucket.name}`); - if (body.truncated && body.cursor) lines.push(`Next cursor: ${body.cursor}`); + const lines = [`R2 buckets in ${cell(body.namespace)}:`]; + for (const bucket of body.buckets || []) lines.push(` ${cell(bucket.name)}`); + if (body.truncated && body.cursor) lines.push(`Next cursor: ${cell(body.cursor)}`); return lines; } @@ -36,12 +40,12 @@ export function formatBucketList(body) { * @returns {string[]} */ export function formatObjectList(body) { - const lines = [`R2 objects in ${body.namespace}/${body.bucket}:`]; - for (const prefix of body.delimitedPrefixes || []) lines.push(` ${prefix}`); + const lines = [`R2 objects in ${cell(body.namespace)}/${cell(body.bucket)}:`]; + for (const prefix of body.delimitedPrefixes || []) lines.push(` ${cell(prefix)}`); for (const obj of body.objects || []) { - lines.push(` ${obj.key}\t${obj.size}\t${obj.etag || "-"}\t${obj.uploaded || "-"}`); + lines.push(` ${cell(obj.key)}\t${cell(obj.size)}\t${cell(obj.etag || "-")}\t${cell(obj.uploaded || "-")}`); } - if (body.truncated && body.cursor) lines.push(`Next cursor: ${body.cursor}`); + if (body.truncated && body.cursor) lines.push(`Next cursor: ${cell(body.cursor)}`); return lines; } @@ -59,17 +63,17 @@ export function formatObjectList(body) { * @returns {string[]} */ export function formatObjectHead(body) { - const lines = [`R2 object ${body.namespace}/${body.bucket}/${body.key}:`]; - lines.push(` size: ${body.size}`); - lines.push(` etag: ${body.etag || "-"}`); - lines.push(` uploaded: ${body.uploaded || "-"}`); + const lines = [`R2 object ${cell(body.namespace)}/${cell(body.bucket)}/${cell(body.key)}:`]; + lines.push(` size: ${cell(body.size)}`); + lines.push(` etag: ${cell(body.etag || "-")}`); + lines.push(` uploaded: ${cell(body.uploaded || "-")}`); const hm = body.httpMetadata || {}; for (const [key, value] of Object.entries(hm)) { - lines.push(` httpMetadata.${key}: ${value}`); + lines.push(` httpMetadata.${cell(key)}: ${cell(value)}`); } const cm = body.customMetadata || {}; for (const [key, value] of Object.entries(cm)) { - lines.push(` customMetadata.${key}: ${value}`); + lines.push(` customMetadata.${cell(key)}: ${cell(value)}`); } return lines; } diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index 9211da5..58f4505 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -26,12 +26,13 @@ import { checkWranglerVersion, formatWranglerFailure, resolveWranglerCommand, + runWranglerWithoutProjectDotEnv, wranglerChildEnv, } from "./wrangler/command.js"; import { collectRoutes, createWranglerBundleConfig, - formatAiEnvNonInheritanceWarning, + formatBindingEnvNonInheritanceWarnings, formatWranglerConfigShadowWarning, loadWranglerConfig, parseSessionPolicy, @@ -64,7 +65,7 @@ export { parseWranglerMajorVersion, resolveWranglerCommand, wranglerChildEnv } f export { collectRoutes, createWranglerBundleConfig, - formatAiEnvNonInheritanceWarning, + formatBindingEnvNonInheritanceWarnings, formatWranglerConfigShadowWarning, loadWranglerConfig, parseJsonc, @@ -161,9 +162,13 @@ export async function packWranglerProject({ validateUnsupportedWranglerConfig(rawCfg, selectedEnv, configRel); return resolveWranglerConfig(rawCfg, selectedEnv, configRel); }); - const aiEnvWarning = formatAiEnvNonInheritanceWarning(rawCfg, envName, configRel); - // Verbose mode inherits Wrangler stderr and already shows its native warning. - if (aiEnvWarning && !verbose) stderr(`warning: ${aiEnvWarning}`); + // Verbose Wrangler output already includes its [ai] warning. WDL-only + // sections need our warning in both modes because Wrangler never sees them. + for (const warning of formatBindingEnvNonInheritanceWarnings(rawCfg, envName, configRel, { + includeAi: !verbose, + })) { + stderr(`warning: ${warning}`); + } // Validate the type, not just truthiness: the dry-run bundle uses a sanitized // temp name, so Wrangler never checks the original cfg.name — a non-string // would otherwise be asserted as the string workerName below. @@ -252,12 +257,29 @@ export async function packWranglerProject({ for (const name of Object.keys(vars)) { claimBinding(name); } - // A present-but-non-table [assets] is a config error, not "no assets": reject - // it before bundling instead of letting asRecord() null it out and silently - // skip assets. - if (cfg.assets != null && asRecord(cfg.assets) == null) { + // Wrangler does not see assets in the temporary bundle config, so a + // declared section must be validated here before bundling. + const hasAssets = Object.hasOwn(cfg, "assets"); + const declaredAssets = hasAssets ? asRecord(cfg.assets) : null; + if (hasAssets && !declaredAssets) { throw new CliError(`${shownConfig}: [assets] must be a table`); } + if (declaredAssets) { + if (typeof declaredAssets.directory !== "string" || !declaredAssets.directory.trim()) { + throw new CliError(`${shownConfig}: assets.directory must be a non-empty string`); + } + const unsupported = Object.keys(declaredAssets).filter( + (key) => !["directory", "binding", "run_worker_first"].includes(key) + ); + if (unsupported.length) { + throw new CliError( + `${shownConfig}: [assets] contains unsupported field(s): ${unsupported.map(escapeTerminalText).join(", ")}` + ); + } + if (declaredAssets.binding !== undefined && declaredAssets.binding !== "ASSETS") { + throw new CliError(`${shownConfig}: assets.binding must be "ASSETS" on WDL`); + } + } const crons = wrapCli(() => parseTriggers(cfg.triggers, configRel)); const { routes, workersDev, sessionPolicy } = wrapCli(() => { const routes = collectRoutes(cfg, configRel); @@ -301,7 +323,7 @@ export async function packWranglerProject({ wranglerOpts.encoding = "utf8"; wranglerOpts.maxBuffer = WRANGLER_OUTPUT_MAX_BUFFER; } - execFile(wrangler.command, wranglerArgs, wranglerOpts); + runWranglerWithoutProjectDotEnv(execFile, wrangler.command, wranglerArgs, wranglerOpts); stdout(" bundled by wrangler"); } catch (err) { bundlingFailed = true; @@ -343,12 +365,8 @@ export async function packWranglerProject({ ); } - const assetsCfg = asRecord(cfg.assets); - const assetsDirRel = assetsCfg ? assetsCfg.directory : undefined; - // Gate on "present", not truthy, so an empty/malformed directory reaches the - // validator instead of being silently skipped. - if (assetsDirRel !== undefined) { - const assetsDir = wrapCli(() => resolveAssetsDir(absProject, assetsDirRel, configRel)); + if (declaredAssets) { + const assetsDir = wrapCli(() => resolveAssetsDir(absProject, declaredAssets.directory, configRel)); /** @type {string[]} */ const skippedAssets = []; const assets = wrapCli(() => diff --git a/lib/wrangler/assets.js b/lib/wrangler/assets.js index 2c43774..ec4024c 100644 --- a/lib/wrangler/assets.js +++ b/lib/wrangler/assets.js @@ -11,7 +11,7 @@ export const ASSETS_IGNORE_FILENAME = ".assetsignore"; // Cloudflare's Workers Assets ignores only its own metafiles by default and // reads user patterns from `.assetsignore` (gitignore syntax). WDL keeps that -// mechanism and additionally skips repo/tooling artifacts and .env credential +// mechanism and additionally skips repo/tooling artifacts and local credential // files by default; a `!pattern` line in .assetsignore can deliberately // re-include anything here (last match wins). const DEFAULT_ASSET_IGNORE_PATTERNS = [ @@ -22,8 +22,9 @@ const DEFAULT_ASSET_IGNORE_PATTERNS = [ "/.wrangler", "/.deploy-dist", WRANGLER_WDL_TMP_IGNORE_PATTERN, - "**/.env", - "**/.env.*", + "**/.env*", + "**/.dev.vars*", + "**/.wdl-empty.env", ]; /** @param {string} configRel */ diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index 7a6245e..360c003 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -154,6 +154,10 @@ export function parseQueues(queues, configRel = "wrangler config") { if (!queuesTable) { throw new Error(`${configRel}: [queues] must be a table`); } + const queueKeys = Object.keys(queuesTable).filter((key) => key !== "producers" && key !== "consumers"); + if (queueKeys.length) { + throw new Error(`${configRel}: [queues] contains unknown field(s): ${formatConfigKeyList(queueKeys)}`); + } /** @type {QueueProducer[]} */ const producers = []; if (queuesTable.producers != null) { @@ -165,6 +169,14 @@ export function parseQueues(queues, configRel = "wrangler config") { if (!p) { throw new Error(`${configRel}: [[queues.producers]] entry must be a table`); } + const producerKeys = Object.keys(p).filter( + (key) => !["binding", "queue", "delivery_delay", "remote"].includes(key) + ); + if (producerKeys.length) { + throw new Error( + `${configRel}: [[queues.producers]] contains unknown field(s): ${formatConfigKeyList(producerKeys)}` + ); + } if (typeof p.binding !== "string" || !p.binding.trim()) { throw new Error(`${configRel}: [[queues.producers]].binding is required`); } @@ -196,6 +208,27 @@ export function parseQueues(queues, configRel = "wrangler config") { if (!c) { throw new Error(`${configRel}: [[queues.consumers]] entry must be a table`); } + const consumerKeys = Object.keys(c).filter( + (key) => + ![ + "queue", + "type", + "max_batch_size", + "max_batch_timeout", + "max_retries", + "dead_letter_queue", + "max_concurrency", + "retry_delay", + ].includes(key) + ); + if (consumerKeys.length) { + throw new Error( + `${configRel}: [[queues.consumers]] contains unknown field(s): ${formatConfigKeyList(consumerKeys)}` + ); + } + if (c.type !== undefined && c.type !== "worker") { + throw new Error(`${configRel}: [[queues.consumers]].type must be "worker"`); + } if (typeof c.queue !== "string" || !c.queue.trim()) { throw new Error(`${configRel}: [[queues.consumers]].queue is required`); } @@ -428,6 +461,12 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { if (!entry) { throw new Error(`${configRel}: [[services]] entry must be a table`); } + const unknownKeys = Object.keys(entry).filter( + (key) => !["binding", "service", "entrypoint", "ns", "remote"].includes(key) + ); + if (unknownKeys.length) { + throw new Error(`${configRel}: [[services]] contains unsupported field(s): ${formatConfigKeyList(unknownKeys)}`); + } if (entry.binding == null || entry.service == null) { throw new Error(`${configRel}: [[services]] entry needs both 'binding' and 'service'`); } @@ -549,10 +588,16 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { if (!entry) { throw new Error(`${configRel}: [[durable_objects.bindings]] entry must be a table`); } + const unknownKeys = Object.keys(entry).filter((key) => !["name", "class_name", "script_name"].includes(key)); + if (unknownKeys.length) { + throw new Error( + `${configRel}: [[durable_objects.bindings]] contains unsupported field(s): ${formatConfigKeyList(unknownKeys)}` + ); + } if (typeof entry.name !== "string" || !entry.name.trim()) { throw new Error(`${configRel}: [[durable_objects.bindings]].name is required`); } - if (entry.script_name != null) { + if (Object.hasOwn(entry, "script_name")) { throw new Error( `${configRel}: [[durable_objects.bindings]] ${formatConfigLabel(entry.name)}: script_name is not supported by WDL Durable Objects yet` ); diff --git a/lib/wrangler/command.js b/lib/wrangler/command.js index aee2d20..715a8ec 100644 --- a/lib/wrangler/command.js +++ b/lib/wrangler/command.js @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { CliError } from "../common.js"; @@ -118,7 +119,7 @@ export function checkWranglerVersion({ execFile = execFileSync, cwd, env, wrangl export function probeWranglerVersion({ execFile = execFileSync, cwd, env, wrangler, fallbackVersion }) { let output; try { - output = execFile(wrangler.command, [...wrangler.args, "--version"], { + output = runWranglerWithoutProjectDotEnv(execFile, wrangler.command, [...wrangler.args, "--version"], { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", @@ -138,6 +139,26 @@ export function probeWranglerVersion({ execFile = execFileSync, cwd, env, wrangl return { version, major: Number(version.split(".")[0]) }; } +/** + * Wrangler loads project .env files after process-env scrubbing. An explicit + * empty file prevents that automatic reload; it is not a sandbox for build + * hooks, which can still read project files directly. + * @param {typeof execFileSync} execFile + * @param {string} command + * @param {string[]} args + * @param {import("node:child_process").ExecFileSyncOptions} options + */ +export function runWranglerWithoutProjectDotEnv(execFile, command, args, options) { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-wrangler-env-")); + try { + const envFile = path.join(dir, "empty.env"); + writeFileSync(envFile, "", { mode: 0o600 }); + return execFile(command, [...args, "--env-file", envFile], options); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + /** * @param {NodeJS.ProcessEnv} env * @returns {NodeJS.ProcessEnv} @@ -319,6 +340,9 @@ function formatWranglerVersionFailure(rawErr) { "\nNo runnable wrangler found. Install wrangler@^4 in the Worker project " + "(npm i -D wrangler), set WDL_WRANGLER_BIN to a runnable wrangler entry, " + "or set WDL_ALLOW_NPX_WRANGLER=1."; + } else if (/Unknown arguments?:[^\r\n]*env-file/i.test(output)) { + message += + "\nWDL passes --env-file, which requires Wrangler >=4.27.0 <5.0.0. Upgrade the selected Wrangler installation."; } return message; } diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index 48df6c2..053b5c8 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -24,8 +24,9 @@ const TOP_LEVEL_ONLY_ENV_KEYS = new Set(["name", "keep_vars", "send_metrics"]); // Runtime/deploy-facing Wrangler keys the WDL manifest has no mapping for. // Reject them loudly: wrangler dry-run accepts them happily, so a silent // drop here would surface as missing bindings or ignored deploy policy. -// Bundling-only keys (build, alias, tsconfig, rules, etc.) stay allowed -// because Wrangler consumes them before the CLI collects the output manifest. +// Bundling-only keys (build, alias, tsconfig, etc.) stay allowed because +// Wrangler consumes them before the CLI collects the output manifest. Custom +// module rules are excluded: the output files do not carry their rule types. const UNSUPPORTED_WRANGLER_KEYS = [ "addresses", "agent_memory", @@ -60,6 +61,7 @@ const UNSUPPORTED_WRANGLER_KEYS = [ "previews", "python_modules", "ratelimits", + "rules", "secrets_store_secrets", "send_email", "site", @@ -100,6 +102,8 @@ const NON_INHERITABLE_ENV_KEYS = new Set([ "ai_search", "vectorize", "services", + "exports", + "platform_bindings", "queues", "workflows", "tail_consumers", @@ -166,32 +170,50 @@ export function formatWranglerConfigShadowWarning(loaded) { } /** - * Wrangler bindings do not inherit into named environments. The normal deploy - * path captures successful Wrangler stderr, so surface the AI-specific warning - * directly before a top-level binding can disappear from the WDL manifest. + * Runtime bindings do not inherit into named environments. Wrangler may warn + * for [ai], but WDL-only sections are stripped from its temporary config. * @param {unknown} rawCfg * @param {string | null} envName * @param {string} [configRel] + * @param {{ includeAi?: boolean }} [options] + * @returns {string[]} */ -export function formatAiEnvNonInheritanceWarning(rawCfg, envName, configRel = "wrangler config") { - if (!envName) return null; +export function formatBindingEnvNonInheritanceWarnings( + rawCfg, + envName, + configRel = "wrangler config", + { includeAi = true } = {} +) { + if (!envName) return []; const cfg = asRecord(rawCfg); const envTable = asRecord(cfg?.env); const envCfg = asRecord(envTable?.[envName]); - if (!cfg || !Object.hasOwn(cfg, "ai") || !envCfg || Object.hasOwn(envCfg, "ai")) return null; + if (!cfg || !envCfg) return []; const shownConfig = escapeTerminalText(configRel); const shownEnv = escapeTerminalText(envName); - return ( - `${shownConfig}: top-level [ai] is not inherited into env.${shownEnv}; ` + - `declare ai inside env.${shownEnv} to bind AI in this environment` - ); + /** @type {string[]} */ + const warnings = []; + for (const [key, label] of [ + ["ai", "[ai]"], + ["exports", "[[exports]]"], + ["platform_bindings", "[[platform_bindings]]"], + ]) { + if (key === "ai" && !includeAi) continue; + if (Object.hasOwn(cfg, key) && !Object.hasOwn(envCfg, key)) { + warnings.push( + `${shownConfig}: top-level ${label} is not inherited into env.${shownEnv}; ` + + `declare ${label} inside env.${shownEnv} to use it in this environment` + ); + } + } + return warnings; } /** * Build the config passed to Wrangler's dry-run bundler. WDL-only extensions - * are removed, while standard Wrangler fields such as [ai] stay available for - * Wrangler validation. The source config stays untouched because WDL still - * needs the full shape for its deploy manifest. + * and assets collected separately by the CLI are removed, while standard + * Wrangler fields such as [ai] stay available for Wrangler validation. The + * source config stays untouched for the deploy manifest. * @param {unknown} rawCfg * @returns {WranglerConfig} */ @@ -199,13 +221,13 @@ export function createWranglerBundleConfig(rawCfg) { const cfg = asRecord(rawCfg); if (!cfg) throw new Error("wrangler config must be an object"); - const projected = stripWdlConfigExtensions(cfg); + const projected = projectBundleConfigSection(cfg); const envTable = asRecord(cfg.env); if (envTable) { const projectedEnv = { ...envTable }; for (const [name, rawEnvCfg] of Object.entries(envTable)) { const envCfg = asRecord(rawEnvCfg); - if (envCfg) projectedEnv[name] = stripWdlConfigExtensions(envCfg); + if (envCfg) projectedEnv[name] = projectBundleConfigSection(envCfg); } projected.env = projectedEnv; } @@ -217,8 +239,9 @@ export function createWranglerBundleConfig(rawCfg) { * @param {Record} cfg * @returns {WranglerConfig} */ -function stripWdlConfigExtensions(cfg) { +function projectBundleConfigSection(cfg) { const projected = { ...cfg }; + delete projected.assets; delete projected.exports; delete projected.platform_bindings; delete projected.wdl; @@ -285,10 +308,8 @@ export function collectRoutes(cfg, configRel = "wrangler config") { collected.push(rawEntry); return; } - const entry = asRecord(rawEntry); - if (entry && typeof entry.pattern === "string") { - collected.push(entry.pattern); - return; + if (asRecord(rawEntry)) { + throw new Error(`${shownConfig}: unsupported ${source} entry: use a string pattern instead of a route object`); } throw new Error(`unsupported ${source} entry: ${formatDiagnosticValue(rawEntry)}`); }; @@ -299,7 +320,7 @@ export function collectRoutes(cfg, configRel = "wrangler config") { if (cfg.route !== undefined) pushEntry(cfg.route, "route"); if (cfg.routes !== undefined) { if (!Array.isArray(cfg.routes)) { - throw new Error(`${shownConfig}: "routes" must be an array of strings or { pattern } tables`); + throw new Error(`${shownConfig}: "routes" must be an array of string patterns`); } for (const route of cfg.routes) pushEntry(route, "routes"); } @@ -432,6 +453,8 @@ export function resolveWranglerConfig(rawCfg, envName, configRel = "wrangler con if (key === "__proto__") continue; resolved[key] = value; } + if (Object.hasOwn(envCfg, "route") && !Object.hasOwn(envCfg, "routes")) delete resolved.routes; + if (Object.hasOwn(envCfg, "routes") && !Object.hasOwn(envCfg, "route")) delete resolved.route; return { cfg: resolved, envName }; } diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 8865b84..4d5e720 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -61,6 +61,9 @@ includes the platform-domain URL only while it is enabled. Cloudflare's separate `preview_urls` field is unsupported and rejected by the CLI. `[[connect]]` TCP listeners, Cloudflare Artifacts `triggers.events` subscriptions, and R2 `local_dev.experimental_s3_credentials` are also unsupported and rejected. +Custom module `rules`, unsupported `[assets]` options, queue consumer types +other than `worker`, unmapped queue/service/DO entry fields, and route objects +are also rejected before bundling. The implicit asset binding is named `ASSETS`. `[[workflows]]` supports only `name`, `binding`, and `class_name`; `script_name` and all other fields, including `schedules`, `limits`, `default_retention`, and `concurrency`, are rejected before bundling. Use per-instance `create()` @@ -72,12 +75,13 @@ Responses config, then edit model-specific capabilities before `providers put`. Initializer output rejects non-text input, `previous_response_id` continuation, and binary WebSocket frames until their corresponding declarations are enabled; the bundled AI agent demo already enables its required `previousResponseId`. -Like other bindings, `[ai]` is not inherited into named environments; deploy -warns when the selected environment omits a top-level AI binding. -`[wdl] session_policy = "restart"` makes every promotion close the Worker's open -WebSockets with `1012` and retire stale Durable Object facets on their next -dispatch; the default `preserve` leaves facets on the version that built them -and keeps open WebSockets draining while their backend stays healthy. +Like other bindings, `[ai]`, `[[exports]]`, and `[[platform_bindings]]` are not +inherited into named environments; deploy warns when the selected environment +omits one of these top-level bindings. `[wdl] session_policy = "restart"` makes +every promotion close the Worker's open WebSockets with `1012` and retire stale +Durable Object facets on their next dispatch; the default `preserve` leaves +facets on the version that built them and keeps open WebSockets draining while +their backend stays healthy. ## Runnable end-to-end examples @@ -118,10 +122,14 @@ When a snippet is not enough and you need a complete working file tree: ```bash npm install # once -npx wrangler deploy --dry-run --outdir=.deploy-dist # bundle check -npm run deploy # deploy to WDL +npm run dry-run # bundle check +npm run deploy # deploy to WDL ``` +The generated `dry-run` script uses an empty `.wdl-empty.env` so Wrangler does +not inject project `.env` values into build hooks. Build hooks can still read +project files directly; run them only in projects you trust. + `wdl init` bakes `--ns ` into the `deploy` script in `package.json` when you pass it; without `--ns` the script is `wdl deploy .` and the namespace is resolved at deploy time (`--ns`, `WDL_NS`, a project `.env`, or a `wdl token` @@ -136,9 +144,10 @@ Wrangler in two key ways: named `my-worker` deployed with `--env production` is still `my-worker` on WDL, where standard Cloudflare Workers / Wrangler would typically produce `my-worker-production`. -- `vars`, KV, D1, R2, AI, Durable Objects, queues, services, workflows, and the - like are env-scoped / non-inheritable — top-level config of the same kind does - not flow into the selected env; redeclare it inside the `env.` block. +- `vars`, KV, D1, R2, AI, Durable Objects, queues, services, workflows, + `[[exports]]`, and `[[platform_bindings]]` are env-scoped / non-inheritable; + top-level config of the same kind does not flow into the selected env; + redeclare it inside the `env.` block. Full rules are in `env-overrides.md`. diff --git a/tests/unit/cli-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index 4fd7395..5362b6f 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -179,7 +179,7 @@ test("config explain prints final values and sources", async () => { cwd, env: {}, /** @param {string} line */ - stdout: (line) => lines.push(line), + stdout: (/** @type {string} */ line) => lines.push(line), }); const out = lines.join("\n"); @@ -397,6 +397,57 @@ test("doctor reports local checks plus remote whoami", async () => { }); }); +test("doctor escapes control-supplied check labels and details", async () => { + await withTempDir(async (cwd) => { + /** @type {string[]} */ + const lines = []; + await runDoctorCommand(["--ns", "acme", "--token", "secret-token"], { + cwd, + env: { CONTROL_URL: "https://api.wdl.dev" }, + execFile: () => "4.131.0\n", + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async () => + response({ + ok: true, + principal: { kind: "ns\n\u2713 FORGED", ns: "acme" }, + tokenId: "tok\n\u2713 FORGED", + platformVersion: "wdl\n\u2713 FORGED", + minCliVersion: "1.9.0", + urls: { control: "https://api.wdl.dev/\n\u2713 FORGED" }, + }), + }); + const out = lines.join("\n"); + assert.doesNotMatch(out, /\n\u2713 FORGED/); + assert.match(out, /Principal ns\\n\u2713 FORGED\/acme/); + assert.match(out, /token id: tok\\n\u2713 FORGED/); + assert.match(out, /Platform wdl\\n\u2713 FORGED/); + }); +}); + +test("doctor --json preserves control-supplied check values", async () => { + await withTempDir(async (cwd) => { + /** @type {string[]} */ + const lines = []; + await runDoctorCommand(["--json", "--ns", "acme", "--token", "secret-token"], { + cwd, + env: { CONTROL_URL: "https://api.wdl.dev" }, + execFile: () => "4.131.0\n", + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async () => + response({ + ok: true, + principal: { kind: "ns\nFORGED", ns: "acme" }, + tokenId: "tok\nFORGED", + minCliVersion: "1.9.0", + urls: { control: "https://api.wdl.dev/\nFORGED" }, + }), + }); + const body = /** @type {{ checks: Array<{ label: string, detail: string }> }} */ (JSON.parse(lines.join("\n"))); + assert.equal(body.checks.find((item) => item.label.startsWith("Principal "))?.label, "Principal ns\nFORGED/acme"); + assert.equal(body.checks.find((item) => item.label === "ADMIN_TOKEN valid")?.detail, "token id: tok\nFORGED"); + }); +}); + test("doctor --strict exits non-zero when any check fails", async () => { await withTempDir(async (cwd) => { writeFileSync(path.join(cwd, "wrangler.jsonc"), "{}"); diff --git a/tests/unit/cli-control-fetch.test.js b/tests/unit/cli-control-fetch.test.js index d35af56..2be63b3 100644 --- a/tests/unit/cli-control-fetch.test.js +++ b/tests/unit/cli-control-fetch.test.js @@ -485,3 +485,18 @@ test("controlFetch omits TLS SNI for HTTPS IP literals", async () => { assert.equal(opts.port, 443); assert.equal(opts.servername, undefined); }); + +test("controlFetch validates an IP authority certificate against the URL, not the connect override", async () => { + const { seen, transport } = captureSuccessfulRequestOptions(); + await controlFetch("https://127.0.0.1/whoami", { + env: { CONTROL_CONNECT_HOST: "localhost" }, + transport, + }); + const opts = /** @type {import("node:https").RequestOptions} */ (seen[0]); + assert.equal(opts.host, "localhost"); + assert.equal(opts.servername, undefined); + const certificate = /** @type {import("node:tls").DetailedPeerCertificate} */ ( + /** @type {unknown} */ ({ subjectaltname: "DNS:localhost", subject: { CN: "localhost" } }) + ); + assert.match(opts.checkServerIdentity?.("localhost", certificate)?.message || "", /IP: 127\.0\.0\.1/); +}); diff --git a/tests/unit/cli-credentials.test.js b/tests/unit/cli-credentials.test.js index 9c583ce..4e79019 100644 --- a/tests/unit/cli-credentials.test.js +++ b/tests/unit/cli-credentials.test.js @@ -648,6 +648,37 @@ test("loadCliControlEnv trusts a .env control endpoint when the token is also fr } }); +test("loadCliControlEnv drops a .env connect override when the effective URL comes from elsewhere", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-crossorigin-connect-")); + try { + for (const options of [ + { dotenvUrl: "", env: { CONTROL_URL: "https://operator.example" }, controlUrlFromFlag: false }, + { dotenvUrl: "CONTROL_URL=https://env.example\n", env: {}, controlUrlFromFlag: true }, + { dotenvUrl: "CONTROL_URL=\n", env: { WDL_NS: "acme" }, controlUrlFromFlag: false }, + ]) { + writeFileSync( + path.join(dir, ".env"), + `ADMIN_TOKEN=env-token\nCONTROL_CONNECT_HOST=attacker.example\n${options.dotenvUrl}` + ); + /** @type {NodeJS.ProcessEnv} */ + const env = { ...options.env }; + /** @type {string[]} */ + const warned = []; + loadCliControlEnv(env, { + dotenvPath: path.join(dir, ".env"), + controlUrlFromFlag: options.controlUrlFromFlag, + readStore: () => ({ namespaces: { acme: { CONTROL_URL: "https://store.example" } } }), + onCrossOrigin: (line) => warned.push(line), + }); + assert.equal(env.CONTROL_CONNECT_HOST, undefined); + assert.match(warned[0], /ignoring CONTROL_CONNECT_HOST from \.env/); + if (options.dotenvUrl === "CONTROL_URL=\n") assert.equal(env.CONTROL_URL, "https://store.example"); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("loadCliControlEnv keeps the documented multi-ns layout (URL in base, token in [ns])", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-multins-")); try { diff --git a/tests/unit/cli-d1.test.js b/tests/unit/cli-d1.test.js index 5129a2c..2b594f6 100644 --- a/tests/unit/cli-d1.test.js +++ b/tests/unit/cli-d1.test.js @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { runD1Command, serializeMigrationStatusRequest } from "../../commands/d1.js"; import { LONG_CONTROL_TIMEOUT_MS } from "../../lib/control-fetch.js"; +import { formatD1List } from "../../lib/d1-format.js"; import { ESC, MODE_BITS_ENFORCED_ONLY, @@ -18,6 +19,11 @@ import { /** @typedef {import("../../lib/control-fetch.js").ControlFetchInit} ControlFetchInit */ /** @typedef {import("./helpers.js").ControlCall} RecordedCall */ +test("D1 list escapes line and column controls in returned fields", () => { + const lines = formatD1List({ databases: [{ databaseId: "db\nFORGED", databaseName: "name\tcolumn" }] }); + assert.deepEqual(lines, ["db\\nFORGED\tname=name\\tcolumn\tcreated=-"]); +}); + /** * @param {unknown} err * @param {RegExp} expected @@ -265,6 +271,34 @@ test("d1 migrations apply reads sorted SQL files from --dir", async () => { } }); +test("d1 migrations apply preserves bounded progress when a later migration fails", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-migration-progress-")); + try { + mkdirSync(path.join(dir, "migrations")); + writeFileSync(path.join(dir, "migrations", "001_init.sql"), "select 1;"); + await assert.rejects( + () => + runD1Command(["migrations", "apply", "main", "--dir", "migrations", "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + controlFetch: async () => + response( + { + error: "d1_migration_apply_failed", + message: "later migration failed", + applied: [{ id: "001_init.sql" }], + skipped: [{ id: "old\nFORGED.sql" }], + }, + 409 + ), + }), + /applied before failure=001_init\.sql; skipped before failure=old\\nFORGED\.sql/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("d1 migrations apply surfaces control request body size errors", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-migrations-413-")); try { diff --git a/tests/unit/cli-delete.test.js b/tests/unit/cli-delete.test.js index 95a3808..2831cf5 100644 --- a/tests/unit/cli-delete.test.js +++ b/tests/unit/cli-delete.test.js @@ -202,6 +202,40 @@ test("delete worker dry-run reports state presence without overstating deletion" ]); }); +test("delete worker reports retained Durable Object storage in preview and result", () => { + const durableObjects = { storageRetention: { retained: true, objects: 3 } }; + for (const dryRun of [true, false]) { + const lines = formatWorkerDelete({ + namespace: "demo", + name: "api", + dryRun, + deleted: true, + durableObjects, + }); + assert.ok(lines.includes(" Durable Object storage retained=yes objects=3")); + } +}); + +test("delete worker omits empty Durable Object retention but reports affected objects", () => { + for (const dryRun of [true, false]) { + const base = { namespace: "demo", name: "api", dryRun, deleted: true }; + const empty = { storageRetention: { retained: false, objects: 0 } }; + assert.ok( + formatWorkerDelete({ ...base, durableObjects: empty }).every((line) => !line.includes("storage retained")) + ); + assert.ok( + formatWorkerDelete({ ...base, deleted: false, durableObjects: empty }).every( + (line) => !line.includes("storage retained") + ) + ); + assert.ok( + formatWorkerDelete({ ...base, durableObjects: { storageRetention: { retained: false, objects: 2 } } }).includes( + " Durable Object storage retained=no objects=2" + ) + ); + } +}); + test("delete worker dry-run renders workflow blockers in human output", async () => { const hostile = `bad${ESC}[2J\nFORGED\rBAD`; const body = { diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index d50e96f..b3e8a8d 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -435,10 +435,22 @@ test("runDeployCommand sanitizes wrangler.name via temp --config so mixed-case w } }); -test("runDeployCommand warns when a selected environment does not inherit top-level AI", async (t) => { +test("runDeployCommand warns when a selected environment does not inherit top-level bindings", async (t) => { const dir = createDeployProject( t, - ['name = "api"', 'main = "src/index.js"', "[ai]", 'binding = "AI"', "[env.prod]"].join("\n"), + [ + 'name = "api"', + 'main = "src/index.js"', + "[ai]", + 'binding = "AI"', + "[[exports]]", + 'entrypoint = "Api"', + 'allowed_callers = ["demo"]', + "[[platform_bindings]]", + 'binding = "PAY"', + 'platform = "STRIPE"', + "[env.prod]", + ].join("\n"), "wdl-run-deploy-ai-env-warning-" ); const { calls, controlFetch } = deployPromoteFetch( @@ -458,10 +470,28 @@ test("runDeployCommand warns when a selected environment does not inherit top-le assert.deepEqual(warnings, [ "warning: wrangler.toml: top-level [ai] is not inherited into env.prod; " + - "declare ai inside env.prod to bind AI in this environment", + "declare [ai] inside env.prod to use it in this environment", + "warning: wrangler.toml: top-level [[exports]] is not inherited into env.prod; " + + "declare [[exports]] inside env.prod to use it in this environment", + "warning: wrangler.toml: top-level [[platform_bindings]] is not inherited into env.prod; " + + "declare [[platform_bindings]] inside env.prod to use it in this environment", ]); const manifest = JSON.parse(/** @type {string} */ (calls[0].init.body)); assert.equal(manifest.bindings, undefined); + assert.equal(manifest.exports, undefined); + assert.equal(manifest.platformBindings, undefined); + + /** @type {string[]} */ + const verboseWarnings = []; + const verboseControl = deployPromoteFetch({ version: "v2", warnings: [] }, { platformDomain: "workers.example" }); + await runDeployCommand([dir, "--env", "prod", "--ns", "demo", "--control-url", "http://ctl.test", "--verbose"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: (/** @type {string} */ line) => verboseWarnings.push(line), + execFile: fakeWranglerExecFile, + controlFetch: verboseControl.controlFetch, + }); + assert.deepEqual(verboseWarnings, warnings.slice(1)); }); test("runDeployCommand removes the sanitized temp config when wrangler exec fails", async () => { @@ -1438,6 +1468,54 @@ test("runDeployCommand treats an empty assets directory as an implicit ASSETS bi } }); +test("packWranglerProject rejects unmapped assets settings before invoking Wrangler", async (t) => { + const dir = createDeployProject(t, 'name = "api"\nmain = "src/index.js"\n'); + const configPath = path.join(dir, "wrangler.json"); + for (const assets of [ + { directory: "public", binding: "STATIC" }, + { directory: "public", html_handling: "auto-trailing-slash" }, + { directory: "public", not_found_handling: "404-page" }, + ]) { + writeFileSync(configPath, JSON.stringify({ name: "api", main: "src/index.js", assets })); + await assert.rejects( + () => + packWranglerProject({ + projectDir: dir, + execFile: () => { + throw new Error("Wrangler must not run"); + }, + }), + /assets\.binding must be "ASSETS"|\[assets\] contains unsupported field\(s\)/ + ); + } +}); + +test("packWranglerProject requires a directory whenever assets is declared", async (t) => { + const dir = createDeployProject(t, 'name = "api"\nmain = "src/index.js"\n'); + const configPath = path.join(dir, "wrangler.json"); + /** @type {Array<[unknown, RegExp]>} */ + const invalidAssets = [ + [null, /\[assets\] must be a table/], + [{}, /assets\.directory must be a non-empty string/], + [{ binding: "ASSETS" }, /assets\.directory must be a non-empty string/], + [{ directory: "" }, /assets\.directory must be a non-empty string/], + [{ directory: 42 }, /assets\.directory must be a non-empty string/], + ]; + for (const [assets, expected] of invalidAssets) { + writeFileSync(configPath, JSON.stringify({ name: "api", main: "src/index.js", assets })); + await assert.rejects( + () => + packWranglerProject({ + projectDir: dir, + execFile: () => { + throw new Error("Wrangler must not run"); + }, + }), + expected + ); + } +}); + test("runDeployCommand rejects vars that collide with empty declared assets", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-empty-assets-var-collision-")); try { diff --git a/tests/unit/cli-init.test.js b/tests/unit/cli-init.test.js index 1f275bc..753f455 100644 --- a/tests/unit/cli-init.test.js +++ b/tests/unit/cli-init.test.js @@ -189,6 +189,8 @@ test("init scaffolds files with --ns and --worker", async () => { const cliPkg = JSON.parse(readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")); assert.equal(pkg.name, "demo"); assert.equal(pkg.scripts.deploy, "wdl deploy . --ns acme"); + assert.match(pkg.scripts["dry-run"], /--env-file=\.wdl-empty\.env/); + assert.equal(readFileSync(path.join(projectDir, ".wdl-empty.env"), "utf8"), ""); assert.equal(pkg.scripts["deploy:prod"], undefined); assert.equal(pkg.devDependencies.wrangler, cliPkg.dependencies.wrangler); assert.ok(pkg.devDependencies["@wdl-dev/cli"]); diff --git a/tests/unit/cli-r2.test.js b/tests/unit/cli-r2.test.js index 790e2a3..8f6a04f 100644 --- a/tests/unit/cli-r2.test.js +++ b/tests/unit/cli-r2.test.js @@ -6,11 +6,25 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { runR2Command } from "../../commands/r2.js"; +import { formatObjectList } from "../../lib/r2-format.js"; import { LONG_CONTROL_TIMEOUT_MS, UNLIMITED_CONTROL_BODY_BYTES } from "../../lib/control-fetch.js"; import { INVALID_PAGE_LIMITS, mockDeps, response, stdinFrom } from "./helpers.js"; /** @typedef {import("./helpers.js").ControlCall} ControlCall */ +test("R2 object list keeps untrusted keys and cursors on one output line", () => { + const lines = formatObjectList({ + namespace: "demo", + bucket: "uploads", + truncated: true, + cursor: "next\nFORGED", + objects: [{ key: "ok\nNext cursor: forged\tcolumn", size: 1 }], + }); + assert.equal(lines.length, 3); + assert.match(lines[1], /ok\\nNext cursor: forged\\tcolumn/); + assert.equal(lines[2], "Next cursor: next\\nFORGED"); +}); + test("r2 buckets and objects commands call encoded control endpoints", async () => { /** @type {ControlCall[]} */ const calls = []; diff --git a/tests/unit/cli-tail.test.js b/tests/unit/cli-tail.test.js index d6eb9f9..4901b53 100644 --- a/tests/unit/cli-tail.test.js +++ b/tests/unit/cli-tail.test.js @@ -186,6 +186,224 @@ test("wdl tail escapes control error details", async () => { ); }); +test("wdl tail reconnects after a transient control 503", async () => { + let requests = 0; + /** @type {string[]} */ + const stderrLines = []; + const fakeTransport = { + /** @param {import("node:https").RequestOptions} _opts @param {(res: import("node:http").IncomingMessage) => void} cb */ + request(_opts, cb) { + const req = fakeHttpReq(); + requests += 1; + const attempt = requests; + setImmediate(() => { + const res = Object.assign(fakeHttpRes(), { statusCode: attempt === 1 ? 503 : 200 }); + cb(res); + if (attempt === 1) res.emit("data", Buffer.from('{"message":"Internal error"}')); + if (attempt === 1) res.emit("end"); + else res.emit("error", new CliError("test stop")); + }); + return req; + }, + }; + await assert.rejects( + () => + runTailCommand(["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { + env: {}, + stdout: () => {}, + stderr: (/** @type {string} */ line) => stderrLines.push(line), + transport: fakeTransport, + sleepFn: async () => {}, + }), + /test stop/ + ); + assert.equal(requests, 2); + assert.ok(stderrLines.some((line) => /HTTP 503 Internal error; will reconnect/.test(line))); +}); + +test("wdl tail treats ctx_unavailable as a fatal control error", async () => { + let requests = 0; + const fakeTransport = { + /** @param {import("node:https").RequestOptions} _opts @param {(res: import("node:http").IncomingMessage) => void} cb */ + request(_opts, cb) { + const req = fakeHttpReq(); + requests += 1; + setImmediate(() => { + const res = Object.assign(fakeHttpRes(), { statusCode: 503 }); + cb(res); + res.emit( + "data", + Buffer.from('{"error":"ctx_unavailable","message":"Streaming response requires ctx.waitUntil"}') + ); + res.emit("end"); + }); + return req; + }, + }; + await assert.rejects( + () => + runTailCommand(["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { + env: {}, + stdout: () => {}, + stderr: () => {}, + transport: fakeTransport, + sleepFn: async () => { + throw new Error("tail must not reconnect"); + }, + }), + /HTTP 503 ctx_unavailable: Streaming response requires ctx\.waitUntil/ + ); + assert.equal(requests, 1); +}); + +test("wdl tail retains transient control details when the reconnect cap is reached", async () => { + let requests = 0; + /** @type {string[]} */ + const stderrLines = []; + const fakeTransport = { + /** @param {import("node:https").RequestOptions} _opts @param {(res: import("node:http").IncomingMessage) => void} cb */ + request(_opts, cb) { + const req = fakeHttpReq(); + requests += 1; + setImmediate(() => { + const res = Object.assign(fakeHttpRes(), { statusCode: 503 }); + cb(res); + res.emit("data", Buffer.from('{"error":"control_busy","message":"retry later"}')); + res.emit("end"); + }); + return req; + }, + }; + await assert.rejects( + () => + runTailCommand( + ["foo", "--max-reconnects", "1", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], + { + env: {}, + stdout: () => {}, + stderr: (/** @type {string} */ line) => stderrLines.push(line), + transport: fakeTransport, + sleepFn: async () => {}, + } + ), + /gave up after 1 consecutive reconnects.*last control error: HTTP 503 control_busy: retry later/s + ); + assert.ok(requests > 1); + assert.ok(stderrLines.some((line) => /HTTP 503 control_busy: retry later; will reconnect/.test(line))); +}); + +test("wdl tail backs off across repeated SSE idle timeouts", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let requests = 0; + let nowMs = 0; + /** @type {number[]} */ + const sleepCalls = []; + /** @type {() => void} */ + let firstConnected = () => {}; + const firstConnection = new Promise((resolve) => { + firstConnected = () => resolve(undefined); + }); + /** @type {() => void} */ + let secondConnected = () => {}; + const secondConnection = new Promise((resolve) => { + secondConnected = () => resolve(undefined); + }); + const fakeTransport = { + /** @param {import("node:https").RequestOptions} _opts @param {(res: import("node:http").IncomingMessage) => void} cb */ + request(_opts, cb) { + const req = fakeHttpReq(); + requests += 1; + const attempt = requests; + setImmediate(() => { + const res = fakeHttpRes(); + cb(res); + if (attempt <= 2) res.emit("data", ": tail-open\n\n"); + if (attempt === 1) firstConnected(); + else if (attempt === 2) secondConnected(); + else res.emit("error", new CliError("test stop")); + }); + return req; + }, + }; + const running = runTailCommand(["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { + env: {}, + stdout: () => {}, + stderr: () => {}, + transport: fakeTransport, + now: () => nowMs, + sleepFn: async (/** @type {number} */ ms) => { + sleepCalls.push(ms); + nowMs += ms; + }, + }); + await firstConnection; + nowMs += 30_000; + t.mock.timers.tick(30_000); + await secondConnection; + nowMs += 30_000; + t.mock.timers.tick(30_000); + await assert.rejects(running, /test stop/); + assert.equal(requests, 3); + assert.deepEqual(sleepCalls, [1_000, 2_000]); +}); + +test("wdl tail resets backoff after an active session later goes idle", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let requests = 0; + let nowMs = 0; + /** @type {number[]} */ + const sleepCalls = []; + /** @type {(response: import("node:http").IncomingMessage) => void} */ + let markConnected = () => {}; + /** @type {Promise} */ + const activeConnection = new Promise((resolve) => { + markConnected = resolve; + }); + const fakeTransport = { + /** @param {import("node:https").RequestOptions} _opts @param {(res: import("node:http").IncomingMessage) => void} cb */ + request(_opts, cb) { + const req = fakeHttpReq(); + const attempt = ++requests; + setImmediate(() => { + const res = fakeHttpRes(); + cb(res); + if (attempt <= 3) res.emit("error", new Error("transient")); + else if (attempt === 4) { + markConnected(res); + } else res.emit("error", new CliError("test stop")); + }); + return req; + }, + }; + const running = runTailCommand( + ["foo", "--max-reconnects", "1", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], + { + env: {}, + stdout: () => {}, + stderr: () => {}, + transport: fakeTransport, + now: () => nowMs, + sleepFn: async (/** @type {number} */ ms) => { + sleepCalls.push(ms); + nowMs += ms; + }, + } + ); + const activeResponse = await activeConnection; + activeResponse.emit("data", ":hb\n\n"); + nowMs += 20_000; + t.mock.timers.tick(20_000); + activeResponse.emit("data", ":hb\n\n"); + nowMs += 20_000; + t.mock.timers.tick(20_000); + activeResponse.emit("data", ":hb\n\n"); + nowMs += 30_000; + t.mock.timers.tick(30_000); + await assert.rejects(running, /test stop/); + assert.equal(requests, 5); + assert.deepEqual(sleepCalls, [1_000, 2_000, 4_000, 1_000]); +}); + /** @returns {import("../../lib/control-fetch.js").ControlClientRequest} */ function fakeHttpReq() { return /** @type {import("../../lib/control-fetch.js").ControlClientRequest} */ ( diff --git a/tests/unit/cli-wrangler-bindings.test.js b/tests/unit/cli-wrangler-bindings.test.js index fd696ad..f385d9c 100644 --- a/tests/unit/cli-wrangler-bindings.test.js +++ b/tests/unit/cli-wrangler-bindings.test.js @@ -166,6 +166,20 @@ test("parseQueues: rejects wrong shape", () => { assert.throws(() => parseQueues({ consumers: "no" }), /must be an array/); }); +test("parseQueues: rejects fields that would be dropped from WDL queue bindings", () => { + assert.throws(() => parseQueues({ typo: [] }), /\[queues\] contains unknown field\(s\): typo/); + assert.throws( + () => parseQueues({ producers: [{ binding: "Q", queue: "q", bogus: true }] }), + /\[\[queues\.producers\]\] contains unknown field\(s\): bogus/ + ); + assert.throws( + () => parseQueues({ consumers: [{ queue: "q", visibility_timeout_ms: 5000 }] }), + /\[\[queues\.consumers\]\] contains unknown field\(s\): visibility_timeout_ms/ + ); + assert.throws(() => parseQueues({ consumers: [{ queue: "q", type: "http_pull" }] }), /type must be "worker"/); + assert.deepEqual(parseQueues({ consumers: [{ queue: "q", type: "worker" }] }).consumers, [{ queue: "q" }]); +}); + test("parseQueues: rejects runtime-internal producer binding names", () => { assert.throws( () => parseQueues({ producers: [{ binding: "__WDL_RESERVED__", queue: "q" }] }), @@ -316,16 +330,18 @@ test("parseDurableObjectsFromCfg: parses local DO bindings with new_classes or n }), /\[\[durable_objects\.bindings\]\]\.name is required/ ); - assert.throws( - () => - parseDurableObjectsFromCfg({ - durable_objects: { - bindings: [{ name: "ROOMS", class_name: "Room", script_name: "other" }], - }, - migrations: [{ tag: "v1", new_classes: ["Room"] }], - }), - /script_name is not supported/ - ); + for (const scriptName of ["other", null]) { + assert.throws( + () => + parseDurableObjectsFromCfg({ + durable_objects: { + bindings: [{ name: "ROOMS", class_name: "Room", script_name: scriptName }], + }, + migrations: [{ tag: "v1", new_classes: ["Room"] }], + }), + /script_name is not supported/ + ); + } assert.throws( () => parseDurableObjectsFromCfg({ @@ -373,6 +389,16 @@ test("parseDurableObjectsFromCfg: rejects runtime-internal binding names", () => ); }); +test("parseDurableObjectsFromCfg: rejects unmapped binding fields", () => { + assert.throws( + () => + parseDurableObjectsFromCfg({ + durable_objects: { bindings: [{ name: "ROOMS", class_name: "Room", environment: "prod" }] }, + }), + /unsupported field\(s\): environment/ + ); +}); + test("parseKvNamespacesFromCfg: validates shape and non-empty string binding/id", () => { assert.deepEqual(parseKvNamespacesFromCfg({}), []); assert.deepEqual(parseKvNamespacesFromCfg({ kv_namespaces: [] }), []); @@ -503,6 +529,15 @@ test("parseServicesFromCfg: rejects runtime-reserved entrypoint names (__Wdl…_ ); }); +test("parseServicesFromCfg: rejects service props and other unmapped fields", () => { + for (const extra of [{ props: { role: "admin" } }, { environment: "prod" }]) { + assert.throws( + () => parseServicesFromCfg({ services: [{ binding: "API", service: "api", ...extra }] }), + /\[\[services\]\] contains unsupported field\(s\)/ + ); + } +}); + test("wrangler binding parser diagnostics escape terminal controls", () => { const bad = `bad${ESC}[2J\nFORGED\rBAD`; const badConfigRel = `wrangler${ESC}[2J\nFORGED\rBAD.json`; diff --git a/tests/unit/cli-wrangler-command.test.js b/tests/unit/cli-wrangler-command.test.js index 10c7e0f..b2548d2 100644 --- a/tests/unit/cli-wrangler-command.test.js +++ b/tests/unit/cli-wrangler-command.test.js @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -53,6 +53,21 @@ test("probeWranglerVersion returns one parsed version shape for deploy and docto }); }); +test("probeWranglerVersion prevents Wrangler from auto-loading project dotenv", () => { + /** @type {string | undefined} */ + let envFile; + /** @param {string} _command @param {string[]} args */ + const fake = (_command, args) => { + envFile = args[args.indexOf("--env-file") + 1]; + assert.ok(envFile); + assert.equal(readFileSync(envFile, "utf8"), ""); + return "4.131.0"; + }; + const execFile = /** @type {typeof import("node:child_process").execFileSync} */ (/** @type {unknown} */ (fake)); + probeWranglerVersion({ execFile, cwd: "/tmp", env: {}, wrangler: { command: "wrangler", args: [] } }); + assert.equal(existsSync(/** @type {string} */ (envFile)), false); +}); + test("checkWranglerVersion accepts only the supported v4 major", () => { const base = { cwd: "/tmp/project", @@ -143,6 +158,23 @@ test("checkWranglerVersion ENOENT hint mentions the npx opt-in", () => { ); }); +test("checkWranglerVersion explains the --env-file error from older Wrangler", () => { + const execFile = /** @type {typeof import("node:child_process").execFileSync} */ ( + /** @type {unknown} */ ( + () => { + throw Object.assign(new Error("wrangler exited"), { + status: 1, + stderr: "Unknown arguments: env-file, envFile", + }); + } + ) + ); + assert.throws( + () => checkWranglerVersion({ cwd: "/tmp/project", env: {}, wrangler: { command: "wrangler", args: [] }, execFile }), + /requires Wrangler >=4\.27\.0 <5\.0\.0.*Upgrade the selected Wrangler installation/s + ); +}); + test("formatWranglerFailure escapes captured dry-run diagnostics", () => { const message = formatWranglerFailure( Object.assign(new Error(`boom${ESC}[2J\nFORGED\rBAD\u009b`), { diff --git a/tests/unit/cli-wrangler-config.test.js b/tests/unit/cli-wrangler-config.test.js index 7f49194..defc53d 100644 --- a/tests/unit/cli-wrangler-config.test.js +++ b/tests/unit/cli-wrangler-config.test.js @@ -282,6 +282,8 @@ test("resolveWranglerConfig: non-inheritable keys are env-scoped while inheritab kv_namespaces: [{ binding: "KV", id: "top" }], ai: { binding: "AI" }, services: [{ binding: "AUTH", service: "auth" }], + exports: [{ entrypoint: "Api", allowed_callers: ["acme"] }], + platform_bindings: [{ binding: "PAY", platform: "STRIPE" }], queues: { producers: [{ binding: "Q", queue: "top-q" }] }, assets: { directory: "./top-public" }, route: "api.example/*", @@ -304,11 +306,41 @@ test("resolveWranglerConfig: non-inheritable keys are env-scoped while inheritab assert.deepEqual(cfg.ai, { binding: "PROD_AI" }); assert.deepEqual(cfg.queues, { consumers: [{ queue: "jobs" }] }); assert.equal(cfg.services, undefined); + assert.equal(cfg.exports, undefined); + assert.equal(cfg.platform_bindings, undefined); assert.deepEqual(cfg.assets, { directory: "./top-public" }); assert.equal(cfg.route, "api.example/*"); assert.equal(cfg.workers_dev, false); }); +test("resolveWranglerConfig: env routes replace the other top-level route form", () => { + const base = { name: "demo", main: "src/index.js" }; + const withRoutes = resolveWranglerConfig( + { ...base, route: "old.example/*", env: { prod: { routes: ["new.example/*"] } } }, + "prod" + ).cfg; + assert.equal(withRoutes.route, undefined); + assert.deepEqual(collectRoutes(withRoutes), ["new.example/*"]); + const withRoute = resolveWranglerConfig( + { ...base, routes: ["old.example/*"], env: { prod: { route: "new.example/*" } } }, + "prod" + ).cfg; + assert.equal(withRoute.routes, undefined); + assert.deepEqual(collectRoutes(withRoute), ["new.example/*"]); +}); + +test("resolveWranglerConfig: an environment cannot declare route and routes together", () => { + const { cfg } = resolveWranglerConfig( + { + name: "demo", + main: "src/index.js", + env: { prod: { route: "one.example/*", routes: ["two.example/*"] } }, + }, + "prod" + ); + assert.throws(() => collectRoutes(cfg), /specify either "route" or "routes"/); +}); + test("resolveWranglerConfig: a top-level AI binding does not inherit into a selected environment", () => { const { cfg } = resolveWranglerConfig( { @@ -447,12 +479,13 @@ test("resolveWranglerConfig drops __proto__ keys instead of rewriting the merged assert.deepEqual(cfg.vars, { A: "1" }); }); -test("createWranglerBundleConfig keeps standard fields while projecting WDL extensions", () => { +test("createWranglerBundleConfig keeps bundle fields while projecting manifest-only fields", () => { const rawCfg = { name: "demo", main: "src/index.js", build: { command: "npm run build" }, vars: { MODE: "top" }, + assets: { directory: "." }, triggers: { crons: ["*/5 * * * *"], schedules: [{ cron: "0 9 * * 1-5", timezone: "Asia/Shanghai" }], @@ -474,6 +507,7 @@ test("createWranglerBundleConfig keeps standard fields while projecting WDL exte env: { staging: { define: { BUILD_ENV: '"staging"' }, + assets: { directory: "./staging-public" }, triggers: { crons: ["0 * * * *"], schedules: [{ cron: "0 8 * * *", timezone: "Europe/London" }], @@ -498,6 +532,7 @@ test("createWranglerBundleConfig keeps standard fields while projecting WDL exte assert.equal(projected.wdl, undefined); assert.deepEqual(projected.build, { command: "npm run build" }); assert.deepEqual(projected.vars, { MODE: "top" }); + assert.equal(projected.assets, undefined); assert.deepEqual(projected.triggers, { crons: ["*/5 * * * *"] }); assert.deepEqual(projected.services, [ { @@ -510,6 +545,7 @@ test("createWranglerBundleConfig keeps standard fields while projecting WDL exte ]); const projectedEnv = /** @type {Record>} */ (projected.env); assert.deepEqual(projectedEnv.staging.define, { BUILD_ENV: '"staging"' }); + assert.equal(projectedEnv.staging.assets, undefined); assert.deepEqual(projectedEnv.staging.triggers, { crons: ["0 * * * *"] }); assert.deepEqual(projectedEnv.staging.services, [{ binding: "API", service: "api-worker", remote: false }]); assert.equal(projectedEnv.staging.exports, undefined); @@ -549,10 +585,10 @@ test("parseSessionPolicy validates the [wdl] session policy", () => { ); }); -test("collectRoutes: accepts strings and { pattern } tables, rejects non-arrays", () => { +test("collectRoutes: accepts string patterns and rejects route objects", () => { assert.deepEqual(collectRoutes({}, "wrangler.toml"), []); assert.deepEqual(collectRoutes({ route: "dev.example.com/*" }, "wrangler.toml"), ["dev.example.com/*"]); - assert.deepEqual(collectRoutes({ routes: ["a.example.com/*", { pattern: "b.example.com/*" }] }, "wrangler.toml"), [ + assert.deepEqual(collectRoutes({ routes: ["a.example.com/*", "b.example.com/*"] }, "wrangler.toml"), [ "a.example.com/*", "b.example.com/*", ]); @@ -561,6 +597,14 @@ test("collectRoutes: accepts strings and { pattern } tables, rejects non-arrays" () => collectRoutes({ routes: { pattern: "a.example.com/*" } }, "wrangler.toml"), /"routes" must be an array/ ); + assert.throws( + () => collectRoutes({ route: { pattern: "a.example.com/*" } }, "wrangler.toml"), + /use a string pattern instead of a route object/ + ); + assert.throws( + () => collectRoutes({ routes: [{ pattern: "a.example.com/*", zone_id: "zone" }] }, "wrangler.toml"), + /use a string pattern instead of a route object/ + ); assert.throws( () => collectRoutes({ route: "a", routes: ["b"] }, "wrangler.toml"), /specify either "route" or "routes"/ @@ -631,6 +675,15 @@ test("validateUnsupportedWranglerConfig: rejects session_policy hoisted out of [ ); }); +test("validateUnsupportedWranglerConfig: rejects custom module rules before bundling", () => { + const rules = [{ type: "Text", globs: ["**/*.bin"] }]; + assert.throws(() => validateUnsupportedWranglerConfig({ rules }, null), /unsupported Wrangler field "rules"/); + assert.throws( + () => validateUnsupportedWranglerConfig({ env: { prod: { rules } } }, "prod"), + /env\.prod uses unsupported Wrangler field "rules"/ + ); +}); + test("[wdl] resolves per environment like the policies beside it", () => { const topLevelWdl = { name: "demo", diff --git a/tests/unit/cli-wrangler-files.test.js b/tests/unit/cli-wrangler-files.test.js index ae56190..49ac7f9 100644 --- a/tests/unit/cli-wrangler-files.test.js +++ b/tests/unit/cli-wrangler-files.test.js @@ -39,6 +39,16 @@ test("collectModules: preserves prototype-shaped module names as own manifest ke } }); +test("collectModules: preserves Wrangler's default SQL text module type", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-collect-sql-")); + try { + writeFileSync(path.join(dir, "query.sql"), "SELECT 1;"); + assert.deepEqual(collectModules(dir)["query.sql"], { text: "SELECT 1;" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("collectModules: refuses to follow a symlink in wrangler's outdir", () => { const parent = mkdtempSync(path.join(tmpdir(), "wdl-mod-sym-")); const outdir = path.join(parent, "out"); @@ -139,12 +149,17 @@ test("collectAssets: rejects a file that exceeds the per-file cap", () => { } }); -test("collectAssets skips repo/tooling artifacts and .env files by default", () => { +test("collectAssets skips repo/tooling artifacts and local credential files by default", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-assets-ignore-")); try { writeFileSync(path.join(dir, "index.html"), ""); writeFileSync(path.join(dir, ".env"), "ADMIN_TOKEN=leak"); writeFileSync(path.join(dir, ".env.production"), "ADMIN_TOKEN=leak"); + writeFileSync(path.join(dir, ".envrc"), "ADMIN_TOKEN=leak"); + writeFileSync(path.join(dir, ".dev.vars"), "ADMIN_TOKEN=leak"); + writeFileSync(path.join(dir, ".dev.vars.production"), "ADMIN_TOKEN=leak"); + writeFileSync(path.join(dir, ".dev.varsrc"), "ADMIN_TOKEN=leak"); + writeFileSync(path.join(dir, ".wdl-empty.env"), ""); mkdirSync(path.join(dir, ".git"), { recursive: true }); writeFileSync(path.join(dir, ".git", "HEAD"), "ref: refs/heads/main"); mkdirSync(path.join(dir, "node_modules", "pkg"), { recursive: true }); @@ -156,6 +171,8 @@ test("collectAssets skips repo/tooling artifacts and .env files by default", () mkdirSync(path.join(dir, "sub", "node_modules"), { recursive: true }); writeFileSync(path.join(dir, "sub", "node_modules", "y.js"), "y"); writeFileSync(path.join(dir, "sub", ".env"), "NESTED=leak"); + writeFileSync(path.join(dir, "sub", ".envrc"), "NESTED=leak"); + writeFileSync(path.join(dir, "sub", ".wdl-empty.env"), ""); writeFileSync(path.join(dir, ".DS_Store"), "junk"); const out = collectAssets(dir); diff --git a/tests/unit/deploy-helpers.js b/tests/unit/deploy-helpers.js index 23160c8..1bc83d9 100644 --- a/tests/unit/deploy-helpers.js +++ b/tests/unit/deploy-helpers.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { response } from "./helpers.js"; @@ -29,6 +29,8 @@ export function createDeployProject(t, config, prefix = "wdl-run-deploy-") { * @param {readonly string[]} args */ export function fakeWranglerExecFile(_cmd, args) { + const envFile = args[args.indexOf("--env-file") + 1]; + assert.equal(readFileSync(envFile, "utf8"), ""); if (args.includes("--version")) return "wrangler 4.94.0"; const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); mkdirSync(outDir, { recursive: true }); @@ -85,10 +87,8 @@ function assertWranglerCommand(cmd) { */ export function assertWranglerVersionProbe(call) { assertWranglerCommand(call.cmd); - if (call.cmd === process.execPath) { - assert.match(call.args[0] || "", /wrangler[\\/]bin[\\/]wrangler\.js$/); - assert.deepEqual(call.args.slice(1), ["--version"]); - return; - } - assert.deepEqual(call.args, ["--version"]); + const args = call.cmd === process.execPath ? call.args.slice(1) : call.args; + if (call.cmd === process.execPath) assert.match(call.args[0] || "", /wrangler[\\/]bin[\\/]wrangler\.js$/); + assert.deepEqual(args.slice(0, 2), ["--version", "--env-file"]); + assert.equal(path.basename(args[2] || ""), "empty.env"); }