From 04d0028b743db790faac0c39f0c66558b6be999d Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Wed, 8 Jul 2026 12:51:03 +0800 Subject: [PATCH 01/12] Adapt CLI to wdl.20260701.1 and audit findings Harden control transport, effective CONTROL_CONNECT_HOST handling, header validation, request error wrapping, response caps, and token-store writes. Tighten command behavior for tail reconnects, D1 migration uploads, R2 object access, secret mutation errors, workflow status flags, doctor strict mode, and unexpected arguments. Adapt Wrangler packaging for WDL by following config priority, rejecting unsupported modules and flags, checking env binding collisions including implicit ASSETS, capping queue timeout, and cleaning temp configs. Refresh docs, scaffolded examples, and live/unit coverage for the new deploy, runtime, credential, and diagnostics behavior. Signed-off-by: Lu Zhang --- .claude/skills/wdl-deploy/SKILL.md | 9 + AGENTS.md | 3 +- CHANGELOG.md | 68 +++ GUIDE-zh.md | 40 +- GUIDE.md | 87 ++- README-zh.md | 2 +- bin/wdl.js | 7 +- commands/d1.js | 59 +- commands/delete.js | 13 +- commands/deploy.js | 121 +++- commands/doctor.js | 31 +- commands/init.js | 2 +- commands/r2.js | 42 +- commands/secret.js | 86 +-- commands/tail.js | 157 ++++-- commands/token.js | 3 +- commands/whoami.js | 3 +- commands/workflows.js | 9 +- docs/d1-zh.md | 4 + docs/d1.md | 13 + docs/deploy-zh.md | 16 +- docs/deploy.md | 30 +- docs/durable-objects-zh.md | 2 + docs/durable-objects.md | 4 + docs/env-overrides-zh.md | 4 +- docs/env-overrides.md | 4 +- docs/queues-zh.md | 2 +- docs/queues.md | 6 +- docs/r2-zh.md | 6 +- docs/r2.md | 9 +- docs/secrets-zh.md | 3 + docs/secrets.md | 9 + examples/cron-demo/wrangler.toml | 2 +- examples/d1-demo/wrangler.toml | 2 +- examples/durable-objects-demo/wrangler.toml | 2 +- examples/env-overrides-demo/wrangler.toml | 2 +- examples/hello-jsonc/wrangler.jsonc | 2 +- examples/inspection-demo/README.md | 2 +- examples/inspection-demo/wrangler.toml | 2 +- examples/kv-demo/wrangler.toml | 2 +- examples/pages-assets/wrangler.jsonc | 2 +- examples/queues-demo/wrangler.toml | 2 +- examples/workflows-demo/wrangler.toml | 2 +- lib/bundle-modules.js | 6 +- lib/command.js | 2 +- lib/common.js | 46 +- lib/control-fetch.js | 180 ++++-- lib/credentials.js | 60 +- lib/d1-files.js | 22 +- lib/token-store.js | 15 +- lib/whoami.js | 6 +- lib/wrangler-pack.js | 60 +- lib/wrangler/assets.js | 12 +- lib/wrangler/bindings.js | 11 +- lib/wrangler/command.js | 19 +- lib/wrangler/config.js | 29 +- lib/wrangler/module-limits.js | 30 + lib/wrangler/modules.js | 7 + templates/AGENTS.md | 10 + tests/integration/cli-live.test.js | 239 +++++++- tests/unit/cli-bundle-modules.test.js | 2 +- tests/unit/cli-config-doctor.test.js | 123 ++++- tests/unit/cli-control-fetch.test.js | 199 ++++++- tests/unit/cli-credentials.test.js | 52 +- tests/unit/cli-d1.test.js | 127 ++++- tests/unit/cli-deploy.test.js | 346 +++++++++++- tests/unit/cli-init.test.js | 2 +- tests/unit/cli-lifecycle.test.js | 579 +++++++++++++++++++- tests/unit/cli-token.test.js | 18 +- 69 files changed, 2764 insertions(+), 314 deletions(-) create mode 100644 lib/wrangler/module-limits.js diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index 8e8cb72..6bb713f 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -44,6 +44,15 @@ Open the relevant doc before answering: Each doc has a Chinese twin at `docs/-zh.md`; both languages are 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. WDL follows Wrangler config priority (`wrangler.json`, then +`wrangler.jsonc`, then `wrangler.toml`) and rejects Python Workers modules, +unsupported workerd experimental compatibility flags, modules named like WDL +injected `_wdl-*.js` runtime modules, and `[vars]` keys that collide with +bindings. The CLI locally rejects the explicit `experimental` compatibility +flag; the control plane is canonical for other unsupported experimental flags. + 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 diff --git a/AGENTS.md b/AGENTS.md index 31757ad..9063a4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,8 @@ capability docs live in `docs/`, while `templates/AGENTS.md` and and AI agents. Example tenant projects live under `examples/` and pin `wrangler@^4`; the deploy bundling step expects v4, so keep new examples and any docs that recommend a wrangler version on v4. New Wrangler configs should start -with `compatibility_date = "2026-05-31"` unless the WDL runtime moves again. +with `compatibility_date = "2026-06-17"` unless a project feature requires a +newer target. ## Build, Test, and Development Commands diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b5c89..304a6ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # Changelog +## Unreleased + +### Changed + +- `wdl init`, examples, and docs now default new Wrangler configs to + `compatibility_date = "2026-06-17"` for the documented feature baseline. +- `wdl deploy` now follows Wrangler config priority + (`wrangler.json`, `wrangler.jsonc`, `wrangler.toml`) and rejects unsupported + Python Worker modules, the explicit workerd `experimental` compatibility + flag, WDL-reserved runtime module names, and runtime env name collisions + (including `[vars]`, explicit bindings, and the implicit `ASSETS` binding) + locally. The control plane remains canonical for other unsupported + experimental compatibility flags. +- `wdl tail` now recognizes control-initiated `session_idle` / + `session_expired` stream recycling and reconnects without presenting it as an + unknown warning. +- `wdl doctor --strict` now exits non-zero when any local or remote readiness + check fails, while the default `wdl doctor` remains report-only. +- `wdl d1 migrations status/apply` now preflights the 1 MiB control-plane JSON + request cap before uploading migration metadata or SQL. +- `wdl r2` preserves empty object-key path segments while still rejecting `.` + and `..` segments, validates list `--limit` locally, and requires `--out` + when `objects get` would otherwise write raw bytes to an interactive terminal. +- `wdl workflows status --step-limit` now requires `--include-steps`, matching + the control request it affects. +- Queue consumer `max_batch_timeout` is now capped locally at the platform's + 60-second limit. +- Top-level help for successful requests now prints to stdout, and + `wdl help ` prints that command's help. +- When multiple Wrangler config files exist, deploy and D1 migrations now warn + which file is selected by Wrangler priority and which lower-priority files are + ignored; `wdl doctor` reports the same detail. + +### Fixed + +- Control connection failures, invalid 2xx JSON responses, unreadable + `wdl d1 execute --file` inputs, and unexpected positional arguments now fail + with CLI errors instead of raw Node errors or silently ignored input. +- `wdl d1 migrations apply` now rejects symlinked `.sql` migration files instead + of silently dropping them. +- `wdl deploy` now renders deploy warnings attached to failed upload responses, + including missing caller-secret hints, before reporting the control error. +- Invalid `.assetsignore` patterns now report the offending pattern instead of + a bare `RegExp` error. +- Project `.env` files with non-WDL dotenv extensions no longer break every WDL + command; the four WDL-consumed keys remain strictly parsed. +- `wdl secret put/delete` no longer reports obsolete deferred-promote warnings; + current worker-secret mutations either promote atomically or return a retryable + control error. +- Secret mutation failures now add command-specific guidance for env-budget, + contention, and secret-envelope errors, including that the failed mutation was + not written. + +### Security + +- `wdl token` credential writes now use a temp-file plus rename so an existing + credentials symlink is replaced rather than followed. +- `wdl tail` now caps SSE line buffering to avoid unbounded memory growth on a + malformed stream. +- `wdl deploy` now registers process-exit and SIGINT/SIGTERM cleanup for its + temporary Wrangler config file. + +### Tests + +- Live CLI integration now exercises assets, Durable Objects, queues, cron + registration, and `wdl deploy --env` in addition to the existing command + surface. + ## 1.3.1 ### Fixed diff --git a/GUIDE-zh.md b/GUIDE-zh.md index f2780ea..b7c069e 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -91,7 +91,7 @@ CLI 只会从 `.env` 读取 WDL 平台变量:`ADMIN_TOKEN`、`CONTROL_URL`、` `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 config explain` 查看最终 namespace、control URL、脱敏 token 以及每个值的来源。用 `wdl whoami` 调 control-plane `/whoami`,查看当前 authenticated principal、token id、platform version、最低支持 CLI version 和 URL hints。用 `wdl doctor` 做本地可用性检查,包括 Node.js、wdl-cli、Wrangler、配置文件是否存在、凭据是否能解析,以及 `/whoami` 是否可达。当 control plane 暴露 `/whoami` 时,`doctor` 可以发现 token 是否有效、principal namespace、platform version 和 CLI compatibility;更细的 capability 检查仍需要额外的 control endpoint。 +用 `wdl config explain` 查看最终 namespace、control URL、脱敏 token 以及每个值的来源。用 `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。 ## 脚手架新 Worker @@ -136,16 +136,18 @@ export default { ```toml name = "hello" main = "src/index.js" -compatibility_date = "2026-05-31" +compatibility_date = "2026-06-17" [vars] APP_NAME = "hello" ``` -新项目建议使用 `compatibility_date = "2026-05-31"`;除非管理方明确给了其它目标日期。 +新项目建议使用 `compatibility_date = "2026-06-17"`;除非需要的功能或管理方明确要求更新的目标日期。 你可以继续使用 `wrangler dev` 做本地开发;部署到本平台时改用 `wdl deploy`。平台部署命令会调用 `wrangler deploy --dry-run`(Wrangler v4)打包项目,解析顺序是 `WDL_WRANGLER_BIN`、Worker 项目本地 wrangler、CLI 包本地 wrangler、最后是 `PATH`。TypeScript、模块解析、esbuild 打包等流程仍走 wrangler 的标准路径。 +如果同时存在多个 Wrangler 配置文件,WDL 跟随 Wrangler 的优先级:`wrangler.json`,然后 `wrangler.jsonc`,最后 `wrangler.toml`。 + 配置好 CLI 默认值后执行: ```bash @@ -199,6 +201,8 @@ wdl tail hello --max-reconnects 0 # 不限制自动重连次数 `wdl tail` 是 best-effort 实时调试工具,不是审计历史。高流量 worker 或终端连接消费太慢时,可能跳过中间事件。过大的 console 或 exception 事件会整条丢弃,并以较小的 warning 事件报告,而不是截断后输出。事故复盘和完整 payload 请使用管理方提供的常规日志平台。 +control 可能主动回收长时间运行的 tail 会话:客户端约 15s 不读会收到 `session_idle`,会话达到运维方配置的最大时长(默认 15 分钟)会收到 `session_expired`。CLI 会打印 warning 并自动重连;如果反复出现,通常说明终端或外层 wrapper 没有及时消费输出。 + 普通格式化输出会把 worker 名前缀拼到 fetch path 上:worker 内部看到的 `/` 会显示成 `//`,便于和浏览器访问路径对应。`--raw` 保留原始JSON payload。 cron / queue delivery 会在 `wdl tail` 里显示 start/finish 事件,包含 worker、request id、outcome 和 duration,所以能看到 worker 是否确实被定时器或队列触发。scheduled / queue handler 里的 `console.*` 不会出现在 `wdl tail` 中;这类细节请到管理方提供的常规日志平台按时间窗口排查。 @@ -222,6 +226,8 @@ https://.// ## 支持的 wrangler 配置 +WDL 会拒绝一些 Wrangler 能打包、但平台不能运行的形状:Python Workers modules、不支持的 workerd experimental compatibility flags、使用 WDL 保留注入模块名(例如 `_wdl-wrapper.js`)的模块,以及和 runtime binding 名冲突的 `[vars]` key。CLI 本地只拒绝显式的 `experimental` compatibility flag;其它不支持的 experimental flags 仍以 control plane 校验为准。Deploy 和 secret mutation 还会校验留有 headroom 的 workerd 1 MiB `workerLoader` env budget;过大的 `[vars]`、secrets、binding metadata 或 retained versions 可能触发 `worker_env_too_large`。 + | 配置 | 支持情况 | | --- | --- | | `name` / `main` / `compatibility_date` / `compatibility_flags` | 支持 | @@ -248,6 +254,10 @@ R2 custom metadata key 读取时会按 HTTP header 语义归一成小写。R2 ob 删除 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 错误体可解析。 +`wdl r2 objects get` 会写出原始 object bytes。需要 stream bytes 时请 pipe 或重定向 stdout;在交互终端中请使用 `--out `。 + +R2 object key 可以包含开头、结尾或连续的 `/` 分隔符;CLI 会保留这些 empty path segments。`.` 和 `..` segment 会被拒绝,避免 key 和 control-plane URL traversal 混淆。 + ### 环境覆盖 如果 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、queues、services、workflows 等不会自动进入该 env。需要同时跑 staging / production 时,默认用不同 namespace 区分,除非管理方另有约定。 @@ -368,6 +378,8 @@ wdl deploy . Migration 是 forward-only。WDL 使用 migration 文件名作为 migration id,已经 apply 的 migration 文件不应重命名或修改;重命名会被视为一条新的 migration。平台不提供自动 down/rollback workflow。若 Worker 版本可能 rollback,migration 应按 expand/contract 方式编写。 +以 `_cf_` 开头的 SQLite object name 是 workerd 保留名,大小写不敏感。不要创建或 `RENAME TO` 到 `_cf_*` 形式的 D1 table、index、trigger 或 view;包含这类 DDL 的 migration 在新数据库上可能失败。已经 apply 的 migration 文件不要回改;需要修正时新增 forward migration,把应用数据迁到非保留名称。 + 常用命令: ```bash @@ -379,12 +391,14 @@ wdl d1 migrations apply main wdl d1 delete main ``` -`wdl d1 execute` 要求 `--sql` 和 `--file` 二选一(即使是 `--sql ""` 也会和 `--file` 互斥),且被选中的 SQL 来源必须非空。 +`wdl d1 execute` 要求 `--sql` 和 `--file` 二选一(即使是 `--sql ""` 也会和 `--file` 互斥),且被选中的 SQL 来源必须非空。`--file` 必须存在、可读,并留在项目根目录内;文件缺失或不可读时 CLI 会先在本地拒绝,不会联系 control。 `wdl d1 delete` 默认会要求确认。自动化脚本里只有在已有独立安全检查后,才建议传 `--yes`。 D1 运行时请求在执行前有边界:binary query body 最大 8 MiB;解码后的请求最多 1000 条 SQL 语句,SQL 加 params 聚合最大 8 MiB;结果 body 受平台默认 16 MiB 聚合上限保护。多语句 `exec()` 会在同一个 SQLite transaction 中执行;如果后面的语句失败,这次 `exec()` 里之前已经执行的语句会回滚。 +`wdl d1 migrations status/apply` 走 control-plane JSON request parser,所以请求体上限是 1 MiB。特别大的 migration 集合或 SQL 文件应拆成更小批次再 apply。 + `examples/d1-demo` 提供了一个最小 visitor counter 示例,包含 D1 binding 和 forward-only migration。 ### Durable Objects @@ -427,6 +441,8 @@ export default { 当前支持 `stub.fetch()`、JSON-structured `stub.method(...args)` RPC、native `ctx.storage`、同步 `ctx.storage.sql`、alarm、普通 WebSocket upgrade 以及 native WebSocket hibernation API surface。跨 script binding、rename/delete migration、平台级 WebSocket session/cursor 恢复暂未实现。 +使用 `ctx.storage.sql` 时,不要使用以 `_cf_` 开头的应用表名;workerd 对这个前缀做大小写不敏感保留。`ctx.storage.deleteAll()` 也会保留平台自有的 `_cf_*` 表。 + `examples/durable-objects-demo` 提供了最小的同 Worker Durable Object 计数器,使用 SQLite-backed storage 保存状态。 ### Workflows @@ -488,9 +504,10 @@ printf '%s' "$DATABASE_URL" | wdl secret put --scope ns DATABASE_URL 生效时机: - 已有线上版本的 Worker 修改 worker-level secret 时,平台会自动创建并 promote 一个新版本,因此新流量会 cold-load 更新后的 secret。已经加载的历史版本可能继续持有旧值,直到 runtime eviction 或 recycle。 +- worker-level secret 修改是原子的。如果 mutation 期间 active version 变化,control 会返回 `secret_mutation_contention`,CLI 会要求重试,而不是留下"已存储但未 promote"的半成功状态。 - worker-level secret 可以在第一次部署前设置;第一次部署会读取这些 secret。 - namespace-level secret 会共享给 namespace 下的所有 Worker,但不会批量 bump 所有 Worker。它会在下一次自然 cold-load 时生效,例如新部署、runtime recycle 或 isolate eviction。 -- secret key 必须符合环境变量命名规则,例如 `STRIPE_KEY`;value 最大 64 KiB。 +- secret key 必须符合环境变量命名规则,例如 `STRIPE_KEY`;value 最大 64 KiB,并且和 `[vars]` 一样计入 workerLoader env budget。 ### Queues @@ -553,7 +570,7 @@ Queue consumers 是 runtime dispatch 目标。把它声明在可路由的 tenant | 重试延迟 | `[[queues.consumers]].retry_delay` 是默认重试延迟,单位秒。`msg.retry({ delaySeconds })` / `batch.retryAll({ delaySeconds })` 会覆盖默认值;`delaySeconds: 0` 表示立即重试。 | | attempts | handler 看到的 `msg.attempts` 首投从 `1` 开始。`max_retries = N` 时,一条消息最多会被投递 `N + 1` 次,然后进入死信处理。 | | 死信队列 | 尊重 `dead_letter_queue` 配置;未配置时使用该 queue 的默认 DLQ。 | -| Batch timeout | `max_batch_timeout` 会被解析并保存,以兼容 Cloudflare 配置;但当前 dispatch 由 `max_batch_size` 截断,不应依赖 timeout 触发 batch flush。 | +| Batch timeout | `max_batch_timeout` 必须是 0..60 秒;它会被解析并保存,以兼容 Cloudflare 配置;但当前 dispatch 由 `max_batch_size` 截断,不应依赖 timeout 触发 batch flush。 | | 不支持的配置 | `max_concurrency` 会在部署阶段直接拒绝,不会静默忽略。 | `examples/queues-demo` 提供了单 Worker 生产、消费 queue 消息,并把投递状态写入 KV 的完整示例。 @@ -708,7 +725,7 @@ export default { ### 客户端断开 -响应头发出后,`request.signal` **不再可靠** —— workerd 认为响应已提交,不再 abort 入站 Request。可靠的断开钩子是响应 body 的 `ReadableStream.cancel` 回调(或 `controller.enqueue` 在下游读者离开后抛异常)。如果有必须在响应流水线拆除之后仍然完成的副作用(日志、计数器等),请在请求一开始就用 `ctx.waitUntil` 登记一个由 `cancel` resolve 的 Promise —— 从 `cancel` 内部再调用 `waitUntil` 会和 IoContext 的 teardown 赛跑。 +响应头发出后,`request.signal` **不再可靠** —— workerd 认为响应已提交,不再 abort 入站 Request。响应 body 的 `ReadableStream.cancel` 也只能按 best-effort 使用:长流应配合周期性写入/heartbeat,捕获下游读者离开后 `controller.enqueue` 抛错的情况,并为需要确定性清理的协议保留应用层 timeout 或 close message。如果有必须在响应流水线拆除之后仍然完成的副作用(日志、计数器等),请在请求一开始就注册 `ctx.waitUntil`;从 `cancel` 内部再调用 `waitUntil` 会和 IoContext teardown 赛跑。 ```js const { promise: outcome, resolve: resolveOutcome } = Promise.withResolvers(); @@ -716,8 +733,12 @@ ctx.waitUntil((async () => { console.log("client:", await outcome); })()); const stream = new ReadableStream({ async start(controller) { - // … enqueue chunks … - resolveOutcome("ended-normally"); + try { + controller.enqueue(new TextEncoder().encode(": heartbeat\n\n")); + resolveOutcome("ended-normally"); + } catch { + resolveOutcome("downstream-gone"); + } }, cancel() { resolveOutcome("cancel"); }, }); @@ -787,6 +808,7 @@ wdl tail hello | service binding 仍调用旧目标行为 | binding 在调用方部署时固定版本 | 重新部署调用方 Worker | | `wdl tail` 没有历史日志 | tail 是 live-only;首次连接只看之后的新事件 | 先打开 `wdl tail `,再触发请求;需要手动续读时使用单 worker 的 `--since ` | | 多 worker `wdl tail` 重连后可能少日志 | 一个连接无法同时保存多个 worker 的独立续读位置 | 对关键 worker 单独运行 `wdl tail ` | +| `tail session_idle` / `tail session_expired` | control 因客户端停止读取或会话达到时长上限而回收 live-tail stream | CLI 会自动重连;如果反复出现,确认终端或外层 wrapper 正在消费输出 | | scheduled / queue handler 的 `console.*` 没出现在 `wdl tail` | `wdl tail` 显示 fetch / scheduled / queue start/finish;scheduled / queue handler 内部 console 不进入 tail 流 | 用 `wdl tail` 确认触发和 outcome;handler 内部 console 到常规日志平台按时间窗口排查 | ## 兼容性总结 diff --git a/GUIDE.md b/GUIDE.md index 3459e87..3e57330 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -148,10 +148,11 @@ token, and where each value came from. Use `wdl whoami` to call control-plane `/whoami` and display the authenticated principal, token id, platform version, minimum supported CLI version, and URL hints. Use `wdl doctor` for local readiness checks covering Node.js, wdl-cli, Wrangler, config presence, resolved -credentials, and `/whoami` reachability. `doctor` can detect token validity, -principal namespace, platform version, and CLI compatibility when the control -plane exposes `/whoami`; deeper capability checks still require additional -control endpoints. +credentials, and `/whoami` reachability. Add `--strict` when using it as a CI +gate; the command still prints the checks, then exits non-zero if any check +fails. `doctor` can detect token validity, principal namespace, platform +version, and CLI compatibility when the control plane exposes `/whoami`; deeper +capability checks still require additional control endpoints. ## Scaffolding a New Worker @@ -207,14 +208,14 @@ Minimal `wrangler.toml`: ```toml name = "hello" main = "src/index.js" -compatibility_date = "2026-05-31" +compatibility_date = "2026-06-17" [vars] APP_NAME = "hello" ``` -For new projects, use `compatibility_date = "2026-05-31"` unless your operator -has given you a different target. +For new projects, use `compatibility_date = "2026-06-17"` unless a required +feature or your operator gives you a newer target. You can keep using `wrangler dev` for local development. To deploy to this platform, use `wdl deploy` instead. The deploy command runs @@ -223,6 +224,9 @@ platform, use `wdl deploy` instead. The deploy command runs wrangler, then `PATH`. TypeScript, module resolution, esbuild bundling, and related build behavior still follow Wrangler. +When several Wrangler config files exist, WDL follows Wrangler's priority: +`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`. + After configuring the CLI defaults: ```bash @@ -289,6 +293,12 @@ Oversized console or exception events are dropped whole and reported as small warning events instead of being truncated. Use the normal log platform your operator provides for incident reconstruction and full payloads. +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 +reconnects automatically; frequent repeats usually mean the terminal or wrapper +process is not consuming output fast enough. + ## URLs and Routes | Purpose | URL | Use | @@ -316,6 +326,16 @@ for you. ## Supported Wrangler Configuration +WDL rejects a few shapes that Wrangler can bundle but the platform cannot run: +Python Workers modules, unsupported workerd experimental compatibility flags, +modules using WDL-reserved injected names such as `_wdl-wrapper.js`, and +`[vars]` keys that collide with runtime binding names. The CLI locally rejects +the explicit `experimental` compatibility flag; the control plane remains the +canonical check for other unsupported experimental flags. Deploy and secret +mutation also enforce the headroomed 1 MiB workerd `workerLoader` env budget; +large `[vars]`, secrets, binding metadata, or retained versions can fail with +`worker_env_too_large`. + | Configuration | Support | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` / `main` / `compatibility_date` / `compatibility_flags` | Supported | @@ -361,6 +381,13 @@ 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. +`wdl r2 objects get` writes raw object bytes. Pipe or redirect stdout when you +intend to stream bytes; on an interactive terminal, pass `--out `. + +R2 object keys may contain leading, trailing, or doubled `/` separators; the CLI +preserves those empty path segments. It rejects `.` and `..` segments so keys +cannot be confused with control-plane URL traversal. + ### Environment overrides If the Wrangler config contains `[env.]` sections, you must select one @@ -525,6 +552,12 @@ 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. +SQLite object names starting with `_cf_` are reserved by workerd, +case-insensitively. Avoid creating or renaming D1 tables, indexes, triggers, or +views to `_cf_*`; a migration containing that DDL can fail on a new database. +Do not edit an already-applied migration file — add a forward migration that +moves application data to a non-reserved name when needed. + Useful commands: ```bash @@ -538,7 +571,8 @@ wdl d1 delete main `wdl d1 execute` requires exactly one of `--sql` or `--file` (even `--sql ""` conflicts with `--file`), and the selected SQL source must be -non-empty. +non-empty. `--file` must exist, be readable, and stay inside the project root; +missing/unreadable files are rejected before control is contacted. `wdl d1 delete` asks for confirmation by default. In automation, pass `--yes` only after a separate safety check. @@ -550,6 +584,10 @@ platform default of 16 MiB. Multi-statement `exec()` runs in one SQLite transaction; if a later statement fails, earlier statements from that `exec()` call are rolled back. +`wdl d1 migrations status/apply` uses the control-plane JSON request parser, so +its request body is capped at 1 MiB. Split very large migration sets or SQL +files into smaller batches before applying. + See `examples/d1-demo` for a minimal visitor counter using D1 plus a forward-only migration. @@ -599,6 +637,10 @@ Supported DO surface includes `stub.fetch()`, JSON-structured hibernation API surface. Cross-script bindings, renamed/deleted migrations, and platform-level WebSocket session/cursor recovery are not currently available. +For `ctx.storage.sql`, avoid application table names starting with `_cf_`; +workerd reserves that prefix case-insensitively. `ctx.storage.deleteAll()` also +leaves platform-owned `_cf_*` tables alone. + See `examples/durable-objects-demo` for a minimal same-worker Durable Object counter using SQLite-backed storage. @@ -683,13 +725,17 @@ Effect timing: - Worker-level secret changes on an active Worker create and promote a new version, so new traffic cold-loads the updated secret. Already-loaded historical versions can keep old values until runtime eviction or recycle. +- Worker-level secret changes are atomic. If the active version changes during + the mutation, control returns `secret_mutation_contention` and the CLI asks you + to retry instead of leaving a stored-but-not-promoted partial update. - Worker-level secrets can be set before the first deploy; the first deploy will pick them up. - Namespace-level secret changes are shared by every Worker in the namespace, but they do not bump all Workers. They take effect on the next natural cold-load, such as a new deploy, runtime recycle, or isolate eviction. - Secret keys must use environment-variable grammar, for example `STRIPE_KEY`; - values are limited to 64 KiB. + values are limited to 64 KiB and count toward the same workerLoader env budget + as `[vars]`. ### Queues @@ -757,7 +803,7 @@ Queue behavior tenants can rely on: | Retry delay | `[[queues.consumers]].retry_delay` is the default retry delay in seconds. `msg.retry({ delaySeconds })` / `batch.retryAll({ delaySeconds })` override it; `delaySeconds: 0` means immediate retry. | | Attempts | The handler sees `msg.attempts` starting at `1`. With `max_retries = N`, a message can be delivered up to `N + 1` times before dead-letter handling. | | Dead letter queue | `dead_letter_queue` is honored. If omitted, failed messages use the queue's default DLQ. | -| Batch timeout | `max_batch_timeout` is parsed and saved for Cloudflare config compatibility, but dispatch is currently capped by `max_batch_size`; do not depend on timeout-based batch flushing. | +| Batch timeout | `max_batch_timeout` must be 0..60 seconds. It is parsed and saved for Cloudflare config compatibility, but dispatch is currently capped by `max_batch_size`; do not depend on timeout-based batch flushing. | | Unsupported config | `max_concurrency` is rejected during deploy instead of being silently ignored. | See `examples/queues-demo` for a single Worker that produces queue messages, @@ -954,11 +1000,13 @@ export default { `request.signal` is **not** a reliable disconnect signal once a streaming response has started — workerd considers the response committed and does not -abort the inbound Request. Use the body stream's `cancel` callback (or catch -`controller.enqueue` throwing when the downstream reader is gone). If you need a -side effect to survive teardown (logging, counters), register `ctx.waitUntil` -up-front on a promise that `cancel` resolves; scheduling `waitUntil` from inside -`cancel` races IoContext teardown. +abort the inbound Request. Treat the body stream's `cancel` callback as +best-effort too: pair it with periodic writes/heartbeats, catch +`controller.enqueue` throwing when the downstream reader is gone, and keep an +application timeout or close message for protocols that need deterministic +cleanup. If a side effect must survive teardown (logging, counters), register +`ctx.waitUntil` up-front; scheduling `waitUntil` from inside `cancel` races +IoContext teardown. ```js const { promise: outcome, resolve: resolveOutcome } = Promise.withResolvers(); @@ -966,8 +1014,12 @@ ctx.waitUntil((async () => { console.log("client:", await outcome); })()); const stream = new ReadableStream({ async start(controller) { - // … enqueue chunks … - resolveOutcome("ended-normally"); + try { + controller.enqueue(new TextEncoder().encode(": heartbeat\n\n")); + resolveOutcome("ended-normally"); + } catch { + resolveOutcome("downstream-gone"); + } }, cancel() { resolveOutcome("cancel"); }, }); @@ -1045,6 +1097,7 @@ wdl tail hello | A service binding still calls the old target behavior | Bindings are pinned at caller deploy time | Redeploy the caller Worker | | `wdl tail` has no history | Tail is live-only; first connect starts at the current stream tail | Start `wdl tail ` before triggering the request; use single-worker `--since ` only for manual resume | | Multi-worker `wdl tail` can miss logs after reconnect | One connection cannot preserve independent resume positions for multiple workers | Use a dedicated `wdl tail ` session for critical debugging | +| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream because the client stopped reading or the session hit its lifetime cap | The CLI reconnects automatically; if it repeats, make sure the terminal or wrapper is consuming output | | Scheduled / queue handler `console.*` output is absent from `wdl tail` | Tail shows fetch / scheduled / queue start/finish; scheduled / queue handler console does not enter the tail stream | Use `wdl tail` for trigger/outcome and the normal log platform for handler console details | ## Compatibility Summary diff --git a/README-zh.md b/README-zh.md index 1506bfe..1471661 100644 --- a/README-zh.md +++ b/README-zh.md @@ -60,7 +60,7 @@ wdl tail hello # 边访问 URL 边看实时日志 Worker 此时位于 `https://./hello/`。 -不想用存储?凭证也可以来自 shell env(`WDL_NS` / `ADMIN_TOKEN` / `CONTROL_URL`)或带命名空间分段的项目 `.env`(复制 [`.env.example`](https://github.com/wdl-dev/cli/blob/main/.env.example))——完整优先级(flag 高于 shell env,高于 `.env`,高于 `wdl token` store)见 [docs/deploy.md](https://github.com/wdl-dev/cli/blob/main/docs/deploy.md)。 +不想用存储?凭证也可以来自 shell env(`WDL_NS` / `ADMIN_TOKEN` / `CONTROL_URL`)或带命名空间分段的项目 `.env`(复制 [`.env.example`](https://github.com/wdl-dev/cli/blob/main/.env.example))——完整优先级(flag 高于 shell env,高于 `.env`,高于 `wdl token` store)见 [docs/deploy-zh.md](https://github.com/wdl-dev/cli/blob/main/docs/deploy-zh.md)。 ## 命令 diff --git a/bin/wdl.js b/bin/wdl.js index fb79a90..1380c7e 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -55,6 +55,9 @@ for (const c of REGISTRY) { export async function main(argv = process.argv.slice(2), deps = {}) { const [command, ...rest] = argv; + if (command === "help" && rest.length === 1 && COMMANDS[rest[0]]) { + return await COMMANDS[rest[0]].main(["--help"]); + } if (!command || command === "-h" || command === "--help" || command === "help") { usage(command ? 0 : 1); return; @@ -136,10 +139,12 @@ function usage(exitCode) { (aliasesByTarget[target] ??= []).push(alias); } const width = Math.max(...REGISTRY.map((c) => c.meta.name.length)) + 1; - console.error(formatHelp({ + const write = exitCode === 0 ? console.log : console.error; + write(formatHelp({ usage: [ "wdl [args] [options]", "wdl --help", + "wdl help ", "wdl --version", ], description: "Manage deployments, diagnostics, secrets, workers, D1, R2, and Workflows for a WDL control plane.", diff --git a/commands/d1.js b/commands/d1.js index 53a2390..ab04770 100644 --- a/commands/d1.js +++ b/commands/d1.js @@ -2,6 +2,7 @@ import path from "node:path"; import { existsSync, realpathSync } from "node:fs"; import { readMigrationFiles, readSql } from "../lib/d1-files.js"; import { + formatWranglerConfigShadowWarning, loadWranglerConfig, parseD1DatabasesFromCfg, resolveWranglerConfig, @@ -20,6 +21,7 @@ import { confirmAction } from "../lib/stdin.js"; import { writeResult } from "../lib/output.js"; const D1_EXECUTE_MODES = ["all", "raw", "run", "exec"]; +export const D1_MIGRATIONS_JSON_BODY_MAX_BYTES = 1024 * 1024; const D1_OPTIONS = [ defineCliOption("sql", { type: "string" }, "--sql ", "SQL text for execute."), @@ -67,19 +69,21 @@ async function runD1({ values, positionals, context }) { const ns = context.resolveNamespace(); if (!subcommand || !ns) throw new CliError(usageText()); - const { headers } = context.resolveControl(); - if (subcommand === "migrations") { const action = firstArg; const databaseRef = positionals[2]; + const extraArg = positionals[3]; if (!action || !databaseRef) { throw new CliError("d1 migrations requires "); } + if (extraArg) throw new CliError(`d1 migrations ${action} received unexpected argument: ${extraArg}`); await runMigrationsCommand({ action, databaseRef, context }); return; } if (subcommand === "list") { + if (firstArg) throw new CliError(`d1 list received unexpected argument: ${firstArg}`); + const { headers } = context.resolveControl(); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(context.nsUrl("d1", "databases"), { headers }, "list d1 databases") ); @@ -90,6 +94,8 @@ async function runD1({ values, positionals, context }) { if (subcommand === "create") { const databaseName = firstArg; if (!databaseName) throw new CliError("d1 create requires "); + if (positionals[2]) throw new CliError(`d1 create received unexpected argument: ${positionals[2]}`); + const { headers } = context.resolveControl(); const body = /** @type {{ namespace?: string, databaseId?: string, databaseName?: string }} */ ( await context.fetchJson(context.nsUrl("d1", "databases"), { method: "POST", @@ -108,6 +114,8 @@ async function runD1({ values, positionals, context }) { if (subcommand === "delete") { const databaseRef = firstArg; if (!databaseRef) throw new CliError("d1 delete requires "); + if (positionals[2]) throw new CliError(`d1 delete received unexpected argument: ${positionals[2]}`); + const { headers } = context.resolveControl(); await confirmAction({ yes: values.yes === true, stdin, @@ -130,6 +138,7 @@ async function runD1({ values, positionals, context }) { if (subcommand === "execute") { const databaseRef = firstArg; if (!databaseRef) throw new CliError("d1 execute requires "); + if (positionals[2]) throw new CliError(`d1 execute received unexpected argument: ${positionals[2]}`); const sql = readSql(values, context.cwd); const mode = values.mode || "all"; if (!D1_EXECUTE_MODES.includes(mode)) { @@ -151,6 +160,7 @@ async function runD1({ values, positionals, context }) { if (!Array.isArray(parsed)) throw new CliError("--params must be a JSON array"); params = parsed; } + const { headers } = context.resolveControl(); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(context.nsUrl("d1", "databases", databaseRef, "query"), { method: "POST", @@ -172,8 +182,11 @@ async function runD1({ values, positionals, context }) { /** @param {{ action: string, databaseRef: string, context: import("../lib/command.js").CommandContext }} arg */ async function runMigrationsCommand({ action, databaseRef, context }) { - const { env, stdout, cwd } = context; + const { env, stdout, cwd, warn } = context; const values = /** @type {D1Flags} */ (context.values); + if (!["list", "status", "apply"].includes(action)) { + throw new CliError(`unknown d1 migrations subcommand: ${action}`); + } const { headers } = context.resolveControl(); const migrationsBase = context.nsUrl("d1", "databases", databaseRef, "migrations"); @@ -186,12 +199,15 @@ async function runMigrationsCommand({ action, databaseRef, context }) { } if (action === "status") { - const migrations = loadLocalMigrations({ values, env, cwd, databaseRef }); + const migrations = loadLocalMigrations({ values, env, cwd, databaseRef, warn }); + const requestBody = serializeD1MigrationsBody({ + migrations: migrations.map(({ sql: _sql, ...rest }) => rest), + }, "d1 migrations status"); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(`${migrationsBase}/status`, { method: "POST", headers, - body: JSON.stringify({ migrations: migrations.map(({ sql: _sql, ...rest }) => rest) }), + body: requestBody, }, "show d1 migration status") ); writeResult(values.json === true, body, () => formatD1MigrationStatus(body), stdout); @@ -199,12 +215,13 @@ async function runMigrationsCommand({ action, databaseRef, context }) { } if (action === "apply") { - const migrations = loadLocalMigrations({ values, env, cwd, databaseRef }); + const migrations = loadLocalMigrations({ values, env, cwd, databaseRef, warn }); + const requestBody = serializeD1MigrationsBody({ migrations }, "d1 migrations apply"); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(`${migrationsBase}/apply`, { method: "POST", headers, - body: JSON.stringify({ migrations }), + body: requestBody, timeoutMs: LONG_CONTROL_TIMEOUT_MS, }, "apply d1 migrations") ); @@ -212,11 +229,26 @@ async function runMigrationsCommand({ action, databaseRef, context }) { return; } - throw new CliError(`unknown d1 migrations subcommand: ${action}`); } /** - * @typedef {{ values: D1Flags, env: NodeJS.ProcessEnv, cwd: string, databaseRef: string }} MigrationsDirArgs + * @param {unknown} body + * @param {string} label + * @param {number} [maxBytes] + */ +export function serializeD1MigrationsBody(body, label, maxBytes = D1_MIGRATIONS_JSON_BODY_MAX_BYTES) { + const text = JSON.stringify(body); + const bytes = Buffer.byteLength(text); + if (bytes > maxBytes) { + throw new CliError( + `${label} request is ${bytes} bytes, exceeds ${maxBytes} byte control-plane request cap` + ); + } + return text; +} + +/** + * @typedef {{ values: D1Flags, env: NodeJS.ProcessEnv, cwd: string, databaseRef: string, warn?: (line: string) => void }} MigrationsDirArgs */ /** @@ -231,8 +263,8 @@ async function runMigrationsCommand({ action, databaseRef, context }) { // status and apply share the same local-migrations contract: resolve the dir, // read the .sql files, and fail loudly on an empty/mis-pointed dir. /** @param {MigrationsDirArgs} arg */ -function loadLocalMigrations({ values, env, cwd, databaseRef }) { - const { dir, display } = resolveMigrationsDir({ values, env, cwd, databaseRef }); +function loadLocalMigrations({ values, env, cwd, databaseRef, warn }) { + const { dir, display } = resolveMigrationsDir({ values, env, cwd, databaseRef, warn }); const migrations = readMigrationFiles(dir); if (migrations.length === 0) { throw new CliError(`no .sql migration files found in ${display}`); @@ -244,7 +276,7 @@ function loadLocalMigrations({ values, env, cwd, databaseRef }) { * @param {MigrationsDirArgs} arg * @returns {{ dir: string, display: string }} */ -function resolveMigrationsDir({ values, env, cwd, databaseRef }) { +function resolveMigrationsDir({ values, env, cwd, databaseRef, warn }) { if (values.dir) { return { dir: resolveExplicitMigrationsDir({ cwd, dir: values.dir }), @@ -268,6 +300,9 @@ function resolveMigrationsDir({ values, env, cwd, databaseRef }) { throw new CliError(message); } + const shadowWarning = formatWranglerConfigShadowWarning(loaded); + if (shadowWarning) warn?.(`warning: ${shadowWarning}`); + const configRel = path.basename(loaded.path); const selectedEnv = values.env || env.CLOUDFLARE_ENV || null; /** @type {{ d1_databases?: unknown }} */ diff --git a/commands/delete.js b/commands/delete.js index fe48cf7..c914b48 100644 --- a/commands/delete.js +++ b/commands/delete.js @@ -41,14 +41,16 @@ async function runDelete({ values, positionals, context }) { throw new CliError(`unknown subcommand: ${subcommand}\n${usageText()}`); } - const { headers } = context.resolveControl(); - if (subcommand === "version") { - const worker = values.worker || firstArg; - const version = values.version || secondArg; + let positionalIndex = 1; + const worker = values.worker || positionals[positionalIndex++]; + const version = values.version || positionals[positionalIndex++]; + const extraArg = positionals[positionalIndex]; if (!worker || !version) { throw new CliError("version delete requires or --worker/--version"); } + if (extraArg) throw new CliError(`delete version received unexpected argument: ${extraArg}`); + const { headers } = context.resolveControl(); const body = await context.fetchJson( context.nsUrl("worker", worker, "versions", version), { method: "DELETE", headers }, @@ -60,9 +62,12 @@ async function runDelete({ values, positionals, context }) { if (subcommand === "worker") { const worker = values.worker || firstArg; + const extraArg = values.worker ? firstArg : secondArg; if (!worker) { throw new CliError("worker delete requires or --worker "); } + if (extraArg) throw new CliError(`delete worker received unexpected argument: ${extraArg}`); + const { headers } = context.resolveControl(); const dryRun = values["dry-run"] === true; await confirmAction({ yes: dryRun || values.yes === true, diff --git a/commands/deploy.js b/commands/deploy.js index ff417db..0850670 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -4,7 +4,7 @@ import { execFileSync } from "node:child_process"; import { LONG_CONTROL_TIMEOUT_MS } from "../lib/control-fetch.js"; import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, isMain, optionHelp } from "../lib/common.js"; +import { CliError, defineCliOption, formatHelp, formatHttpError, isMain, optionHelp, readJsonOrFail } from "../lib/common.js"; import { escapeTerminalText, formatKnownWarning, writeStatusLine } from "../lib/output.js"; import { isLocalDevHost } from "../lib/credentials.js"; import { packWranglerProject } from "../lib/wrangler-pack.js"; @@ -65,34 +65,22 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, // `version` comes from the control response; keep the raw value for the // promote request body — display sites escape via writeStatusLine. const { version, warnings } = /** @type {{ version: unknown, warnings?: DeployWarning[] }} */ ( - await context.fetchJson( - context.nsUrl("worker", workerName, "deploy"), - { + await fetchDeployJson({ + context, + url: context.nsUrl("worker", workerName, "deploy"), + init: { method: "POST", headers: jsonHeaders, body: deployBody, timeoutMs: LONG_CONTROL_TIMEOUT_MS, }, - "deploy", - ) + label: "deploy", + ns, + workerName, + stderr, + }) ); - // Control's deploy warnings are the only signal for several binding - // misconfigurations — surface them so failures don't defer to runtime. - if (Array.isArray(warnings) && warnings.length) { - for (const w of warnings) { - if (w && Array.isArray(w.missingCallerSecrets) && w.missingCallerSecrets.length) { - const keys = escapeTerminalText(w.missingCallerSecrets.join(", ")); - stderr( - `warning: platform binding "${escapeTerminalText(w.binding)}" (platform="${escapeTerminalText(w.platform)}"): ` + - `missing caller secrets ${keys}\n` + - ` ns-wide: wdl secret put --ns ${ns} --scope ns \n` + - ` per-worker: wdl secret put --ns ${ns} --worker ${workerName} ` - ); - } else { - stderr(`warning: ${formatKnownWarning(w, DEPLOY_WARNING_KEYS)}`); - } - } - } + renderDeployWarnings(warnings, { ns, workerName, stderr }); writeStatusLine(stdout, `[3/3] promoting ${version}`); /** @type {{ platformDomain?: unknown }} */ @@ -119,6 +107,91 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, return { version, platformDomain: promoteBody.platformDomain }; } +/** + * Control may attach deploy warnings to both success and failure bodies. The + * failure path must render them before converting the response into CliError, + * otherwise actionable missing-caller-secret hints collapse into one JSON blob. + * @param {{ + * context: import("../lib/command.js").CommandContext, + * url: string, + * init: import("../lib/control-fetch.js").ControlFetchInit, + * label: string, + * ns: string, + * workerName: string, + * stderr: (line: string) => void, + * }} arg + */ +async function fetchDeployJson({ context, url, init, label, ns, workerName, stderr }) { + const res = await context.controlFetch(url, init); + if (res.ok) return await readJsonOrFail(res, label); + const text = await res.text(); + renderDeployWarningsFromErrorBody(text, { ns, workerName, stderr }); + throw new CliError(`${label} failed: ${formatHttpError(res.status, text, res.headers)}${deployErrorHint(text)}`); +} + +/** + * @param {unknown} warnings + * @param {{ ns: string, workerName: string, stderr: (line: string) => void }} arg + */ +function renderDeployWarnings(warnings, { ns, workerName, stderr }) { + // Control's deploy warnings are the only signal for several binding + // misconfigurations — surface them so failures don't defer to runtime. + if (!Array.isArray(warnings) || warnings.length === 0) return; + for (const w of warnings) { + if (w && Array.isArray(w.missingCallerSecrets) && w.missingCallerSecrets.length) { + const keys = escapeTerminalText(w.missingCallerSecrets.join(", ")); + stderr( + `warning: platform binding "${escapeTerminalText(w.binding)}" (platform="${escapeTerminalText(w.platform)}"): ` + + `missing caller secrets ${keys}\n` + + ` ns-wide: wdl secret put --ns ${ns} --scope ns \n` + + ` per-worker: wdl secret put --ns ${ns} --worker ${workerName} ` + ); + } else { + stderr(`warning: ${formatKnownWarning(w, DEPLOY_WARNING_KEYS)}`); + } + } +} + +/** + * @param {string} text + * @param {{ ns: string, workerName: string, stderr: (line: string) => void }} arg + */ +function renderDeployWarningsFromErrorBody(text, arg) { + try { + const body = /** @type {{ warnings?: unknown }} */ (JSON.parse(text)); + renderDeployWarnings(body.warnings, arg); + } catch {} +} + +/** @param {string} text */ +function deployErrorHint(text) { + /** @type {unknown} */ + let body; + try { + body = JSON.parse(text); + } catch { + return ""; + } + if (!body || typeof body !== "object" || Array.isArray(body)) return ""; + const error = /** @type {{ error?: unknown }} */ (body).error; + if (error === "worker_env_too_large") { + return "; reduce [vars], secrets, or binding metadata. If the error names a retained version, redeploy/delete that version."; + } + if (error === "worker_code_too_large") { + return "; reduce generated Worker code size or split the worker."; + } + if (error === "worker_code_invalid") { + return "; rename modules that collide with WDL-reserved _wdl-*.js runtime module names."; + } + if (error === "python_workers_unsupported") { + return "; Python Workers modules are not supported by WDL."; + } + if (error === "experimental_compat_flag_unsupported") { + return "; remove the unsupported workerd experimental compatibility flag."; + } + return ""; +} + /** * @param {unknown} manifest * @param {number} [maxBytes] @@ -171,9 +244,11 @@ async function runDeploy({ values, positionals, context: baseContext }) { const ns = context.resolveNamespace(); const [projectDir] = positionals; + const extraArg = positionals[1]; if (!projectDir || !ns) { throw new CliError(usageText()); } + if (extraArg) throw new CliError(`deploy received unexpected argument: ${extraArg}`); const { controlUrl, headers: authHeaders } = context.resolveControl(); const selectedEnv = values.env || env.CLOUDFLARE_ENV || null; diff --git a/commands/doctor.js b/commands/doctor.js index 8772349..1214997 100644 --- a/commands/doctor.js +++ b/commands/doctor.js @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { defineCommand } from "../lib/command.js"; -import { CliError, formatHelp, isMain, isNonEmptyString, optionHelp } from "../lib/common.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 { readTokenStore, tokenStorePath } from "../lib/token-store.js"; @@ -20,8 +20,15 @@ import { resolveWranglerCommand, wranglerChildEnv, } from "../lib/wrangler/command.js"; +import { WRANGLER_CONFIG_CANDIDATES } from "../lib/wrangler/config.js"; -const DOCTOR_OPTIONS = ["ns", "control", "json", "help"]; +const DOCTOR_OPTIONS = [ + defineCliOption("strict", { type: "boolean" }, "--strict", "Exit non-zero if any check fails."), + "ns", + "control", + "json", + "help", +]; const command = defineCommand({ name: "doctor", @@ -43,7 +50,7 @@ export const meta = command.meta; * @typedef {import("../lib/command.js").CommandContext & { execFile: typeof execFileSync }} DoctorContext */ -/** @param {{ values: import("../lib/command.js").PresetFlags<"ns" | "control" | "json">, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +/** @param {{ values: import("../lib/command.js").PresetFlags<"ns" | "control" | "json"> & { strict?: boolean }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ async function runDoctor({ values, positionals, context: baseContext }) { if (positionals.length > 0) throw new CliError(usageText()); @@ -68,6 +75,9 @@ async function runDoctor({ values, positionals, context: baseContext }) { const body = { checks, whoami: remote.whoami, whoamiError: remote.error }; writeResult(Boolean(values.json), body, () => formatDoctor(checks), context.stdout); + if (values.strict === true && checks.some((item) => !item.ok)) { + throw new CliError("doctor checks failed"); + } } /** @@ -203,13 +213,17 @@ function checkTokenStore(state) { /** @param {string} cwd */ function checkWranglerConfig(cwd) { - const name = ["wrangler.toml", "wrangler.jsonc", "wrangler.json"].find((candidate) => - existsSync(path.join(cwd, candidate)) - ); + const found = WRANGLER_CONFIG_CANDIDATES.filter((candidate) => existsSync(path.join(cwd, candidate))); + const name = found[0]; + const shadowed = found.slice(1); return check({ ok: Boolean(name), label: name ? `Wrangler config ${name}` : "Wrangler config", - detail: name ? "found in current directory" : "not found in current directory; needed for deploy from this path", + detail: name + ? shadowed.length > 0 + ? `found in current directory; ignoring ${shadowed.join(", ")} by Wrangler priority` + : "found in current directory" + : "not found in current directory; needed for deploy from this path", }); } @@ -231,13 +245,14 @@ async function checkRemoteWhoami({ state, controlFetch, warn }) { checks: [], }; } - warnIfInsecureControlUrl(control.controlUrl, warn); + warnIfInsecureControlUrl(control.controlUrl, warn, state.env); try { const remote = summarizeWhoami(await fetchWhoami({ controlUrl: control.controlUrl, headers: control.headers, controlFetch, + env: state.env, })); const tokenNs = namespaceFromPrincipal(remote.principal ?? undefined); const checks = [ diff --git a/commands/init.js b/commands/init.js index ce4f513..76e45be 100644 --- a/commands/init.js +++ b/commands/init.js @@ -10,7 +10,7 @@ const NAME_REGEX = /^[A-Za-z][A-Za-z0-9-]*$/; const WORKER_NAME_REGEX = /^[A-Za-z0-9][A-Za-z0-9_-]{0,254}$/; const TENANT_NS_RE = new RegExp(`^${NS_PATTERN}$`); const IGNORABLE_DIR_ENTRIES = new Set([".git", ".DS_Store"]); -const DEFAULT_COMPATIBILITY_DATE = "2026-05-31"; +const DEFAULT_COMPATIBILITY_DATE = "2026-06-17"; const CLI_ROOT = path.resolve(fileURLToPath(import.meta.url), "../.."); const INIT_OPTIONS = [ diff --git a/commands/r2.js b/commands/r2.js index 95aea55..cb8532b 100644 --- a/commands/r2.js +++ b/commands/r2.js @@ -16,7 +16,7 @@ const R2_OPTIONS = [ defineCliOption("prefix", { type: "string" }, "--prefix ", "Object key prefix for objects list."), defineCliOption("delimiter", { type: "string" }, "--delimiter ", "Group object list results by delimiter."), defineCliOption("cursor", { type: "string" }, "--cursor ", "Continue a previous list response."), - defineCliOption("limit", { type: "string" }, "--limit ", "Maximum list results, capped at 1000."), + defineCliOption("limit", { type: "string" }, "--limit ", "Maximum list results (1..1000)."), defineCliOption("out", { type: "string" }, "--out ", "Write object bytes to a file instead of stdout."), defineCliOption("yes", { type: "boolean" }, "--yes", "Confirm destructive actions."), "ns", @@ -55,19 +55,21 @@ async function runR2({ values, positionals, context: baseContext }) { const { stdout, stderr, stdin, stdoutStream } = context; const [group, action, bucket, key] = positionals; + const extraArg = positionals[4]; const ns = context.resolveNamespace(); if (!group || !action || !ns) throw new CliError(usageText()); - const { headers } = context.resolveControl(); // Object keys can contain "/" and must reject . / .. segments, so they use // encodeR2KeyPath rather than nsUrl's per-segment encodePath. const objectUrl = (/** @type {string} */ objectKey) => `${context.nsUrl("r2", "buckets", bucket, "objects")}/${encodeR2KeyPath(objectKey)}`; if (group === "buckets" && action === "list") { + if (bucket) throw new CliError(`r2 buckets list received unexpected argument: ${bucket}`); + const { headers } = context.resolveControl(); const url = withQuery(context.nsUrl("r2", "buckets"), { cursor: values.cursor, - limit: values.limit, + limit: normalizeListLimit(values.limit), }); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(url, { headers }, "list R2 buckets") @@ -78,11 +80,13 @@ async function runR2({ values, positionals, context: baseContext }) { if (group === "objects" && action === "list") { if (!bucket) throw new CliError("r2 objects list requires "); + if (key) throw new CliError(`r2 objects list received unexpected argument: ${key}`); + const { headers } = context.resolveControl(); const url = withQuery(context.nsUrl("r2", "buckets", bucket, "objects"), { prefix: values.prefix, delimiter: values.delimiter, cursor: values.cursor, - limit: values.limit, + limit: normalizeListLimit(values.limit), }); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(url, { headers }, "list R2 objects") @@ -93,7 +97,12 @@ async function runR2({ values, positionals, context: baseContext }) { if (group === "objects" && action === "get") { if (!bucket) throw new CliError("r2 objects get requires "); + if (extraArg) throw new CliError(`r2 objects get received unexpected argument: ${extraArg}`); const objectKey = requireR2ObjectKey(key); + if (!values.out && isInteractiveStdout(stdoutStream)) { + throw new CliError("r2 objects get refuses to write raw object bytes to an interactive terminal; pass --out "); + } + const { headers } = context.resolveControl(); const res = await context.fetchStream(objectUrl(objectKey), { headers, timeoutMs: LONG_CONTROL_TIMEOUT_MS, @@ -113,7 +122,9 @@ async function runR2({ values, positionals, context: baseContext }) { if (group === "objects" && action === "head") { if (!bucket) throw new CliError("r2 objects head requires "); + if (extraArg) throw new CliError(`r2 objects head received unexpected argument: ${extraArg}`); const objectKey = requireR2ObjectKey(key); + const { headers } = context.resolveControl(); const res = await context.fetchStream(objectUrl(objectKey), { method: "HEAD", headers, @@ -130,7 +141,9 @@ async function runR2({ values, positionals, context: baseContext }) { if (group === "objects" && action === "delete") { if (!bucket) throw new CliError("r2 objects delete requires "); + if (extraArg) throw new CliError(`r2 objects delete received unexpected argument: ${extraArg}`); const objectKey = requireR2ObjectKey(key); + const { headers } = context.resolveControl(); await confirmAction({ yes: values.yes === true, stdin, @@ -177,15 +190,25 @@ function requireR2ObjectKey(key) { return String(key); } +/** + * @param {string | undefined} limit + * @returns {string | undefined} + */ +function normalizeListLimit(limit) { + if (limit == null || limit === "") return undefined; + const n = Number(limit); + if (!Number.isInteger(n) || n < 1 || n > 1000) { + throw new CliError("r2 --limit must be an integer in [1, 1000]"); + } + return String(n); +} + /** @param {string} key */ function encodeR2KeyPath(key) { const segments = String(key).split("/"); if (segments.some((segment) => segment === "." || segment === "..")) { throw new CliError("R2 object key must not contain . or .. path segments"); } - if (segments.some((segment) => segment === "")) { - throw new CliError("R2 object key must not contain empty path segments (leading, trailing, or doubled slashes)"); - } return segments.map((segment) => encodeURIComponent(segment)).join("/"); } @@ -268,6 +291,11 @@ async function writeBodyToStdout(body, stdoutStream) { } } +/** @param {NodeJS.WritableStream} stdoutStream */ +function isInteractiveStdout(stdoutStream) { + return /** @type {{ isTTY?: boolean }} */ (stdoutStream).isTTY === true; +} + /** * @param {import("node:stream").Readable} body * @param {string} outPath diff --git a/commands/secret.js b/commands/secret.js index 840830c..b33979b 100644 --- a/commands/secret.js +++ b/commands/secret.js @@ -3,7 +3,7 @@ // "meant worker, wrote ns" credential leaks. import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, isMain, isNonEmptyString, optionHelp } from "../lib/common.js"; +import { CliError, defineCliOption, formatHelp, formatHttpError, isMain, isNonEmptyString, optionHelp, readJsonOrFail } from "../lib/common.js"; import { confirmAction, readSecretStdin } from "../lib/stdin.js"; import { writeJsonOr, writeStatusLine } from "../lib/output.js"; @@ -29,14 +29,6 @@ export const main = command.main; export const runSecretCommand = command.run; export const meta = command.meta; -/** - * A deferred-reload warning surfaced by control on put/delete. - * @typedef {object} PromoteWarning - * @property {string} [kind] - * @property {string} [reason] - * @property {string} [nextPickup] - */ - /** * The fields this command reads off control's /secrets responses. Control may * return more; only these are consumed here. @@ -45,7 +37,6 @@ export const meta = command.meta; * @property {boolean} [deleted] * @property {string} [version] * @property {string} [previousVersion] - * @property {PromoteWarning[]} [warnings] */ /** @param {{ values: import("../lib/command.js").PresetFlags<"ns" | "control" | "json"> & { worker?: string, scope?: string, yes?: boolean }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ @@ -53,6 +44,7 @@ async function runSecret({ values, positionals, context }) { const { stdout, stderr, stdin } = context; const [subcommand, keyArg] = positionals; + const extraArg = positionals[2]; const ns = context.resolveNamespace(); if (!subcommand || !ns) { throw new CliError(usageText()); @@ -73,11 +65,12 @@ async function runSecret({ values, positionals, context }) { throw new CliError(`--scope must be "ns" (only supported value); omit for worker scope`); } - const { headers } = context.resolveControl(); const secretPath = worker ? ["worker", worker, "secrets"] : ["secrets"]; const scopeLabel = worker ? `${ns}/${worker}` : `${ns} (ns)`; if (subcommand === "list") { + if (keyArg) throw new CliError(`secret list received unexpected argument: ${keyArg}`); + const { headers } = context.resolveControl(); const body = /** @type {SecretResponse} */ (await context.fetchJson(context.nsUrl(...secretPath), { headers }, "list")); if (writeJsonOr(Boolean(values.json), body, stdout)) return; const keys = Array.isArray(body.keys) ? body.keys : []; @@ -88,22 +81,20 @@ async function runSecret({ values, positionals, context }) { if (subcommand === "put") { if (!keyArg) throw new CliError("put requires a KEY argument"); + if (extraArg) throw new CliError(`secret put received unexpected argument: ${extraArg}`); + const { headers } = context.resolveControl(); // Empty string is a set secret (≠ unset), matching wrangler. const value = await readSecretStdin(stdin, { prompt: `Enter secret value for ${scopeLabel}/${keyArg} (input hidden): `, stderr, }); - const body = /** @type {SecretResponse} */ (await context.fetchJson(context.nsUrl(...secretPath, keyArg), { + const body = /** @type {SecretResponse} */ (await fetchSecretMutationJson(context, context.nsUrl(...secretPath, keyArg), { method: "PUT", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify({ value }), }, "put")); if (writeJsonOr(Boolean(values.json), body, stdout)) return; - const warning = pickPromoteWarning(body); - if (warning) { - writeStatusLine(stdout, `⚠ ${scopeLabel}/${keyArg} set — stored, reload deferred: ${warning.reason}`); - writeStatusLine(stdout, ` next pickup: ${warning.nextPickup}`); - } else if (hasWorker && body.version) { + if (hasWorker && body.version) { writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} set — promoted ${body.previousVersion} → ${body.version}`); } else if (hasWorker) { writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} set — stored; will apply on first deploy`); @@ -115,6 +106,8 @@ async function runSecret({ values, positionals, context }) { if (subcommand === "delete") { if (!keyArg) throw new CliError("delete requires a KEY argument"); + if (extraArg) throw new CliError(`secret delete received unexpected argument: ${extraArg}`); + const { headers } = context.resolveControl(); await confirmAction({ yes: values.yes === true, stdin, @@ -122,21 +115,12 @@ async function runSecret({ values, positionals, context }) { prompt: `Are you sure you want to delete secret "${scopeLabel}/${keyArg}"? [y/N] `, action: `delete secret "${scopeLabel}/${keyArg}"`, }); - const body = /** @type {SecretResponse} */ (await context.fetchJson(context.nsUrl(...secretPath, keyArg), { + const body = /** @type {SecretResponse} */ (await fetchSecretMutationJson(context, context.nsUrl(...secretPath, keyArg), { method: "DELETE", headers, }, "delete")); if (writeJsonOr(Boolean(values.json), body, stdout)) return; - const warning = pickPromoteWarning(body); - if (!body.deleted && !warning) writeStatusLine(stdout, `(${keyArg} was not set)`); - else if (warning && body.deleted) { - writeStatusLine(stdout, `⚠ ${scopeLabel}/${keyArg} deleted — stored, reload deferred: ${warning.reason}`); - writeStatusLine(stdout, ` next pickup: ${warning.nextPickup}`); - } - else if (warning) { - writeStatusLine(stdout, `⚠ ${scopeLabel}/${keyArg} unchanged — reload deferred: ${warning.reason}`); - writeStatusLine(stdout, ` next pickup: ${warning.nextPickup}`); - } + if (!body.deleted) writeStatusLine(stdout, `(${keyArg} was not set)`); else if (hasWorker && body.version) writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} deleted — promoted ${body.previousVersion} → ${body.version}`); else if (hasWorker) writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} deleted — no active worker version to promote`); else writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} deleted — effect on next natural cold-load`); @@ -147,12 +131,48 @@ async function runSecret({ values, positionals, context }) { } /** - * @param {SecretResponse} body - * @returns {PromoteWarning | null} + * @param {import("../lib/command.js").CommandContext} context + * @param {string} url + * @param {import("../lib/control-fetch.js").ControlFetchInit} init + * @param {string} label */ -function pickPromoteWarning(body) { - const warnings = Array.isArray(body?.warnings) ? body.warnings : []; - return warnings.find((w) => w?.kind === "promote_failed") || null; +async function fetchSecretMutationJson(context, url, init, label) { + const res = await context.controlFetch(url, init); + if (res.ok) return await readJsonOrFail(res, label); + const text = await res.text(); + throw new CliError(`${label} failed: ${formatHttpError(res.status, text, res.headers)}${secretMutationHint(text)}`); +} + +/** @param {string} text */ +function secretMutationHint(text) { + /** @type {unknown} */ + let body; + try { + body = JSON.parse(text); + } catch { + return ""; + } + if (!body || typeof body !== "object" || Array.isArray(body)) return ""; + const error = /** @type {{ error?: unknown }} */ (body).error; + if (error === "worker_env_too_large") { + return "; secret mutation was not written. Reduce [vars], secrets, or binding metadata; if source_version names a retained version, redeploy/delete that version. estimated_version may be a sizing placeholder. Namespace-scope mutations can be blocked by another worker's retained metadata."; + } + if (error === "secret_mutation_contention" || error === "namespace_secret_mutation_contention") { + return "; secret mutation was not written. Retry after concurrent worker metadata updates settle."; + } + if (isSecretEnvelopeError(error)) { + return "; secret mutation was not written. A stored secret envelope needs operator repair before retrying."; + } + return ""; +} + +/** @param {unknown} error */ +function isSecretEnvelopeError(error) { + return error === "invalid_envelope" || + error === "secret_decrypt_failed" || + error === "secret_not_encrypted" || + error === "unsupported_envelope" || + error === "unknown_kid"; } function usageText() { diff --git a/commands/tail.js b/commands/tail.js index 051f514..8bb54e3 100644 --- a/commands/tail.js +++ b/commands/tail.js @@ -6,7 +6,11 @@ import http from "node:http"; import https from "node:https"; import { defineCommand } from "../lib/command.js"; -import { controlRequestOptions } from "../lib/control-fetch.js"; +import { + controlRequestError, + controlRequestOptions, + validateControlHeaders, +} from "../lib/control-fetch.js"; import { CliError, defineCliOption, formatHelp, isMain, isNonEmptyString, optionHelp } from "../lib/common.js"; import { escapeTerminalLines, escapeTerminalText } from "../lib/output.js"; @@ -16,7 +20,9 @@ const RECONNECT_STABLE_MS = 30_000; // Default for --max-reconnects: bail after this many consecutive // 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_ERROR_BODY_MAX_BYTES = 64 * 1024; +export const SSE_MAX_LINE_CHARS = 1024 * 1024; // 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([ @@ -41,6 +47,11 @@ function isExpectedAbortError(err) { return false; } +/** @param {unknown} err */ +function toError(err) { + return err instanceof Error ? err : new Error(String(err)); +} + const command = defineCommand({ name: "tail", summary: "Live-tail worker console output and uncaught exceptions.", @@ -100,7 +111,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 }} StreamResult + * @typedef {{ fatal?: string, serverRecycle?: boolean }} StreamResult */ /** @@ -197,12 +208,12 @@ async function runTail({ values, positionals, context: baseContext }) { url: attempts === 0 || (values.since && !hasResumeCursor) ? initialUrl : reconnectUrl, - headers: requestHeaders, signal: ctrl.signal, transport, + headers: requestHeaders, signal: ctrl.signal, env: context.env, transport, // renderEvent writes stdout synchronously — fine for TTY // backpressure; tail isn't a guaranteed-delivery surface. onEvent: (event) => { if (event.id) lastEventId = event.id; - renderEvent({ event, raw, stdout, stderr, isMultiWorker }); + return renderEvent({ event, raw, stdout, stderr, isMultiWorker }); }, onConnected: () => { connectedAt = now(); @@ -225,6 +236,10 @@ async function runTail({ values, positionals, context: baseContext }) { if (result?.fatal) { throw new CliError(result.fatal); } + 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; @@ -293,58 +308,92 @@ function sleep(ms, signal) { * url: string, * headers: Record, * signal: AbortSignal | undefined, + * env: NodeJS.ProcessEnv, * transport: import("../lib/control-fetch.js").ControlTransport | null, - * onEvent: (event: SseEvent) => void, + * onEvent: (event: SseEvent) => "server-recycle" | void, * onConnected?: () => void, * }} arg * @returns {Promise} */ -function streamSse({ url, headers, signal, transport, onEvent, onConnected }) { - /** @type {() => void} */ - let onAbort; +function streamSse({ url, headers, signal, env, transport, onEvent, onConnected }) { + /** @type {(() => void) | null} */ + let onAbort = null; /** @type {Promise} */ const promise = new Promise((resolve, reject) => { const u = new URL(url); const lib = transport || (u.protocol === "https:" ? https : http); - const reqOpts = controlRequestOptions(u); + const reqOpts = controlRequestOptions(u, env); reqOpts.method = "GET"; reqOpts.headers = { ...reqOpts.headers, Accept: "text/event-stream", ...headers }; - - const req = lib.request(reqOpts, (/** @type {import("node:http").IncomingMessage} */ res) => { - const status = res.statusCode || 0; - /** @param {unknown} err */ - const onResponseError = (err) => { - if (signal?.aborted && isExpectedAbortError(err)) return resolve({}); - reject(err); - }; - res.on("error", onResponseError); - if (status < 200 || status >= 300) { - /** @type {Buffer[]} */ - const chunks = []; - let total = 0; - res.on("data", (/** @type {Buffer} */ c) => { - total += c.length; - if (total <= TAIL_ERROR_BODY_MAX_BYTES) chunks.push(c); + validateControlHeaders(/** @type {import("node:http").OutgoingHttpHeaders} */ (reqOpts.headers)); + /** @type {ReturnType | null} */ + let connectTimer = null; + const clearConnectTimer = () => { + if (connectTimer) clearTimeout(connectTimer); + connectTimer = null; + }; + + let serverRecycle = false; + /** @type {import("../lib/control-fetch.js").ControlClientRequest} */ + let req; + try { + req = lib.request(reqOpts, (/** @type {import("node:http").IncomingMessage} */ res) => { + clearConnectTimer(); + const status = res.statusCode || 0; + /** @param {unknown} err */ + const onResponseError = (err) => { + if (signal?.aborted && isExpectedAbortError(err)) return resolve({}); + reject(err); + }; + res.on("error", onResponseError); + if (status < 200 || status >= 300) { + /** @type {Buffer[]} */ + const chunks = []; + let total = 0; + res.on("data", (/** @type {Buffer} */ c) => { + total += c.length; + if (total <= TAIL_ERROR_BODY_MAX_BYTES) chunks.push(c); + }); + res.on("end", () => { + let detail; + try { + const body = /** @type {{ message?: string, error?: string }} */ (JSON.parse(Buffer.concat(chunks).toString("utf8"))); + detail = escapeTerminalText(body.message || body.error || `HTTP ${status}`); + } catch { + detail = `HTTP ${status}`; + } + resolve({ fatal: detail }); + }); + return; + } + onConnected?.(); + const parser = new SseParser((event) => { + if (onEvent(event) === "server-recycle") serverRecycle = true; + }); + res.setEncoding("utf8"); + res.on("data", (/** @type {string} */ chunk) => { + try { + parser.push(chunk); + } catch (err) { + req.destroy(); + reject(err); + } }); res.on("end", () => { - let detail; try { - const body = /** @type {{ message?: string, error?: string }} */ (JSON.parse(Buffer.concat(chunks).toString("utf8"))); - detail = escapeTerminalText(body.message || body.error || `HTTP ${status}`); - } catch { - detail = `HTTP ${status}`; + parser.flush(); + resolve({ serverRecycle }); + } catch (err) { + reject(err); } - resolve({ fatal: detail }); }); - return; - } - onConnected?.(); - const parser = new SseParser((event) => onEvent(event)); - res.setEncoding("utf8"); - res.on("data", (/** @type {string} */ chunk) => parser.push(chunk)); - res.on("end", () => { parser.flush(); resolve({}); }); - }); + }); + } catch (err) { + reject(controlRequestError(toError(err))); + return; + } req.on("error", (/** @type {unknown} */ err) => { + clearConnectTimer(); if (signal?.aborted && isExpectedAbortError(err)) return resolve({}); reject(err); }); @@ -352,12 +401,19 @@ function streamSse({ url, headers, signal, transport, onEvent, onConnected }) { if (signal) { signal.addEventListener("abort", onAbort, { once: true }); } + connectTimer = setTimeout(() => { + req.destroy(); + reject(new Error(`tail connection timed out after ${TAIL_CONNECT_TIMEOUT_MS}ms before response headers`)); + }, TAIL_CONNECT_TIMEOUT_MS); + if (typeof connectTimer === "object" && typeof connectTimer.unref === "function") { + connectTimer.unref(); + } req.end(); }); // Drop the session-long signal listener once this connection settles so a // flapping reconnect loop doesn't accumulate one closure per attempt. return signal - ? promise.finally(() => signal.removeEventListener("abort", onAbort)) + ? promise.finally(() => { if (onAbort) signal.removeEventListener("abort", onAbort); }) : promise; } @@ -370,6 +426,7 @@ export class SseParser { constructor(onEvent) { this.onEvent = onEvent; this.buffer = ""; + this.maxLineChars = SSE_MAX_LINE_CHARS; this.event = "message"; /** @type {string | null} */ this.id = null; @@ -384,11 +441,14 @@ export class SseParser { let line = this.buffer.slice(0, idx); this.buffer = this.buffer.slice(idx + 1); if (line.endsWith("\r")) line = line.slice(0, -1); + this.assertLineLength(line); this.consumeLine(line); } + this.assertLineLength(this.buffer); } flush() { if (this.buffer.length > 0) { + this.assertLineLength(this.buffer); this.consumeLine(this.buffer); this.buffer = ""; } @@ -427,6 +487,12 @@ export class SseParser { // overwrites it. We don't reset it. this.data = []; } + /** @param {string} line */ + assertLineLength(line) { + if (line.length > this.maxLineChars) { + throw new CliError(`tail SSE line exceeded ${this.maxLineChars} characters`); + } + } } /** @@ -444,9 +510,13 @@ function renderEvent({ event, raw, stdout, stderr, isMultiWorker }) { try { payload = JSON.parse(event.data); } catch { payload = { event: event.event, raw: event.data }; } + const eventType = payload.event || event.event; + const isServerRecycle = eventType === "tail_warning" && + (payload.code === "session_idle" || payload.code === "session_expired"); + if (raw) { stdout(JSON.stringify(payload)); - return; + return isServerRecycle ? "server-recycle" : undefined; } // Everything below interpolates worker-controlled text (console args, @@ -454,8 +524,11 @@ function renderEvent({ event, raw, stdout, stderr, isMultiWorker }) { // terminal. Escape control sequences so a logged "\x1b]0;…" can't drive // the terminal; multi-line payloads (console output, stacks) keep their // newlines but every line is escaped. - const eventType = payload.event || event.event; if (eventType === "tail_warning") { + if (isServerRecycle) { + stderr(`tail ${escapeTerminalText(payload.code)}: ${escapeTerminalText(payload.message || "session closed by control")}`); + return "server-recycle"; + } stderr(`! tail_warning ${escapeTerminalText(payload.code || "")}: ${escapeTerminalText(payload.message || "")}`); return; } diff --git a/commands/token.js b/commands/token.js index e897e89..fb5c4a0 100644 --- a/commands/token.js +++ b/commands/token.js @@ -93,7 +93,7 @@ async function tokenSet({ values, context }) { const controlUrl = resolveControlUrl(values, context.env); // Warn before a plaintext token travels unencrypted, like every other path // that sends the token. - warnIfInsecureControlUrl(controlUrl, context.warn); + warnIfInsecureControlUrl(controlUrl, context.warn, context.env); const token = (await readSecretStdin(context.stdin, { prompt: `Token for ${ns} @ ${controlUrl} (input hidden): `, @@ -108,6 +108,7 @@ async function tokenSet({ values, context }) { controlUrl, headers: { "x-admin-token": token }, controlFetch: context.controlFetch, + env: context.env, }); const principalNs = namespaceFromPrincipal(whoami.principal); if (principalNs !== ns) { diff --git a/commands/whoami.js b/commands/whoami.js index 9d81c15..fd22e53 100644 --- a/commands/whoami.js +++ b/commands/whoami.js @@ -32,11 +32,12 @@ async function runWhoami({ values, positionals, context }) { const state = resolveCliConfigState({ values, env: context.env, cwd: context.cwd, warn: context.warn }); const control = ensureControlContextFromConfigState(state); - warnIfInsecureControlUrl(control.controlUrl, context.warn); + warnIfInsecureControlUrl(control.controlUrl, context.warn, state.env); const remote = summarizeWhoami(await fetchWhoami({ controlUrl: control.controlUrl, headers: control.headers, controlFetch: context.controlFetch, + env: state.env, })); const body = buildWhoamiBody(state, remote); writeResult(values.json === true, body, () => formatWhoami(body), context.stdout); diff --git a/commands/workflows.js b/commands/workflows.js index f7a966c..8fbe703 100644 --- a/commands/workflows.js +++ b/commands/workflows.js @@ -41,10 +41,9 @@ async function runWorkflows({ values, positionals, context }) { const ns = context.resolveNamespace(); if (!subcommand || !ns) throw new CliError(usageText()); - const { headers } = context.resolveControl(); - if (subcommand === "list") { requireNoExtraPositionals(positionals, 1, "workflows list"); + const { headers } = context.resolveControl(); const body = /** @type {{ workflows?: import("../lib/workflows-format.js").WorkflowSummary[] }} */ ( await context.fetchJson(context.nsUrl("workflows"), { headers }, "list workflows") ); @@ -54,6 +53,7 @@ async function runWorkflows({ values, positionals, context }) { if (subcommand === "instances") { const { worker, workflow } = requireWorkflowRef(positionals, "workflows instances"); + const { headers } = context.resolveControl(); const url = new URL(context.nsUrl("workflows", worker, workflow, "instances")); if (values.limit) url.searchParams.set("limit", values.limit); if (values.cursor) url.searchParams.set("cursor", values.cursor); @@ -66,6 +66,10 @@ async function runWorkflows({ values, positionals, context }) { if (subcommand === "status") { const { worker, workflow, instanceId } = requireInstanceRef(positionals, "workflows status"); + if (values["step-limit"] && !values["include-steps"]) { + throw new CliError("workflows status --step-limit requires --include-steps"); + } + const { headers } = context.resolveControl(); const url = new URL(context.nsUrl("workflows", worker, workflow, "instances", instanceId)); if (values["include-steps"]) url.searchParams.set("includeSteps", "true"); if (values["step-limit"]) url.searchParams.set("stepLimit", values["step-limit"]); @@ -78,6 +82,7 @@ async function runWorkflows({ values, positionals, context }) { if (LIFECYCLE_ACTIONS.has(subcommand)) { const { worker, workflow, instanceId } = requireInstanceRef(positionals, `workflows ${subcommand}`); + const { headers } = context.resolveControl(); if (subcommand === "restart" || subcommand === "terminate") { await confirmAction({ yes: values.yes === true, diff --git a/docs/d1-zh.md b/docs/d1-zh.md index dbc3394..8a3212c 100644 --- a/docs/d1-zh.md +++ b/docs/d1-zh.md @@ -111,6 +111,10 @@ wdl d1 execute main --sql "DELETE FROM tmp" --mode run # all | raw | run | exe D1 请求在执行前会被限流:binary query body 最大 8 MiB;解码后的请求最多 1000 条 SQL 语句,SQL 加 params 聚合最大 8 MiB;每条语句最多返回 65,536 行(对齐 Cloudflare D1,超限报 `limit-exceeded`);结果 body 受平台默认 16 MiB 聚合上限保护。多语句 `exec()` 在同一个 SQLite transaction 中执行;如果后面的语句失败,这次 `exec()` 里之前已经执行的语句会回滚。 +D1 migration 管理走 control-plane JSON request parser,所以 `wdl d1 migrations status/apply` 请求体上限是 1 MiB。特别大的 migration 集合或 SQL 文件应拆成更小批次再 apply。`d1 execute --file` 的路径必须存在、可读,并且留在项目根目录内;文件缺失或不可读时 CLI 会在本地拒绝,不会联系 control。 + +以 `_cf_` 开头的 SQLite object name 是 workerd 保留名,大小写不敏感。不要创建或 `RENAME TO` 到 `_cf_*` 形式的应用 table、index、trigger 或 view;包含这类 DDL 的 migration 在新数据库上可能失败。已经 apply 的 migration 文件不要回改;需要修正时新增 forward migration,把应用数据迁到非保留名称。 + ## 删除数据库 ```bash diff --git a/docs/d1.md b/docs/d1.md index 0df1961..2dd0b24 100644 --- a/docs/d1.md +++ b/docs/d1.md @@ -131,6 +131,19 @@ bodies are protected by the platform default 16 MiB aggregate cap. Multi-stateme if a later statement fails, the statements already executed in that `exec()` call are rolled back. +D1 migration management uses the control-plane JSON request parser, so +`wdl d1 migrations status/apply` request bodies are capped at 1 MiB. Split very +large migration sets or large SQL files into smaller batches before applying. +`--file` paths for `d1 execute` must exist, be readable, and stay inside the +project root; the CLI rejects missing or unreadable files before contacting +control. + +SQLite object names beginning with `_cf_` are reserved by workerd, +case-insensitively. Do not create or rename application tables, indexes, +triggers, or views to `_cf_*`; a migration containing that DDL can fail on a new +database. Do not edit an already-applied migration file — add a forward +migration to move application data to a non-reserved name when needed. + ## Deleting a database ```bash diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index a71d52b..940da58 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -38,7 +38,7 @@ CLI 需要三个值: **不可信项目:** `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 config explain`;要确认 token 实际连到哪个 control、principal、platform version 和 URL hints,运行 `wdl whoami`;本机与远端基础排查运行 `wdl doctor`。当 control 支持 `/whoami` 时,`doctor` 会验证远端 token、principal namespace、platform version 和 CLI compatibility。 +不确定最终取了哪个值时,运行 `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`。 运行时密钥(与 `ADMIN_TOKEN` 不同)见 [secrets-zh.md](./secrets-zh.md)。 @@ -78,6 +78,10 @@ Worker 看到的路径是**剥掉 `/` 之后的路径**。除非运 Deploy 上传给 control 的 manifest JSON 最大 32 MiB。Assets 在部署时会嵌进这个 JSON 请求;如果静态文件集合较大,可能先撞到 control request cap。大体积或频繁变化的文件用 R2,不要放进 assets。 +Deploy 还会预检 Wrangler 自己看不到的 WDL runtime 约束:`[vars]` 名称和 bindings 共享同一个运行时 `env` 命名空间,同名会被拒;形如 WDL 注入 runtime 模块的名字(`_wdl-*.js`,例如 `_wdl-wrapper.js`)是保留模块名;Python Workers modules 不支持;显式的 workerd `experimental` compatibility flag 会在打包前被拒。其它不支持的 experimental compatibility flags 由 control plane 返回 `experimental_compat_flag_unsupported`。 + +Control plane 会按留有 headroom 的 workerd 1 MiB `workerLoader` environment 预算校验(可用 1,040,384 bytes)。过大的 `[vars]`、secrets、binding metadata 或 retained versions 可能触发 `worker_env_too_large`;减少 env payload,或在错误点名已有版本时 redeploy/delete 该 retained version。 + ## 环境覆盖 当 wrangler 配置有 `[env.]` 段时,`--env `(或 `CLOUDFLARE_ENV`)是**必填**的 —— CLI 不会替你挑默认值。明确指定: @@ -91,9 +95,11 @@ wdl deploy . --env production ## 支持 / 不支持的 wrangler 配置 +如果同时存在多个 Wrangler 配置文件,CLI 跟随 Wrangler 的优先级:`wrangler.json`,然后 `wrangler.jsonc`,最后 `wrangler.toml`。 + **支持:** `name`、`main`、`compatibility_date`/`flags`、`[vars]`、`[[kv_namespaces]]`、`[[d1_databases]]`、`[[durable_objects.bindings]]`、`[[workflows]]`、`[[r2_buckets]]`、`[assets] directory`、`[triggers] crons`、`[[triggers.schedules]]`(带 timezone,平台扩展)、`[[queues.producers]]` / `[[queues.consumers]]`、`[[services]]`、`[[platform_bindings]]`、`[env.]`。 -**不支持(部署失败):** 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` 仅在运维方启用时支持。`assets.run_worker_first` 会被静默忽略。 +**不支持(部署失败):** 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 experimental compatibility flags 不支持。`assets.run_worker_first` 会被静默忽略。 Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在可路由的 tenant Worker 上。通过 `[[platform_bindings]]` 选择的 Worker 是冷加载的平台能力,不是 public/runtime dispatch 目标,不能声明 cron triggers 或 queue consumers。 @@ -112,10 +118,16 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 | `401 unknown_token: unauthorized` | Token 对这个控制平面 / 命名空间无效。重新检查 `ADMIN_TOKEN`。 | | `[vars] must be an object` | 用 `[vars]` 表/对象;数组不合法。 | | `[vars] : only string/number/boolean values are supported` | 移除嵌套值;敏感字符串改用 secret。 | +| `binding name collision: ` | `[vars]` key 或另一个 binding 复用了 runtime env 名称;重命名其中一个。 | +| `experimental_compat_flag_unsupported` | 移除 experimental workerd compatibility flag。 | +| `python_workers_unsupported` | WDL 不支持 Python Workers;移除 Python Worker modules。 | +| `worker_env_too_large` | 减少 `[vars]`、secrets 或 binding metadata;如果错误点名 retained version,redeploy/delete 该版本。 | +| `worker_code_invalid` | 重命名与 WDL 保留 `_wdl-*.js` runtime module 冲突的模块。 | | `wrangler build failed` | 在项目里跑 `npx wrangler deploy --dry-run` 然后在那边修。 | | 部署成功但 promote 失败 | 自定义主机或服务绑定的目标校验问题;检查绑定目标。 | | Worker URL 返回 404 | URL 缺了 `/` 这一段。 | | `wdl tail` 没有历史日志 | tail 是 live-only;先打开 `wdl tail ` 再触发请求。 | +| `tail session_idle` / `tail session_expired` | control 回收了 live-tail stream;CLI 会自动重连,除非达到重连上限。 | | Namespace secret 没生效 | NS 级 secret 不会强制 bump worker;重新部署一次或改用 worker 级 secret。 | | 服务绑定还在打老目标 | 绑定在调用方部署时就锁定了;重新部署调用方。 | diff --git a/docs/deploy.md b/docs/deploy.md index 75816a5..7320ad0 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -74,7 +74,8 @@ When unsure which value won, run `wdl config explain`; to confirm which control the token actually reaches, plus the principal, platform version, and URL hints, run `wdl whoami`; for baseline local and remote diagnostics, run `wdl doctor`. When the control plane supports `/whoami`, `doctor` verifies the remote token, -principal namespace, platform version, and CLI compatibility. +principal namespace, platform version, and CLI compatibility. Use +`wdl doctor --strict` in CI when a failed check should make the job fail. For runtime secrets (distinct from `ADMIN_TOKEN`), see [secrets.md](./secrets.md). @@ -133,6 +134,19 @@ embedded in that JSON request at deploy time; a large static file set can hit the control request cap first. Put bulk or frequently changing files in R2, not in assets. +Deploy also preflights WDL runtime constraints that Wrangler cannot see: +`[vars]` names share the same runtime `env` namespace as bindings, so collisions +are rejected; modules named like WDL injected runtime modules (`_wdl-*.js`, such +as `_wdl-wrapper.js`) are reserved; Python Workers modules are not supported; +and the explicit workerd `experimental` compatibility flag is rejected before +bundling. Other unsupported experimental compatibility flags are rejected by +the control plane with `experimental_compat_flag_unsupported`. + +The control plane enforces a headroomed 1 MiB workerd `workerLoader` environment +budget (1,040,384 bytes usable). Large `[vars]`, secrets, binding metadata, or +retained versions can fail with `worker_env_too_large`; reduce the env payload, +or redeploy/delete the retained version named in the error when one is shown. + ## Environment overrides When the wrangler config has `[env.]` sections, `--env ` (or @@ -151,6 +165,9 @@ names like `my-worker-preview`; WDL does not append that suffix. See ## Supported / unsupported wrangler configuration +When multiple Wrangler config files exist, the CLI follows Wrangler's priority: +`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`. + **Supported:** `name`, `main`, `compatibility_date`/`flags`, `[vars]`, `[[kv_namespaces]]`, `[[d1_databases]]`, `[[durable_objects.bindings]]`, `[[workflows]]`, `[[r2_buckets]]`, `[assets] directory`, `[triggers] crons`, @@ -164,8 +181,9 @@ implemented yet. WDL Workflows supports only workflow classes defined in the current Worker — not full Cloudflare Workflows parity; `script_name`, cross-worker workflows, cross-worker callbacks, service-binding callbacks, and the Cloudflare source-AST visualizer are not supported. `route` / `routes` are -supported only when the operator enables them. `assets.run_worker_first` is -silently ignored. +supported only when the operator enables them. Python Workers modules and +workerd experimental compatibility flags are not supported. `assets.run_worker_first` +is silently ignored. Cron triggers and queue consumers are runtime dispatch features; declare them only on routeable tenant Workers. Workers selected through @@ -191,10 +209,16 @@ Deleting a worker does **not** delete R2 data — see [r2.md](./r2.md). | `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: ` | A `[vars]` key or another binding reused a runtime env name. Rename one of them. | +| `experimental_compat_flag_unsupported` | Remove the experimental workerd compatibility flag. | +| `python_workers_unsupported` | Python Workers are not supported by WDL; remove Python Worker modules. | +| `worker_env_too_large` | Reduce `[vars]`, secrets, or binding metadata; redeploy/delete any retained version named in the error. | +| `worker_code_invalid` | Rename modules that collide with WDL-reserved `_wdl-*.js` runtime module names. | | `wrangler build failed` | Run `npx wrangler deploy --dry-run` inside the project and fix it there. | | Deploy succeeds but promote fails | Custom host or service-binding target validation issue; check the binding targets. | | 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 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. | diff --git a/docs/durable-objects-zh.md b/docs/durable-objects-zh.md index c7bebd9..f5cd641 100644 --- a/docs/durable-objects-zh.md +++ b/docs/durable-objects-zh.md @@ -44,6 +44,8 @@ export default { 当前支持 `stub.fetch()`、JSON-structured `stub.method(...args)` RPC、native `ctx.storage`、同步 `ctx.storage.sql`、alarm、普通 WebSocket upgrade 以及 native WebSocket hibernation API surface。 +使用 `ctx.storage.sql` 时,不要使用以 `_cf_` 开头的应用表名;workerd 对这个前缀做大小写不敏感保留。`ctx.storage.deleteAll()` 也会保留平台自有的 `_cf_*` 表。 + ## 端到端示例 `../examples/durable-objects-demo` —— 同 Worker 内 `Room` Durable Object,展示内存计数和 SQLite-backed storage 计数。 diff --git a/docs/durable-objects.md b/docs/durable-objects.md index 7624a84..d71f902 100644 --- a/docs/durable-objects.md +++ b/docs/durable-objects.md @@ -50,6 +50,10 @@ Currently supported: `stub.fetch()`, JSON-structured `stub.method(...args)` RPC, native `ctx.storage`, synchronous `ctx.storage.sql`, alarms, ordinary WebSocket upgrade, and the native WebSocket hibernation API surface. +For `ctx.storage.sql`, avoid application table names beginning with `_cf_`; +workerd reserves that prefix case-insensitively. `ctx.storage.deleteAll()` also +leaves platform-owned `_cf_*` tables alone. + ## End-to-end example `../examples/durable-objects-demo` — a same-worker `Room` Durable Object showing diff --git a/docs/env-overrides-zh.md b/docs/env-overrides-zh.md index a9d97a2..a3230d8 100644 --- a/docs/env-overrides-zh.md +++ b/docs/env-overrides-zh.md @@ -16,7 +16,7 @@ Wrangler 配置可以定义环境级的覆盖(`vars`、`assets`、绑定等) ```toml name = "my-worker" main = "src/index.js" -compatibility_date = "2026-05-31" +compatibility_date = "2026-06-17" [vars] SHARED = "top-level" @@ -45,7 +45,7 @@ directory = "./prod-public" { "name": "my-worker", "main": "src/index.js", - "compatibility_date": "2026-05-31", + "compatibility_date": "2026-06-17", "vars": { "SHARED": "top-level", "BASE_ONLY": "always-this" }, "assets": { "directory": "./top-public" }, "env": { diff --git a/docs/env-overrides.md b/docs/env-overrides.md index 6955094..a40de9a 100644 --- a/docs/env-overrides.md +++ b/docs/env-overrides.md @@ -22,7 +22,7 @@ If preview and production config are completely identical, do not use ```toml name = "my-worker" main = "src/index.js" -compatibility_date = "2026-05-31" +compatibility_date = "2026-06-17" [vars] SHARED = "top-level" @@ -51,7 +51,7 @@ Or in `wrangler.jsonc` — same shape, JSON syntax: { "name": "my-worker", "main": "src/index.js", - "compatibility_date": "2026-05-31", + "compatibility_date": "2026-06-17", "vars": { "SHARED": "top-level", "BASE_ONLY": "always-this" }, "assets": { "directory": "./top-public" }, "env": { diff --git a/docs/queues-zh.md b/docs/queues-zh.md index 97292bb..1c394de 100644 --- a/docs/queues-zh.md +++ b/docs/queues-zh.md @@ -53,7 +53,7 @@ export default { - `retry_delay` 和 `message.retry({ delaySeconds })` 可用于延迟重试;显式传 `delaySeconds` 会覆盖 consumer 的 `retry_delay`,传 `0` 表示立即重试。 - `message.attempts` 从 1 起计,因此 `max_retries = N` 意味着 handler 最多观察到 N + 1 次尝试,之后消息进入 dead-letter queue。 - Dead-letter queue 是有界的诊断通道(默认约 1 万条,近似裁剪)——应及时排空,不要当作持久归档使用。 -- `max_batch_timeout` 会被解析保存,但当前不要依赖它做完整的等待聚合;实际 dispatch 主要由 `max_batch_size` 和平台调度节奏截断。 +- `max_batch_timeout` 必须是 0..60 秒;它会被解析保存,但当前不要依赖它做完整的等待聚合;实际 dispatch 主要由 `max_batch_size` 和平台调度节奏截断。 - `max_concurrency` 当前不支持,部署时会被拒绝。 - Queue consumer 是 runtime dispatch 目标,应声明在可路由的 tenant Worker 上,不要声明在 platform binding target Worker 上。 diff --git a/docs/queues.md b/docs/queues.md index f788221..e0ea7a2 100644 --- a/docs/queues.md +++ b/docs/queues.md @@ -66,9 +66,9 @@ and leave it to the platform's `max_retries` and `dead_letter_queue` handling. - The dead-letter queue is a bounded diagnostic channel (about 10k entries by default, approximately trimmed) — drain it promptly rather than treating it as a durable archive. -- `max_batch_timeout` is parsed and saved, but do not rely on it for full - wait-based aggregation yet; actual dispatch is mostly cut off by - `max_batch_size` and the platform's scheduling cadence. +- `max_batch_timeout` must be 0..60 seconds. It is parsed and saved, but do not + rely on it for full 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. - 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 31e0cd6..e8599d9 100644 --- a/docs/r2-zh.md +++ b/docs/r2-zh.md @@ -100,7 +100,9 @@ wdl r2 objects get --out file # 下载 wdl r2 objects delete --yes # 破坏性 —— 先确认 ``` -列表被截断时输出会带 `Next cursor: `;把它传给下一次 `--cursor` 继续翻页(`wdl r2 buckets list` 同样支持 `--cursor` / `--limit`,上限 1000): +`wdl r2 objects get` 会写出原始 object bytes。需要 stream bytes 时请 pipe 或重定向 stdout;在交互终端中请使用 `--out `。 + +列表被截断时输出会带 `Next cursor: `;把它传给下一次 `--cursor` 继续翻页(`wdl r2 buckets list` 同样支持 `--cursor` / `--limit`,其中 `--limit` 必须是 1..1000): ```bash wdl r2 objects list receipts --limit 100 @@ -111,6 +113,8 @@ wdl r2 objects list receipts --limit 100 --cursor eyJrZXkiOiJyZWNlaXB0cy8wMDk5In `wdl r2 objects head` 是只读检查工具 —— 在做破坏性操作前先确认目标对象是否正确。对象不存在时,control 遵循 HTTP `HEAD` 语义返回空 404;CLI 会显示状态码,不会有 JSON 错误体可解析。 +R2 object key 可以包含开头、结尾或连续的 `/` 路径分隔符;CLI 会保留这些 empty path segments。`.` 和 `..` segment 会被拒绝,避免 object key 和 control-plane URL path traversal 混淆。 + ## Worker 删除后的清理 删除 worker **不会**删除 R2 数据。`wdl delete worker ` 之后 R2 对象还在。要清理: diff --git a/docs/r2.md b/docs/r2.md index 89aec69..4a14386 100644 --- a/docs/r2.md +++ b/docs/r2.md @@ -116,9 +116,12 @@ wdl r2 objects get --out file # download wdl r2 objects delete --yes # destructive — confirm first ``` +`wdl r2 objects get` writes raw object bytes. Pipe or redirect stdout when you +intend to stream bytes; on an interactive terminal, pass `--out `. + When the list is truncated, the output includes `Next cursor: `; pass it to the next `--cursor` to keep paging (`wdl r2 buckets list` also supports -`--cursor` / `--limit`, capped at 1000): +`--cursor` / `--limit`, with `--limit` in 1..1000): ```bash wdl r2 objects list receipts --limit 100 @@ -132,6 +135,10 @@ is correct before any destructive operation. For a missing object, control follows HTTP `HEAD` semantics and returns an empty 404; the CLI shows the status code, with no JSON error body to parse. +R2 object keys may contain leading, trailing, or doubled `/` path separators. +The CLI preserves those empty path segments. It rejects `.` and `..` segments so +an object key cannot be confused with a control-plane URL path traversal. + ## Cleanup after worker deletion Deleting a worker does **not** delete R2 data. R2 objects remain after diff --git a/docs/secrets-zh.md b/docs/secrets-zh.md index f0cb846..cb1f639 100644 --- a/docs/secrets-zh.md +++ b/docs/secrets-zh.md @@ -48,10 +48,13 @@ worker 级 secret > 命名空间级 secret > [vars] 修改 worker 级 secret 会创建并 promote 新版本,但已经加载的历史版本可能继续持有旧值,直到 runtime eviction 或 recycle。需要严格撤销时,应同时考虑禁用旧凭据。 +Worker 级 secret mutation 是原子的:如果更新期间 active version 变化,control 会返回 `secret_mutation_contention`,CLI 会要求重试,而不是留下"已存储但未 promote"的半成功状态。Namespace secret mutation 在 retained worker metadata 持续变化时也可能返回 `namespace_secret_mutation_contention`。 + ## 约束 - Key 必须符合环境变量命名规范:`[A-Z_][A-Z0-9_]*` —— 例如 `STRIPE_KEY`、`API_TOKEN`、`SIGNING_SECRET`。 - 值上限 64 KiB。 +- Secrets 会和 `[vars]`、binding metadata 一起计入 workerLoader env budget。如果 mutation 返回 `worker_env_too_large`,减少 env payload;如果错误点名 retained version,redeploy/delete 该版本。 ## Worker 端读取 diff --git a/docs/secrets.md b/docs/secrets.md index be6c6ad..290d761 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -61,11 +61,20 @@ already-loaded historical versions can keep holding the old value until runtime eviction or recycle. When strict revocation matters, also consider disabling the old credential. +Worker-level secret mutations are atomic: if the active version changes during +the update, control returns `secret_mutation_contention` and the CLI asks you to +retry instead of leaving a stored-but-not-promoted partial update. Namespace +secret mutations can similarly return `namespace_secret_mutation_contention` +when retained worker metadata keeps changing. + ## Constraints - Keys must follow environment-variable grammar: `[A-Z_][A-Z0-9_]*` — e.g. `STRIPE_KEY`, `API_TOKEN`, `SIGNING_SECRET`. - Values are limited to 64 KiB. +- Secrets count toward the workerLoader env budget together with `[vars]` and + binding metadata. If a mutation returns `worker_env_too_large`, reduce the + env payload or redeploy/delete the retained version named in the error. ## Reading in the Worker diff --git a/examples/cron-demo/wrangler.toml b/examples/cron-demo/wrangler.toml index 1901073..b637c9d 100644 --- a/examples/cron-demo/wrangler.toml +++ b/examples/cron-demo/wrangler.toml @@ -1,6 +1,6 @@ name = "cron-demo" main = "src/index.js" -compatibility_date = "2026-05-31" +compatibility_date = "2026-06-17" [[kv_namespaces]] binding = "CRON_STATE" diff --git a/examples/d1-demo/wrangler.toml b/examples/d1-demo/wrangler.toml index dfd051c..fe5d63d 100644 --- a/examples/d1-demo/wrangler.toml +++ b/examples/d1-demo/wrangler.toml @@ -1,6 +1,6 @@ name = "d1-demo" main = "src/index.js" -compatibility_date = "2026-05-31" +compatibility_date = "2026-06-17" # Create the database first: # wdl d1 create main diff --git a/examples/durable-objects-demo/wrangler.toml b/examples/durable-objects-demo/wrangler.toml index 09ae30a..d7d2fe7 100644 --- a/examples/durable-objects-demo/wrangler.toml +++ b/examples/durable-objects-demo/wrangler.toml @@ -1,6 +1,6 @@ name = "durable-objects-demo" main = "src/index.js" -compatibility_date = "2026-05-31" +compatibility_date = "2026-06-17" [[durable_objects.bindings]] name = "ROOMS" diff --git a/examples/env-overrides-demo/wrangler.toml b/examples/env-overrides-demo/wrangler.toml index 2187ad1..355e269 100644 --- a/examples/env-overrides-demo/wrangler.toml +++ b/examples/env-overrides-demo/wrangler.toml @@ -1,6 +1,6 @@ name = "env-overrides-demo" main = "src/index.js" -compatibility_date = "2026-05-31" +compatibility_date = "2026-06-17" [vars] BASE_ONLY = "top-level-only" diff --git a/examples/hello-jsonc/wrangler.jsonc b/examples/hello-jsonc/wrangler.jsonc index 38938d3..1871896 100644 --- a/examples/hello-jsonc/wrangler.jsonc +++ b/examples/hello-jsonc/wrangler.jsonc @@ -2,7 +2,7 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "hello-jsonc", "main": "src/index.js", - "compatibility_date": "2026-05-31", + "compatibility_date": "2026-06-17", // Proves the deploy CLI handles wrangler.jsonc (comments + JSON) the same // way it handles wrangler.toml. No bindings — this is the minimal demo. diff --git a/examples/inspection-demo/README.md b/examples/inspection-demo/README.md index 33e9cdb..c206298 100644 --- a/examples/inspection-demo/README.md +++ b/examples/inspection-demo/README.md @@ -24,7 +24,7 @@ wdl d1 create inspection-main wdl deploy examples/inspection-demo ``` -The Worker uses `compatibility_date = "2026-05-31"` and expects Wrangler v4 in +The Worker uses `compatibility_date = "2026-06-17"` and expects Wrangler v4 in this example directory. If dependencies are missing, run: ```bash diff --git a/examples/inspection-demo/wrangler.toml b/examples/inspection-demo/wrangler.toml index 2031a3b..0a7a55b 100644 --- a/examples/inspection-demo/wrangler.toml +++ b/examples/inspection-demo/wrangler.toml @@ -1,6 +1,6 @@ name = "inspection-demo" main = "src/index.js" -compatibility_date = "2026-05-31" +compatibility_date = "2026-06-17" [[r2_buckets]] binding = "IMAGES" diff --git a/examples/kv-demo/wrangler.toml b/examples/kv-demo/wrangler.toml index 574535b..71d5e2a 100644 --- a/examples/kv-demo/wrangler.toml +++ b/examples/kv-demo/wrangler.toml @@ -1,6 +1,6 @@ name = "kv-demo" main = "src/index.js" -compatibility_date = "2026-05-31" +compatibility_date = "2026-06-17" [[kv_namespaces]] binding = "VISITS" diff --git a/examples/pages-assets/wrangler.jsonc b/examples/pages-assets/wrangler.jsonc index 353b12c..5bd7283 100644 --- a/examples/pages-assets/wrangler.jsonc +++ b/examples/pages-assets/wrangler.jsonc @@ -2,7 +2,7 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "pages-assets", "main": "src/index.js", - "compatibility_date": "2026-05-31", + "compatibility_date": "2026-06-17", // Files under ./public are uploaded to the platform asset store; the // worker uses env.ASSETS.url() to get CDN URLs back. Same field shape diff --git a/examples/queues-demo/wrangler.toml b/examples/queues-demo/wrangler.toml index 6cb90ea..cbba469 100644 --- a/examples/queues-demo/wrangler.toml +++ b/examples/queues-demo/wrangler.toml @@ -1,6 +1,6 @@ name = "queues-demo" main = "src/index.js" -compatibility_date = "2026-05-31" +compatibility_date = "2026-06-17" [[kv_namespaces]] binding = "QUEUE_STATE" diff --git a/examples/workflows-demo/wrangler.toml b/examples/workflows-demo/wrangler.toml index b7c0b13..44d4ec0 100644 --- a/examples/workflows-demo/wrangler.toml +++ b/examples/workflows-demo/wrangler.toml @@ -1,6 +1,6 @@ name = "workflows-demo" main = "src/index.js" -compatibility_date = "2026-05-31" +compatibility_date = "2026-06-17" [[workflows]] name = "orders" diff --git a/lib/bundle-modules.js b/lib/bundle-modules.js index 34c3248..5d73b96 100644 --- a/lib/bundle-modules.js +++ b/lib/bundle-modules.js @@ -7,14 +7,15 @@ export function inferType(filePath) { const ext = path.extname(filePath).toLowerCase(); if (ext === ".js" || ext === ".mjs") return "module"; if (ext === ".cjs") return "cjs"; - if (ext === ".py") return "py"; if (ext === ".json") return "json"; if (TEXT_EXTS.has(ext)) return "text"; if (ext === ".wasm") return "wasm"; return "data"; } -// Inverse of `control/lib.js::normalizeModule` - keep the two in sync. +// Inverse of `control/bundle.js::normalizeModule` for WDL-supported module +// types. Python Workers modules are intentionally not emitted: current control +// rejects `{ py: ... }` with python_workers_unsupported. /** * `type` is normally an {@link inferType} result, but the default case rejects * anything else, so the honest input type is `string`. @@ -25,7 +26,6 @@ export function toWireModule(buf, type) { switch (type) { case "module": return buf.toString("utf8"); case "cjs": return { cjs: buf.toString("utf8") }; - case "py": return { py: buf.toString("utf8") }; case "text": return { text: buf.toString("utf8") }; case "json": return { json: JSON.parse(buf.toString("utf8")) }; case "wasm": return { wasm_b64: buf.toString("base64") }; diff --git a/lib/command.js b/lib/command.js index 9f70269..19abec4 100644 --- a/lib/command.js +++ b/lib/command.js @@ -195,7 +195,7 @@ function buildContext(deps, commandDefaults, values, positionals) { let controlMemo; const resolveControlFn = () => (controlMemo ??= (() => { const control = resolveControlContext(values, env); - warnIfInsecureControlUrl(control.controlUrl, warn); + warnIfInsecureControlUrl(control.controlUrl, warn, env); return control; })()); context.resolveControl = resolveControlFn; diff --git a/lib/common.js b/lib/common.js index b0cf885..a9a300b 100644 --- a/lib/common.js +++ b/lib/common.js @@ -266,7 +266,12 @@ export async function readJsonOrFail(res, label) { await throwHttpErrorIfNotOk(res, label); // A 2xx response always carries a json reader. if (typeof res.json !== "function") throw new CliError(`${label} failed: response is not JSON`); - return await res.json(); + try { + return await res.json(); + } catch (err) { + const message = err instanceof Error && err.message ? err.message : String(err); + throw new CliError(`${label} failed: response is not valid JSON: ${escapeTerminalText(message)}`); + } } /** @@ -275,7 +280,7 @@ export async function readJsonOrFail(res, label) { */ export async function throwHttpErrorIfNotOk(res, label) { if (res.ok) return; - throw new CliError(`${label} failed: ${formatHttpError(res.status, await res.text())}`); + throw new CliError(`${label} failed: ${formatHttpError(res.status, await res.text(), res.headers)}`); } const ERROR_SUMMARY_KEYS = new Set([ @@ -295,21 +300,23 @@ const ARRAY_CONTEXT_KEYS = new Set([ /** * @param {number | undefined} status * @param {unknown} text + * @param {import("node:http").IncomingHttpHeaders} [headers] */ -function formatHttpError(status, text) { +export function formatHttpError(status, text, headers = {}) { const raw = typeof text === "string" ? text.trim() : ""; - if (!raw) return String(status); + const location = redirectLocation(status, headers); + if (!raw) return appendLocationContext(String(status), location); /** @type {unknown} */ let body; try { body = JSON.parse(raw); } catch { - return `${status} ${escapeTerminalText(raw)}`; + return appendLocationContext(`${status} ${escapeTerminalText(raw)}`, location); } if (!body || typeof body !== "object" || Array.isArray(body)) { - return `${status} ${escapeTerminalText(raw)}`; + return appendLocationContext(`${status} ${escapeTerminalText(raw)}`, location); } const record = /** @type {Record} */ (body); @@ -324,11 +331,12 @@ function formatHttpError(status, text) { } parts.push(summary); } else { - return `${status} ${escapeTerminalText(raw)}`; + return appendLocationContext(`${status} ${escapeTerminalText(raw)}`, location); } /** @type {string[]} */ const context = []; + if (location) context.push(`location=${formatContextValue(location)}`); for (const [key, value] of Object.entries(record)) { if (ERROR_SUMMARY_KEYS.has(key)) continue; if (value == null) continue; @@ -346,6 +354,24 @@ function formatHttpError(status, text) { return parts.join(" "); } +/** + * @param {number | undefined} status + * @param {import("node:http").IncomingHttpHeaders} headers + */ +function redirectLocation(status, headers) { + if (status == null || status < 300 || status >= 400) return ""; + const value = headers.location; + return Array.isArray(value) ? value[0] || "" : value || ""; +} + +/** + * @param {string} message + * @param {string} location + */ +function appendLocationContext(message, location) { + return location ? `${message} location=${formatContextValue(location)}` : message; +} + /** @param {unknown} value */ function scalarString(value) { if (typeof value === "string") return value; @@ -363,7 +389,11 @@ function formatContextValue(value) { /** @param {string} segment */ export function encodePath(segment) { - return encodeURIComponent(segment); + const text = String(segment); + if (text === "" || text === "." || text === "..") { + throw new CliError(`invalid URL path segment: ${JSON.stringify(text)}`); + } + return encodeURIComponent(text); } // True when `target` is `root` or lives inside it. Bare startsWith("..") would diff --git a/lib/control-fetch.js b/lib/control-fetch.js index ddb40b4..fa89b45 100644 --- a/lib/control-fetch.js +++ b/lib/control-fetch.js @@ -7,12 +7,14 @@ import http from "node:http"; import https from "node:https"; +import { isIP } from "node:net"; import { Transform } from "node:stream"; import { CliError } from "./common.js"; +import { currentCliVersion } from "./package-info.js"; export const DEFAULT_CONTROL_TIMEOUT_MS = 30_000; export const LONG_CONTROL_TIMEOUT_MS = 5 * 60_000; -export const DEFAULT_CONTROL_MAX_BODY_BYTES = 10 * 1024 * 1024; +export const DEFAULT_CONTROL_MAX_BODY_BYTES = 16 * 1024 * 1024; export const UNLIMITED_CONTROL_BODY_BYTES = 0; /** @@ -36,6 +38,7 @@ export const UNLIMITED_CONTROL_BODY_BYTES = 0; * @typedef {object} ControlResponseStatus * @property {boolean} ok * @property {number} [status] + * @property {import("node:http").IncomingHttpHeaders} [headers] * @property {() => Promise} text */ @@ -69,6 +72,7 @@ export const UNLIMITED_CONTROL_BODY_BYTES = 0; * @property {number} [timeoutMs] * @property {number} [maxBodyBytes] * @property {AbortSignal} [signal] + * @property {NodeJS.ProcessEnv} [env] * @property {string} [method] * @property {import("node:http").OutgoingHttpHeaders} [headers] * @property {boolean} [streamResponse] @@ -87,9 +91,10 @@ export function controlFetch(urlStr, init = {}) { const maxBodyBytes = init.maxBodyBytes ?? DEFAULT_CONTROL_MAX_BODY_BYTES; const signal = init.signal; - const requestOpts = controlRequestOptions(u); + const requestOpts = controlRequestOptions(u, init.env); requestOpts.method = init.method || "GET"; requestOpts.headers = { ...requestOpts.headers, ...(init.headers || {}) }; + validateControlHeaders(/** @type {import("node:http").OutgoingHttpHeaders} */ (requestOpts.headers)); return new Promise((resolve, reject) => { let settled = false; @@ -110,14 +115,14 @@ export function controlFetch(urlStr, init = {}) { if (settled) return; settled = true; cleanup(); - req.destroy(); - reject(err); + req?.destroy(); + reject(controlRequestError(err)); }; /** @param {Error} err */ const failStream = (err) => { cleanup(); - req.destroy(err); + req?.destroy(err); if (streamRes) streamRes.destroy(err); if (streamBody) streamBody.destroy(err); }; @@ -146,30 +151,37 @@ export function controlFetch(urlStr, init = {}) { } }; - const req = transport.request(requestOpts, (res) => { - if (init.streamResponse) { - if (settled) return; - settled = true; - streamRes = res; - const body = createTimeoutResetStream(resetTimer); - streamBody = body; - res.pipe(body); - res.once("end", cleanup); - res.once("error", (err) => { + /** @type {ControlClientRequest | null} */ + let req = null; + try { + req = transport.request(requestOpts, (res) => { + if (init.streamResponse) { + if (settled) return; + settled = true; + streamRes = res; + const body = createTimeoutResetStream(resetTimer); + streamBody = body; + res.pipe(body); + res.once("end", cleanup); + res.once("error", (err) => { + cleanup(); + body.destroy(err); + }); + res.once("close", cleanup); + resolve(streamControlResponse(res, body)); + return; + } + readControlResponse(res, { maxBodyBytes }).then((response) => { + if (settled) return; + settled = true; cleanup(); - body.destroy(err); - }); - res.once("close", cleanup); - resolve(streamControlResponse(res, body)); - return; - } - readControlResponse(res, { maxBodyBytes }).then((response) => { - if (settled) return; - settled = true; - cleanup(); - resolve(response); - }, fail); - }); + resolve(response); + }, fail); + }); + } catch (err) { + reject(controlRequestError(toError(err))); + return; + } req.on("error", fail); resetTimer(); if (signal) signal.addEventListener("abort", onAbort, { once: true }); @@ -178,6 +190,42 @@ export function controlFetch(urlStr, init = {}) { }); } +/** + * @param {import("node:http").OutgoingHttpHeaders | undefined} headers + */ +export function validateControlHeaders(headers) { + if (!headers) return; + for (const [name, value] of Object.entries(headers)) { + try { + http.validateHeaderName(name); + if (Array.isArray(value)) { + for (const item of value) http.validateHeaderValue(name, item); + } else if (value !== undefined) { + http.validateHeaderValue(name, typeof value === "number" ? String(value) : value); + } + } catch (err) { + const message = err instanceof Error && err.message ? err.message : String(err); + throw new CliError(`control request failed: invalid HTTP header ${JSON.stringify(name)}: ${message}`); + } + } +} + +/** @param {unknown} err */ +function toError(err) { + return err instanceof Error ? err : new Error(String(err)); +} + +/** @param {Error} err */ +export function controlRequestError(err) { + if (err instanceof CliError) return err; + const codeValue = (/** @type {{ code?: unknown }} */ (/** @type {unknown} */ (err))).code; + const code = typeof codeValue === "string" + ? `${codeValue} ` + : ""; + const message = err.message || String(err); + return new CliError(`control request failed: ${code}${message}`); +} + // 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 @@ -185,29 +233,91 @@ export function controlFetch(urlStr, init = {}) { // admin host. /** * @param {URL} u + * @param {NodeJS.ProcessEnv} [env] * @returns {import("node:https").RequestOptions} */ -export function controlRequestOptions(u) { +export function controlRequestOptions(u, env = process.env) { const isHttps = u.protocol === "https:"; + const target = controlConnectTarget(u, isHttps, env); + const tlsServerName = bareHostname(u); /** @type {import("node:https").RequestOptions} */ const opts = { - host: process.env.CONTROL_CONNECT_HOST || bareHostname(u), - port: Number(u.port) || (isHttps ? 443 : 80), + host: target.host, + port: target.port, path: u.pathname + u.search, // u.host carries the port when it isn't the scheme default (RFC 9112). - headers: { Host: u.host }, + headers: { + Host: u.host, + "User-Agent": `wdl-cli/${currentCliVersion() || "unknown"}`, + }, agent: false, }; - if (isHttps) opts.servername = bareHostname(u); + if (isHttps && isIP(tlsServerName) === 0) opts.servername = tlsServerName; return opts; } +/** + * @param {URL} u + * @param {boolean} isHttps + * @param {NodeJS.ProcessEnv} env + * @returns {{ host: string, port: number }} + */ +function controlConnectTarget(u, isHttps, env) { + const defaultPort = Number(u.port) || (isHttps ? 443 : 80); + const override = env.CONTROL_CONNECT_HOST; + if (!override) return { host: bareHostname(u), port: defaultPort }; + return parseConnectHost(override, defaultPort); +} + +/** + * @param {string} raw + * @param {number} defaultPort + * @returns {{ host: string, port: number }} + */ +function parseConnectHost(raw, defaultPort) { + const text = raw.trim(); + if (/^[a-z][a-z\d+.-]*:\/\//i.test(text)) { + try { + const parsed = new URL(text); + return { host: bareUrlHostname(parsed), port: validPort(parsed.port || defaultPort, raw) }; + } catch (err) { + if (err instanceof CliError) throw err; + throw new CliError(`Invalid CONTROL_CONNECT_HOST ${JSON.stringify(raw)}.`); + } + } + + const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(text); + if (bracketed) { + return { host: bracketed[1], port: validPort(bracketed[2] || defaultPort, raw) }; + } + const hostPort = /^([^:]+):(\d+)$/.exec(text); + if (hostPort) return { host: hostPort[1], port: validPort(hostPort[2], raw) }; + return { host: text, port: defaultPort }; +} + +/** + * @param {string | number} value + * @param {string} raw + */ +function validPort(value, raw) { + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new CliError(`Invalid CONTROL_CONNECT_HOST ${JSON.stringify(raw)}: port must be in [1, 65535]`); + } + return port; +} + +/** @param {URL} u */ +function bareUrlHostname(u) { + const match = /^\[(.*)\]$/.exec(u.hostname); + return match ? match[1] : u.hostname; +} + // URL.hostname keeps IPv6 literals bracketed ("[::1]"), but the socket layer // (DNS lookup, SNI) needs the bare address. /** @param {URL} u */ function bareHostname(u) { - const match = /^\[(.*)\]$/.exec(u.hostname); - return match ? match[1] : u.hostname; + return bareUrlHostname(u); } /** @param {() => void} resetTimer */ diff --git a/lib/credentials.js b/lib/credentials.js index 77331cb..45dd1de 100644 --- a/lib/credentials.js +++ b/lib/credentials.js @@ -6,6 +6,7 @@ import { readFileSync } from "node:fs"; import { CliError, hasErrorCode, isNonEmptyString } from "./common.js"; import { CLI_DOTENV_KEYS, parseDotEnvSection, parseDotEnvValue } from "./dotenv.js"; import { isAdminAcceptableNs } from "./ns-pattern.js"; +import { escapeTerminalText } from "./output.js"; // Control-plane endpoint keys: where the admin token gets sent. A cwd .env // must not redirect these for a token that came from the shell/--token. @@ -88,23 +89,39 @@ export function resolveControlContext(values, env = process.env) { /** * @param {string} controlUrl * @param {(line: string) => void} [warn] + * @param {NodeJS.ProcessEnv} [env] */ -export function warnIfInsecureControlUrl(controlUrl, warn = (line) => console.error(line)) { - if (!isInsecureControlUrl(controlUrl)) return; - warn(`warning: control URL ${controlUrl} is plain http on a non-local host; the admin token will be sent unencrypted`); +export function warnIfInsecureControlUrl(controlUrl, warn = (line) => console.error(line), env = process.env) { + const reason = insecureControlUrlReason(controlUrl, env); + if (!reason) return; + warn(`warning: ${reason}; the admin token will be sent unencrypted`); } // True when the admin token would travel unencrypted to a host that doesn't // look like a local/dev target. -/** @param {string} controlUrl */ -function isInsecureControlUrl(controlUrl) { +/** + * @param {string} controlUrl + * @param {NodeJS.ProcessEnv} env + */ +function insecureControlUrlReason(controlUrl, env) { let parsed; try { parsed = new URL(controlUrl); } catch { - return false; + return null; } - return parsed.protocol === "http:" && !isLocalDevHost(parsed.hostname); + if (parsed.protocol !== "http:") return null; + if (!isLocalDevHost(parsed.hostname)) { + return `control URL ${escapeTerminalText(controlUrl)} is plain http on a non-local host`; + } + const connectHost = env.CONTROL_CONNECT_HOST; + if (!isNonEmptyString(connectHost)) return null; + const actualHost = controlConnectHostname(connectHost); + if (!isLocalDevHost(actualHost)) { + return `control URL ${escapeTerminalText(controlUrl)} is plain http and ` + + `CONTROL_CONNECT_HOST=${escapeTerminalText(connectHost)} is non-local`; + } + return null; } // Loopback / dev-TLD hosts, shared by the bare-URL scheme default and the @@ -122,6 +139,21 @@ export function isLocalDevHost(host) { ); } +/** @param {string} value */ +function controlConnectHostname(value) { + const text = value.trim(); + if (/^[a-z][a-z\d+.-]*:\/\//i.test(text)) { + try { + return new URL(text).hostname; + } catch {} + } + const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(text); + if (bracketed) return bracketed[1]; + const hostPort = /^([^:]+):\d+$/.exec(text); + if (hostPort) return hostPort[1]; + return text; +} + /** * @param {Record} values * @param {NodeJS.ProcessEnv} [env] @@ -209,14 +241,18 @@ export function loadCliDotEnv( const body = line.startsWith("export ") ? line.slice("export ".length).trimStart() : line; const eq = body.indexOf("="); if (eq <= 0) { - throw new CliError(`Invalid .env line ${idx + 1}: expected KEY=value`); + const key = firstDotEnvToken(body); + if (CLI_DOTENV_KEYS.has(key)) { + throw new CliError(`Invalid .env line ${idx + 1}: expected KEY=value`); + } + continue; } const key = body.slice(0, eq).trim(); + if (!CLI_DOTENV_KEYS.has(key)) continue; if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { throw new CliError(`Invalid .env line ${idx + 1}: invalid key "${key}"`); } - if (!CLI_DOTENV_KEYS.has(key)) continue; const shouldLoad = section === null ? loadBase : section === selectedSection; @@ -235,6 +271,12 @@ export function loadCliDotEnv( return loaded; } +/** @param {string} body */ +function firstDotEnvToken(body) { + const match = /^([A-Za-z_][A-Za-z0-9_]*)\b/.exec(body.trim()); + return match ? match[1] : ""; +} + // Two-phase .env load (base, then [resolved-ns] overlay) plus a cross-origin // guard, shared by the bin dispatcher and config-state so both apply the same // trust model. Returns the set of keys actually loaded from .env. diff --git a/lib/d1-files.js b/lib/d1-files.js index 956d05f..6c1ed30 100644 --- a/lib/d1-files.js +++ b/lib/d1-files.js @@ -27,7 +27,14 @@ export function readSql(values, cwd = process.cwd()) { if (!isPathInside(root, resolved)) { throw new CliError("--file must stay inside the project"); } - return requireSqlText(readFileSync(resolved, "utf8"), "SQL file"); + let sql; + try { + sql = readFileSync(resolved, "utf8"); + } catch (err) { + const message = err instanceof Error && err.message ? err.message : String(err); + throw new CliError(`cannot read SQL file ${values.file}: ${message}`); + } + return requireSqlText(sql, "SQL file"); } throw new CliError("d1 execute requires --sql or --file "); } @@ -65,9 +72,16 @@ export function readMigrationFiles(dir = "migrations") { const message = err instanceof Error && err.message ? err.message : String(err); throw new CliError(`cannot read migrations dir ${dir}: ${message}`); } - return entries - .filter((entry) => entry.isFile() && entry.name.endsWith(".sql")) - .map((entry) => entry.name) + /** @type {string[]} */ + const filenames = []; + for (const entry of entries) { + if (!entry.name.endsWith(".sql")) continue; + if (entry.isSymbolicLink()) { + throw new CliError(`d1 migrations: ${entry.name} is a symlink; copy the SQL file into the migrations dir`); + } + if (entry.isFile()) filenames.push(entry.name); + } + return filenames .toSorted(compareMigrationFilenames) .map((filename) => { const file = path.join(root, filename); diff --git a/lib/token-store.js b/lib/token-store.js index 57f9fe3..66a369d 100644 --- a/lib/token-store.js +++ b/lib/token-store.js @@ -1,4 +1,4 @@ -import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { CliError, hasErrorCode, isNonEmptyString } from "./common.js"; @@ -167,17 +167,16 @@ export function writeTokenStore(storePath, store) { // best-effort } assertStoreDirSecure(storeDir); - // Tighten an existing file to 0600 BEFORE writing token bytes: writeFileSync's - // mode only applies when it creates the file, so without this a pre-existing - // permissive (e.g. 0644) credentials file would receive the secret while still - // world-readable, with a window until a post-write chmod. A non-ENOENT failure - // aborts before the token is written. + const tmpPath = `${storePath}.${process.pid}.${Date.now()}.tmp`; try { + writeFileSync(tmpPath, lines.join("\n"), { mode: 0o600, flag: "wx" }); + chmodSync(tmpPath, 0o600); + renameSync(tmpPath, storePath); chmodSync(storePath, 0o600); } catch (err) { - if (!hasErrorCode(err) || err.code !== "ENOENT") throw err; + rmSync(tmpPath, { force: true }); + throw err; } - writeFileSync(storePath, lines.join("\n"), { mode: 0o600 }); } // The `readStore` loadCliControlEnv expects: the real disk reader, or a no-op diff --git a/lib/whoami.js b/lib/whoami.js index de38e08..7a3f997 100644 --- a/lib/whoami.js +++ b/lib/whoami.js @@ -39,11 +39,13 @@ import { currentCliVersion } from "./package-info.js"; * controlUrl: string, * headers: Record, * controlFetch: typeof import("./control-fetch.js").controlFetch, + * env?: NodeJS.ProcessEnv, * }} options * @returns {Promise} */ -export async function fetchWhoami({ controlUrl, headers, controlFetch }) { - const res = await controlFetch(`${controlUrl}/whoami`, { headers }); +export async function fetchWhoami({ controlUrl, headers, controlFetch, env }) { + const init = env ? { headers, env } : { headers }; + const res = await controlFetch(`${controlUrl}/whoami`, init); const body = /** @type {WhoamiBody} */ (await readJsonOrFail(res, "whoami")); if (body?.ok !== true) throw new CliError("whoami failed: invalid control response"); return body; diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index f1548db..754eb87 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -31,6 +31,7 @@ import { wranglerChildEnv, } from "./wrangler/command.js"; import { + formatWranglerConfigShadowWarning, loadWranglerConfig, resolveWranglerConfig, validateUnsupportedWranglerConfig, @@ -65,6 +66,7 @@ export { wranglerChildEnv, } from "./wrangler/command.js"; export { + formatWranglerConfigShadowWarning, loadWranglerConfig, parseJsonc, resolveWranglerConfig, @@ -148,6 +150,8 @@ export async function packWranglerProject({ const loadedConfig = wrapCli(() => loadWranglerConfig(absProject)); const { path: configPath, cfg: rawCfg } = loadedConfig; const configRel = path.basename(configPath); + const shadowWarning = formatWranglerConfigShadowWarning(loadedConfig); + if (shadowWarning) stderr(`warning: ${shadowWarning}`); const { cfg, envName } = wrapCli(() => { validateUnsupportedWranglerConfig(rawCfg, selectedEnv, configRel); return resolveWranglerConfig(rawCfg, selectedEnv, configRel); @@ -233,6 +237,14 @@ export async function packWranglerProject({ } const vars = normalizeVars(cfg.vars); + for (const name of Object.keys(vars)) claimBinding(name); + + if (Array.isArray(cfg.compatibility_flags) && cfg.compatibility_flags.includes("experimental")) { + throw new CliError( + `${configRel}: compatibility_flags "experimental" is not supported by WDL` + ); + } + // 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. @@ -254,6 +266,7 @@ export async function packWranglerProject({ writeFileSync(tmpConfigPath, JSON.stringify({ ...rawCfgObject, name: "wdl-bundle-tmp" }), { flag: "wx", }); + const cleanupTempConfig = installTempFileCleanup(tmpConfigPath); try { const wranglerArgs = [ ...wrangler.args, @@ -282,7 +295,7 @@ export async function packWranglerProject({ } catch (err) { throw new CliError(formatWranglerFailure(err)); } finally { - rmSync(tmpConfigPath, { force: true }); + cleanupTempConfig(); } const modules = wrapCli(() => collectModules(outDir)); @@ -343,7 +356,10 @@ export async function packWranglerProject({ `defaults and .assetsignore rules apply — re-include with a !pattern line` ); } - if (Object.keys(assets).length) manifest.assets = assets; + if (Object.keys(assets).length) { + claimBinding("ASSETS"); + manifest.assets = assets; + } } return { absProject, workerName: cfg.name, manifest }; @@ -382,6 +398,46 @@ export function collectRoutes(cfg, configRel) { return collected; } +/** + * @param {string} filePath + * @param {{ + * once(event: string, listener: () => void): unknown, + * off(event: string, listener: () => void): unknown, + * }} [processLike] + * @param {(signal: "SIGINT" | "SIGTERM") => void} [terminate] + * @returns {() => void} + */ +export function installTempFileCleanup( + filePath, + processLike = process, + terminate = (signal) => { process.kill(process.pid, signal); } +) { + let active = true; + const cleanup = () => rmSync(filePath, { force: true }); + const onExit = () => cleanup(); + const uninstall = () => { + if (!active) return; + active = false; + processLike.off("exit", onExit); + processLike.off("SIGINT", onSigint); + processLike.off("SIGTERM", onSigterm); + }; + const handleSignal = (/** @type {"SIGINT" | "SIGTERM"} */ signal) => { + cleanup(); + uninstall(); + terminate(signal); + }; + const onSigint = () => handleSignal("SIGINT"); + const onSigterm = () => handleSignal("SIGTERM"); + processLike.once("exit", onExit); + processLike.once("SIGINT", onSigint); + processLike.once("SIGTERM", onSigterm); + return () => { + cleanup(); + uninstall(); + }; +} + /** * @template T * @param {() => T} fn diff --git a/lib/wrangler/assets.js b/lib/wrangler/assets.js index f038e63..5c9451b 100644 --- a/lib/wrangler/assets.js +++ b/lib/wrangler/assets.js @@ -172,6 +172,7 @@ function createAssetIgnoreMatcher(patterns) { * @returns {{ regex: RegExp, dirOnly: boolean, negated: boolean }} */ function compileAssetIgnoreRule(pattern) { + const originalPattern = pattern; let negated = false; if (pattern.startsWith("!")) { negated = true; @@ -190,8 +191,17 @@ function compileAssetIgnoreRule(pattern) { anchored = true; } const prefix = anchored ? "" : "(?:[^/]+/)*"; + let regex; + try { + regex = new RegExp(`^${prefix}${assetGlobToRegex(pattern)}$`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`assets: invalid .assetsignore pattern ${JSON.stringify(originalPattern)}: ${message}`, { + cause: err, + }); + } return { - regex: new RegExp(`^${prefix}${assetGlobToRegex(pattern)}$`), + regex, dirOnly, negated, }; diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index bde94d6..97a3fdf 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -17,6 +17,7 @@ import { asRecord } from "./utils.js"; const NS_RE = new RegExp(`^${NS_PATTERN}$`); const MAX_QUEUE_DELAY_SECONDS = 86_400; +const MAX_QUEUE_CONSUMER_BATCH_TIMEOUT_SECONDS = 60; // UPPER_SNAKE for `as` / `platform` / required_caller_secrets - narrower // than binding names to read as registered identifiers. @@ -183,7 +184,8 @@ export function parseQueues(queues, configRel = "wrangler config") { entry.maxBatchTimeoutMs = normalizeQueueDelayConfig( c.max_batch_timeout, configRel, - `[[queues.consumers]] ${c.queue}.max_batch_timeout` + `[[queues.consumers]] ${c.queue}.max_batch_timeout`, + MAX_QUEUE_CONSUMER_BATCH_TIMEOUT_SECONDS ) * 1000; } if (c.max_retries != null) entry.maxRetries = c.max_retries; @@ -705,11 +707,12 @@ export function parsePlatformBindingsFromCfg(cfg, configRel = "wrangler config") * @param {unknown} value * @param {string} configRel * @param {string} field + * @param {number} [maxSeconds] * @returns {number} */ -function normalizeQueueDelayConfig(value, configRel, field) { - if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_QUEUE_DELAY_SECONDS) { - throw new Error(`${configRel}: ${field} must be an integer in [0, ${MAX_QUEUE_DELAY_SECONDS}]`); +function normalizeQueueDelayConfig(value, configRel, field, maxSeconds = MAX_QUEUE_DELAY_SECONDS) { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > maxSeconds) { + throw new Error(`${configRel}: ${field} must be an integer in [0, ${maxSeconds}]`); } return value; } diff --git a/lib/wrangler/command.js b/lib/wrangler/command.js index e35ad78..e00e3f5 100644 --- a/lib/wrangler/command.js +++ b/lib/wrangler/command.js @@ -1,11 +1,13 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { CliError } from "../common.js"; import { WRANGLER_SCRUB_KEYS } from "../dotenv.js"; const CLI_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const resolveFromHere = createRequire(import.meta.url); export const MIN_WRANGLER_MAJOR = 4; /** @@ -47,12 +49,18 @@ export function resolveWranglerCommand({ return { command: env.WDL_WRANGLER_BIN, args: [], source: "WDL_WRANGLER_BIN" }; } - for (const dir of uniquePaths([absProject, ...packageDirs])) { + const searchDirs = uniquePaths([absProject, ...packageDirs]); + for (const dir of searchDirs) { if (!dir) continue; const local = localWrangler(dir, platform); if (local) return { ...local, source: "local" }; } + if (searchDirs.includes(CLI_DIR)) { + const bundled = bundledWrangler(); + if (bundled) return { ...bundled, source: "package" }; + } + const fromPath = pathWrangler(env, platform); if (fromPath) return { ...fromPath, source: "path" }; @@ -196,6 +204,15 @@ function wranglerScript(dir) { return existsSync(script) ? script : null; } +function bundledWrangler() { + try { + const script = resolveFromHere.resolve("wrangler/bin/wrangler.js"); + return { command: process.execPath, args: [script] }; + } catch { + return null; + } +} + /** * @param {NodeJS.ProcessEnv} env * @param {NodeJS.Platform} platform diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index 2dfed5b..938f03f 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -74,20 +74,26 @@ const NON_INHERITABLE_ENV_KEYS = new Set([ "secrets_store_secrets", ]); +export const WRANGLER_CONFIG_CANDIDATES = Object.freeze(["wrangler.json", "wrangler.jsonc", "wrangler.toml"]); + +/** + * @typedef {{ path: string, cfg: unknown, shadowed: string[] }} LoadedWranglerConfig + */ + /** * @param {string} dir - * @returns {{ path: string, cfg: unknown }} + * @returns {LoadedWranglerConfig} */ export function loadWranglerConfig(dir) { - const candidates = ["wrangler.toml", "wrangler.jsonc", "wrangler.json"]; - for (const name of candidates) { + const found = WRANGLER_CONFIG_CANDIDATES.filter((name) => existsSync(path.join(dir, name))); + for (const name of found) { const p = path.join(dir, name); - if (!existsSync(p)) continue; const raw = readFileSync(p, "utf8"); try { - if (name.endsWith(".toml")) return { path: p, cfg: parseToml(raw) }; - if (name.endsWith(".jsonc")) return { path: p, cfg: parseJsonc(raw) }; - return { path: p, cfg: JSON.parse(raw) }; + const shadowed = found.slice(1); + if (name.endsWith(".toml")) return { path: p, cfg: parseToml(raw), shadowed }; + if (name.endsWith(".jsonc")) return { path: p, cfg: parseJsonc(raw), shadowed }; + return { path: p, cfg: JSON.parse(raw), shadowed }; } catch (err) { const message = err instanceof Error && err.message ? err.message : String(err); throw new Error(`failed to parse ${name}: ${message}`, { cause: err }); @@ -96,6 +102,15 @@ export function loadWranglerConfig(dir) { throw new Error(`no wrangler.{toml,jsonc,json} found in ${dir}`); } +/** + * @param {LoadedWranglerConfig} loaded + * @returns {string | null} + */ +export function formatWranglerConfigShadowWarning(loaded) { + if (loaded.shadowed.length === 0) return null; + return `multiple Wrangler config files found; using ${path.basename(loaded.path)} and ignoring ${loaded.shadowed.join(", ")}`; +} + /** * @param {unknown} rawCfg * @param {string | null | undefined} envName diff --git a/lib/wrangler/module-limits.js b/lib/wrangler/module-limits.js new file mode 100644 index 0000000..1c7b90c --- /dev/null +++ b/lib/wrangler/module-limits.js @@ -0,0 +1,30 @@ +// CLI-local copy of the WDL runtime/do-runtime injected module names that a +// tenant bundle must not use. Keep in sync with runtime/load/module-rewrite.js +// and do-runtime/load-code-budget.js in wdl. + +export const WDL_RESERVED_MODULE_NAMES = Object.freeze([ + "_wdl-cloudflare-workflows.js", + "_wdl-d1-data-field.js", + "_wdl-d1-client.js", + "_wdl-d1-params.js", + "_wdl-sql-splitter.js", + "_wdl-d1-transport.js", + "_wdl-r2-client.js", + "_wdl-r2-utils.js", + "_wdl-do-client.js", + "_wdl-do-transport.js", + "_wdl-owner-endpoint.js", + "_wdl-owner-hint-cache.js", + "_wdl-request-id.js", + "_wdl-workflows-client.js", + "_wdl-wrapper.js", + "_wdl-do-runtime-wrapper.js", + "_wdl-do-alarm-shim.js", +]); + +const WDL_RESERVED_MODULE_SET = new Set(WDL_RESERVED_MODULE_NAMES); + +/** @param {string} name */ +export function isWdlReservedModuleName(name) { + return WDL_RESERVED_MODULE_SET.has(name); +} diff --git a/lib/wrangler/modules.js b/lib/wrangler/modules.js index ea1006c..bc2f676 100644 --- a/lib/wrangler/modules.js +++ b/lib/wrangler/modules.js @@ -1,6 +1,7 @@ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { inferType, toWireModule } from "../bundle-modules.js"; +import { isWdlReservedModuleName } from "./module-limits.js"; import { manifestMap } from "./utils.js"; // Skip only the known incidentals (.map, README.md). Dropping any other @@ -27,6 +28,12 @@ export function collectModules(dir) { } if (!st.isFile()) continue; if (entry.endsWith(".map") || skip.has(relPath)) continue; + if (isWdlReservedModuleName(relPath)) { + throw new Error(`wrangler output uses WDL reserved module name ${relPath}`); + } + if (path.extname(entry).toLowerCase() === ".py") { + throw new Error(`Python Workers modules are not supported by WDL; remove ${relPath} from the bundle`); + } out[relPath] = toWireModule(readFileSync(full), inferType(entry)); } })(dir, ""); diff --git a/templates/AGENTS.md b/templates/AGENTS.md index a94c6ce..222cf78 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -38,6 +38,16 @@ Open the relevant doc before editing `wrangler.jsonc` / `wrangler.toml` or `src/`. When combining features (say "cron + KV + assets"), read each matching doc and merge their wrangler config snippets. +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. WDL follows Wrangler config priority +(`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`) and rejects +Python Workers modules, unsupported workerd experimental compatibility flags, +modules named like WDL injected `_wdl-*.js` runtime modules, and `[vars]` keys +that collide with bindings. The CLI locally rejects the explicit `experimental` +compatibility flag; the control plane is canonical for other unsupported +experimental flags. + ## Runnable end-to-end examples When a snippet is not enough and you need a complete working file tree: diff --git a/tests/integration/cli-live.test.js b/tests/integration/cli-live.test.js index 0a74e28..e5f04ab 100644 --- a/tests/integration/cli-live.test.js +++ b/tests/integration/cli-live.test.js @@ -125,6 +125,7 @@ const DEFAULT_LOCAL_CONTROL_URL = `http://admin.test:${LOCAL_GATEWAY_PORT}`; const DEFAULT_LOCAL_ADMIN_TOKEN = "local-dev-token"; const DEFAULT_LOCAL_PLATFORM_DOMAIN = "workers.local"; const DEFAULT_LOCAL_GATEWAY_ORIGIN = `http://localhost:${LOCAL_GATEWAY_PORT}`; +const LIVE_WORKER_COMPATIBILITY_DATE = process.env.WDL_LIVE_COMPATIBILITY_DATE || "2026-06-17"; const LIVE_TIMEOUT_MS = 20 * 60_000; const TENANT_REQUEST_TIMEOUT_MS = 30_000; @@ -136,7 +137,9 @@ test("live CLI integration covers command surface against a WDL control plane", /** @type {Array<() => Promise>} */ const cleanup = []; let appDir = ""; + let doDir = ""; let wfDir = ""; + let envDir = ""; let initDir = ""; /** @type {NodeJS.ProcessEnv | null} */ let storeEnv = null; @@ -177,10 +180,13 @@ test("live CLI integration covers command surface against a WDL control plane", const ns = activeTenant.ns; const appWorker = "cli-live-app"; + const doWorker = "cli-live-do"; const wfWorker = "cli-live-wf"; + const envWorker = "cli-live-env"; const dbName = `${ns}-main`; const bucket = `cli-live-${ns}`; const kvId = `${ns}-kv`; + const queueName = `${ns}-jobs`; const objectKey = `objects/${ns}/sample.txt`; const xdg = path.join(tempRoot, "xdg"); const commonEnv = integrationEnv(ctx, { XDG_CONFIG_HOME: xdg }); @@ -225,7 +231,7 @@ test("live CLI integration covers command surface against a WDL control plane", await step("init command scaffolds a project", () => { initDir = path.join(tempRoot, "init-project"); run(["init", "init-project", "--ns", ns, "--worker", "init-worker"], { cwd: tempRoot }); - assert.match(readFileSync(path.join(initDir, "wrangler.jsonc"), "utf8"), /2026-05-31/); + assert.match(readFileSync(path.join(initDir, "wrangler.jsonc"), "utf8"), /2026-06-17/); }); await step("token store commands", () => { @@ -261,8 +267,10 @@ test("live CLI integration covers command surface against a WDL control plane", }); await step("write live app and workflow fixtures", () => { - appDir = writeAppProject(tempRoot, { worker: appWorker, dbName, bucket, kvId }); + appDir = writeAppProject(tempRoot, { worker: appWorker, dbName, bucket, kvId, queueName }); + doDir = writeDurableObjectProject(tempRoot, { worker: doWorker }); wfDir = writeWorkflowProject(tempRoot, { worker: wfWorker }); + envDir = writeEnvProject(tempRoot, { worker: envWorker }); }); cleanupStep("delete d1 database", () => { @@ -271,6 +279,13 @@ test("live CLI integration covers command surface against a WDL control plane", cleanupStep("delete app worker", () => { if (!cleaned.appWorker) run(["delete", "worker", appWorker, "--yes", "--json"], { env: directTenantEnv }); }); + cleanupStep("delete durable object worker", () => { + try { + run(["delete", "worker", doWorker, "--yes", "--json"], { env: directTenantEnv }); + } catch { + // The DO smoke may not have deployed if an earlier step failed. + } + }); cleanupStep("delete workflow worker", () => { try { run(["delete", "worker", wfWorker, "--yes", "--json"], { env: directTenantEnv }); @@ -283,6 +298,13 @@ test("live CLI integration covers command surface against a WDL control plane", throw err; } }); + cleanupStep("delete env worker", () => { + try { + run(["delete", "worker", envWorker, "--yes", "--json"], { env: directTenantEnv }); + } catch { + // The env smoke may not have deployed if an earlier step failed. + } + }); await step("d1 commands create, migrate, list, execute", () => { const createdDb = /** @type {D1CreateResult} */ (runJson(["d1", "create", dbName, "--json"], { env: storeEnv })); @@ -349,6 +371,30 @@ test("live CLI integration covers command surface against a WDL control plane", assert.deepEqual(kvPut, { key: "counter", value: 1 }); }); + await step("tenant runtime exercises assets, queues, and cron registration", async () => { + const assetUrl = /** @type {{ url?: string }} */ (await tenantJson(ctx, ns, appWorker, "/asset-url")); + assert.match(String(assetUrl.url), /hello\.txt/); + + const queued = /** @type {{ id?: string }} */ ( + await tenantJson(ctx, ns, appWorker, "/queue/enqueue?id=live-1", { method: "POST" }) + ); + assert.equal(queued.id, "live-1"); + await waitForTenantJson(ctx, ns, appWorker, "/queue/jobs?id=live-1", (body) => { + const typed = /** @type {{ jobs?: Array<{ id?: string, queue?: string }> }} */ (body); + const jobs = typed.jobs || []; + return jobs.some((job) => job.id === "live-1" && job.queue === queueName); + }); + }); + + await step("deploy command publishes Durable Object worker", async () => { + const doDeploy = run(["deploy", doDir], { env: storeEnv, timeoutMs: 5 * 60_000 }); + assertDeployPrintedLiveVersion(doDeploy.stdout); + const durableObjectHit = /** @type {{ storedHits?: number }} */ ( + await tenantJson(ctx, ns, doWorker, "/do?room=live") + ); + assert.equal(durableObjectHit.storedHits, 1); + }); + await step("r2 commands list, head, get, delete objects", () => { assert.ok(/** @type {R2BucketsResult} */ (runJson(["r2", "buckets", "list", "--json"], { env: storeEnv })) .buckets.some((b) => b.name === bucket)); @@ -368,6 +414,13 @@ test("live CLI integration covers command surface against a WDL control plane", }); }); + await step("deploy --env publishes selected environment overrides", async () => { + const envDeploy = run(["deploy", envDir, "--env", "staging"], { env: storeEnv, timeoutMs: 5 * 60_000 }); + assertDeployPrintedLiveVersion(envDeploy.stdout); + await waitForTenantJson(ctx, ns, envWorker, "/health", (body) => + /** @type {{ label?: string }} */ (body).label === "cli-live-staging"); + }); + await step("workers and delete version commands", () => { writeAppRevision(appDir, appWorker, "v2"); const secondDeploy = run(["deploy", appDir], { env: storeEnv, timeoutMs: 5 * 60_000 }); @@ -688,13 +741,14 @@ async function controlJson(ctx, pathName, token, init = {}) { /** * @param {string} root - * @param {{ worker: string, dbName: string, bucket: string, kvId: string }} fixture + * @param {{ worker: string, dbName: string, bucket: string, kvId: string, queueName: string }} fixture * @returns {string} */ -function writeAppProject(root, { worker, dbName, bucket, kvId }) { +function writeAppProject(root, { worker, dbName, bucket, kvId, queueName }) { const dir = path.join(root, "app"); mkdirSync(path.join(dir, "src"), { recursive: true }); mkdirSync(path.join(dir, "migrations"), { recursive: true }); + mkdirSync(path.join(dir, "public"), { recursive: true }); writeFileSync(path.join(dir, "package.json"), JSON.stringify({ private: true, type: "module", @@ -702,7 +756,7 @@ function writeAppProject(root, { worker, dbName, bucket, kvId }) { writeFileSync(path.join(dir, "wrangler.toml"), ` name = "${worker}" main = "src/index.js" -compatibility_date = "2026-05-31" +compatibility_date = "${LIVE_WORKER_COMPATIBILITY_DATE}" [[d1_databases]] binding = "DB" @@ -717,9 +771,27 @@ bucket_name = "${bucket}" binding = "KV" id = "${kvId}" +[[queues.producers]] +binding = "JOBS" +queue = "${queueName}" + +[[queues.consumers]] +queue = "${queueName}" +max_batch_size = 10 +max_batch_timeout = 5 +max_retries = 3 +retry_delay = 1 + +[triggers] +crons = ["*/5 * * * *"] + +[assets] +directory = "public" + [vars] LABEL = "cli-live" `); + writeFileSync(path.join(dir, "public", "hello.txt"), "hello from live assets\n"); writeFileSync(path.join(dir, "migrations", "001_init.sql"), ` create table if not exists cli_live_items ( name text primary key, @@ -788,6 +860,20 @@ export default { await env.KV.put(key, String(next)); return json({ key, value: next }); } + if (url.pathname === "/asset-url") { + return json({ url: await env.ASSETS.url("hello.txt") }); + } + if (url.pathname === "/queue/enqueue") { + const id = url.searchParams.get("id") || crypto.randomUUID(); + await env.JOBS.send({ id, queuedAt: new Date().toISOString() }); + return json({ id, status: "queued" }); + } + if (url.pathname === "/queue/jobs") { + const id = url.searchParams.get("id") || ""; + const { keys } = await env.KV.list({ prefix: id ? \`queue:\${id}\` : "queue:", limit: 20 }); + const jobs = await Promise.all(keys.map((key) => env.KV.get(key.name, { type: "json" }))); + return json({ jobs: jobs.filter(Boolean) }); + } if (url.pathname === "/log") { const id = url.searchParams.get("id") || "log"; console.log("wdl-cli-live-log", id); @@ -795,10 +881,151 @@ export default { } return json({ error: "not_found", path: url.pathname }, { status: 404 }); }, + + async queue(batch, env) { + for (const message of batch.messages) { + const body = message.body || {}; + const id = body.id || message.id; + await env.KV.put(\`queue:\${id}\`, JSON.stringify({ + id, + queue: batch.queue, + attempts: message.attempts, + queuedAt: body.queuedAt || null, + receivedAt: new Date().toISOString(), + })); + message.ack(); + } + }, + + async scheduled(event, env) { + await env.KV.put("cron:last", JSON.stringify({ + cron: event.cron, + scheduledTime: event.scheduledTime, + })); + }, +}; +`; +} + +/** + * @param {string} root + * @param {{ worker: string }} fixture + * @returns {string} + */ +function writeDurableObjectProject(root, { worker }) { + const dir = path.join(root, "durable-object"); + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "package.json"), JSON.stringify({ + private: true, + type: "module", + }, null, 2) + "\n"); + writeFileSync(path.join(dir, "wrangler.toml"), ` +name = "${worker}" +main = "src/index.js" +compatibility_date = "${LIVE_WORKER_COMPATIBILITY_DATE}" + +[[durable_objects.bindings]] +name = "ROOMS" +class_name = "Room" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["Room"] +`); + writeFileSync(path.join(dir, "src", "index.js"), durableObjectWorkerSource(worker)); + return dir; +} + +/** + * @param {string} worker + * @returns {string} + */ +function durableObjectWorkerSource(worker) { + return ` +import { DurableObject } from "cloudflare:workers"; + +function json(value, init = {}) { + const headers = new Headers(init.headers); + headers.set("content-type", "application/json; charset=utf-8"); + return new Response(JSON.stringify(value), { ...init, headers }); +} + +export class Room extends DurableObject { + ensureSchema() { + this.ctx.storage.sql.exec( + "CREATE TABLE IF NOT EXISTS counters (name TEXT PRIMARY KEY, value INTEGER NOT NULL)" + ); + } + + hit(name) { + this.ensureSchema(); + this.ctx.storage.sql.exec( + "INSERT INTO counters (name, value) VALUES (?, 1) ON CONFLICT(name) DO UPDATE SET value = value + 1", + name + ); + const rows = this.ctx.storage.sql.exec("SELECT value FROM counters WHERE name = ?", name); + return [...rows][0]?.value ?? 0; + } + + async fetch(request) { + const url = new URL(request.url); + const room = url.searchParams.get("room") || "main"; + return json({ room, storedHits: this.hit(room) }); + } +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === "/health") { + return json({ worker: "${worker}" }); + } + if (url.pathname === "/do") { + const room = url.searchParams.get("room") || "main"; + const id = env.ROOMS.idFromName(room); + return env.ROOMS.get(id).fetch(request); + } + return json({ error: "not_found", path: url.pathname }, { status: 404 }); + }, }; `; } +/** + * @param {string} root + * @param {{ worker: string }} fixture + * @returns {string} + */ +function writeEnvProject(root, { worker }) { + const dir = path.join(root, "env-project"); + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "package.json"), JSON.stringify({ + private: true, + type: "module", + }, null, 2) + "\n"); + writeFileSync(path.join(dir, "wrangler.toml"), ` +name = "${worker}" +main = "src/index.js" +compatibility_date = "${LIVE_WORKER_COMPATIBILITY_DATE}" + +[vars] +LABEL = "base" + +[env.staging.vars] +LABEL = "cli-live-staging" +`); + writeFileSync(path.join(dir, "src", "index.js"), ` +export default { + async fetch(_request, env) { + return new Response(JSON.stringify({ label: env.LABEL }), { + headers: { "content-type": "application/json; charset=utf-8" }, + }); + }, +}; +`); + return dir; +} + /** * @param {string} root * @param {{ worker: string }} fixture @@ -814,7 +1041,7 @@ function writeWorkflowProject(root, { worker }) { writeFileSync(path.join(dir, "wrangler.toml"), ` name = "${worker}" main = "src/index.js" -compatibility_date = "2026-05-31" +compatibility_date = "${LIVE_WORKER_COMPATIBILITY_DATE}" [[workflows]] name = "orders" diff --git a/tests/unit/cli-bundle-modules.test.js b/tests/unit/cli-bundle-modules.test.js index f119a79..1ec4ac6 100644 --- a/tests/unit/cli-bundle-modules.test.js +++ b/tests/unit/cli-bundle-modules.test.js @@ -9,7 +9,7 @@ test("bundle module helpers infer file types", () => { assert.equal(inferType("worker.js"), "module"); assert.equal(inferType("x.mjs"), "module"); assert.equal(inferType("x.cjs"), "cjs"); - assert.equal(inferType("x.py"), "py"); + assert.equal(inferType("x.py"), "data"); assert.equal(inferType("x.json"), "json"); assert.equal(inferType("x.svg"), "text"); assert.equal(inferType("x.wasm"), "wasm"); diff --git a/tests/unit/cli-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index fcfec19..e1ce2ed 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -223,10 +223,9 @@ test("whoami calls control introspection and prints platform compatibility", asy }, }); - assert.deepEqual(calls, [{ - url: "http://ctl.test/whoami", - init: { headers: { "x-admin-token": "secret-token" } }, - }]); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "http://ctl.test/whoami"); + assert.deepEqual(calls[0].init.headers, { "x-admin-token": "secret-token" }); const out = lines.join("\n"); assert.match(out, /Control URL: http:\/\/ctl\.test/); assert.match(out, /Namespace:\s+acme/); @@ -319,10 +318,57 @@ test("doctor reports local checks plus remote whoami", async () => { assert.match(out, /✓ Token namespace acme\n {2}matches configured namespace acme/); assert.ok(childEnv); assert.equal(childEnv.ADMIN_TOKEN, undefined); - assert.deepEqual(calls, [{ - url: "https://api.wdl.dev/whoami", - init: { headers: { "x-admin-token": "secret-token" } }, - }]); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://api.wdl.dev/whoami"); + assert.deepEqual(calls[0].init.headers, { "x-admin-token": "secret-token" }); + assert.equal(calls[0].init.env?.CONTROL_URL, "https://api.wdl.dev"); + }); +}); + +test("doctor --strict exits non-zero when any check fails", async () => { + await withTempDir(async (cwd) => { + writeFileSync(path.join(cwd, "wrangler.jsonc"), "{}"); + /** @type {string[]} */ + const lines = []; + + await assert.rejects( + () => runDoctorCommand(["--strict", "--ns", "acme", "--token", "secret-token"], { + cwd, + env: { CONTROL_URL: "https://api.wdl.dev" }, + execFile: () => "wrangler 3.99.0\n", + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async () => response({ + ok: true, + principal: { kind: "ns", ns: "acme" }, + minCliVersion: "0.7.1", + }), + }), + /doctor checks failed/ + ); + + const out = lines.join("\n"); + assert.match(out, /✗ Wrangler 3\.99\.0/); + assert.match(out, /wdl deploy requires Wrangler v4/); + }); +}); + +test("doctor reports shadowed Wrangler config files", async () => { + await withTempDir(async (cwd) => { + writeFileSync(path.join(cwd, "wrangler.json"), "{}"); + writeFileSync(path.join(cwd, "wrangler.toml"), 'name = "old"\n'); + /** @type {string[]} */ + const lines = []; + + await runDoctorCommand([], { + cwd, + env: {}, + execFile: () => "wrangler 4.99.0\n", + stdout: (/** @type {string} */ line) => lines.push(line), + }); + + const out = lines.join("\n"); + assert.match(out, /✓ Wrangler config wrangler\.json/); + assert.match(out, /ignoring wrangler\.toml by Wrangler priority/); }); }); @@ -535,3 +581,64 @@ test("whoami and doctor warn when the token would travel over plain http to a no assert.match(doctorWarnings[0], /plain http on a non-local host/); }); }); + +test("whoami and doctor pass effective .env CONTROL_CONNECT_HOST to whoami requests", async () => { + const whoamiBody = { + ok: true, + principal: { kind: "ns", ns: "acme" }, + tokenId: "tok_123", + minCliVersion: "0.7.1", + urls: {}, + }; + + await withTempDir(async (cwd) => { + writeFileSync(path.join(cwd, ".env"), [ + "CONTROL_URL=http://admin.test:8080", + "CONTROL_CONNECT_HOST=127.0.0.1:18080", + "ADMIN_TOKEN=env-token", + ].join("\n")); + /** @type {import("../../lib/control-fetch.js").ControlFetchInit[]} */ + const seen = []; + await runWhoamiCommand(["--ns", "acme"], { + cwd, + env: {}, + stdout: () => {}, + controlFetch: async ( + /** @type {string} */ _url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}, + ) => { + seen.push(init); + return response(whoamiBody); + }, + }); + + assert.equal(seen[0].env?.CONTROL_CONNECT_HOST, "127.0.0.1:18080"); + assert.deepEqual(seen[0].headers, { "x-admin-token": "env-token" }); + }); + + await withTempDir(async (cwd) => { + writeFileSync(path.join(cwd, ".env"), [ + "CONTROL_URL=http://admin.test:8080", + "CONTROL_CONNECT_HOST=127.0.0.1:18080", + "ADMIN_TOKEN=env-token", + ].join("\n")); + /** @type {import("../../lib/control-fetch.js").ControlFetchInit[]} */ + const seen = []; + await runDoctorCommand(["--ns", "acme"], { + cwd, + env: {}, + execFile: () => "4.94.0\n", + stdout: () => {}, + controlFetch: async ( + /** @type {string} */ _url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}, + ) => { + seen.push(init); + return response(whoamiBody); + }, + }); + + assert.equal(seen[0].env?.CONTROL_CONNECT_HOST, "127.0.0.1:18080"); + assert.deepEqual(seen[0].headers, { "x-admin-token": "env-token" }); + }); +}); diff --git a/tests/unit/cli-control-fetch.test.js b/tests/unit/cli-control-fetch.test.js index d42e1c3..d96388f 100644 --- a/tests/unit/cli-control-fetch.test.js +++ b/tests/unit/cli-control-fetch.test.js @@ -2,7 +2,13 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; -import { controlFetch, readControlResponse } from "../../lib/control-fetch.js"; +import { CliError } from "../../lib/common.js"; +import { + DEFAULT_CONTROL_MAX_BODY_BYTES, + controlFetch, + readControlResponse, +} from "../../lib/control-fetch.js"; +import { currentCliVersion } from "../../lib/package-info.js"; /** * @param {{ statusCode?: number, headers?: import("node:http").IncomingHttpHeaders }} [init] @@ -32,6 +38,10 @@ test("readControlResponse rejects oversized response bodies", async () => { await assert.rejects(promise, /control response exceeded 3 bytes/); }); +test("default buffered control response cap matches platform response cap", () => { + assert.equal(DEFAULT_CONTROL_MAX_BODY_BYTES, 16 * 1024 * 1024); +}); + test("readControlResponse aborts the stream and ignores further chunks once the cap is exceeded", async () => { let destroyed = false; const res = Object.assign(new EventEmitter(), { @@ -74,6 +84,64 @@ test("controlFetch rejects pre-aborted requests before opening a socket", async ); }); +test("controlFetch wraps request socket errors as CliError", async () => { + /** @type {import("../../lib/control-fetch.js").ControlTransport} */ + const transport = { + request() { + const req = Object.assign(new EventEmitter(), { + write() {}, + end() { + req.emit("error", Object.assign(new Error("connect refused"), { code: "ECONNREFUSED" })); + }, + destroy() {}, + }); + return req; + }, + }; + + await assert.rejects( + () => controlFetch("http://ctl.test/whoami", { transport }), + (err) => err instanceof CliError && + err.message === "control request failed: ECONNREFUSED connect refused" + ); +}); + +test("controlFetch rejects invalid header values before opening a socket", async () => { + let opened = false; + /** @type {import("../../lib/control-fetch.js").ControlTransport} */ + const transport = { + request() { + opened = true; + throw new Error("request should not be opened"); + }, + }; + + await assert.rejects( + async () => controlFetch("http://ctl.test/whoami", { + headers: { "x-admin-token": "tok\nnext" }, + transport, + }), + (err) => err instanceof CliError && + err.message.includes('control request failed: invalid HTTP header "x-admin-token"') + ); + assert.equal(opened, false); +}); + +test("controlFetch wraps synchronous request construction errors as CliError", async () => { + /** @type {import("../../lib/control-fetch.js").ControlTransport} */ + const transport = { + request() { + throw Object.assign(new Error("bad request options"), { code: "ERR_BAD_REQUEST" }); + }, + }; + + await assert.rejects( + async () => controlFetch("http://ctl.test/whoami", { transport }), + (err) => err instanceof CliError && + err.message === "control request failed: ERR_BAD_REQUEST bad request options" + ); +}); + test("controlFetch keeps timeout active while streaming the response body", async () => { const res = Object.assign(new PassThrough(), { statusCode: 200, @@ -255,4 +323,133 @@ test("controlFetch carries the URL port in Host and strips IPv6 brackets for the assert.equal(seen[1].headers.Host, "127.0.0.1:9090"); // No explicit port: Host stays the bare hostname. assert.equal(seen[2].headers.Host, "ctl.test"); + assert.equal(seen[2].headers["User-Agent"], `wdl-cli/${currentCliVersion()}`); +}); + +test("controlFetch parses CONTROL_CONNECT_HOST host:port overrides", async () => { + const oldConnectHost = process.env.CONTROL_CONNECT_HOST; + process.env.CONTROL_CONNECT_HOST = "[::1]:18443"; + /** @type {import("node:https").RequestOptions | null} */ + let seen = null; + /** @type {import("../../lib/control-fetch.js").ControlTransport} */ + const transport = { + request(opts, onResponse) { + seen = opts; + return Object.assign(new EventEmitter(), { + write() {}, + end() { + const res = fakeResponse(); + onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); + res.emit("data", Buffer.from("{}")); + res.emit("end"); + }, + destroy() {}, + }); + }, + }; + + try { + await controlFetch("https://ctl.example/whoami", { transport }); + } finally { + if (oldConnectHost == null) delete process.env.CONTROL_CONNECT_HOST; + else process.env.CONTROL_CONNECT_HOST = oldConnectHost; + } + + assert.ok(seen); + const opts = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen); + assert.equal(opts.host, "::1"); + assert.equal(opts.port, 18443); + assert.equal(opts.headers.Host, "ctl.example"); + assert.equal(opts.servername, "ctl.example"); +}); + +test("controlFetch uses init env for CONTROL_CONNECT_HOST overrides", async () => { + const oldConnectHost = process.env.CONTROL_CONNECT_HOST; + process.env.CONTROL_CONNECT_HOST = "process.example:19000"; + /** @type {import("node:https").RequestOptions | null} */ + let seen = null; + /** @type {import("../../lib/control-fetch.js").ControlTransport} */ + const transport = { + request(opts, onResponse) { + seen = opts; + return Object.assign(new EventEmitter(), { + write() {}, + end() { + const res = fakeResponse(); + onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); + res.emit("data", Buffer.from("{}")); + res.emit("end"); + }, + destroy() {}, + }); + }, + }; + + try { + await controlFetch("http://admin.test:8080/whoami", { + env: { CONTROL_CONNECT_HOST: "127.0.0.1:18080" }, + transport, + }); + } finally { + if (oldConnectHost == null) delete process.env.CONTROL_CONNECT_HOST; + else process.env.CONTROL_CONNECT_HOST = oldConnectHost; + } + + assert.ok(seen); + const opts = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen); + assert.equal(opts.host, "127.0.0.1"); + assert.equal(opts.port, 18080); + assert.equal(opts.headers.Host, "admin.test:8080"); +}); + +test("controlFetch rejects invalid CONTROL_CONNECT_HOST ports before opening a socket", () => { + const oldConnectHost = process.env.CONTROL_CONNECT_HOST; + try { + for (const value of ["127.0.0.1:99999", "[::1]:99999", "http://127.0.0.1:99999"]) { + process.env.CONTROL_CONNECT_HOST = value; + assert.throws( + () => controlFetch("http://ctl.test/whoami", { + transport: { + request() { + throw new Error("request should not be opened"); + }, + }, + }), + (err) => err instanceof CliError && + err.message.includes(`Invalid CONTROL_CONNECT_HOST ${JSON.stringify(value)}`) + ); + } + } finally { + if (oldConnectHost == null) delete process.env.CONTROL_CONNECT_HOST; + else process.env.CONTROL_CONNECT_HOST = oldConnectHost; + } +}); + +test("controlFetch omits TLS SNI for HTTPS IP literals", async () => { + /** @type {import("node:https").RequestOptions | null} */ + let seen = null; + /** @type {import("../../lib/control-fetch.js").ControlTransport} */ + const transport = { + request(opts, onResponse) { + seen = opts; + return Object.assign(new EventEmitter(), { + write() {}, + end() { + const res = fakeResponse(); + onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); + res.emit("data", Buffer.from("{}")); + res.emit("end"); + }, + destroy() {}, + }); + }, + }; + + await controlFetch("https://127.0.0.1/whoami", { transport }); + + assert.ok(seen); + const opts = /** @type {import("node:https").RequestOptions} */ (seen); + assert.equal(opts.host, "127.0.0.1"); + assert.equal(opts.port, 443); + assert.equal(opts.servername, undefined); }); diff --git a/tests/unit/cli-credentials.test.js b/tests/unit/cli-credentials.test.js index 1f60061..f7a2676 100644 --- a/tests/unit/cli-credentials.test.js +++ b/tests/unit/cli-credentials.test.js @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { isTokenStoreDisabled, loadCliControlEnv, loadCliDotEnv, protectedEnvKeys, resolveControlContext, resolveControlUrl, resolveNamespace } from "../../lib/credentials.js"; +import { isTokenStoreDisabled, loadCliControlEnv, loadCliDotEnv, protectedEnvKeys, resolveControlContext, resolveControlUrl, resolveNamespace, warnIfInsecureControlUrl } from "../../lib/credentials.js"; test("isTokenStoreDisabled honors the flag and WDL_TOKEN_STORE=off", () => { assert.equal(isTokenStoreDisabled({}, false), false); @@ -82,6 +82,35 @@ test("resolveControlContext centralizes admin token and headers", () => { ); }); +test("warnIfInsecureControlUrl escapes control endpoint text before warning", () => { + /** @type {string[]} */ + const warnings = []; + warnIfInsecureControlUrl( + "http://localhost/\u001b[31m", + (line) => warnings.push(line), + { CONTROL_CONNECT_HOST: "evil.example\nsecond" }, + ); + + assert.equal(warnings.length, 1); + assert.match(warnings[0], /http:\/\/localhost\/\\u001b\[31m/); + assert.match(warnings[0], /CONTROL_CONNECT_HOST=evil\.example\\nsecond/); + assert.equal(warnings[0].includes("\u001b"), false); + assert.equal(warnings[0].includes("\n"), false); +}); + +test("warnIfInsecureControlUrl treats local CONTROL_CONNECT_HOST host:port overrides as local", () => { + for (const connectHost of ["localhost:18080", "dev.local:18080", "[::1]:18080", "http://localhost:18080"]) { + /** @type {string[]} */ + const warnings = []; + warnIfInsecureControlUrl( + "http://admin.test:8080", + (line) => warnings.push(line), + { CONTROL_CONNECT_HOST: connectHost }, + ); + assert.deepEqual(warnings, []); + } +}); + test("resolveNamespace prefers explicit namespace before WDL_NS", () => { assert.equal(resolveNamespace({ ns: "flag" }, { WDL_NS: "env" }), "flag"); assert.equal(resolveNamespace({}, { WDL_NS: "env" }), "env"); @@ -161,6 +190,27 @@ test("loadCliDotEnv rejects malformed quoted values", () => { } }); +test("loadCliDotEnv ignores non-WDL dotenv lines it cannot parse", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-env-")); + const file = path.join(dir, ".env"); + try { + writeFileSync(file, [ + "ADMIN_TOKEN=tok", + "PRIVATE_KEY=\"-----BEGIN PRIVATE KEY-----", + "not a KEY=value continuation", + "-----END PRIVATE KEY-----\"", + "CONTROL_URL=https://ctl.example", + ].join("\n")); + + const env = emptyEnv(); + assert.deepEqual(loadCliDotEnv(env, file), ["ADMIN_TOKEN", "CONTROL_URL"]); + assert.equal(env.ADMIN_TOKEN, "tok"); + assert.equal(env.CONTROL_URL, "https://ctl.example"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("loadCliDotEnv overlays the resolved namespace section over base", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-env-")); const file = path.join(dir, ".env"); diff --git a/tests/unit/cli-d1.test.js b/tests/unit/cli-d1.test.js index 7caedff..ab20470 100644 --- a/tests/unit/cli-d1.test.js +++ b/tests/unit/cli-d1.test.js @@ -1,9 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { runD1Command } from "../../commands/d1.js"; +import { D1_MIGRATIONS_JSON_BODY_MAX_BYTES, runD1Command, serializeD1MigrationsBody } from "../../commands/d1.js"; import { LONG_CONTROL_TIMEOUT_MS } from "../../lib/control-fetch.js"; import { mockDeps as sharedMockDeps, response } from "./helpers.js"; @@ -256,6 +256,41 @@ test("d1 migrations apply reads sorted SQL files from --dir", async () => { } }); +test("d1 migrations apply rejects symlinked SQL files", { skip: process.platform === "win32" }, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-migrations-symlink-")); + try { + const migrations = path.join(dir, "migrations"); + mkdirSync(migrations); + const target = path.join(dir, "target.sql"); + writeFileSync(target, "create table t (id integer);"); + symlinkSync(target, path.join(migrations, "001_link.sql")); + + 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 () => { + throw new Error("controlFetch should not be called"); + }, + }), + /001_link\.sql is a symlink/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("serializeD1MigrationsBody enforces the control request body cap", () => { + assert.equal(D1_MIGRATIONS_JSON_BODY_MAX_BYTES, 1024 * 1024); + assert.equal(serializeD1MigrationsBody({ migrations: [] }, "d1 migrations apply", 32), '{"migrations":[]}'); + assert.throws( + () => serializeD1MigrationsBody({ + migrations: [{ id: "001_big.sql", name: "001_big", checksum: "x", sql: "x".repeat(80) }], + }, "d1 migrations apply", 64), + /d1 migrations apply request is \d+ bytes, exceeds 64 byte control-plane request cap/ + ); +}); + test("d1 migrations_dir from wrangler config cannot escape the project", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-contained-")); const outside = mkdtempSync(path.join(tmpdir(), "wdl-d1-outside-")); @@ -299,6 +334,49 @@ test("d1 migrations_dir from wrangler config cannot escape the project", async ( } }); +test("d1 migrations warns when multiple Wrangler configs exist", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-config-shadow-")); + try { + const migrations = path.join(dir, "schema"); + mkdirSync(migrations); + writeFileSync(path.join(migrations, "001_init.sql"), "create table t (id integer);"); + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: "src/index.js", + d1_databases: [{ + binding: "DB", + database_name: "main", + database_id: "main-id", + migrations_dir: "schema", + }], + })); + writeFileSync(path.join(dir, "wrangler.toml"), [ + 'name = "old"', + 'main = "old.js"', + "", + "[[d1_databases]]", + 'binding = "DB"', + 'database_name = "main"', + 'migrations_dir = "old-migrations"', + "", + ].join("\n")); + + /** @type {string[]} */ + const warnings = []; + await runD1Command(["migrations", "status", "main", "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + warn: (/** @type {string} */ line) => warnings.push(line), + stdout: () => {}, + controlFetch: async () => response({ migrations: [] }), + }); + + assert.ok(warnings.some((line) => /using wrangler\.json and ignoring wrangler\.toml/.test(line))); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("d1 migrations --dir cannot escape the project", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-dir-contained-")); const outside = mkdtempSync(path.join(tmpdir(), "wdl-d1-dir-outside-")); @@ -411,6 +489,51 @@ test("d1 execute --file rejects a path outside the project", async () => { } }); +test("d1 execute --file wraps missing file read errors", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-file-missing-")); + try { + await assert.rejects( + () => runD1Command(["execute", "main", "--file", "missing.sql", "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), + /cannot read SQL file missing\.sql:/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("d1 commands reject unexpected positional arguments", async () => { + const deps = { + env: { WDL_NS: "demo" }, + stdout: () => {}, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }; + await assert.rejects( + () => runD1Command(["list", "extra", "--control-url", "http://ctl.test"], deps), + /d1 list received unexpected argument: extra/ + ); + await assert.rejects( + () => runD1Command(["execute", "main", "extra", "--sql", "select 1", "--control-url", "http://ctl.test"], deps), + /d1 execute received unexpected argument: extra/ + ); + await assert.rejects( + () => runD1Command(["migrations", "apply", "main", "extra", "--control-url", "http://ctl.test"], deps), + /d1 migrations apply received unexpected argument: extra/ + ); + await assert.rejects( + () => runD1Command(["migrations", "bogus", "main", "--control-url", "http://ctl.test"], deps), + /unknown d1 migrations subcommand: bogus/ + ); +}); + test("d1 execute rejects an unknown --mode before calling control", async () => { let fetched = false; await assert.rejects( diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 3599974..527c44a 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -12,6 +13,8 @@ import { collectAssets, collectModules, collectRoutes, + formatWranglerConfigShadowWarning, + installTempFileCleanup, loadWranglerConfig, MAX_ASSET_FILE_BYTES, parseD1DatabasesFromCfg, @@ -229,6 +232,17 @@ test("parseQueues: consumers normalize max_batch_timeout and retry_delay", () => ]); }); +test("parseQueues: consumer max_batch_timeout is capped at the platform limit", () => { + assert.deepEqual( + parseQueues({ consumers: [{ queue: "orders", max_batch_timeout: 60 }] }).consumers, + [{ queue: "orders", maxBatchTimeoutMs: 60_000 }] + ); + assert.throws( + () => parseQueues({ consumers: [{ queue: "orders", max_batch_timeout: 61 }] }), + /max_batch_timeout must be an integer in \[0, 60\]/ + ); +}); + test("parseQueues: omits optional consumer fields when absent", () => { assert.deepEqual( parseQueues({ consumers: [{ queue: "orders" }] }), @@ -823,6 +837,16 @@ test("collectAssets character classes never match the path separator", () => { } }); +test("collectAssets reports invalid .assetsignore patterns with context", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-assetsignore-invalid-")); + try { + writeFileSync(path.join(dir, ".assetsignore"), "bad[z-a]\n"); + assert.throws(() => collectAssets(dir), /invalid \.assetsignore pattern "bad\[z-a\]"/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("collectAssets skips crash-leftover wdl temp configs by default", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-assets-tmpcfg-")); try { @@ -907,10 +931,14 @@ test("resolveAssetsDir: accepts a directory that is inside project root", () => } }); -test("loadWranglerConfig: prefers wrangler.toml when multiple config files exist", () => { +test("loadWranglerConfig: prefers wrangler.json when multiple config files exist", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-config-")); try { writeFileSync(path.join(dir, "wrangler.toml"), 'name = "toml-demo"\nmain = "src/index.js"\n'); + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "json-demo", + main: "src/index.js", + })); writeFileSync( path.join(dir, "wrangler.jsonc"), '{ "name": "jsonc-demo", "main": "src/index.js" }' @@ -918,8 +946,13 @@ test("loadWranglerConfig: prefers wrangler.toml when multiple config files exist const loaded = loadWranglerConfig(dir); const cfg = /** @type {{ name?: string, main?: string }} */ (loaded.cfg); - assert.equal(loaded.path, path.join(dir, "wrangler.toml")); - assert.equal(cfg.name, "toml-demo"); + assert.equal(loaded.path, path.join(dir, "wrangler.json")); + assert.equal(cfg.name, "json-demo"); + assert.deepEqual(loaded.shadowed, ["wrangler.jsonc", "wrangler.toml"]); + assert.equal( + formatWranglerConfigShadowWarning(loaded), + "multiple Wrangler config files found; using wrangler.json and ignoring wrangler.jsonc, wrangler.toml" + ); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -942,6 +975,32 @@ test("loadWranglerConfig: parses JSONC when TOML is absent", () => { assert.equal(loaded.path, path.join(dir, "wrangler.jsonc")); assert.equal(cfg.name, "jsonc-demo"); assert.equal(cfg.main, "src/index.js"); + assert.deepEqual(loaded.shadowed, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("installTempFileCleanup removes temp files on process exit and signals", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-temp-cleanup-")); + try { + const processLike = /** @type {EventEmitter & { off(event: string, listener: () => void): EventEmitter }} */ (new EventEmitter()); + /** @type {string[]} */ + const terminated = []; + const sigintFile = path.join(dir, "sigint.json"); + writeFileSync(sigintFile, "{}"); + installTempFileCleanup(sigintFile, processLike, (signal) => terminated.push(signal)); + processLike.emit("SIGINT"); + assert.equal(existsSync(sigintFile), false); + assert.deepEqual(terminated, ["SIGINT"]); + + const exitFile = path.join(dir, "exit.json"); + writeFileSync(exitFile, "{}"); + const cleanup = installTempFileCleanup(exitFile, processLike, (signal) => terminated.push(signal)); + processLike.emit("exit"); + assert.equal(existsSync(exitFile), false); + cleanup(); + assert.deepEqual(terminated, ["SIGINT"]); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -962,7 +1021,7 @@ test("resolveWranglerConfig: selected environment inherits supported top-level k const { cfg, envName } = resolveWranglerConfig({ name: "demo", main: "src/index.js", - compatibility_date: "2026-05-31", + compatibility_date: "2026-06-17", compatibility_flags: ["nodejs_compat"], route: "dev.example.com/*", triggers: { crons: ["*/5 * * * *"] }, @@ -976,7 +1035,7 @@ test("resolveWranglerConfig: selected environment inherits supported top-level k assert.equal(envName, "staging"); assert.equal(cfg.name, "demo"); assert.equal(cfg.main, "src/index.js"); - assert.equal(cfg.compatibility_date, "2026-05-31"); + assert.equal(cfg.compatibility_date, "2026-06-17"); assert.deepEqual(cfg.compatibility_flags, ["nodejs_als"]); assert.equal(cfg.route, "dev.example.com/*"); assert.deepEqual(cfg.triggers, { crons: ["*/5 * * * *"] }); @@ -1546,7 +1605,7 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a [ 'name = "api"', 'main = "src/index.js"', - 'compatibility_date = "2026-05-31"', + 'compatibility_date = "2026-06-17"', "", "[[d1_databases]]", 'binding = "DB"', @@ -1648,7 +1707,7 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a assert.deepEqual(manifest.workflows, [ { name: "order-workflow", binding: "ORDER_WORKFLOW", className: "OrderWorkflow" }, ]); - assert.equal(manifest.compatibilityDate, "2026-05-31"); + assert.equal(manifest.compatibilityDate, "2026-06-17"); assert.equal(fetchCalls[1].url, "http://ctl.test/ns/demo%20space/worker/api/promote"); assert.equal(fetchCalls[1].init.method, "POST"); @@ -1660,6 +1719,18 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a } }); +test("runDeployCommand rejects unexpected positional arguments", async () => { + await assert.rejects( + () => runDeployCommand([".", "extra", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + throw new Error("execFile should not be called"); + }, + }), + /deploy received unexpected argument: extra/ + ); +}); + test("runDeployCommand sanitizes wrangler.name via temp --config so mixed-case wdl names bundle", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-mixedcase-")); try { @@ -1671,15 +1742,18 @@ test("runDeployCommand sanitizes wrangler.name via temp --config so mixed-case w main: "src/index.js", vars: { GREETING: "hi" }, })); + writeFileSync(path.join(dir, "wrangler.toml"), 'name = "old"\nmain = "old.js"\n'); let tmpConfigSeen = null; let tmpConfigContentAtExec = /** @type {{ name?: string, main?: string, vars?: unknown } | null} */ (null); /** @type {RecordedFetch[]} */ const fetchCalls = []; + /** @type {string[]} */ + const warnings = []; await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, - stderr: () => {}, + stderr: (/** @type {string} */ line) => warnings.push(line), execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { if (args.includes("--version")) return "wrangler 4.94.0"; const cfgIdx = args.indexOf("--config"); @@ -1711,6 +1785,7 @@ test("runDeployCommand sanitizes wrangler.name via temp --config so mixed-case w assert.notEqual(tmpConfigSeen, path.join(dir, ".wrangler.wdl-tmp.json")); assert.equal(existsSync(tmpConfigSeen), false, "temp config should be removed after a successful bundle"); assert.equal(readFileSync(path.join(dir, ".wrangler.wdl-tmp.json"), "utf8"), "user-owned"); + assert.ok(warnings.some((line) => /using wrangler\.json and ignoring wrangler\.toml/.test(line))); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -1850,7 +1925,7 @@ test("runDeployCommand prints a direct http URL for a local deploy", async () => try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), 'export default { fetch() { return new Response("ok"); } };'); - writeFileSync(path.join(dir, "wrangler.toml"), ['name = "api"', 'main = "src/index.js"', 'compatibility_date = "2026-05-31"'].join("\n")); + writeFileSync(path.join(dir, "wrangler.toml"), ['name = "api"', 'main = "src/index.js"', 'compatibility_date = "2026-06-17"'].join("\n")); /** @type {string[]} */ const lines = []; @@ -2052,6 +2127,157 @@ test("runDeployCommand rejects Object.prototype-shaped vars before bundling", as } }); +test("runDeployCommand rejects vars that collide with bindings before bundling", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-var-collision-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: "src/index.js", + kv_namespaces: [{ binding: "CACHE", id: "cache" }], + vars: { CACHE: "shadow" }, + })); + + let execCalled = false; + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + execCalled = true; + throw new Error("execFile should not be called"); + }, + }), + { message: "binding name collision: CACHE" } + ); + assert.equal(execCalled, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand rejects vars that collide with the implicit assets binding", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-assets-var-collision-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + mkdirSync(path.join(dir, "public"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "public", "index.html"), "

ok

"); + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: "src/index.js", + assets: { directory: "./public" }, + vars: { ASSETS: "shadow" }, + })); + + let fetched = false; + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetched = true; + throw new Error("controlFetch should not be called"); + }, + }), + { message: "binding name collision: ASSETS" } + ); + assert.equal(fetched, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand rejects explicit bindings that collide with the implicit assets binding", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-assets-binding-collision-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + mkdirSync(path.join(dir, "public"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "public", "index.html"), "

ok

"); + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: "src/index.js", + assets: { directory: "./public" }, + kv_namespaces: [{ binding: "ASSETS", id: "cache" }], + })); + + let fetched = false; + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetched = true; + throw new Error("controlFetch should not be called"); + }, + }), + { message: "binding name collision: ASSETS" } + ); + assert.equal(fetched, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand rejects the explicit workerd experimental compatibility flag before bundling", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-experimental-flag-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: "src/index.js", + compatibility_flags: ["nodejs_compat", "experimental"], + })); + + let execCalled = false; + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + execCalled = true; + throw new Error("execFile should not be called"); + }, + }), + /compatibility_flags "experimental" is not supported by WDL/ + ); + assert.equal(execCalled, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("collectModules rejects WDL reserved module names", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-reserved-module-")); + try { + writeFileSync(path.join(dir, "_wdl-wrapper.js"), "export default {}"); + assert.throws( + () => collectModules(dir), + /WDL reserved module name _wdl-wrapper\.js/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("collectModules rejects Python Workers modules locally", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-python-module-")); + try { + writeFileSync(path.join(dir, "worker.py"), "export default {}"); + assert.throws( + () => collectModules(dir), + /Python Workers modules are not supported by WDL; remove worker\.py/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("runDeployCommand passes through wrangler output in verbose mode", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-verbose-")); try { @@ -2188,6 +2414,74 @@ test("runDeployCommand warns with wdl secret hints for missing caller secrets", } }); +test("runDeployCommand renders deploy warnings from error responses", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-error-warning-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); + + /** @type {string[]} */ + const warnings = []; + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: (/** @type {string} */ line) => warnings.push(/** @type {string} */ line), + execFile: fakeWranglerExecFile, + controlFetch: async () => response({ + error: "deploy_failed", + message: "missing caller secrets", + warnings: [{ + binding: "PAYMENT", + platform: "STRIPE", + missingCallerSecrets: ["API_KEY"], + }], + }, 400), + }), + /deploy failed: 400 deploy_failed: missing caller secrets/ + ); + + assert.equal(warnings.length, 1); + assert.match(warnings[0], /wdl secret put --ns demo --scope ns /); + assert.match(warnings[0], /wdl secret put --ns demo --worker api /); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand explains deploy env-budget failures at the command layer", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-env-budget-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); + + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => response({ + error: "worker_env_too_large", + message: "env too large", + source_version: "v2", + }, 400), + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /worker_env_too_large/); + assert.match(message, /source_version=v2/); + assert.match(message, /reduce \[vars\], secrets, or binding metadata/); + return true; + } + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("runDeployCommand projects unknown deploy warnings before printing", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-warning-project-")); try { @@ -2234,6 +2528,40 @@ test("serializeDeployManifest enforces the control request body cap", () => { ); }); +test("runDeployCommand explains a failed promote after upload", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-promote-fail-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); + + /** @type {string[]} */ + const stderrLines = []; + let fetchCount = 0; + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: (/** @type {string} */ line) => stderrLines.push(/** @type {string} */ line), + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetchCount += 1; + if (fetchCount === 1) return response({ version: "v9", warnings: [] }); + return response({ error: "promote_failed", message: "routing unavailable" }, 503); + }, + }), + /promote failed: 503 promote_failed: routing unavailable/ + ); + + assert.equal(fetchCount, 2); + assert.ok(stderrLines.some((line) => + /version v9 was uploaded and retained but NOT promoted/.test(line) + )); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("runDeployCommand warns that DO named entrypoints must be declared exports", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-do-warning-")); try { diff --git a/tests/unit/cli-init.test.js b/tests/unit/cli-init.test.js index f53a67b..a9434ad 100644 --- a/tests/unit/cli-init.test.js +++ b/tests/unit/cli-init.test.js @@ -134,7 +134,7 @@ test("init scaffolds files with --ns and --worker", async () => { const wranglerJsonc = readFileSync(path.join(projectDir, "wrangler.jsonc"), "utf8"); assert.match(wranglerJsonc, /"name":\s*"site"/); - assert.match(wranglerJsonc, /"compatibility_date":\s*"2026-05-31"/); + assert.match(wranglerJsonc, /"compatibility_date":\s*"2026-06-17"/); assert.doesNotMatch(wranglerJsonc, /"env"/); const indexJs = readFileSync(path.join(projectDir, "src", "index.js"), "utf8"); diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index 88f339c..1d2ab6c 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url"; import { runDeleteCommand } from "../../commands/delete.js"; import { runR2Command } from "../../commands/r2.js"; import { runSecretCommand } from "../../commands/secret.js"; -import { SseParser, runTailCommand } from "../../commands/tail.js"; +import { SSE_MAX_LINE_CHARS, SseParser, runTailCommand } from "../../commands/tail.js"; import { runWorkersCommand, formatWorkersList } from "../../commands/workers.js"; import { runWorkflowsCommand } from "../../commands/workflows.js"; import { main as wdlMain } from "../../bin/wdl.js"; @@ -170,6 +170,37 @@ test("readJsonOrFail preserves non-json response bodies", async () => { ); }); +test("readJsonOrFail includes redirect locations in HTTP errors", async () => { + await assert.rejects( + () => readJsonOrFail({ + status: 302, + ok: false, + headers: { location: "https://login.example/\u001b[31m" }, + text: async () => "", + }, "whoami"), + { message: "whoami failed: 302 location=https://login.example/\\u001b[31m" } + ); +}); + +test("readJsonOrFail wraps invalid JSON from successful responses", async () => { + await assert.rejects( + () => readJsonOrFail(response("not-json"), "deploy"), + /deploy failed: response is not valid JSON/ + ); +}); + +test("nsUrl rejects dot path segments before calling control", async () => { + await assert.rejects( + () => runSecretCommand(["list", "--ns", ".", "--scope", "ns", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), + /invalid URL path segment: "\."/ + ); +}); + test("readJsonOrFail surfaces warnings arrays attached to error bodies", async () => { const warnings = [{ code: "assets_cleanup_task_failed", message: "queue full" }]; @@ -206,6 +237,17 @@ test("commands warn when the admin token would travel over plain http to a non-l controlFetch: async () => response({ workers: [] }), }); assert.deepEqual(quiet, []); + + /** @type {string[]} */ + const connectWarnings = []; + await runWorkersCommand(["--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok", CONTROL_CONNECT_HOST: "control.prod.example" }, + stdout: () => {}, + warn: (/** @type {string} */ line) => connectWarnings.push(line), + controlFetch: async () => response({ workers: [] }), + }); + assert.equal(connectWarnings.length, 1); + assert.match(connectWarnings[0], /CONTROL_CONNECT_HOST=control\.prod\.example is non-local/); }); test("workers command lists namespace worker state", async () => { @@ -500,6 +542,23 @@ test("delete command exposes only documented destructive subcommands", async () ); }); +test("delete command rejects unexpected positional arguments", async () => { + const deps = { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }; + await assert.rejects( + () => runDeleteCommand(["version", "--ns", "demo", "api", "v1", "extra"], deps), + /delete version received unexpected argument: extra/ + ); + await assert.rejects( + () => runDeleteCommand(["worker", "--ns", "demo", "--worker", "api", "extra"], deps), + /delete worker received unexpected argument: extra/ + ); +}); + test("secret list accepts flags before the subcommand", async () => { const { calls, deps } = mockDeps({ keys: [] }); @@ -653,6 +712,61 @@ test("secret put reports worker version promotion", async () => { assert.deepEqual(lines, ["✓ demo/api/KEY set — promoted v1 → v2"]); }); +test("secret put explains env-budget failures as unwritten mutations", async () => { + await assert.rejects( + () => runSecretCommand( + ["put", "--ns", "demo", "--scope", "ns", "KEY", "--control-url", "http://ctl.test"], + { + env: { ADMIN_TOKEN: "tok" }, + stdin: stdinFrom("secret-value\n"), + controlFetch: async () => response({ + error: "worker_env_too_large", + message: "env too large", + source_version: "v2", + estimated_version: "v9007199254740991", + }, 400), + } + ), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /worker_env_too_large/); + assert.match(message, /secret mutation was not written/); + assert.match(message, /source_version=v2/); + assert.match(message, /estimated_version=v9007199254740991/); + return true; + } + ); +}); + +test("secret mutation errors explain retry and operator-repair cases", async () => { + await assert.rejects( + () => runSecretCommand( + ["delete", "--ns", "demo", "--worker", "api", "KEY", "--yes", "--control-url", "http://ctl.test"], + { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => response({ + error: "secret_mutation_contention", + message: "active version changed", + }, 503), + } + ), + /Retry after concurrent worker metadata updates settle/ + ); + await assert.rejects( + () => runSecretCommand( + ["delete", "--ns", "demo", "--scope", "ns", "KEY", "--yes", "--control-url", "http://ctl.test"], + { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => response({ + error: "secret_decrypt_failed", + message: "bad envelope", + }, 503), + } + ), + /stored secret envelope needs operator repair/ + ); +}); + test("secret put and delete support raw json output", async () => { /** @type {ControlCall[]} */ const calls = []; @@ -705,6 +819,23 @@ test("secret list refuses ambiguous scope before calling control", async () => { assert.equal(calls.length, 0); }); +test("secret list and delete reject unexpected positional arguments", async () => { + const deps = { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }; + await assert.rejects( + () => runSecretCommand(["list", "--ns", "demo", "--scope", "ns", "extra"], deps), + /secret list received unexpected argument: extra/ + ); + await assert.rejects( + () => runSecretCommand(["delete", "--ns", "demo", "--scope", "ns", "KEY", "extra", "--yes"], deps), + /secret delete received unexpected argument: extra/ + ); +}); + test("secret delete calls worker endpoint and reports promoted bump", async () => { /** @type {ControlCall[]} */ const calls = []; @@ -769,7 +900,7 @@ test("secret delete proceeds after interactive confirmation", async () => { assert.equal(stdin.paused, true); }); -test("secret delete warning does not claim deletion when control reports deleted=false", async () => { +test("secret delete ignores obsolete deferred-promote warnings", async () => { /** @type {string[]} */ const lines = []; await runSecretCommand( @@ -787,11 +918,30 @@ test("secret delete warning does not claim deletion when control reports deleted ); assert.deepEqual(lines, [ - "⚠ demo/api/KEY unchanged — reload deferred: active version changed", - " next pickup: next deploy", + "(KEY was not set)", ]); }); +test("secret put rejects an unexpected VALUE positional before reading stdin", async () => { + let read = false; + await assert.rejects( + () => runSecretCommand( + ["put", "--ns", "demo", "--scope", "ns", "KEY", "VALUE", "--control-url", "http://ctl.test"], + { + env: { ADMIN_TOKEN: "tok" }, + stdin: Object.assign(new EventEmitter(), { + setEncoding() { read = true; }, + }), + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + } + ), + /secret put received unexpected argument: VALUE/ + ); + assert.equal(read, false); +}); + test("r2 buckets and objects commands call encoded control endpoints", async () => { /** @type {ControlCall[]} */ const calls = []; @@ -918,6 +1068,23 @@ test("r2 buckets list accepts flags before the group/action", async () => { assert.equal(calls[0].url, "http://ctl.test/ns/demo/r2/buckets"); }); +test("r2 list --limit is validated locally", async () => { + const { calls, deps } = mockDeps({ namespace: "demo", buckets: [] }); + + await runR2Command(["buckets", "list", "--ns", "demo", "--limit", "1000", "--control-url", "http://ctl.test"], deps); + assert.equal(calls[0].url, "http://ctl.test/ns/demo/r2/buckets?limit=1000"); + + await assert.rejects( + () => runR2Command(["buckets", "list", "--ns", "demo", "--limit", "1001", "--control-url", "http://ctl.test"], deps), + /--limit must be an integer/ + ); + await assert.rejects( + () => runR2Command(["objects", "list", "--ns", "demo", "uploads", "--limit", "1.5", "--control-url", "http://ctl.test"], deps), + /--limit must be an integer/ + ); + assert.equal(calls.length, 1); +}); + test("r2 object get waits for stdout backpressure", async () => { /** @type {string[]} */ const events = []; @@ -951,6 +1118,25 @@ test("r2 object get waits for stdout backpressure", async () => { assert.deepEqual(events, ["write:a", "drain", "write:b"]); }); +test("r2 object get refuses raw output to an interactive terminal", async () => { + const stdoutStream = Object.assign(new EventEmitter(), { + isTTY: true, + write() { + throw new Error("stdout should not be written"); + }, + }); + await assert.rejects( + () => runR2Command(["objects", "get", "--ns", "demo", "uploads", "file.txt"], { + env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, + stdoutStream, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), + /refuses to write raw object bytes to an interactive terminal/ + ); +}); + test("r2 object get --out escapes a control-char path in the success line", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-r2-out-escape-")); try { @@ -982,7 +1168,7 @@ test("r2 object get --out escapes a control-char path in the success line", asyn test("r2 object get, head, and delete reject blank keys", async () => { const deps = { - env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, + env: { CONTROL_URL: "http://ctl.test" }, controlFetch: async () => { throw new Error("controlFetch should not be called"); }, @@ -1002,14 +1188,52 @@ test("r2 object get, head, and delete reject blank keys", async () => { ); }); -test("r2 object key rejects empty path segments", async () => { +test("r2 object key preserves empty path segments but rejects dot segments", async () => { + /** @type {ControlCall[]} */ + const calls = []; + await runR2Command(["objects", "head", "bkt", "a//b", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + calls.push({ url, init }); + return { + status: 200, + ok: true, + headers: {}, + text: async () => "", + }; + }, + }); + assert.equal(calls[0].url, "http://ctl.test/ns/demo/r2/buckets/bkt/objects/a//b"); + await assert.rejects( - () => runR2Command(["objects", "get", "bkt", "a//b", "--ns", "demo", "--control-url", "http://ctl.test"], { + () => runR2Command(["objects", "get", "bkt", "a/./b", "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, controlFetch: async () => response({}), }), - /empty path segments/ + /must not contain \. or \.\. path segments/ + ); +}); + +test("r2 commands reject unexpected positional arguments", async () => { + const deps = { + env: { CONTROL_URL: "http://ctl.test" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }; + await assert.rejects( + () => runR2Command(["buckets", "list", "extra", "--ns", "demo"], deps), + /r2 buckets list received unexpected argument: extra/ + ); + await assert.rejects( + () => runR2Command(["objects", "list", "uploads", "extra", "--ns", "demo"], deps), + /r2 objects list received unexpected argument: extra/ + ); + await assert.rejects( + () => runR2Command(["objects", "get", "uploads", "key", "extra", "--ns", "demo"], deps), + /r2 objects get received unexpected argument: extra/ ); }); @@ -1121,7 +1345,7 @@ test("workflows commands reject unexpected positional arguments", async () => { /** @type {boolean[]} */ const calls = []; const deps = { - env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, + env: { CONTROL_URL: "http://ctl.test" }, controlFetch: async () => { calls.push(true); return response({}); @@ -1136,6 +1360,10 @@ test("workflows commands reject unexpected positional arguments", async () => { () => runWorkflowsCommand(["instances", "--ns", "demo", "api", "orders", "extra"], deps), /workflows instances received unexpected argument: extra/ ); + await assert.rejects( + () => runWorkflowsCommand(["status", "--ns", "demo", "api", "orders", "id", "--step-limit", "10"], deps), + /--step-limit requires --include-steps/ + ); await assert.rejects( () => runWorkflowsCommand(["restart", "--ns", "demo", "api", "orders", "id", "extra", "--yes"], deps), /workflows restart received unexpected argument: extra/ @@ -1146,6 +1374,7 @@ test("workflows commands reject unexpected positional arguments", async () => { test("wdl dispatcher routes documented commands and rejects unknown commands", async () => { const oldExit = process.exit; const oldError = console.error; + const oldLog = console.log; /** @type {string[]} */ const seen = []; @@ -1153,6 +1382,7 @@ test("wdl dispatcher routes documented commands and rejects unknown commands", a throw new Error(`exit:${code}`); }; console.error = (msg) => seen.push(String(msg)); + console.log = (msg) => seen.push(String(msg)); try { await assert.rejects(() => wdlMain(["help"], { loadEnv: null }), /exit:0/); @@ -1177,7 +1407,22 @@ test("wdl dispatcher routes documented commands and rejects unknown commands", a } finally { process.exit = oldExit; console.error = oldError; + console.log = oldLog; + } +}); + +test("wdl help prints that command help", async () => { + const oldLog = console.log; + /** @type {string[]} */ + const lines = []; + console.log = (msg) => lines.push(String(msg)); + try { + await wdlMain(["help", "r2"], { loadEnv: null }); + } finally { + console.log = oldLog; } + assert.match(lines.join("\n"), /wdl r2 objects get /); + assert.doesNotMatch(lines.join("\n"), /wdl \[args\]/); }); test("wdl dispatcher prints the CLI version for --version, -v, and version", async () => { @@ -1292,7 +1537,7 @@ test("wdl dispatcher reports a malformed .env without a Node stack", async () => let errors; try { process.chdir(dir); - writeFileSync(path.join(dir, ".env"), "BADLINE\n"); + writeFileSync(path.join(dir, ".env"), "CONTROL_URL\n"); errors = await withMockedExit(async () => { await assert.rejects(() => wdlMain(["workers", "--ns", "demo"], {}), /exit:1/); }); @@ -1309,6 +1554,7 @@ test("wdl dispatcher reports a malformed .env without a Node stack", async () => test("wdl dispatcher skips dotenv for top-level help and unknown commands", async () => { const oldExit = process.exit; const oldError = console.error; + const oldLog = console.log; /** @type {string[]} */ const errors = []; /** @type {string[]} */ @@ -1318,6 +1564,7 @@ test("wdl dispatcher skips dotenv for top-level help and unknown commands", asyn throw new Error(`exit:${code}`); }; console.error = (msg) => errors.push(String(msg)); + console.log = () => {}; try { await assert.rejects( @@ -1333,6 +1580,7 @@ test("wdl dispatcher skips dotenv for top-level help and unknown commands", asyn } finally { process.exit = oldExit; console.error = oldError; + console.log = oldLog; } }); @@ -1395,6 +1643,43 @@ test("SseParser handles CRLF line endings and flushes trailing events", () => { assert.equal(events[1].data, "y"); }); +test("SseParser rejects overlong lines", () => { + const parser = new SseParser(() => {}); + assert.throws(() => parser.push(`data: ${"x".repeat(SSE_MAX_LINE_CHARS)}`), /SSE line exceeded/); +}); + +test("wdl tail rejects errors raised while flushing a trailing SSE event", async () => { + const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} _opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ + request(_opts, cb) { + const req = fakeHttpReq(); + setImmediate(() => { + const res = fakeHttpRes(); + cb(res); + res.emit("data", 'data: {"event":"worker_console","message":["x"]}'); + res.emit("end"); + }); + return req; + }, + }; + + await assert.rejects( + () => runTailCommand( + ["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], + { + env: {}, + stdout: () => { throw new CliError("stdout stop"); }, + stderr: () => {}, + transport: fakeTransport, + } + ), + { message: "stdout stop" } + ); +}); + test("wdl tail rejects --since for multi-worker sessions", async () => { await assert.rejects( () => runTailCommand( @@ -1669,6 +1954,82 @@ test("wdl tail accepts bare CONTROL_URL hosts by defaulting to https", async () assert.equal(requestsSeen[0].path, "/ns/demo/logs/tail?worker=kv-demo"); }); +test("wdl tail uses effective CONTROL_CONNECT_HOST for SSE sockets", async () => { + /** @type {import("node:https").RequestOptions[]} */ + const requestsSeen = []; + const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ + request(opts, cb) { + requestsSeen.push(opts); + const req = fakeHttpReq(); + setImmediate(() => { + const res = fakeHttpRes(); + cb(res); + res.emit("error", new CliError("test stop")); + }); + return req; + }, + }; + + await assert.rejects( + () => runTailCommand( + ["kv-demo"], + { + env: { + ADMIN_TOKEN: "tok", + CONTROL_URL: "http://admin.test:8080", + CONTROL_CONNECT_HOST: "127.0.0.1:18080", + WDL_NS: "demo", + }, + stdout: () => {}, + stderr: () => {}, + transport: fakeTransport, + } + ), + { message: "test stop" } + ); + + assert.equal(requestsSeen[0].host, "127.0.0.1"); + assert.equal(requestsSeen[0].port, 18080); + assert.equal(/** @type {import("node:http").OutgoingHttpHeaders} */ (requestsSeen[0].headers).Host, "admin.test:8080"); + assert.equal(requestsSeen[0].path, "/ns/demo/logs/tail?worker=kv-demo"); +}); + +test("wdl tail rejects invalid auth headers before opening an SSE request", async () => { + let opened = false; + const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} _opts + * @param {(res: import("node:http").IncomingMessage) => void} _cb + */ + request(_opts, _cb) { + opened = true; + throw new Error("request should not be opened"); + }, + }; + + await assert.rejects( + () => runTailCommand( + ["foo", "--ns", "demo", "--token", "tok\nnext", "--control-url", "http://ctl.test"], + { + env: {}, + stdout: () => {}, + stderr: () => {}, + transport: fakeTransport, + sleepFn: async () => { + throw new Error("tail should not enter the reconnect loop"); + }, + } + ), + (err) => err instanceof CliError && + err.message.includes('control request failed: invalid HTTP header "x-admin-token"') + ); + assert.equal(opened, false); +}); + test("wdl tail sends --since on the initial URL, not duplicated as Last-Event-ID", async () => { /** @type {Array<{ path: import("node:https").RequestOptions["path"], headers: import("node:http").OutgoingHttpHeaders }>} */ const requestsSeen = []; @@ -1894,6 +2255,120 @@ test("wdl tail reconnects with Last-Event-ID after transport errors", async () = assert.ok(stderrLines.some((line) => /transport error/i.test(line))); }); +test("wdl tail treats session recycle warnings as control-initiated reconnects", async () => { + /** @type {number[]} */ + const sleepCalls = []; + /** @type {string[]} */ + const stderrLines = []; + let requestCount = 0; + const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} _opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ + request(_opts, cb) { + requestCount += 1; + const req = fakeHttpReq(); + setImmediate(() => { + const res = fakeHttpRes(); + cb(res); + if (requestCount === 1) { + setImmediate(() => { + res.emit("data", `event: tail_warning\ndata: ${JSON.stringify({ + event: "tail_warning", + code: "session_idle", + message: "client idle", + })}\n\n`); + res.emit("end"); + }); + } else { + setImmediate(() => { + 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 (/** @type {number} */ ms) => sleepCalls.push(ms), + } + ), + { message: "test stop" } + ); + + assert.deepEqual(sleepCalls, [1000]); + assert.ok(stderrLines.some((line) => /tail session_idle: client idle/.test(line))); + assert.ok(!stderrLines.some((line) => /! tail_warning session_idle/.test(line))); +}); + +test("wdl tail --raw still treats session recycle warnings as control-initiated reconnects", async () => { + /** @type {number[]} */ + const sleepCalls = []; + /** @type {string[]} */ + const stdoutLines = []; + let requestCount = 0; + const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} _opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ + request(_opts, cb) { + requestCount += 1; + const req = fakeHttpReq(); + setImmediate(() => { + const res = fakeHttpRes(); + cb(res); + if (requestCount <= 3) { + setImmediate(() => { + res.emit("data", `event: tail_warning\ndata: ${JSON.stringify({ + event: "tail_warning", + code: "session_idle", + message: "client idle", + })}\n\n`); + res.emit("end"); + }); + } else { + setImmediate(() => { + res.emit("error", new CliError("test stop")); + }); + } + }); + return req; + }, + }; + + await assert.rejects( + () => runTailCommand( + ["foo", "--raw", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], + { + env: {}, + stdout: (/** @type {string} */ line) => stdoutLines.push(line), + stderr: () => {}, + transport: fakeTransport, + sleepFn: async (/** @type {number} */ ms) => sleepCalls.push(ms), + } + ), + { message: "test stop" } + ); + + assert.deepEqual(sleepCalls, [1000, 1000, 1000]); + assert.equal(stdoutLines.length, 3); + assert.deepEqual(JSON.parse(stdoutLines[0]), { + event: "tail_warning", + code: "session_idle", + message: "client idle", + }); +}); + test("wdl tail increases backoff until a stable session resets it", async () => { /** @type {number[]} */ const sleepCalls = []; @@ -1951,6 +2426,90 @@ test("wdl tail increases backoff until a stable session resets it", async () => assert.ok(stderrLines.some((line) => /reconnecting in 2000ms/.test(line))); }); +test("wdl tail gives up after reconnects repeatedly hit the cap", async () => { + /** @type {number[]} */ + const sleepCalls = []; + let requestCount = 0; + const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} _opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ + request(_opts, cb) { + requestCount += 1; + const req = fakeHttpReq(); + setImmediate(() => { + const res = fakeHttpRes(); + cb(res); + setImmediate(() => res.emit("end")); + }); + return req; + }, + }; + + await assert.rejects( + () => runTailCommand( + ["foo", "--max-reconnects", "2", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], + { + env: {}, + stdout: () => {}, + stderr: () => {}, + transport: fakeTransport, + sleepFn: async (/** @type {number} */ ms) => sleepCalls.push(ms), + } + ), + /gave up after 2 consecutive reconnects/ + ); + + assert.equal(requestCount, 5); + assert.deepEqual(sleepCalls, [1000, 2000, 4000, 5000]); +}); + +test("wdl tail --max-reconnects 0 disables the cap", async () => { + /** @type {number[]} */ + const sleepCalls = []; + let requestCount = 0; + const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} _opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ + request(_opts, cb) { + requestCount += 1; + const req = fakeHttpReq(); + setImmediate(() => { + const res = fakeHttpRes(); + cb(res); + setImmediate(() => { + if (requestCount >= 6) { + res.emit("error", new CliError("test stop")); + } else { + res.emit("end"); + } + }); + }); + return req; + }, + }; + + await assert.rejects( + () => runTailCommand( + ["foo", "--max-reconnects", "0", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], + { + env: {}, + stdout: () => {}, + stderr: () => {}, + transport: fakeTransport, + sleepFn: async (/** @type {number} */ ms) => sleepCalls.push(ms), + } + ), + { message: "test stop" } + ); + + assert.equal(requestCount, 6); + assert.deepEqual(sleepCalls, [1000, 2000, 4000, 5000, 5000]); +}); + test("cli package exposes only the wdl binary", () => { const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")); assert.deepEqual(pkg.bin, { diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index dae4c67..7e0bec4 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; -import { mkdtempSync, rmSync } from "node:fs"; +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { runTokenCommand } from "../../commands/token.js"; @@ -296,6 +296,22 @@ test("token set warns before sending the token to a plain-http non-local host", }); }); +test("writeTokenStore replaces a symlink instead of following it", { skip: process.platform === "win32" }, async () => { + await withTempXdg(async (xdg) => { + const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + mkdirSync(path.dirname(p), { recursive: true }); + const target = path.join(xdg, "outside-credentials"); + writeFileSync(target, "outside\n", { mode: 0o600 }); + symlinkSync(target, p); + + writeTokenStore(p, { defaultNs: "acme", namespaces: { acme: { ADMIN_TOKEN: "secret" } } }); + + assert.equal(readFileSync(target, "utf8"), "outside\n"); + assert.equal(lstatSync(p).isSymbolicLink(), false); + assert.deepEqual(readTokenStore(p), { defaultNs: "acme", namespaces: { acme: { ADMIN_TOKEN: "secret" } } }); + }); +}); + test("token does not accept a --token flag (the token comes from stdin)", async () => { await withTempXdg(async (xdg) => { await assert.rejects( From 59cca8c211a0eceda9e3f3f289fb336348f34459 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Thu, 9 Jul 2026 16:11:31 +0800 Subject: [PATCH 02/12] Adapt CLI to WDL 20260701 audit findings Keep deploy validation to CLI-owned packaging checks: Python module fail-fast, binding collisions including implicit ASSETS, and D1 migration 413 surfacing. Remove duplicated worker module-limit policy and leave canonical worker-code/compat-flag rejection to control. Harden token-store mutations with lock ownership, retry-safe temp writes, and escaped token diagnostics. Sync deploy/token/queue docs and agent guidance; make tail abort close SSE sockets with an explicit abort error. Signed-off-by: Lu Zhang --- .claude/skills/wdl-deploy/SKILL.md | 11 +- CHANGELOG.md | 22 +- GUIDE-zh.md | 4 +- GUIDE.md | 19 +- commands/d1.js | 27 +- commands/deploy.js | 2 +- commands/tail.js | 13 +- commands/token.js | 121 ++++--- docs/deploy-zh.md | 10 +- docs/deploy.md | 22 +- docs/queues-zh.md | 2 +- docs/queues.md | 8 +- lib/bundle-modules.js | 4 +- lib/token-store.js | 493 ++++++++++++++++++++++++- lib/wrangler-pack.js | 15 +- lib/wrangler/bindings.js | 11 +- lib/wrangler/module-limits.js | 30 -- lib/wrangler/modules.js | 12 +- templates/AGENTS.md | 12 +- tests/unit/cli-bundle-modules.test.js | 3 +- tests/unit/cli-d1.test.js | 109 +++++- tests/unit/cli-deploy.test.js | 253 ++++++++++--- tests/unit/cli-lifecycle.test.js | 49 +++ tests/unit/cli-token-store.test.js | 494 +++++++++++++++++++++++++- tests/unit/cli-token.test.js | 60 +++- 25 files changed, 1547 insertions(+), 259 deletions(-) delete mode 100644 lib/wrangler/module-limits.js diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index 6bb713f..ac00210 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -47,11 +47,12 @@ 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. WDL follows Wrangler config priority (`wrangler.json`, then -`wrangler.jsonc`, then `wrangler.toml`) and rejects Python Workers modules, -unsupported workerd experimental compatibility flags, modules named like WDL -injected `_wdl-*.js` runtime modules, and `[vars]` keys that collide with -bindings. The CLI locally rejects the explicit `experimental` compatibility -flag; the control plane is canonical for other unsupported experimental flags. +`wrangler.jsonc`, then `wrangler.toml`). The control plane is canonical for +unsupported runtime shapes such as unsupported workerd experimental +compatibility flags and WDL-reserved injected module names; the CLI still +fails fast for cheap local cases such as Python Workers modules and ambiguous +runtime `env` name collisions between `[vars]`, explicit bindings, and the +implicit `ASSETS` binding. Never recommend setting `CONTROL_CONNECT_HOST` outside local development: it overrides the TCP target the admin token connects to (Host header + TLS SNI diff --git a/CHANGELOG.md b/CHANGELOG.md index 304a6ac..dc5751b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,26 +7,21 @@ - `wdl init`, examples, and docs now default new Wrangler configs to `compatibility_date = "2026-06-17"` for the documented feature baseline. - `wdl deploy` now follows Wrangler config priority - (`wrangler.json`, `wrangler.jsonc`, `wrangler.toml`) and rejects unsupported - Python Worker modules, the explicit workerd `experimental` compatibility - flag, WDL-reserved runtime module names, and runtime env name collisions - (including `[vars]`, explicit bindings, and the implicit `ASSETS` binding) - locally. The control plane remains canonical for other unsupported - experimental compatibility flags. + (`wrangler.json`, `wrangler.jsonc`, `wrangler.toml`); the control plane + remains canonical for runtime/workerd bundle errors, while the CLI still + rejects cheap local cases like Python Worker modules and ambiguous runtime env + name collisions between `[vars]`, explicit bindings, and the implicit + `ASSETS` binding. - `wdl tail` now recognizes control-initiated `session_idle` / `session_expired` stream recycling and reconnects without presenting it as an unknown warning. - `wdl doctor --strict` now exits non-zero when any local or remote readiness check fails, while the default `wdl doctor` remains report-only. -- `wdl d1 migrations status/apply` now preflights the 1 MiB control-plane JSON - request cap before uploading migration metadata or SQL. - `wdl r2` preserves empty object-key path segments while still rejecting `.` and `..` segments, validates list `--limit` locally, and requires `--out` when `objects get` would otherwise write raw bytes to an interactive terminal. - `wdl workflows status --step-limit` now requires `--include-steps`, matching the control request it affects. -- Queue consumer `max_batch_timeout` is now capped locally at the platform's - 60-second limit. - Top-level help for successful requests now prints to stdout, and `wdl help ` prints that command's help. - When multiple Wrangler config files exist, deploy and D1 migrations now warn @@ -52,11 +47,18 @@ - Secret mutation failures now add command-specific guidance for env-budget, contention, and secret-envelope errors, including that the failed mutation was not written. +- `wdl token list` now escapes credential labels and endpoints before rendering + the human table output. ### Security - `wdl token` credential writes now use a temp-file plus rename so an existing credentials symlink is replaced rather than followed. +- `wdl token set/use/rm` now serialize credential-store read-modify-write + mutations with a recoverable lock, and credential-store temporary filenames + are unguessable. +- Credential-store read and write failures now escape filesystem details before + rendering CLI errors. - `wdl tail` now caps SSE line buffering to avoid unbounded memory growth on a malformed stream. - `wdl deploy` now registers process-exit and SIGINT/SIGTERM cleanup for its diff --git a/GUIDE-zh.md b/GUIDE-zh.md index b7c069e..378ef61 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -226,7 +226,7 @@ https://.// ## 支持的 wrangler 配置 -WDL 会拒绝一些 Wrangler 能打包、但平台不能运行的形状:Python Workers modules、不支持的 workerd experimental compatibility flags、使用 WDL 保留注入模块名(例如 `_wdl-wrapper.js`)的模块,以及和 runtime binding 名冲突的 `[vars]` key。CLI 本地只拒绝显式的 `experimental` compatibility flag;其它不支持的 experimental flags 仍以 control plane 校验为准。Deploy 和 secret mutation 还会校验留有 headroom 的 workerd 1 MiB `workerLoader` env budget;过大的 `[vars]`、secrets、binding metadata 或 retained versions 可能触发 `worker_env_too_large`。 +Wrangler 能打包、但 WDL 不能运行的形状由 control plane 作为 canonical validator 拒绝,包括不支持的 workerd experimental compatibility flags 和 WDL 保留注入模块名。CLI 仍会对低成本的本地问题 fail-fast,例如 Python Workers modules,以及 `[vars]`、显式 bindings、隐式 `ASSETS` binding 之间的 runtime `env` 名称冲突。Deploy 和 secret mutation 还会校验留有 headroom 的 workerd 1 MiB `workerLoader` env budget;过大的 `[vars]`、secrets、binding metadata 或 retained versions 可能触发 `worker_env_too_large`。 | 配置 | 支持情况 | | --- | --- | @@ -570,7 +570,7 @@ Queue consumers 是 runtime dispatch 目标。把它声明在可路由的 tenant | 重试延迟 | `[[queues.consumers]].retry_delay` 是默认重试延迟,单位秒。`msg.retry({ delaySeconds })` / `batch.retryAll({ delaySeconds })` 会覆盖默认值;`delaySeconds: 0` 表示立即重试。 | | attempts | handler 看到的 `msg.attempts` 首投从 `1` 开始。`max_retries = N` 时,一条消息最多会被投递 `N + 1` 次,然后进入死信处理。 | | 死信队列 | 尊重 `dead_letter_queue` 配置;未配置时使用该 queue 的默认 DLQ。 | -| Batch timeout | `max_batch_timeout` 必须是 0..60 秒;它会被解析并保存,以兼容 Cloudflare 配置;但当前 dispatch 由 `max_batch_size` 截断,不应依赖 timeout 触发 batch flush。 | +| Batch timeout | CLI 会转发通过基础整数 delay 解析的 `max_batch_timeout` 以兼容配置;WDL control 负责执行更严格的 Cloudflare 兼容 0..60 秒范围。当前 dispatch 由 `max_batch_size` 截断,不应依赖 timeout 触发 batch flush。 | | 不支持的配置 | `max_concurrency` 会在部署阶段直接拒绝,不会静默忽略。 | `examples/queues-demo` 提供了单 Worker 生产、消费 queue 消息,并把投递状态写入 KV 的完整示例。 diff --git a/GUIDE.md b/GUIDE.md index 3e57330..c607dc0 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -326,15 +326,14 @@ for you. ## Supported Wrangler Configuration -WDL rejects a few shapes that Wrangler can bundle but the platform cannot run: -Python Workers modules, unsupported workerd experimental compatibility flags, -modules using WDL-reserved injected names such as `_wdl-wrapper.js`, and -`[vars]` keys that collide with runtime binding names. The CLI locally rejects -the explicit `experimental` compatibility flag; the control plane remains the -canonical check for other unsupported experimental flags. Deploy and secret -mutation also enforce the headroomed 1 MiB workerd `workerLoader` env budget; -large `[vars]`, secrets, binding metadata, or retained versions can fail with -`worker_env_too_large`. +The control plane is the canonical validator for shapes that Wrangler can +bundle but WDL cannot run, including unsupported workerd experimental +compatibility flags and WDL-reserved injected module names. The CLI still +fails fast for cheap local cases such as Python Worker modules and ambiguous +runtime `env` name collisions between `[vars]`, explicit bindings, and the +implicit `ASSETS` binding. Deploy and secret mutation also enforce the +headroomed 1 MiB workerd `workerLoader` env budget; large `[vars]`, secrets, +binding metadata, or retained versions can fail with `worker_env_too_large`. | Configuration | Support | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -803,7 +802,7 @@ Queue behavior tenants can rely on: | Retry delay | `[[queues.consumers]].retry_delay` is the default retry delay in seconds. `msg.retry({ delaySeconds })` / `batch.retryAll({ delaySeconds })` override it; `delaySeconds: 0` means immediate retry. | | Attempts | The handler sees `msg.attempts` starting at `1`. With `max_retries = N`, a message can be delivered up to `N + 1` times before dead-letter handling. | | Dead letter queue | `dead_letter_queue` is honored. If omitted, failed messages use the queue's default DLQ. | -| Batch timeout | `max_batch_timeout` must be 0..60 seconds. It is parsed and saved for Cloudflare config compatibility, but dispatch is currently capped by `max_batch_size`; do not depend on timeout-based batch flushing. | +| Batch timeout | The CLI forwards `max_batch_timeout` values that pass basic integer delay parsing for config compatibility; WDL control enforces the tighter Cloudflare-compatible 0..60 second range. Dispatch is currently capped by `max_batch_size`; do not depend on timeout-based batch flushing. | | Unsupported config | `max_concurrency` is rejected during deploy instead of being silently ignored. | See `examples/queues-demo` for a single Worker that produces queue messages, diff --git a/commands/d1.js b/commands/d1.js index ab04770..3691f5b 100644 --- a/commands/d1.js +++ b/commands/d1.js @@ -21,7 +21,6 @@ import { confirmAction } from "../lib/stdin.js"; import { writeResult } from "../lib/output.js"; const D1_EXECUTE_MODES = ["all", "raw", "run", "exec"]; -export const D1_MIGRATIONS_JSON_BODY_MAX_BYTES = 1024 * 1024; const D1_OPTIONS = [ defineCliOption("sql", { type: "string" }, "--sql ", "SQL text for execute."), @@ -200,14 +199,11 @@ async function runMigrationsCommand({ action, databaseRef, context }) { if (action === "status") { const migrations = loadLocalMigrations({ values, env, cwd, databaseRef, warn }); - const requestBody = serializeD1MigrationsBody({ - migrations: migrations.map(({ sql: _sql, ...rest }) => rest), - }, "d1 migrations status"); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(`${migrationsBase}/status`, { method: "POST", headers, - body: requestBody, + body: JSON.stringify({ migrations: migrations.map(({ sql: _sql, ...rest }) => rest) }), }, "show d1 migration status") ); writeResult(values.json === true, body, () => formatD1MigrationStatus(body), stdout); @@ -216,37 +212,18 @@ async function runMigrationsCommand({ action, databaseRef, context }) { if (action === "apply") { const migrations = loadLocalMigrations({ values, env, cwd, databaseRef, warn }); - const requestBody = serializeD1MigrationsBody({ migrations }, "d1 migrations apply"); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(`${migrationsBase}/apply`, { method: "POST", headers, - body: requestBody, + body: JSON.stringify({ migrations }), timeoutMs: LONG_CONTROL_TIMEOUT_MS, }, "apply d1 migrations") ); writeResult(values.json === true, body, () => formatD1MigrationApply(body), stdout); return; } - } - -/** - * @param {unknown} body - * @param {string} label - * @param {number} [maxBytes] - */ -export function serializeD1MigrationsBody(body, label, maxBytes = D1_MIGRATIONS_JSON_BODY_MAX_BYTES) { - const text = JSON.stringify(body); - const bytes = Buffer.byteLength(text); - if (bytes > maxBytes) { - throw new CliError( - `${label} request is ${bytes} bytes, exceeds ${maxBytes} byte control-plane request cap` - ); - } - return text; -} - /** * @typedef {{ values: D1Flags, env: NodeJS.ProcessEnv, cwd: string, databaseRef: string, warn?: (line: string) => void }} MigrationsDirArgs */ diff --git a/commands/deploy.js b/commands/deploy.js index 0850670..631b90b 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -181,7 +181,7 @@ function deployErrorHint(text) { return "; reduce generated Worker code size or split the worker."; } if (error === "worker_code_invalid") { - return "; rename modules that collide with WDL-reserved _wdl-*.js runtime module names."; + return "; fix the Worker bundle shape reported by the control plane."; } if (error === "python_workers_unsupported") { return "; Python Workers modules are not supported by WDL."; diff --git a/commands/tail.js b/commands/tail.js index 8bb54e3..9482255 100644 --- a/commands/tail.js +++ b/commands/tail.js @@ -52,6 +52,14 @@ function toError(err) { return err instanceof Error ? err : new Error(String(err)); } +/** @returns {Error & { code: string }} */ +function tailAbortError() { + const err = /** @type {Error & { code: string }} */ (new Error("tail request aborted")); + err.name = "AbortError"; + err.code = "ABORT_ERR"; + return err; +} + const command = defineCommand({ name: "tail", summary: "Live-tail worker console output and uncaught exceptions.", @@ -397,7 +405,10 @@ function streamSse({ url, headers, signal, env, transport, onEvent, onConnected if (signal?.aborted && isExpectedAbortError(err)) return resolve({}); reject(err); }); - onAbort = () => { req.destroy(); }; + onAbort = () => { + clearConnectTimer(); + req.destroy(tailAbortError()); + }; if (signal) { signal.addEventListener("abort", onAbort, { once: true }); } diff --git a/commands/token.js b/commands/token.js index fb5c4a0..3af6fb4 100644 --- a/commands/token.js +++ b/commands/token.js @@ -5,14 +5,15 @@ // which stored namespace is the default when --ns is omitted, and `rm` deletes // the local copy without revoking it. +import { existsSync } from "node:fs"; import { defineCommand } from "../lib/command.js"; import { CliError, defineCliOption, formatHelp, isMain, optionHelp } from "../lib/common.js"; import { flagSet, resolveControlUrl, warnIfInsecureControlUrl } from "../lib/credentials.js"; import { readSecretStdin } from "../lib/stdin.js"; -import { escapeTerminalText, maskToken, writeResult, writeStatusLine } from "../lib/output.js"; +import { escapeTerminalText, maskToken, shellSingleQuote, writeResult, writeStatusLine } from "../lib/output.js"; import { isAdminAcceptableNs } from "../lib/ns-pattern.js"; import { fetchWhoami, namespaceFromPrincipal } from "../lib/whoami.js"; -import { readTokenStore, tokenStorePath, writeTokenStore } from "../lib/token-store.js"; +import { readTokenStore, tokenStorePath, updateTokenStore } from "../lib/token-store.js"; const TOKEN_OPTIONS = [ defineCliOption("label", { type: "string" }, "--label ", "Human label shown by `wdl token list` (set)."), @@ -120,29 +121,30 @@ async function tokenSet({ values, context }) { } const storePath = tokenStorePath(context.env); - const store = readTokenStore(storePath); - const previous = Object.hasOwn(store.namespaces, ns) ? store.namespaces[ns] : {}; - const wasEmpty = Object.keys(store.namespaces).length === 0; - // defineProperty, not `store.namespaces[ns] = …`: a namespace named "__proto__" - // would otherwise hit the prototype setter and never create an own section - // (mirrors readTokenStore's section creation). - Object.defineProperty(store.namespaces, ns, { - value: { - CONTROL_URL: controlUrl, - ADMIN_TOKEN: token, - LABEL: typeof values.label === "string" ? values.label : previous.LABEL, - }, - writable: true, - enumerable: true, - configurable: true, + const becameDefault = updateTokenStore(storePath, (store) => { + const previous = Object.hasOwn(store.namespaces, ns) ? store.namespaces[ns] : {}; + const wasEmpty = Object.keys(store.namespaces).length === 0; + // defineProperty, not `store.namespaces[ns] = …`: a namespace named "__proto__" + // would otherwise hit the prototype setter and never create an own section + // (mirrors readTokenStore's section creation). + Object.defineProperty(store.namespaces, ns, { + value: { + CONTROL_URL: controlUrl, + ADMIN_TOKEN: token, + LABEL: typeof values.label === "string" ? values.label : previous.LABEL, + }, + writable: true, + enumerable: true, + configurable: true, + }); + // Only the first stored namespace (an empty store) auto-becomes the default, + // or an explicit --default. A later set must NOT silently steal the default + // just because it is currently null — the default may have been deliberately + // cleared by removing it from an ambiguous set. + const nextDefault = Boolean(values.default) || wasEmpty; + if (nextDefault) store.defaultNs = ns; + return nextDefault; }); - // Only the first stored namespace (an empty store) auto-becomes the default, - // or an explicit --default. A later set must NOT silently steal the default - // just because it is currently null — the default may have been deliberately - // cleared by removing it from an ambiguous set. - const becameDefault = Boolean(values.default) || wasEmpty; - if (becameDefault) store.defaultNs = ns; - writeTokenStore(storePath, store); writeStatusLine(context.stdout, `Stored token for ${ns} @ ${controlUrl} (${maskToken(token)}).`); if (becameDefault) { writeStatusLine(context.stdout, `${ns} is now the default namespace (used when --ns is omitted).`); @@ -157,12 +159,9 @@ function tokenUse({ values, context, nsArg }) { const ns = nsArg || (flagSet(values, "ns") ? values.ns : null); if (!ns) throw new CliError("token use requires a namespace: wdl token use "); const storePath = tokenStorePath(context.env); - const store = readTokenStore(storePath); - if (!Object.hasOwn(store.namespaces, ns)) { - throw new CliError(`no stored token for namespace "${escapeTerminalText(ns)}" — run \`wdl token set --ns ${escapeTerminalText(ns)}\` first`); - } - store.defaultNs = ns; - writeTokenStore(storePath, store); + updateStoredNamespace(storePath, ns, "use", (store) => { + store.defaultNs = ns; + }); writeStatusLine(context.stdout, `Default namespace set to ${ns} (used when --ns is omitted).`); } @@ -186,20 +185,49 @@ function tokenRemove({ values, context }) { const ns = flagSet(values, "ns") ? values.ns : null; if (!ns) throw new CliError("token rm requires an explicit --ns "); const storePath = tokenStorePath(context.env); - const store = readTokenStore(storePath); - if (!Object.hasOwn(store.namespaces, ns)) throw new CliError(`no stored token for namespace "${escapeTerminalText(ns)}"`); - delete store.namespaces[ns]; - // Keep the "a lone stored namespace is the default" invariant after any - // removal: a sole survivor becomes the default even if an earlier removal - // already cleared it; removing the current default from a still-ambiguous set - // clears it (an explicit --ns or `wdl token use` is then needed). - const remaining = Object.keys(store.namespaces); - if (remaining.length === 1) store.defaultNs = remaining[0]; - else if (store.defaultNs === ns) store.defaultNs = null; - writeTokenStore(storePath, store); + updateStoredNamespace(storePath, ns, "rm", (store) => { + delete store.namespaces[ns]; + // Keep the "a lone stored namespace is the default" invariant after any + // removal: a sole survivor becomes the default even if an earlier removal + // already cleared it; removing the current default from a still-ambiguous set + // clears it (an explicit --ns or `wdl token use` is then needed). + const remaining = Object.keys(store.namespaces); + if (remaining.length === 1) store.defaultNs = remaining[0]; + else if (store.defaultNs === ns) store.defaultNs = null; + }); writeStatusLine(context.stdout, `Removed the stored token for ${ns}. This does not revoke it on the control plane.`); } +/** + * @param {string} storePath + * @param {string} ns + * @param {"use" | "rm"} command + * @param {(store: ReturnType) => void} mutate + */ +function updateStoredNamespace(storePath, ns, command, mutate) { + if (!existsSync(`${storePath}.lock`) && !Object.hasOwn(readTokenStore(storePath).namespaces, ns)) { + throwMissingStoredNamespace(ns, command); + } + updateTokenStore(storePath, (store) => { + if (!Object.hasOwn(store.namespaces, ns)) throwMissingStoredNamespace(ns, command); + mutate(store); + }); +} + +/** + * @param {string} ns + * @param {"use" | "rm"} command + */ +function throwMissingStoredNamespace(ns, command) { + const escaped = escapeTerminalText(ns); + const quoted = escapeTerminalText(shellSingleQuote(ns)); + throw new CliError( + command === "use" + ? `no stored token for namespace "${escaped}" — run \`wdl token set --ns ${quoted}\` first` + : `no stored token for namespace "${escaped}"` + ); +} + /** * @typedef {object} TokenListRow * @property {boolean} default @@ -214,7 +242,16 @@ function tokenRemove({ values, context }) { function formatTokenList(rows) { if (rows.length === 0) return ["(no stored tokens)"]; const header = ["", "NAMESPACE", "LABEL", "CONTROL URL", "TOKEN"]; - const cells = [header, ...rows.map((r) => [r.default ? "*" : "", r.namespace, r.label, r.controlUrl, r.token])]; + const cells = [ + header, + ...rows.map((r) => [ + r.default ? "*" : "", + r.namespace, + r.label, + r.controlUrl, + r.token, + ].map((cell) => escapeTerminalText(cell))), + ]; const widths = header.map((_, col) => Math.max(...cells.map((l) => l[col].length))); const lines = cells.map((l) => l.map((cell, col) => cell.padEnd(widths[col])).join(" ").trimEnd()); if (rows.some((r) => r.default)) lines.push("", "* default namespace (used when --ns is omitted)"); diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index 940da58..c027810 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -78,8 +78,6 @@ Worker 看到的路径是**剥掉 `/` 之后的路径**。除非运 Deploy 上传给 control 的 manifest JSON 最大 32 MiB。Assets 在部署时会嵌进这个 JSON 请求;如果静态文件集合较大,可能先撞到 control request cap。大体积或频繁变化的文件用 R2,不要放进 assets。 -Deploy 还会预检 Wrangler 自己看不到的 WDL runtime 约束:`[vars]` 名称和 bindings 共享同一个运行时 `env` 命名空间,同名会被拒;形如 WDL 注入 runtime 模块的名字(`_wdl-*.js`,例如 `_wdl-wrapper.js`)是保留模块名;Python Workers modules 不支持;显式的 workerd `experimental` compatibility flag 会在打包前被拒。其它不支持的 experimental compatibility flags 由 control plane 返回 `experimental_compat_flag_unsupported`。 - Control plane 会按留有 headroom 的 workerd 1 MiB `workerLoader` environment 预算校验(可用 1,040,384 bytes)。过大的 `[vars]`、secrets、binding metadata 或 retained versions 可能触发 `worker_env_too_large`;减少 env payload,或在错误点名已有版本时 redeploy/delete 该 retained version。 ## 环境覆盖 @@ -99,7 +97,7 @@ wdl deploy . --env production **支持:** `name`、`main`、`compatibility_date`/`flags`、`[vars]`、`[[kv_namespaces]]`、`[[d1_databases]]`、`[[durable_objects.bindings]]`、`[[workflows]]`、`[[r2_buckets]]`、`[assets] directory`、`[triggers] crons`、`[[triggers.schedules]]`(带 timezone,平台扩展)、`[[queues.producers]]` / `[[queues.consumers]]`、`[[services]]`、`[[platform_bindings]]`、`[env.]`。 -**不支持(部署失败):** 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 experimental compatibility flags 不支持。`assets.run_worker_first` 会被静默忽略。 +**不支持(部署失败):** 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 experimental compatibility flags 和 WDL 保留注入模块名会在部署时被拒绝:CLI 会对本地 `.py` module fail-fast,workerd compatibility 与 bundle-shape policy 由 control plane canonical 判断。`assets.run_worker_first` 会被静默忽略。 Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在可路由的 tenant Worker 上。通过 `[[platform_bindings]]` 选择的 Worker 是冷加载的平台能力,不是 public/runtime dispatch 目标,不能声明 cron triggers 或 queue consumers。 @@ -118,11 +116,11 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 | `401 unknown_token: unauthorized` | Token 对这个控制平面 / 命名空间无效。重新检查 `ADMIN_TOKEN`。 | | `[vars] must be an object` | 用 `[vars]` 表/对象;数组不合法。 | | `[vars] : only string/number/boolean values are supported` | 移除嵌套值;敏感字符串改用 secret。 | -| `binding name collision: ` | `[vars]` key 或另一个 binding 复用了 runtime env 名称;重命名其中一个。 | +| `binding name collision: ` | `[vars]`、显式 bindings 或隐式 `ASSETS` binding 复用了 runtime env 名称;重命名其中一个。 | | `experimental_compat_flag_unsupported` | 移除 experimental workerd compatibility flag。 | -| `python_workers_unsupported` | WDL 不支持 Python Workers;移除 Python Worker modules。 | +| `python_workers_unsupported` | WDL 不支持 Python Workers;移除 Python Worker modules。CLI 也会对本地 `.py` module fail-fast。 | | `worker_env_too_large` | 减少 `[vars]`、secrets 或 binding metadata;如果错误点名 retained version,redeploy/delete 该版本。 | -| `worker_code_invalid` | 重命名与 WDL 保留 `_wdl-*.js` runtime module 冲突的模块。 | +| `worker_code_invalid` | 按 control plane 返回的原因修正 Worker bundle 形状,包括 WDL 保留注入模块名。 | | `wrangler build failed` | 在项目里跑 `npx wrangler deploy --dry-run` 然后在那边修。 | | 部署成功但 promote 失败 | 自定义主机或服务绑定的目标校验问题;检查绑定目标。 | | Worker URL 返回 404 | URL 缺了 `/` 这一段。 | diff --git a/docs/deploy.md b/docs/deploy.md index 7320ad0..8b30ba4 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -134,14 +134,6 @@ embedded in that JSON request at deploy time; a large static file set can hit the control request cap first. Put bulk or frequently changing files in R2, not in assets. -Deploy also preflights WDL runtime constraints that Wrangler cannot see: -`[vars]` names share the same runtime `env` namespace as bindings, so collisions -are rejected; modules named like WDL injected runtime modules (`_wdl-*.js`, such -as `_wdl-wrapper.js`) are reserved; Python Workers modules are not supported; -and the explicit workerd `experimental` compatibility flag is rejected before -bundling. Other unsupported experimental compatibility flags are rejected by -the control plane with `experimental_compat_flag_unsupported`. - The control plane enforces a headroomed 1 MiB workerd `workerLoader` environment budget (1,040,384 bytes usable). Large `[vars]`, secrets, binding metadata, or retained versions can fail with `worker_env_too_large`; reduce the env payload, @@ -181,9 +173,11 @@ implemented yet. WDL Workflows supports only workflow classes defined in the current Worker — not full Cloudflare Workflows parity; `script_name`, cross-worker workflows, cross-worker callbacks, service-binding callbacks, and the Cloudflare source-AST visualizer are not supported. `route` / `routes` are -supported only when the operator enables them. Python Workers modules and -workerd experimental compatibility flags are not supported. `assets.run_worker_first` -is silently ignored. +supported only when the operator enables them. Python Workers modules, workerd +experimental compatibility flags, and WDL-reserved injected module names are +rejected during deploy: the CLI fails fast on local `.py` modules, and the +control plane is canonical for workerd compatibility and bundle-shape policy. +`assets.run_worker_first` is silently ignored. Cron triggers and queue consumers are runtime dispatch features; declare them only on routeable tenant Workers. Workers selected through @@ -209,11 +203,11 @@ Deleting a worker does **not** delete R2 data — see [r2.md](./r2.md). | `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: ` | A `[vars]` key or another binding reused a runtime env name. Rename one of them. | +| `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. | -| `python_workers_unsupported` | Python Workers are not supported by WDL; remove Python Worker modules. | +| `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_invalid` | Rename modules that collide with WDL-reserved `_wdl-*.js` runtime module names. | +| `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. | | Deploy succeeds but promote fails | Custom host or service-binding target validation issue; check the binding targets. | | Worker URL returns 404 | The URL is missing the `/` segment. | diff --git a/docs/queues-zh.md b/docs/queues-zh.md index 1c394de..7b1efbe 100644 --- a/docs/queues-zh.md +++ b/docs/queues-zh.md @@ -53,7 +53,7 @@ export default { - `retry_delay` 和 `message.retry({ delaySeconds })` 可用于延迟重试;显式传 `delaySeconds` 会覆盖 consumer 的 `retry_delay`,传 `0` 表示立即重试。 - `message.attempts` 从 1 起计,因此 `max_retries = N` 意味着 handler 最多观察到 N + 1 次尝试,之后消息进入 dead-letter queue。 - Dead-letter queue 是有界的诊断通道(默认约 1 万条,近似裁剪)——应及时排空,不要当作持久归档使用。 -- `max_batch_timeout` 必须是 0..60 秒;它会被解析保存,但当前不要依赖它做完整的等待聚合;实际 dispatch 主要由 `max_batch_size` 和平台调度节奏截断。 +- CLI 会转发通过基础整数 delay 解析的 `max_batch_timeout` 以兼容配置;WDL control 负责执行更严格的 Cloudflare 兼容 0..60 秒范围。当前不要依赖它做完整的等待聚合,实际 dispatch 主要由 `max_batch_size` 和平台调度节奏截断。 - `max_concurrency` 当前不支持,部署时会被拒绝。 - Queue consumer 是 runtime dispatch 目标,应声明在可路由的 tenant Worker 上,不要声明在 platform binding target Worker 上。 diff --git a/docs/queues.md b/docs/queues.md index e0ea7a2..20b2185 100644 --- a/docs/queues.md +++ b/docs/queues.md @@ -66,9 +66,11 @@ and leave it to the platform's `max_retries` and `dead_letter_queue` handling. - The dead-letter queue is a bounded diagnostic channel (about 10k entries by default, approximately trimmed) — drain it promptly rather than treating it as a durable archive. -- `max_batch_timeout` must be 0..60 seconds. It is parsed and saved, but do not - rely on it for full wait-based aggregation yet; actual dispatch is mostly cut - off by `max_batch_size` and the platform's scheduling cadence. +- The CLI forwards `max_batch_timeout` values that pass basic integer delay + parsing for config compatibility; WDL control enforces the tighter + Cloudflare-compatible 0..60 second range. Do not rely on it for full + 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. - Queue consumers are runtime dispatch targets; declare them on routeable tenant Workers, not on platform binding target Workers. diff --git a/lib/bundle-modules.js b/lib/bundle-modules.js index 5d73b96..de2ea9e 100644 --- a/lib/bundle-modules.js +++ b/lib/bundle-modules.js @@ -7,6 +7,7 @@ export function inferType(filePath) { const ext = path.extname(filePath).toLowerCase(); if (ext === ".js" || ext === ".mjs") return "module"; if (ext === ".cjs") return "cjs"; + if (ext === ".py") return "py"; if (ext === ".json") return "json"; if (TEXT_EXTS.has(ext)) return "text"; if (ext === ".wasm") return "wasm"; @@ -14,8 +15,7 @@ export function inferType(filePath) { } // Inverse of `control/bundle.js::normalizeModule` for WDL-supported module -// types. Python Workers modules are intentionally not emitted: current control -// rejects `{ py: ... }` with python_workers_unsupported. +// types. Python Workers are rejected by collectModules before wire encoding. /** * `type` is normally an {@link inferType} result, but the default case rejects * anything else, so the honest input type is `string`. diff --git a/lib/token-store.js b/lib/token-store.js index 66a369d..717fe97 100644 --- a/lib/token-store.js +++ b/lib/token-store.js @@ -1,8 +1,10 @@ -import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { CliError, hasErrorCode, isNonEmptyString } from "./common.js"; import { parseDotEnvSection, parseDotEnvValue, quoteValue } from "./dotenv.js"; +import { escapeTerminalText, shellSingleQuote } from "./output.js"; // The global credential store is the lowest-precedence layer of the same // credential model as a project `.env`: a `dotenv`/INI-subset file in the @@ -15,6 +17,19 @@ import { parseDotEnvSection, parseDotEnvValue, quoteValue } from "./dotenv.js"; // when no --ns/WDL_NS selects one — the store's analogue of a base WDL_NS in a // project `.env`. The parsed store is `{ defaultNs, namespaces }`. const STORE_KEYS = ["CONTROL_URL", "ADMIN_TOKEN", "LABEL"]; +const LOCK_WAIT_MS = 25; +const DEFAULT_LOCK_TIMEOUT_MS = 5_000; +// Store mutations hold the lock only around synchronous disk I/O. A lock older +// than this is recoverable even if its PID appears alive; the owner nonce and +// mtime are refreshed on the final commit path so a superseded writer normally +// fails before replacing the store. +const DEFAULT_STALE_LOCK_MS = 60_000; +const LOCK_OWNER_FILE = "owner"; +const LOCK_RELEASED_FILE = "released"; +const LOCK_CONTENTION_CODE = "WDL_LOCK_CONTENTION"; +const LOCK_SUPERSEDED_CODE = "WDL_LOCK_SUPERSEDED"; +const UPDATE_RETRY_LIMIT = 8; +const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4)); /** * The parsed credential store: an optional default-namespace pointer plus the @@ -64,7 +79,9 @@ export function readTokenStore(storePath) { text = readFileSync(storePath, "utf8"); } catch (err) { if (hasErrorCode(err) && err.code === "ENOENT") return { defaultNs: null, namespaces: {} }; - throw err; + throw new CliError( + `failed to read credential store ${escapeTerminalText(storePath)}: ${formatTokenStoreError(err)}` + ); } /** @type {Record>} */ @@ -110,6 +127,22 @@ export function readTokenStore(storePath) { return { defaultNs, namespaces }; } +/** @param {unknown} err */ +function formatTokenStoreError(err) { + const message = err instanceof Error && err.message ? err.message : String(err); + return escapeTerminalText(message); +} + +/** + * @param {unknown} err + * @param {string} prefix + */ +function wrapTokenStoreFsError(err, prefix) { + if (err instanceof CliError) return err; + if (hasErrorCode(err)) return new CliError(`${prefix}: ${formatTokenStoreError(err)}`); + return err; +} + // A 0600 credentials file is not enough on its own: in a group/world-writable // dir another user can delete, replace, or symlink-swap the fixed-path file. So // refuse to write into a dir anyone else can write. (POSIX only — Windows mode @@ -121,8 +154,10 @@ export function readTokenStore(storePath) { export function assertStoreDirSecure(storeDir, platform = process.platform) { if (platform === "win32") return; if ((statSync(storeDir).mode & 0o022) !== 0) { + const escapedDir = escapeTerminalText(storeDir); + const quotedDir = escapeTerminalText(shellSingleQuote(storeDir)); throw new CliError( - `refusing to write credentials: ${storeDir} is group/world-writable; restrict it with \`chmod 700 ${storeDir}\`` + `refusing to write credentials: ${escapedDir} is group/world-writable; restrict it with \`chmod 700 ${quotedDir}\`` ); } } @@ -137,6 +172,19 @@ export function assertStoreDirSecure(storeDir, platform = process.platform) { * @param {TokenStore} store */ export function writeTokenStore(storePath, store) { + try { + writeTokenStoreFile(storePath, store); + } catch (err) { + throw wrapTokenStoreFsError(err, "failed to write credential store"); + } +} + +/** + * @param {string} storePath + * @param {TokenStore} store + * @param {{ beforeCommit?: () => void, tempDir?: string }} [options] + */ +function writeTokenStoreFile(storePath, store, { beforeCommit = () => {}, tempDir = path.dirname(storePath) } = {}) { const namespaces = store.namespaces || {}; const lines = [ "# Managed by `wdl token`. Do not hand-edit — use a project .env for overrides.", @@ -167,18 +215,453 @@ export function writeTokenStore(storePath, store) { // best-effort } assertStoreDirSecure(storeDir); - const tmpPath = `${storePath}.${process.pid}.${Date.now()}.tmp`; + const tmpPath = path.join(tempDir, `${path.basename(storePath)}.${process.pid}.${randomUUID()}.tmp`); try { writeFileSync(tmpPath, lines.join("\n"), { mode: 0o600, flag: "wx" }); chmodSync(tmpPath, 0o600); + beforeCommit(); renameSync(tmpPath, storePath); - chmodSync(storePath, 0o600); + try { + chmodSync(storePath, 0o600); + } catch { + // The file was created 0600 before the atomic replace. A post-commit + // chmod failure must not make a persisted credential mutation look + // failed to the caller. + } } catch (err) { rmSync(tmpPath, { force: true }); throw err; } } +/** + * Mutates the store under the lock. The update callback may be re-run if + * another writer supersedes the lock before commit; keep it to deterministic + * store mutation. + * + * @template R + * @param {string} storePath + * @param {(store: ReturnType) => R} update + * @param {{ lockTimeoutMs?: number, staleLockMs?: number }} [options] + * @returns {R} + */ +export function updateTokenStore( + storePath, + update, + { + lockTimeoutMs = DEFAULT_LOCK_TIMEOUT_MS, + staleLockMs = DEFAULT_STALE_LOCK_MS, + } = {} +) { + /** @type {R | undefined} */ + let result; + for (let attempt = 0; attempt < UPDATE_RETRY_LIMIT; attempt += 1) { + try { + withTokenStoreLock(storePath, lockTimeoutMs, staleLockMs, (owner) => { + const store = readTokenStore(storePath); + result = update(store); + refreshTokenStoreLockOwner(storePath, owner); + try { + writeTokenStoreFile(storePath, store, { + beforeCommit: () => refreshTokenStoreLockOwner(storePath, owner), + tempDir: `${storePath}.lock`, + }); + } catch (err) { + if (hasErrorCode(err) && (err.code === "ENOENT" || err.code === "ENOTDIR")) { + throw tokenStoreLockSupersededError(); + } + throw err; + } + }); + return /** @type {R} */ (result); + } catch (err) { + if (!isTokenStoreLockSupersededError(err) || attempt === UPDATE_RETRY_LIMIT - 1) { + throw wrapTokenStoreFsError(err, "failed to update credential store"); + } + result = undefined; + Atomics.wait(LOCK_WAIT, 0, 0, LOCK_WAIT_MS); + } + } + throw tokenStoreLockSupersededError(); +} + +/** + * @param {string} storePath + * @param {number} lockTimeoutMs + * @param {number} staleLockMs + * @param {(owner: string) => void} fn + */ +function withTokenStoreLock(storePath, lockTimeoutMs, staleLockMs, fn) { + const storeDir = path.dirname(storePath); + prepareTokenStoreDir(storeDir); + const lockDir = `${storePath}.lock`; + const owner = acquireTokenStoreLock(lockDir, lockTimeoutMs, staleLockMs); + try { + fn(owner); + } finally { + releaseTokenStoreLock(lockDir, owner); + } +} + +/** @param {string} storeDir */ +function prepareTokenStoreDir(storeDir) { + mkdirSync(storeDir, { recursive: true, mode: 0o700 }); + try { + chmodSync(storeDir, 0o700); + } catch { + // best-effort + } + assertStoreDirSecure(storeDir); +} + +/** + * @param {string} lockDir + * @param {number} lockTimeoutMs + * @param {number} staleLockMs + * @returns {string} + */ +function acquireTokenStoreLock(lockDir, lockTimeoutMs, staleLockMs) { + const deadline = Date.now() + Math.max(0, lockTimeoutMs); + const owner = `${process.pid}:${randomUUID()}`; + while (true) { + try { + createTokenStoreLock(lockDir, owner); + return owner; + } catch (err) { + if (!isTokenStoreLockContentionError(err)) throw err; + if (hasErrorCode(err) && err.code === "EEXIST" && + recoverTokenStoreLock(lockDir, owner, staleLockMs)) { + return owner; + } + if (Date.now() >= deadline) { + throw new CliError( + "credential store is locked by another wdl token command; retry after it finishes" + ); + } + Atomics.wait(LOCK_WAIT, 0, 0, LOCK_WAIT_MS); + } + } +} + +/** + * @param {string} lockDir + * @param {string} owner + */ +function createTokenStoreLock(lockDir, owner) { + mkdirSync(lockDir, { mode: 0o700 }); + /** @type {import("node:fs").Stats | null} */ + let created = null; + try { + created = lstatSync(lockDir); + chmodSync(lockDir, 0o700); + writeFileSync(lockOwnerPath(lockDir), owner, { mode: 0o600, flag: "wx" }); + chmodSync(lockOwnerPath(lockDir), 0o600); + } catch (err) { + if (created) removeCreatedTokenStoreLock(lockDir, created, owner); + if (isLockPathContentionFsError(err)) throw tokenStoreLockContentionError(err); + throw err; + } +} + +/** + * @param {string} lockDir + * @param {string} owner + * @param {number} staleLockMs + */ +function recoverTokenStoreLock(lockDir, owner, staleLockMs) { + const recoveredPath = moveReleasedTokenStoreLock(lockDir) || + moveStaleTokenStoreLock(lockDir, staleLockMs); + if (!recoveredPath) return false; + try { + createTokenStoreLock(lockDir, owner); + return true; + } catch (err) { + if (isTokenStoreLockContentionError(err)) return false; + throw err; + } finally { + cleanupRecoveredTokenStoreLock(recoveredPath); + } +} + +/** @param {unknown} err */ +function isTokenStoreLockContentionError(err) { + return hasErrorCode(err) && ( + err.code === "EEXIST" || + err.code === LOCK_CONTENTION_CODE + ); +} + +/** @param {unknown} err */ +function isLockPathContentionFsError(err) { + return hasErrorCode(err) && (err.code === "ENOENT" || err.code === "ENOTDIR"); +} + +/** @param {unknown} err */ +function tokenStoreLockContentionError(err) { + const wrapped = new Error(err instanceof Error && err.message ? err.message : "credential store lock contention"); + Object.defineProperty(wrapped, "code", { value: LOCK_CONTENTION_CODE }); + return wrapped; +} + +/** @param {unknown} err */ +function isTokenStoreLockSupersededError(err) { + return hasErrorCode(err) && err.code === LOCK_SUPERSEDED_CODE; +} + +function tokenStoreLockSupersededError() { + const err = new CliError("credential store lock was superseded before write; retry the wdl token command"); + Object.defineProperty(err, "code", { value: LOCK_SUPERSEDED_CODE }); + return err; +} + +/** + * @param {string} lockDir + * @returns {string | false} + */ +function moveReleasedTokenStoreLock(lockDir) { + try { + const observed = lstatSync(lockDir); + if (!observed.isDirectory()) return false; + const observedOwner = readTokenStoreLockOwner(lockDir); + if (observedOwner === null) return false; + if (readTokenStoreLockRelease(lockDir) !== observedOwner) return false; + return moveObservedTokenStoreLock(lockDir, observed, { + owner: observedOwner, + release: observedOwner, + requireUnchangedMtime: false, + }); + } catch (err) { + if (hasErrorCode(err) && err.code === "ENOENT") return false; + if (isLockRecoveryFsError(err)) throw tokenStoreLockRecoveryError(err); + throw err; + } +} + +/** + * @param {string} lockDir + * @param {number} staleLockMs + * @returns {string | false} + */ +function moveStaleTokenStoreLock(lockDir, staleLockMs) { + const maxAgeMs = Math.max(0, staleLockMs); + try { + const observed = lstatSync(lockDir); + const ageMs = Date.now() - observed.mtimeMs; + if (maxAgeMs > 0 && ageMs < maxAgeMs) return false; + if (!observed.isDirectory()) { + return moveObservedNonDirectoryLock(lockDir, observed); + } + const observedOwner = readTokenStoreLockOwner(lockDir); + return moveObservedTokenStoreLock(lockDir, observed, { + owner: observedOwner, + release: undefined, + requireUnchangedMtime: true, + }); + } catch (err) { + if (hasErrorCode(err) && err.code === "ENOENT") return false; + if (isLockRecoveryFsError(err)) throw tokenStoreLockRecoveryError(err); + throw err; + } +} + +/** + * @param {string} lockDir + * @param {import("node:fs").Stats} created + * @param {string} owner + */ +function removeCreatedTokenStoreLock(lockDir, created, owner) { + try { + const current = lstatSync(lockDir); + if (current.dev !== created.dev || current.ino !== created.ino || !current.isDirectory()) return; + const currentOwner = readTokenStoreLockOwner(lockDir); + if (currentOwner !== null && currentOwner !== owner) return; + rmSync(lockDir, { recursive: true, force: true }); + } catch (err) { + if (hasErrorCode(err) && err.code === "ENOENT") return; + if (hasErrorCode(err) && err.code === "ENOTDIR") return; + throw err; + } +} + +/** + * @param {string} storePath + * @param {string} owner + */ +function assertTokenStoreLockOwner(storePath, owner) { + const lockDir = `${storePath}.lock`; + if (readTokenStoreLockOwner(lockDir) === owner) return; + throw tokenStoreLockSupersededError(); +} + +/** + * @param {string} storePath + * @param {string} owner + */ +function refreshTokenStoreLockOwner(storePath, owner) { + const lockDir = `${storePath}.lock`; + assertTokenStoreLockOwner(storePath, owner); + try { + const now = new Date(); + utimesSync(lockDir, now, now); + } catch (err) { + if (hasErrorCode(err) && err.code === "ENOENT") { + throw tokenStoreLockSupersededError(); + } + throw err; + } + assertTokenStoreLockOwner(storePath, owner); +} + +/** + * @param {string} lockDir + * @param {import("node:fs").Stats} observed + * @param {{ owner: string | null, release?: string, requireUnchangedMtime: boolean }} expected + * @returns {string | false} + */ +function moveObservedTokenStoreLock(lockDir, observed, expected) { + try { + const current = lstatSync(lockDir); + if (current.dev !== observed.dev || current.ino !== observed.ino) return false; + if (expected.requireUnchangedMtime && current.mtimeMs !== observed.mtimeMs) return false; + if (!current.isDirectory()) return false; + if (readTokenStoreLockOwner(lockDir) !== expected.owner) return false; + if (expected.release !== undefined && + (expected.owner === null || readTokenStoreLockRelease(lockDir) !== expected.release)) { + return false; + } + const final = lstatSync(lockDir); + if (final.dev !== observed.dev || final.ino !== observed.ino) return false; + if (expected.requireUnchangedMtime && final.mtimeMs !== observed.mtimeMs) return false; + if (!final.isDirectory()) return false; + const recoveredPath = recoveredTokenStoreLockPath(lockDir); + renameSync(lockDir, recoveredPath); + return recoveredPath; + } catch (err) { + if (hasErrorCode(err) && err.code === "ENOENT") return false; + throw err; + } +} + +/** + * @param {string} lockPath + * @param {import("node:fs").Stats} observed + * @returns {string | false} + */ +function moveObservedNonDirectoryLock(lockPath, observed) { + try { + const current = lstatSync(lockPath); + if (current.dev !== observed.dev || current.ino !== observed.ino) return false; + if (current.isDirectory()) return false; + const recoveredPath = recoveredTokenStoreLockPath(lockPath); + renameSync(lockPath, recoveredPath); + return recoveredPath; + } catch (err) { + if (hasErrorCode(err) && err.code === "ENOENT") return false; + if (hasErrorCode(err) && (err.code === "EISDIR" || err.code === "EPERM")) return false; + throw err; + } +} + +/** @param {string} lockPath */ +function recoveredTokenStoreLockPath(lockPath) { + return `${lockPath}.recovered-${process.pid}-${randomUUID()}`; +} + +/** @param {string} recoveredPath */ +function cleanupRecoveredTokenStoreLock(recoveredPath) { + try { + if (lstatSync(recoveredPath).isDirectory()) chmodSync(recoveredPath, 0o700); + rmSync(recoveredPath, { recursive: true, force: true }); + } catch (err) { + if (hasErrorCode(err) && err.code === "ENOENT") return; + // best-effort cleanup for an old stale lock; the new active lock has already + // been created, so an unrecoverable cleanup path should not break the + // credential-store mutation. + } +} + +/** @param {string} lockDir */ +function lockOwnerPath(lockDir) { + return path.join(lockDir, LOCK_OWNER_FILE); +} + +/** + * @param {string} lockDir + */ +function lockReleasedPath(lockDir) { + return path.join(lockDir, LOCK_RELEASED_FILE); +} + +/** + * @param {string} lockDir + * @returns {string | null} + */ +function readTokenStoreLockOwner(lockDir) { + try { + return readFileSync(lockOwnerPath(lockDir), "utf8"); + } catch (err) { + if (hasErrorCode(err) && err.code === "ENOENT") return null; + if (hasErrorCode(err) && err.code === "ENOTDIR") return null; + // A crash between owner-file creation and chmod can leave an unreadable + // file under a stale lock. Treat it as ownerless; stale takeover still + // applies the age check before replacing the owner. + if (hasErrorCode(err) && (err.code === "EACCES" || err.code === "EPERM")) return null; + throw err; + } +} + +/** + * @param {string} lockDir + * @returns {string | null} + */ +function readTokenStoreLockRelease(lockDir) { + try { + return readFileSync(lockReleasedPath(lockDir), "utf8"); + } catch (err) { + if (hasErrorCode(err) && err.code === "ENOENT") return null; + if (hasErrorCode(err) && err.code === "ENOTDIR") return null; + // A crash or restrictive umask can leave the release marker unreadable. + // Treat it as unconfirmed release; stale takeover can still recover the + // lock after the directory age check without leaking a raw filesystem error. + if (hasErrorCode(err) && (err.code === "EACCES" || err.code === "EPERM")) return null; + throw err; + } +} + +/** @param {unknown} err */ +function isLockRecoveryFsError(err) { + return hasErrorCode(err) && ( + err.code === "EACCES" || + err.code === "EPERM" || + err.code === "ENOTDIR" + ); +} + +/** @param {unknown} err */ +function tokenStoreLockRecoveryError(err) { + const message = err instanceof Error && err.message ? err.message : String(err); + return new CliError( + `credential store lock could not be recovered; remove the stale lock and retry: ${escapeTerminalText(message)}` + ); +} + +/** + * @param {string} lockDir + * @param {string} owner + */ +function releaseTokenStoreLock(lockDir, owner) { + try { + if (readTokenStoreLockOwner(lockDir) !== owner) return; + const releasedPath = lockReleasedPath(lockDir); + writeFileSync(releasedPath, owner, { mode: 0o600 }); + chmodSync(releasedPath, 0o600); + } catch { + // The store mutation has already committed. Do not turn a best-effort + // release marker failure into a false failed write; stale recovery will + // reclaim an unreleased owner lock if needed. + } +} + // The `readStore` loadCliControlEnv expects: the real disk reader, or a no-op // when the store is disabled (--no-token-store / WDL_TOKEN_STORE=off). Shared so // the bin dispatcher and config-state never drift on what "disabled" reads. diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index 754eb87..c08487f 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -237,14 +237,9 @@ export async function packWranglerProject({ } const vars = normalizeVars(cfg.vars); - for (const name of Object.keys(vars)) claimBinding(name); - - if (Array.isArray(cfg.compatibility_flags) && cfg.compatibility_flags.includes("experimental")) { - throw new CliError( - `${configRel}: compatibility_flags "experimental" is not supported by WDL` - ); + 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. @@ -356,10 +351,8 @@ export async function packWranglerProject({ `defaults and .assetsignore rules apply — re-include with a !pattern line` ); } - if (Object.keys(assets).length) { - claimBinding("ASSETS"); - manifest.assets = assets; - } + claimBinding("ASSETS"); + manifest.assets = assets; } return { absProject, workerName: cfg.name, manifest }; diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index 97a3fdf..bde94d6 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -17,7 +17,6 @@ import { asRecord } from "./utils.js"; const NS_RE = new RegExp(`^${NS_PATTERN}$`); const MAX_QUEUE_DELAY_SECONDS = 86_400; -const MAX_QUEUE_CONSUMER_BATCH_TIMEOUT_SECONDS = 60; // UPPER_SNAKE for `as` / `platform` / required_caller_secrets - narrower // than binding names to read as registered identifiers. @@ -184,8 +183,7 @@ export function parseQueues(queues, configRel = "wrangler config") { entry.maxBatchTimeoutMs = normalizeQueueDelayConfig( c.max_batch_timeout, configRel, - `[[queues.consumers]] ${c.queue}.max_batch_timeout`, - MAX_QUEUE_CONSUMER_BATCH_TIMEOUT_SECONDS + `[[queues.consumers]] ${c.queue}.max_batch_timeout` ) * 1000; } if (c.max_retries != null) entry.maxRetries = c.max_retries; @@ -707,12 +705,11 @@ export function parsePlatformBindingsFromCfg(cfg, configRel = "wrangler config") * @param {unknown} value * @param {string} configRel * @param {string} field - * @param {number} [maxSeconds] * @returns {number} */ -function normalizeQueueDelayConfig(value, configRel, field, maxSeconds = MAX_QUEUE_DELAY_SECONDS) { - if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > maxSeconds) { - throw new Error(`${configRel}: ${field} must be an integer in [0, ${maxSeconds}]`); +function normalizeQueueDelayConfig(value, configRel, field) { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_QUEUE_DELAY_SECONDS) { + throw new Error(`${configRel}: ${field} must be an integer in [0, ${MAX_QUEUE_DELAY_SECONDS}]`); } return value; } diff --git a/lib/wrangler/module-limits.js b/lib/wrangler/module-limits.js deleted file mode 100644 index 1c7b90c..0000000 --- a/lib/wrangler/module-limits.js +++ /dev/null @@ -1,30 +0,0 @@ -// CLI-local copy of the WDL runtime/do-runtime injected module names that a -// tenant bundle must not use. Keep in sync with runtime/load/module-rewrite.js -// and do-runtime/load-code-budget.js in wdl. - -export const WDL_RESERVED_MODULE_NAMES = Object.freeze([ - "_wdl-cloudflare-workflows.js", - "_wdl-d1-data-field.js", - "_wdl-d1-client.js", - "_wdl-d1-params.js", - "_wdl-sql-splitter.js", - "_wdl-d1-transport.js", - "_wdl-r2-client.js", - "_wdl-r2-utils.js", - "_wdl-do-client.js", - "_wdl-do-transport.js", - "_wdl-owner-endpoint.js", - "_wdl-owner-hint-cache.js", - "_wdl-request-id.js", - "_wdl-workflows-client.js", - "_wdl-wrapper.js", - "_wdl-do-runtime-wrapper.js", - "_wdl-do-alarm-shim.js", -]); - -const WDL_RESERVED_MODULE_SET = new Set(WDL_RESERVED_MODULE_NAMES); - -/** @param {string} name */ -export function isWdlReservedModuleName(name) { - return WDL_RESERVED_MODULE_SET.has(name); -} diff --git a/lib/wrangler/modules.js b/lib/wrangler/modules.js index bc2f676..0bd7e4a 100644 --- a/lib/wrangler/modules.js +++ b/lib/wrangler/modules.js @@ -1,7 +1,7 @@ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { inferType, toWireModule } from "../bundle-modules.js"; -import { isWdlReservedModuleName } from "./module-limits.js"; +import { escapeTerminalText } from "../output.js"; import { manifestMap } from "./utils.js"; // Skip only the known incidentals (.map, README.md). Dropping any other @@ -28,13 +28,11 @@ export function collectModules(dir) { } if (!st.isFile()) continue; if (entry.endsWith(".map") || skip.has(relPath)) continue; - if (isWdlReservedModuleName(relPath)) { - throw new Error(`wrangler output uses WDL reserved module name ${relPath}`); + const type = inferType(entry); + if (type === "py") { + throw new Error(`Python Workers modules are not supported by WDL (${escapeTerminalText(relPath)})`); } - if (path.extname(entry).toLowerCase() === ".py") { - throw new Error(`Python Workers modules are not supported by WDL; remove ${relPath} from the bundle`); - } - out[relPath] = toWireModule(readFileSync(full), inferType(entry)); + out[relPath] = toWireModule(readFileSync(full), type); } })(dir, ""); return out; diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 222cf78..a60a78e 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -41,12 +41,12 @@ doc and merge their wrangler config snippets. 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. WDL follows Wrangler config priority -(`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`) and rejects -Python Workers modules, unsupported workerd experimental compatibility flags, -modules named like WDL injected `_wdl-*.js` runtime modules, and `[vars]` keys -that collide with bindings. The CLI locally rejects the explicit `experimental` -compatibility flag; the control plane is canonical for other unsupported -experimental flags. +(`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`). The control +plane is canonical for unsupported runtime shapes such as unsupported workerd +experimental compatibility flags and WDL-reserved injected module names; the +CLI still fails fast for cheap local cases such as Python Workers modules and +ambiguous runtime `env` name collisions between `[vars]`, explicit bindings, +and the implicit `ASSETS` binding. ## Runnable end-to-end examples diff --git a/tests/unit/cli-bundle-modules.test.js b/tests/unit/cli-bundle-modules.test.js index 1ec4ac6..3ca2e01 100644 --- a/tests/unit/cli-bundle-modules.test.js +++ b/tests/unit/cli-bundle-modules.test.js @@ -9,7 +9,7 @@ test("bundle module helpers infer file types", () => { assert.equal(inferType("worker.js"), "module"); assert.equal(inferType("x.mjs"), "module"); assert.equal(inferType("x.cjs"), "cjs"); - assert.equal(inferType("x.py"), "data"); + assert.equal(inferType("x.py"), "py"); assert.equal(inferType("x.json"), "json"); assert.equal(inferType("x.svg"), "text"); assert.equal(inferType("x.wasm"), "wasm"); @@ -24,5 +24,6 @@ test("toWireModule encodes supported module types", () => { assert.deepEqual(toWireModule(Buffer.from("{\"a\":1}"), "json"), { json: { a: 1 } }); assert.deepEqual(toWireModule(Buffer.from([1, 2]), "wasm"), { wasm_b64: "AQI=" }); assert.deepEqual(toWireModule(Buffer.from([3, 4]), "data"), { data_b64: "AwQ=" }); + assert.throws(() => toWireModule(Buffer.from("print('hi')"), "py"), /Unknown module type "py"/); assert.throws(() => toWireModule(Buffer.from("x"), "ruby"), /Unknown module type "ruby"/); }); diff --git a/tests/unit/cli-d1.test.js b/tests/unit/cli-d1.test.js index ab20470..82eacde 100644 --- a/tests/unit/cli-d1.test.js +++ b/tests/unit/cli-d1.test.js @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { D1_MIGRATIONS_JSON_BODY_MAX_BYTES, runD1Command, serializeD1MigrationsBody } from "../../commands/d1.js"; +import { runD1Command } from "../../commands/d1.js"; import { LONG_CONTROL_TIMEOUT_MS } from "../../lib/control-fetch.js"; import { mockDeps as sharedMockDeps, response } from "./helpers.js"; @@ -256,6 +256,102 @@ test("d1 migrations apply reads sorted SQL files from --dir", async () => { } }); +test("d1 migrations apply surfaces control request body size errors", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-migrations-413-")); + try { + const migrations = path.join(dir, "migrations"); + mkdirSync(migrations); + writeFileSync(path.join(migrations, "001_init.sql"), `-- ${"x".repeat(1_050_000)}`); + + /** @type {RecordedCall[]} */ + const calls = []; + await assert.rejects( + () => runD1Command([ + "migrations", + "apply", + "main", + "--dir", + "migrations", + "--control-url", + "http://ctl.test", + ], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + /** @param {string} url @param {ControlFetchInit} [init] */ + controlFetch: async (url, init = {}) => { + calls.push({ url, init }); + return response({ + error: "request_body_too_large", + message: "migration request too large", + }, 413); + }, + }), + /apply d1 migrations failed: 413 request_body_too_large: migration request too large/ + ); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "http://ctl.test/ns/demo/d1/databases/main/migrations/apply"); + assert.ok( + typeof calls[0].init.body === "string" && calls[0].init.body.length > 1_048_576, + "oversized local migration bodies are sent to control for canonical rejection" + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("d1 migrations status surfaces control request body size errors", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-status-413-")); + try { + const migrations = path.join(dir, "migrations"); + mkdirSync(migrations); + for (let i = 0; i < 4_600; i += 1) { + const suffix = String(i).padStart(4, "0"); + writeFileSync( + path.join(migrations, `001_${suffix}_${"x".repeat(170)}.sql`), + "select 1;" + ); + } + + /** @type {RecordedCall[]} */ + const calls = []; + await assert.rejects( + () => runD1Command([ + "migrations", + "status", + "main", + "--dir", + "migrations", + "--control-url", + "http://ctl.test", + ], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + /** @param {string} url @param {ControlFetchInit} [init] */ + controlFetch: async (url, init = {}) => { + calls.push({ url, init }); + return response({ + error: "request_body_too_large", + message: "migration status request too large", + }, 413); + }, + }), + /show d1 migration status failed: 413 request_body_too_large: migration status request too large/ + ); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "http://ctl.test/ns/demo/d1/databases/main/migrations/status"); + assert.ok( + typeof calls[0].init.body === "string" && calls[0].init.body.length > 1_048_576, + "oversized local migration status bodies are sent to control for canonical rejection" + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("d1 migrations apply rejects symlinked SQL files", { skip: process.platform === "win32" }, async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-migrations-symlink-")); try { @@ -280,17 +376,6 @@ test("d1 migrations apply rejects symlinked SQL files", { skip: process.platform } }); -test("serializeD1MigrationsBody enforces the control request body cap", () => { - assert.equal(D1_MIGRATIONS_JSON_BODY_MAX_BYTES, 1024 * 1024); - assert.equal(serializeD1MigrationsBody({ migrations: [] }, "d1 migrations apply", 32), '{"migrations":[]}'); - assert.throws( - () => serializeD1MigrationsBody({ - migrations: [{ id: "001_big.sql", name: "001_big", checksum: "x", sql: "x".repeat(80) }], - }, "d1 migrations apply", 64), - /d1 migrations apply request is \d+ bytes, exceeds 64 byte control-plane request cap/ - ); -}); - test("d1 migrations_dir from wrangler config cannot escape the project", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-contained-")); const outside = mkdtempSync(path.join(tmpdir(), "wdl-d1-outside-")); diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 527c44a..55a8abe 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -232,14 +232,10 @@ test("parseQueues: consumers normalize max_batch_timeout and retry_delay", () => ]); }); -test("parseQueues: consumer max_batch_timeout is capped at the platform limit", () => { +test("parseQueues: forwards platform-range batch timeouts for control-side validation", () => { assert.deepEqual( - parseQueues({ consumers: [{ queue: "orders", max_batch_timeout: 60 }] }).consumers, - [{ queue: "orders", maxBatchTimeoutMs: 60_000 }] - ); - assert.throws( - () => parseQueues({ consumers: [{ queue: "orders", max_batch_timeout: 61 }] }), - /max_batch_timeout must be an integer in \[0, 60\]/ + parseQueues({ consumers: [{ queue: "orders", max_batch_timeout: 61 }] }).consumers, + [{ queue: "orders", maxBatchTimeoutMs: 61_000 }] ); }); @@ -658,6 +654,19 @@ test("collectModules: refuses to follow a symlink in wrangler's outdir", () => { } }); +test("collectModules: rejects Python Workers modules before upload", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-collect-py-")); + try { + writeFileSync(path.join(dir, "index.py"), "export default {}"); + assert.throws( + () => collectModules(dir), + /Python Workers modules are not supported by WDL \(index\.py\)/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("collectAssets: recurses and preserves dotfiles as base64", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-assets-")); try { @@ -2128,14 +2137,14 @@ test("runDeployCommand rejects Object.prototype-shaped vars before bundling", as }); test("runDeployCommand rejects vars that collide with bindings before bundling", async () => { - const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-var-collision-")); + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-vars-binding-")); try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ name: "api", main: "src/index.js", - kv_namespaces: [{ binding: "CACHE", id: "cache" }], + kv_namespaces: [{ binding: "CACHE", id: "kv-cache" }], vars: { CACHE: "shadow" }, })); @@ -2143,12 +2152,12 @@ test("runDeployCommand rejects vars that collide with bindings before bundling", await assert.rejects( () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, - execFile: () => { - execCalled = true; - throw new Error("execFile should not be called"); - }, + stdout: () => {}, + stderr: () => {}, + execFile: () => { execCalled = true; }, + controlFetch: async () => response({}), }), - { message: "binding name collision: CACHE" } + /binding name collision: CACHE/ ); assert.equal(execCalled, false); } finally { @@ -2162,11 +2171,11 @@ test("runDeployCommand rejects vars that collide with the implicit assets bindin mkdirSync(path.join(dir, "src"), { recursive: true }); mkdirSync(path.join(dir, "public"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "public", "index.html"), "

ok

"); + writeFileSync(path.join(dir, "public", "index.html"), ""); writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ name: "api", main: "src/index.js", - assets: { directory: "./public" }, + assets: { directory: "public" }, vars: { ASSETS: "shadow" }, })); @@ -2179,10 +2188,10 @@ test("runDeployCommand rejects vars that collide with the implicit assets bindin execFile: fakeWranglerExecFile, controlFetch: async () => { fetched = true; - throw new Error("controlFetch should not be called"); + return response({}); }, }), - { message: "binding name collision: ASSETS" } + /binding name collision: ASSETS/ ); assert.equal(fetched, false); } finally { @@ -2196,12 +2205,12 @@ test("runDeployCommand rejects explicit bindings that collide with the implicit mkdirSync(path.join(dir, "src"), { recursive: true }); mkdirSync(path.join(dir, "public"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "public", "index.html"), "

ok

"); + writeFileSync(path.join(dir, "public", "index.html"), ""); writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ name: "api", main: "src/index.js", - assets: { directory: "./public" }, - kv_namespaces: [{ binding: "ASSETS", id: "cache" }], + assets: { directory: "public" }, + kv_namespaces: [{ binding: "ASSETS", id: "kv-assets" }], })); let fetched = false; @@ -2213,10 +2222,10 @@ test("runDeployCommand rejects explicit bindings that collide with the implicit execFile: fakeWranglerExecFile, controlFetch: async () => { fetched = true; - throw new Error("controlFetch should not be called"); + return response({}); }, }), - { message: "binding name collision: ASSETS" } + /binding name collision: ASSETS/ ); assert.equal(fetched, false); } finally { @@ -2224,55 +2233,67 @@ test("runDeployCommand rejects explicit bindings that collide with the implicit } }); -test("runDeployCommand rejects the explicit workerd experimental compatibility flag before bundling", async () => { - const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-experimental-flag-")); +test("runDeployCommand treats an empty assets directory as an implicit ASSETS binding", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-empty-assets-")); try { mkdirSync(path.join(dir, "src"), { recursive: true }); + mkdirSync(path.join(dir, "public"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ name: "api", main: "src/index.js", - compatibility_flags: ["nodejs_compat", "experimental"], + assets: { directory: "public" }, })); - let execCalled = false; - await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - execFile: () => { - execCalled = true; - throw new Error("execFile should not be called"); - }, - }), - /compatibility_flags "experimental" is not supported by WDL/ - ); - assert.equal(execCalled, false); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); + /** @type {RecordedFetch[]} */ + const fetchCalls = []; + await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + fetchCalls.push({ url, init }); + if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); + return response({ platformDomain: "wdl.sh" }); + }, + }); -test("collectModules rejects WDL reserved module names", () => { - const dir = mkdtempSync(path.join(tmpdir(), "wdl-reserved-module-")); - try { - writeFileSync(path.join(dir, "_wdl-wrapper.js"), "export default {}"); - assert.throws( - () => collectModules(dir), - /WDL reserved module name _wdl-wrapper\.js/ - ); + const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); + assert.deepEqual(manifest.assets, {}); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test("collectModules rejects Python Workers modules locally", () => { - const dir = mkdtempSync(path.join(tmpdir(), "wdl-python-module-")); +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 { - writeFileSync(path.join(dir, "worker.py"), "export default {}"); - assert.throws( - () => collectModules(dir), - /Python Workers modules are not supported by WDL; remove worker\.py/ + mkdirSync(path.join(dir, "src"), { recursive: true }); + mkdirSync(path.join(dir, "public"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: "src/index.js", + assets: { directory: "public" }, + vars: { ASSETS: "shadow" }, + })); + + let fetched = false; + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetched = true; + return response({}); + }, + }), + /binding name collision: ASSETS/ ); + assert.equal(fetched, false); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -2482,6 +2503,126 @@ test("runDeployCommand explains deploy env-budget failures at the command layer" } }); +test("runDeployCommand keeps worker_code_invalid hints generic", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-code-invalid-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); + + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => response({ + error: "worker_code_invalid", + message: "invalid generated module graph", + }, 400), + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /worker_code_invalid/); + assert.match(message, /fix the Worker bundle shape reported by the control plane/); + assert.doesNotMatch(message, /_wdl-\*\.js/); + return true; + } + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand leaves reserved module-shape rejection to control", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-reserved-module-control-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); + + /** @type {RecordedFetch[]} */ + const fetchCalls = []; + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { + 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 }); + writeFileSync(path.join(outDir, "index.js"), "export default {}"); + writeFileSync(path.join(outDir, "_wdl-wrapper.js"), "export default {}"); + }, + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + fetchCalls.push({ url, init }); + return response({ + error: "worker_code_invalid", + message: "reserved injected module name", + }, 400); + }, + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /worker_code_invalid/); + assert.match(message, /fix the Worker bundle shape reported by the control plane/); + assert.doesNotMatch(message, /rename modules that collide/); + return true; + } + ); + + assert.equal(fetchCalls.length, 1); + const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); + assert.equal(Object.hasOwn(manifest.modules, "_wdl-wrapper.js"), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand explains control-rejected experimental compatibility flags", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-experimental-flag-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), [ + 'name = "api"', + 'main = "src/index.js"', + 'compatibility_flags = ["experimental"]', + ].join("\n")); + + /** @type {RecordedFetch[]} */ + const fetchCalls = []; + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + fetchCalls.push({ url, init }); + return response({ + error: "experimental_compat_flag_unsupported", + message: "unsupported workerd experimental compatibility flag", + }, 400); + }, + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /experimental_compat_flag_unsupported/); + assert.match(message, /remove the unsupported workerd experimental compatibility flag/); + return true; + } + ); + + assert.equal(fetchCalls.length, 1); + const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); + assert.deepEqual(manifest.compatibilityFlags, ["experimental"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("runDeployCommand projects unknown deploy warnings before printing", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-warning-project-")); try { diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index 1d2ab6c..94a838c 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -2030,6 +2030,55 @@ test("wdl tail rejects invalid auth headers before opening an SSE request", asyn assert.equal(opened, false); }); +test("wdl tail abort destroys the SSE request with a tolerated abort error", async () => { + /** @type {Array} */ + const destroyedWith = []; + let requestCount = 0; + const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} _opts + * @param {(res: import("node:http").IncomingMessage) => void} _cb + */ + request(_opts, _cb) { + requestCount += 1; + const emitter = new EventEmitter(); + const req = /** @type {import("../../lib/control-fetch.js").ControlClientRequest} */ ( + /** @type {unknown} */ (Object.assign(emitter, { + end() {}, + /** @param {Error & { code?: string }} [err] */ + destroy(err) { + if (err) destroyedWith.push(err); + setImmediate(() => emitter.emit( + "error", + err || Object.assign(new Error("socket closed"), { code: "ECONNRESET" }), + )); + }, + })) + ); + setImmediate(() => process.emit("SIGINT")); + return req; + }, + }; + + await runTailCommand( + ["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], + { + env: {}, + stdout: () => {}, + stderr: () => {}, + transport: fakeTransport, + sleepFn: async () => { + throw new Error("tail should not reconnect after abort"); + }, + } + ); + + assert.equal(requestCount, 1); + assert.equal(destroyedWith.length, 1); + assert.equal(destroyedWith[0].name, "AbortError"); + assert.equal(destroyedWith[0].code, "ABORT_ERR"); +}); + test("wdl tail sends --since on the initial URL, not duplicated as Last-Event-ID", async () => { /** @type {Array<{ path: import("node:https").RequestOptions["path"], headers: import("node:http").OutgoingHttpHeaders }>} */ const requestsSeen = []; diff --git a/tests/unit/cli-token-store.test.js b/tests/unit/cli-token-store.test.js index 385f258..44c3af9 100644 --- a/tests/unit/cli-token-store.test.js +++ b/tests/unit/cli-token-store.test.js @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -8,6 +9,7 @@ import { readTokenStore, tokenStoreDir, tokenStorePath, + updateTokenStore, writeTokenStore, } from "../../lib/token-store.js"; @@ -25,6 +27,17 @@ function withTempDir(fn) { } } +/** + * @param {string} lockDir + * @param {string} owner + */ +function writeLockOwner(lockDir, owner) { + writeFileSync(path.join(lockDir, "owner"), owner, { mode: 0o600 }); +} + +const TOKEN_STORE_MODULE_URL = new URL("../../lib/token-store.js", import.meta.url).href; +const ESC = String.fromCharCode(27); + test("tokenStoreDir honors XDG_CONFIG_HOME, then falls back to ~/.config", () => { assert.equal( tokenStoreDir({ XDG_CONFIG_HOME: "/x/cfg" }, () => "/home/u"), @@ -68,6 +81,429 @@ test("writeTokenStore then readTokenStore round-trips namespaces and fields", () }); }); +test("writeTokenStore uses an unguessable temporary filename", () => { + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const fixedNow = 1234567890; + const oldNow = Date.now; + Date.now = () => fixedNow; + try { + const legacyTmpPath = `${p}.${process.pid}.${fixedNow}.tmp`; + mkdirSync(path.dirname(p), { recursive: true }); + writeFileSync(legacyTmpPath, "stale temp"); + writeTokenStore(p, { namespaces: { acme: { ADMIN_TOKEN: "tok-acme" } } }); + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "tok-acme" } }, + }); + assert.equal(readFileSync(legacyTmpPath, "utf8"), "stale temp"); + } finally { + Date.now = oldNow; + } + }); +}); + +test("updateTokenStore serializes read-modify-write with a lock directory", () => { + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const lockDir = `${p}.lock`; + updateTokenStore(p, (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + store.defaultNs = "acme"; + }); + updateTokenStore(p, (store) => { + store.namespaces.beta = { ADMIN_TOKEN: "tok-beta" }; + }); + assert.deepEqual(readTokenStore(p), { + defaultNs: "acme", + namespaces: { + acme: { ADMIN_TOKEN: "tok-acme" }, + beta: { ADMIN_TOKEN: "tok-beta" }, + }, + }); + assert.throws( + () => { + rmSync(lockDir, { recursive: true, force: true }); + mkdirSync(lockDir, { recursive: true }); + try { + updateTokenStore(p, () => {}, { lockTimeoutMs: 0 }); + } finally { + rmSync(lockDir, { recursive: true, force: true }); + } + }, + /credential store is locked/ + ); + }); +}); + +test("updateTokenStore handles concurrent released-lock recovery", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-token-store-")); + try { + const p = path.join(dir, "wdl", "credentials"); + const workers = Array.from({ length: 32 }, (_, index) => { + const ns = `ns-${index}`; + const token = `tok-${index}`; + const code = ` +import { updateTokenStore } from ${JSON.stringify(TOKEN_STORE_MODULE_URL)}; +updateTokenStore(${JSON.stringify(p)}, (store) => { + store.namespaces[${JSON.stringify(ns)}] = { ADMIN_TOKEN: ${JSON.stringify(token)} }; +}, { lockTimeoutMs: 30_000 }); +`; + const child = spawn(process.execPath, ["--input-type=module", "-e", code], { + stdio: ["ignore", "pipe", "pipe"], + }); + return new Promise((resolve, reject) => { + /** @type {Buffer[]} */ + const stdout = []; + /** @type {Buffer[]} */ + const stderr = []; + child.stdout.on("data", (chunk) => stdout.push(chunk)); + child.stderr.on("data", (chunk) => stderr.push(chunk)); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve(undefined); + return; + } + reject(new Error([ + `child ${ns} exited ${code}`, + Buffer.concat(stdout).toString("utf8"), + Buffer.concat(stderr).toString("utf8"), + ].join("\n"))); + }); + }); + }); + + const results = await Promise.allSettled(workers); + const failures = results.filter((item) => item.status === "rejected"); + if (failures.length > 0) { + assert.fail(failures.map((item) => + item.status === "rejected" ? String(item.reason?.stack || item.reason) : "" + ).join("\n")); + } + + const store = readTokenStore(p); + assert.equal(Object.keys(store.namespaces).length, 32); + for (let index = 0; index < 32; index += 1) { + assert.equal(store.namespaces[`ns-${index}`]?.ADMIN_TOKEN, `tok-${index}`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("updateTokenStore retries a read-modify-write when the lock is superseded before commit", () => { + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + writeTokenStore(p, { namespaces: { existing: { ADMIN_TOKEN: "tok-existing" } } }); + let attempts = 0; + + updateTokenStore(p, (store) => { + attempts += 1; + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + if (attempts === 1) { + rmSync(`${p}.lock`, { recursive: true, force: true }); + updateTokenStore(p, (innerStore) => { + innerStore.namespaces.beta = { ADMIN_TOKEN: "tok-beta" }; + }); + } + }); + + assert.equal(attempts, 2); + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { + acme: { ADMIN_TOKEN: "tok-acme" }, + beta: { ADMIN_TOKEN: "tok-beta" }, + existing: { ADMIN_TOKEN: "tok-existing" }, + }, + }); + }); +}); + +test("updateTokenStore recovers a stale lock directory", () => { + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const lockDir = `${p}.lock`; + mkdirSync(lockDir, { recursive: true }); + const staleTime = new Date(Date.now() - 10_000); + utimesSync(lockDir, staleTime, staleTime); + + updateTokenStore(p, (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, { lockTimeoutMs: 0, staleLockMs: 1 }); + + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "tok-acme" } }, + }); + }); +}); + +test("updateTokenStore takeover clears stale temp files inside the lock", () => { + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const lockDir = `${p}.lock`; + const staleTmp = path.join(lockDir, "credentials.old-writer.tmp"); + mkdirSync(lockDir, { recursive: true }); + writeLockOwner(lockDir, "0:stale-owner"); + writeFileSync(staleTmp, "stale"); + const staleTime = new Date(Date.now() - 10_000); + utimesSync(lockDir, staleTime, staleTime); + + updateTokenStore(p, (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, { lockTimeoutMs: 0, staleLockMs: 1 }); + + assert.equal(existsSync(staleTmp), false); + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "tok-acme" } }, + }); + }); +}); + +test("updateTokenStore creates a usable lock under a restrictive umask", () => { + if (process.platform === "win32") return; + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const oldUmask = process.umask(0o777); + try { + updateTokenStore(p, (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }); + } finally { + process.umask(oldUmask); + } + + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "tok-acme" } }, + }); + const owner = readFileSync(path.join(`${p}.lock`, "owner"), "utf8"); + assert.equal(readFileSync(path.join(`${p}.lock`, "released"), "utf8"), owner); + }); +}); + +test("updateTokenStore does not steal a fresh lock whose owner pid is not visible", () => { + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const lockDir = `${p}.lock`; + mkdirSync(lockDir, { recursive: true }); + writeLockOwner(lockDir, "0:dead-or-remote-test-owner"); + + try { + assert.throws( + () => updateTokenStore(p, () => {}, { lockTimeoutMs: 0, staleLockMs: 60_000 }), + /credential store is locked/ + ); + assert.equal(readFileSync(path.join(lockDir, "owner"), "utf8"), "0:dead-or-remote-test-owner"); + } finally { + rmSync(lockDir, { recursive: true, force: true }); + } + }); +}); + +test("updateTokenStore recovers an old lock whose owner pid appears alive", () => { + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const lockDir = `${p}.lock`; + mkdirSync(lockDir, { recursive: true }); + writeLockOwner(lockDir, `${process.pid}:possibly-reused-test-owner`); + const staleTime = new Date(Date.now() - 10_000); + utimesSync(lockDir, staleTime, staleTime); + + updateTokenStore(p, (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, { lockTimeoutMs: 0, staleLockMs: 1 }); + + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "tok-acme" } }, + }); + }); +}); + +test("updateTokenStore recovers a stale lock with an unreadable owner file", () => { + if (process.platform === "win32") return; + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const lockDir = `${p}.lock`; + mkdirSync(lockDir, { recursive: true }); + writeLockOwner(lockDir, "0:dead-test-owner"); + chmodSync(path.join(lockDir, "owner"), 0o000); + const staleTime = new Date(Date.now() - 10_000); + utimesSync(lockDir, staleTime, staleTime); + + updateTokenStore(p, (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, { lockTimeoutMs: 0, staleLockMs: 1 }); + + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "tok-acme" } }, + }); + }); +}); + +test("updateTokenStore recovers a stale lock with an unreadable release marker", () => { + if (process.platform === "win32") return; + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const lockDir = `${p}.lock`; + const owner = "123:released-test-owner"; + mkdirSync(lockDir, { recursive: true }); + writeLockOwner(lockDir, owner); + const releasedPath = path.join(lockDir, "released"); + writeFileSync(releasedPath, owner, { mode: 0o600 }); + chmodSync(releasedPath, 0o000); + const staleTime = new Date(Date.now() - 10_000); + utimesSync(lockDir, staleTime, staleTime); + + try { + updateTokenStore(p, (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, { lockTimeoutMs: 0, staleLockMs: 1 }); + } finally { + if (existsSync(releasedPath)) chmodSync(releasedPath, 0o600); + } + + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "tok-acme" } }, + }); + }); +}); + +test("updateTokenStore recovers a stale regular-file lock", () => { + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const lockDir = `${p}.lock`; + mkdirSync(path.dirname(p), { recursive: true }); + writeFileSync(lockDir, "not a directory"); + const staleTime = new Date(Date.now() - 10_000); + utimesSync(lockDir, staleTime, staleTime); + + updateTokenStore(p, (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, { lockTimeoutMs: 0, staleLockMs: 1 }); + + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "tok-acme" } }, + }); + }); +}); + +test("updateTokenStore recovers a stale dangling-symlink lock", () => { + if (process.platform === "win32") return; + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const lockDir = `${p}.lock`; + mkdirSync(path.dirname(p), { recursive: true }); + symlinkSync(path.join(dir, "missing-lock-target"), lockDir); + + updateTokenStore(p, (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, { lockTimeoutMs: 0, staleLockMs: 0 }); + + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "tok-acme" } }, + }); + }); +}); + +test("updateTokenStore recovers a stale symlink lock without chmodding its target", () => { + if (process.platform === "win32") return; + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const lockDir = `${p}.lock`; + const target = path.join(dir, "symlink-target"); + mkdirSync(path.dirname(p), { recursive: true }); + writeFileSync(target, "target", { mode: 0o600 }); + symlinkSync(target, lockDir); + + updateTokenStore(p, (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, { lockTimeoutMs: 0, staleLockMs: 0 }); + + assert.equal((statSync(target).mode & 0o777), 0o600); + assert.deepEqual( + readdirSync(path.dirname(p)).filter((name) => name.startsWith(`${path.basename(p)}.lock.recovered-`)), + [] + ); + }); +}); + +test("updateTokenStore does not spin on a fresh dangling-symlink lock", () => { + if (process.platform === "win32") return; + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const lockDir = `${p}.lock`; + mkdirSync(path.dirname(p), { recursive: true }); + symlinkSync(path.join(dir, "missing-lock-target"), lockDir); + + assert.throws( + () => updateTokenStore(p, () => {}, { lockTimeoutMs: 0, staleLockMs: 60_000 }), + /credential store is locked/ + ); + }); +}); + +test("updateTokenStore recovers a stale lock with an unreadable directory", () => { + if (process.platform === "win32") return; + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const lockDir = `${p}.lock`; + mkdirSync(lockDir, { recursive: true }); + writeLockOwner(lockDir, "0:unreadable-dir-owner"); + const staleTime = new Date(Date.now() - 10_000); + utimesSync(lockDir, staleTime, staleTime); + chmodSync(lockDir, 0o000); + + updateTokenStore(p, (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, { lockTimeoutMs: 0, staleLockMs: 1 }); + + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "tok-acme" } }, + }); + assert.deepEqual( + readdirSync(path.dirname(p)).filter((name) => name.startsWith(`${path.basename(p)}.lock.recovered-`)), + [] + ); + }); +}); + +test("updateTokenStore refuses to write or release after lock ownership changes", () => { + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const lockDir = `${p}.lock`; + writeTokenStore(p, { namespaces: { existing: { ADMIN_TOKEN: "tok-existing" } } }); + + try { + assert.throws( + () => updateTokenStore(p, (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + rmSync(lockDir, { recursive: true, force: true }); + mkdirSync(lockDir, { recursive: true }); + writeLockOwner(lockDir, "new-owner"); + }, { lockTimeoutMs: 0 }), + /credential store is locked/ + ); + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { existing: { ADMIN_TOKEN: "tok-existing" } }, + }); + assert.equal(readFileSync(path.join(lockDir, "owner"), "utf8"), "new-owner"); + } finally { + rmSync(lockDir, { recursive: true, force: true }); + } + }); +}); + test("writeTokenStore then readTokenStore round-trips the default namespace", () => { withTempDir((dir) => { const p = path.join(dir, "credentials"); @@ -213,6 +649,20 @@ test("assertStoreDirSecure refuses a group/world-writable store dir", () => { assert.doesNotThrow(() => assertStoreDirSecure(mkdir(0o755))); // read/exec, not writable assert.throws(() => assertStoreDirSecure(mkdir(0o770)), /group\/world-writable/); assert.throws(() => assertStoreDirSecure(mkdir(0o777)), /group\/world-writable/); + const bad = path.join(mkdtempSync(path.join(tmpdir(), "wdl-store-secure-")), `bad${ESC}dir\nFORGED\rBAD`); + mkdirSync(bad); + chmodSync(bad, 0o777); + made.push(path.dirname(bad)); + assert.throws( + () => assertStoreDirSecure(bad), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /bad\\u001bdir\\nFORGED\\rBAD/); + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not reach the error"); + return true; + } + ); // The win32 branch never inspects POSIX mode bits. assert.doesNotThrow(() => assertStoreDirSecure(mkdir(0o777), "win32")); } finally { @@ -220,6 +670,48 @@ test("assertStoreDirSecure refuses a group/world-writable store dir", () => { } }); +test("updateTokenStore escapes write-side filesystem errors", () => { + withTempDir((dir) => { + const badXdg = path.join(dir, `bad${ESC}dir\nFORGED\rBAD`); + writeFileSync(badXdg, ""); + const p = path.join(badXdg, "wdl", "credentials"); + + assert.throws( + () => updateTokenStore(p, (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok" }; + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /failed to update credential store/); + assert.match(message, /bad\\u001bdir\\nFORGED\\rBAD/); + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not reach the error"); + return true; + } + ); + }); +}); + +test("writeTokenStore escapes write-side filesystem errors", () => { + withTempDir((dir) => { + const badXdg = path.join(dir, `bad${ESC}dir\nFORGED\rBAD`); + writeFileSync(badXdg, ""); + const p = path.join(badXdg, "wdl", "credentials"); + + assert.throws( + () => writeTokenStore(p, { defaultNs: null, namespaces: { acme: { ADMIN_TOKEN: "tok" } } }), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /failed to write credential store/); + assert.match(message, /bad\\u001bdir\\nFORGED\\rBAD/); + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not reach the error"); + return true; + } + ); + }); +}); + test("readTokenStore rejects a key outside any section", () => { withTempDir((dir) => { const p = path.join(dir, "credentials"); diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index 7e0bec4..b0a182a 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; -import { lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { runTokenCommand } from "../../commands/token.js"; @@ -345,6 +345,48 @@ test("token list formats stored namespaces with masked tokens and marks the defa }); }); +test("token list escapes terminal controls inside table cells", async () => { + await withTempXdg(async (xdg) => { + writeTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg }), { + defaultNs: "acme", + namespaces: { + acme: { + CONTROL_URL: "https://api.example\nFORGED\rBAD", + ADMIN_TOKEN: "tok-abcd1234", + LABEL: `prod${ESC}[2J\nFORGED`, + }, + }, + }); + const { lines, deps: d } = deps(xdg); + await runTokenCommand(["list"], d); + const out = lines.join("\n"); + + assert.doesNotMatch(out, new RegExp(ESC), "raw ESC must not reach token list output"); + assert.doesNotMatch(out, /\nFORGED|\rBAD/, "raw line controls must not forge table rows"); + assert.match(out, /prod\\u001b\[2J\\nFORGED/); + assert.match(out, /https:\/\/api\.example\\nFORGED\\rBAD/); + }); +}); + +test("token list escapes credential-store read errors", async () => { + await withTempXdg(async (xdg) => { + const badXdg = path.join(xdg, `bad${ESC}dir\nFORGED\rBAD`); + writeFileSync(badXdg, ""); + + await assert.rejects( + () => runTokenCommand(["list"], deps(badXdg).deps), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /failed to read credential store/); + assert.match(message, /bad\\u001bdir\\nFORGED\\rBAD/); + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not reach the error"); + return true; + } + ); + }); +}); + test("token use switches the default namespace and rejects an unstored one", async () => { await withTempXdg(async (xdg) => { const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); @@ -364,6 +406,22 @@ test("token use switches the default namespace and rejects an unstored one", asy }); }); +test("token use/rm unknown namespaces do not create an empty store directory", async () => { + await withTempXdg(async (xdg) => { + const storeDir = path.join(xdg, "wdl"); + await assert.rejects( + () => runTokenCommand(["use", "ghost"], deps(xdg).deps), + /no stored token for namespace "ghost"/ + ); + assert.equal(existsSync(storeDir), false); + await assert.rejects( + () => runTokenCommand(["rm", "--ns", "ghost"], deps(xdg).deps), + /no stored token for namespace "ghost"/ + ); + assert.equal(existsSync(storeDir), false); + }); +}); + test("token list prints a placeholder when empty", async () => { await withTempXdg(async (xdg) => { const { lines, deps: d } = deps(xdg); From 39514e57db9f9105cbf4d079f0a76d51734435b9 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Thu, 9 Jul 2026 17:06:08 +0800 Subject: [PATCH 03/12] Harden CLI error output handling Escape hostile argv, config, and filesystem-derived values before they reach human CLI errors. Wrap D1 migration file read failures as CliError, make direct command runner parse errors safe, and add regression coverage for terminal-control injection paths across init, d1, deploy, lifecycle, and shared command parsing. Signed-off-by: Lu Zhang --- bin/wdl.js | 3 +- commands/d1.js | 29 ++-- commands/delete.js | 10 +- commands/deploy.js | 4 +- commands/init.js | 15 ++- commands/r2.js | 16 +-- commands/secret.js | 12 +- commands/workflows.js | 8 +- lib/command.js | 18 ++- lib/common.js | 11 +- lib/d1-files.js | 57 +++++--- tests/unit/cli-command.test.js | 24 ++++ tests/unit/cli-d1.test.js | 225 ++++++++++++++++++++++++++++++- tests/unit/cli-deploy.test.js | 21 +++ tests/unit/cli-init.test.js | 54 ++++++++ tests/unit/cli-lifecycle.test.js | 53 +++++++- 16 files changed, 487 insertions(+), 73 deletions(-) diff --git a/bin/wdl.js b/bin/wdl.js index 1380c7e..0407aff 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -17,6 +17,7 @@ import * as tokenCmd from "../commands/token.js"; import { isHelpAlias } from "../lib/command.js"; import { commonCliOptions, formatHelp, handleCliError, isMain } from "../lib/common.js"; import { flagSet, isTokenStoreDisabled, loadCliControlEnv } from "../lib/credentials.js"; +import { escapeTerminalText } from "../lib/output.js"; import { currentCliVersion } from "../lib/package-info.js"; import { tokenStoreReader } from "../lib/token-store.js"; @@ -68,7 +69,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) { } const commandModule = COMMANDS[command]; if (!commandModule) { - console.error(`error: unknown command: ${command}`); + console.error(`error: unknown command: ${escapeTerminalText(command)}`); usage(1); return; } diff --git a/commands/d1.js b/commands/d1.js index 3691f5b..7704730 100644 --- a/commands/d1.js +++ b/commands/d1.js @@ -16,9 +16,9 @@ import { } from "../lib/d1-format.js"; import { LONG_CONTROL_TIMEOUT_MS } from "../lib/control-fetch.js"; import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, isMain, isPathInside, optionHelp } from "../lib/common.js"; +import { CliError, defineCliOption, formatHelp, isMain, isPathInside, optionHelp, unexpectedArgument } from "../lib/common.js"; import { confirmAction } from "../lib/stdin.js"; -import { writeResult } from "../lib/output.js"; +import { escapeTerminalText, writeResult } from "../lib/output.js"; const D1_EXECUTE_MODES = ["all", "raw", "run", "exec"]; @@ -75,13 +75,13 @@ async function runD1({ values, positionals, context }) { if (!action || !databaseRef) { throw new CliError("d1 migrations requires "); } - if (extraArg) throw new CliError(`d1 migrations ${action} received unexpected argument: ${extraArg}`); + if (extraArg) throw unexpectedArgument(`d1 migrations ${action}`, extraArg); await runMigrationsCommand({ action, databaseRef, context }); return; } if (subcommand === "list") { - if (firstArg) throw new CliError(`d1 list received unexpected argument: ${firstArg}`); + if (firstArg) throw unexpectedArgument("d1 list", firstArg); const { headers } = context.resolveControl(); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(context.nsUrl("d1", "databases"), { headers }, "list d1 databases") @@ -93,7 +93,7 @@ async function runD1({ values, positionals, context }) { if (subcommand === "create") { const databaseName = firstArg; if (!databaseName) throw new CliError("d1 create requires "); - if (positionals[2]) throw new CliError(`d1 create received unexpected argument: ${positionals[2]}`); + if (positionals[2]) throw unexpectedArgument("d1 create", positionals[2]); const { headers } = context.resolveControl(); const body = /** @type {{ namespace?: string, databaseId?: string, databaseName?: string }} */ ( await context.fetchJson(context.nsUrl("d1", "databases"), { @@ -113,7 +113,7 @@ async function runD1({ values, positionals, context }) { if (subcommand === "delete") { const databaseRef = firstArg; if (!databaseRef) throw new CliError("d1 delete requires "); - if (positionals[2]) throw new CliError(`d1 delete received unexpected argument: ${positionals[2]}`); + if (positionals[2]) throw unexpectedArgument("d1 delete", positionals[2]); const { headers } = context.resolveControl(); await confirmAction({ yes: values.yes === true, @@ -137,7 +137,7 @@ async function runD1({ values, positionals, context }) { if (subcommand === "execute") { const databaseRef = firstArg; if (!databaseRef) throw new CliError("d1 execute requires "); - if (positionals[2]) throw new CliError(`d1 execute received unexpected argument: ${positionals[2]}`); + if (positionals[2]) throw unexpectedArgument("d1 execute", positionals[2]); const sql = readSql(values, context.cwd); const mode = values.mode || "all"; if (!D1_EXECUTE_MODES.includes(mode)) { @@ -176,7 +176,7 @@ async function runD1({ values, positionals, context }) { return; } - throw new CliError(`unknown d1 subcommand: ${subcommand}\n${usageText()}`); + throw new CliError(`unknown d1 subcommand: ${escapeTerminalText(subcommand)}\n${usageText()}`); } /** @param {{ action: string, databaseRef: string, context: import("../lib/command.js").CommandContext }} arg */ @@ -184,7 +184,7 @@ async function runMigrationsCommand({ action, databaseRef, context }) { const { env, stdout, cwd, warn } = context; const values = /** @type {D1Flags} */ (context.values); if (!["list", "status", "apply"].includes(action)) { - throw new CliError(`unknown d1 migrations subcommand: ${action}`); + throw new CliError(`unknown d1 migrations subcommand: ${escapeTerminalText(action)}`); } const { headers } = context.resolveControl(); const migrationsBase = context.nsUrl("d1", "databases", databaseRef, "migrations"); @@ -244,7 +244,7 @@ function loadLocalMigrations({ values, env, cwd, databaseRef, warn }) { const { dir, display } = resolveMigrationsDir({ values, env, cwd, databaseRef, warn }); const migrations = readMigrationFiles(dir); if (migrations.length === 0) { - throw new CliError(`no .sql migration files found in ${display}`); + throw new CliError(`no .sql migration files found in ${escapeTerminalText(display)}`); } return migrations; } @@ -300,7 +300,7 @@ function resolveMigrationsDir({ values, env, cwd, databaseRef, warn }) { const byName = []; for (const [idx, entry] of entries.entries()) { if (entry.migrations_dir != null && (typeof entry.migrations_dir !== "string" || !entry.migrations_dir.trim())) { - throw new CliError(`${configRel}: [[d1_databases]] ${entry.binding || idx}: migrations_dir must be a string`); + throw new CliError(`${escapeTerminalText(configRel)}: [[d1_databases]] ${escapeTerminalText(entry.binding || idx)}: migrations_dir must be a string`); } if (entry.database_id === databaseRef) { byId.push(entry); @@ -313,11 +313,11 @@ function resolveMigrationsDir({ values, env, cwd, databaseRef, warn }) { const matches = byId.length > 0 ? byId : byName; if (matches.length > 1) { - throw new CliError(`${configRel}: multiple [[d1_databases]] entries match ${JSON.stringify(databaseRef)}`); + throw new CliError(`${escapeTerminalText(configRel)}: multiple [[d1_databases]] entries match ${escapeTerminalText(JSON.stringify(databaseRef))}`); } if (matches.length === 0) { throw new CliError( - `${configRel}: no matching [[d1_databases]] entry for ${JSON.stringify(databaseRef)}; ` + + `${escapeTerminalText(configRel)}: no matching [[d1_databases]] entry for ${escapeTerminalText(JSON.stringify(databaseRef))}; ` + "use a configured database_name/database_id or pass --dir explicitly" ); } @@ -360,7 +360,8 @@ function resolveConfiguredMigrationsDir({ configDir, migrationsDir, configRel, b const resolved = existsSync(candidate) ? realpathSync(candidate) : candidate; if (!isPathInside(root, resolved)) { throw new CliError( - `${configRel}: [[d1_databases]] ${binding}: migrations_dir must stay inside the project` + `${escapeTerminalText(configRel)}: [[d1_databases]] ${escapeTerminalText(binding)}: ` + + `migrations_dir must stay inside the project (got ${escapeTerminalText(JSON.stringify(migrationsDir))})` ); } return resolved; diff --git a/commands/delete.js b/commands/delete.js index c914b48..7fa263c 100644 --- a/commands/delete.js +++ b/commands/delete.js @@ -1,7 +1,7 @@ import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, defineHiddenCliOption, formatHelp, isMain, optionHelp } from "../lib/common.js"; +import { CliError, defineCliOption, defineHiddenCliOption, formatHelp, isMain, optionHelp, unexpectedArgument } from "../lib/common.js"; import { confirmAction } from "../lib/stdin.js"; -import { writeResult } from "../lib/output.js"; +import { escapeTerminalText, writeResult } from "../lib/output.js"; import { formatVersionDelete, formatWorkerDelete } from "../lib/delete-format.js"; const DELETE_OPTIONS = [ @@ -38,7 +38,7 @@ async function runDelete({ values, positionals, context }) { } if (subcommand !== "version" && subcommand !== "worker") { - throw new CliError(`unknown subcommand: ${subcommand}\n${usageText()}`); + throw new CliError(`unknown subcommand: ${escapeTerminalText(subcommand)}\n${usageText()}`); } if (subcommand === "version") { @@ -49,7 +49,7 @@ async function runDelete({ values, positionals, context }) { if (!worker || !version) { throw new CliError("version delete requires or --worker/--version"); } - if (extraArg) throw new CliError(`delete version received unexpected argument: ${extraArg}`); + if (extraArg) throw unexpectedArgument("delete version", extraArg); const { headers } = context.resolveControl(); const body = await context.fetchJson( context.nsUrl("worker", worker, "versions", version), @@ -66,7 +66,7 @@ async function runDelete({ values, positionals, context }) { if (!worker) { throw new CliError("worker delete requires or --worker "); } - if (extraArg) throw new CliError(`delete worker received unexpected argument: ${extraArg}`); + if (extraArg) throw unexpectedArgument("delete worker", extraArg); const { headers } = context.resolveControl(); const dryRun = values["dry-run"] === true; await confirmAction({ diff --git a/commands/deploy.js b/commands/deploy.js index 631b90b..4b29d6c 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -4,7 +4,7 @@ import { execFileSync } from "node:child_process"; import { LONG_CONTROL_TIMEOUT_MS } from "../lib/control-fetch.js"; import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, formatHttpError, isMain, optionHelp, readJsonOrFail } from "../lib/common.js"; +import { CliError, defineCliOption, formatHelp, formatHttpError, isMain, optionHelp, readJsonOrFail, unexpectedArgument } from "../lib/common.js"; import { escapeTerminalText, formatKnownWarning, writeStatusLine } from "../lib/output.js"; import { isLocalDevHost } from "../lib/credentials.js"; import { packWranglerProject } from "../lib/wrangler-pack.js"; @@ -248,7 +248,7 @@ async function runDeploy({ values, positionals, context: baseContext }) { if (!projectDir || !ns) { throw new CliError(usageText()); } - if (extraArg) throw new CliError(`deploy received unexpected argument: ${extraArg}`); + if (extraArg) throw unexpectedArgument("deploy", extraArg); const { controlUrl, headers: authHeaders } = context.resolveControl(); const selectedEnv = values.env || env.CLOUDFLARE_ENV || null; diff --git a/commands/init.js b/commands/init.js index 76e45be..5252f6e 100644 --- a/commands/init.js +++ b/commands/init.js @@ -5,6 +5,7 @@ import { parseArgs as nodeParseArgs } from "node:util"; import { isHelpAlias } from "../lib/command.js"; import { CliError, defineCliOption, formatHelp, handleCliError, isMain, isNonEmptyString, optionHelp, optionParseOptions } from "../lib/common.js"; import { NS_PATTERN, RESERVED_TENANT_NS, isReservedNs } from "../lib/ns-pattern.js"; +import { escapeTerminalText } from "../lib/output.js"; const NAME_REGEX = /^[A-Za-z][A-Za-z0-9-]*$/; const WORKER_NAME_REGEX = /^[A-Za-z0-9][A-Za-z0-9_-]{0,254}$/; @@ -45,7 +46,7 @@ export async function main(argv = process.argv.slice(2)) { if (!NAME_REGEX.test(packageName)) { throw new CliError( - `project name "${packageName}" must match ${NAME_REGEX} ` + + `project name "${escapeTerminalText(packageName)}" must match ${NAME_REGEX} ` + `(letter, then letters / digits / hyphens).`, ); } @@ -86,7 +87,7 @@ function parseArgs(argv) { // "unknown flag: " wording (flag name best-effort from the message). if (err instanceof Error && /** @type {{ code?: unknown }} */ (err).code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") { const flag = /'([^']+)'/.exec(err.message)?.[1] ?? ""; - throw new CliError(`unknown flag: ${flag}`); + throw new CliError(`unknown flag: ${escapeTerminalText(flag)}`); } throw err; } @@ -95,7 +96,7 @@ function parseArgs(argv) { const values = parsed.values; const { positionals } = parsed; if (positionals.length > 1) { - throw new CliError(`unexpected argument: ${positionals[1]}`); + throw new CliError(`unexpected argument: ${escapeTerminalText(positionals[1])}`); } const helpAlias = isHelpAlias(positionals); return { @@ -133,7 +134,7 @@ function resolveTarget(target) { function validateNs(value, label) { if (!TENANT_NS_RE.test(value) || RESERVED_TENANT_NS.has(value) || isReservedNs(value)) { throw new CliError( - `${label} "${value}" is not a valid tenant namespace ` + + `${escapeTerminalText(label)} "${escapeTerminalText(value)}" is not a valid tenant namespace ` + `(1-63 lowercase letters / digits / hyphens, start and end with a letter or digit; reserved names are not allowed).`, ); } @@ -146,7 +147,7 @@ function validateNs(value, label) { function validateWorker(value, label) { if (!WORKER_NAME_REGEX.test(value)) { throw new CliError( - `${label} "${value}" must match ${WORKER_NAME_REGEX} ` + + `${escapeTerminalText(label)} "${escapeTerminalText(value)}" must match ${WORKER_NAME_REGEX} ` + `(letter or digit, then letters / digits / underscores / hyphens; up to 255 chars).`, ); } @@ -166,9 +167,9 @@ async function ensureEmpty(dir, isInPlace) { } const offending = entries.filter(name => !IGNORABLE_DIR_ENTRIES.has(name)); if (offending.length === 0) return; - const where = isInPlace ? "current directory" : dir; + const where = isInPlace ? "current directory" : escapeTerminalText(dir); throw new CliError( - `${where} is not empty (found: ${offending.slice(0, 5).join(", ")}` + + `${where} is not empty (found: ${offending.slice(0, 5).map(escapeTerminalText).join(", ")}` + (offending.length > 5 ? ", …" : "") + `). Refusing to overwrite.`, ); diff --git a/commands/r2.js b/commands/r2.js index cb8532b..5705e26 100644 --- a/commands/r2.js +++ b/commands/r2.js @@ -7,9 +7,9 @@ import { UNLIMITED_CONTROL_BODY_BYTES, } from "../lib/control-fetch.js"; import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, isMain, optionHelp } from "../lib/common.js"; +import { CliError, defineCliOption, formatHelp, isMain, optionHelp, unexpectedArgument } from "../lib/common.js"; import { confirmAction } from "../lib/stdin.js"; -import { writeResult, writeStatusLine } from "../lib/output.js"; +import { escapeTerminalText, writeResult, writeStatusLine } from "../lib/output.js"; import { formatBucketList, formatObjectHead, formatObjectList } from "../lib/r2-format.js"; const R2_OPTIONS = [ @@ -65,7 +65,7 @@ async function runR2({ values, positionals, context: baseContext }) { `${context.nsUrl("r2", "buckets", bucket, "objects")}/${encodeR2KeyPath(objectKey)}`; if (group === "buckets" && action === "list") { - if (bucket) throw new CliError(`r2 buckets list received unexpected argument: ${bucket}`); + if (bucket) throw unexpectedArgument("r2 buckets list", bucket); const { headers } = context.resolveControl(); const url = withQuery(context.nsUrl("r2", "buckets"), { cursor: values.cursor, @@ -80,7 +80,7 @@ async function runR2({ values, positionals, context: baseContext }) { if (group === "objects" && action === "list") { if (!bucket) throw new CliError("r2 objects list requires "); - if (key) throw new CliError(`r2 objects list received unexpected argument: ${key}`); + if (key) throw unexpectedArgument("r2 objects list", key); const { headers } = context.resolveControl(); const url = withQuery(context.nsUrl("r2", "buckets", bucket, "objects"), { prefix: values.prefix, @@ -97,7 +97,7 @@ async function runR2({ values, positionals, context: baseContext }) { if (group === "objects" && action === "get") { if (!bucket) throw new CliError("r2 objects get requires "); - if (extraArg) throw new CliError(`r2 objects get received unexpected argument: ${extraArg}`); + if (extraArg) throw unexpectedArgument("r2 objects get", extraArg); const objectKey = requireR2ObjectKey(key); if (!values.out && isInteractiveStdout(stdoutStream)) { throw new CliError("r2 objects get refuses to write raw object bytes to an interactive terminal; pass --out "); @@ -122,7 +122,7 @@ async function runR2({ values, positionals, context: baseContext }) { if (group === "objects" && action === "head") { if (!bucket) throw new CliError("r2 objects head requires "); - if (extraArg) throw new CliError(`r2 objects head received unexpected argument: ${extraArg}`); + if (extraArg) throw unexpectedArgument("r2 objects head", extraArg); const objectKey = requireR2ObjectKey(key); const { headers } = context.resolveControl(); const res = await context.fetchStream(objectUrl(objectKey), { @@ -141,7 +141,7 @@ async function runR2({ values, positionals, context: baseContext }) { if (group === "objects" && action === "delete") { if (!bucket) throw new CliError("r2 objects delete requires "); - if (extraArg) throw new CliError(`r2 objects delete received unexpected argument: ${extraArg}`); + if (extraArg) throw unexpectedArgument("r2 objects delete", extraArg); const objectKey = requireR2ObjectKey(key); const { headers } = context.resolveControl(); await confirmAction({ @@ -163,7 +163,7 @@ async function runR2({ values, positionals, context: baseContext }) { return; } - throw new CliError(`unknown r2 command: ${group} ${action}\n${usageText()}`); + throw new CliError(`unknown r2 command: ${escapeTerminalText(group)} ${escapeTerminalText(action)}\n${usageText()}`); } /** diff --git a/commands/secret.js b/commands/secret.js index b33979b..c35a0c6 100644 --- a/commands/secret.js +++ b/commands/secret.js @@ -3,9 +3,9 @@ // "meant worker, wrote ns" credential leaks. import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, formatHttpError, isMain, isNonEmptyString, optionHelp, readJsonOrFail } from "../lib/common.js"; +import { CliError, defineCliOption, formatHelp, formatHttpError, isMain, isNonEmptyString, optionHelp, readJsonOrFail, unexpectedArgument } from "../lib/common.js"; import { confirmAction, readSecretStdin } from "../lib/stdin.js"; -import { writeJsonOr, writeStatusLine } from "../lib/output.js"; +import { escapeTerminalText, writeJsonOr, writeStatusLine } from "../lib/output.js"; const SECRET_OPTIONS = [ defineCliOption("worker", { type: "string" }, "--worker ", "Use worker-level secret scope."), @@ -69,7 +69,7 @@ async function runSecret({ values, positionals, context }) { const scopeLabel = worker ? `${ns}/${worker}` : `${ns} (ns)`; if (subcommand === "list") { - if (keyArg) throw new CliError(`secret list received unexpected argument: ${keyArg}`); + if (keyArg) throw unexpectedArgument("secret list", keyArg); const { headers } = context.resolveControl(); const body = /** @type {SecretResponse} */ (await context.fetchJson(context.nsUrl(...secretPath), { headers }, "list")); if (writeJsonOr(Boolean(values.json), body, stdout)) return; @@ -81,7 +81,7 @@ async function runSecret({ values, positionals, context }) { if (subcommand === "put") { if (!keyArg) throw new CliError("put requires a KEY argument"); - if (extraArg) throw new CliError(`secret put received unexpected argument: ${extraArg}`); + if (extraArg) throw unexpectedArgument("secret put", extraArg); const { headers } = context.resolveControl(); // Empty string is a set secret (≠ unset), matching wrangler. const value = await readSecretStdin(stdin, { @@ -106,7 +106,7 @@ async function runSecret({ values, positionals, context }) { if (subcommand === "delete") { if (!keyArg) throw new CliError("delete requires a KEY argument"); - if (extraArg) throw new CliError(`secret delete received unexpected argument: ${extraArg}`); + if (extraArg) throw unexpectedArgument("secret delete", extraArg); const { headers } = context.resolveControl(); await confirmAction({ yes: values.yes === true, @@ -127,7 +127,7 @@ async function runSecret({ values, positionals, context }) { return; } - throw new CliError(`unknown subcommand: ${subcommand}`); + throw new CliError(`unknown subcommand: ${escapeTerminalText(subcommand)}`); } /** diff --git a/commands/workflows.js b/commands/workflows.js index 8fbe703..90ded62 100644 --- a/commands/workflows.js +++ b/commands/workflows.js @@ -1,7 +1,7 @@ import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, isMain, optionHelp } from "../lib/common.js"; +import { CliError, defineCliOption, formatHelp, isMain, optionHelp, unexpectedArgument } from "../lib/common.js"; import { confirmAction } from "../lib/stdin.js"; -import { writeResult } from "../lib/output.js"; +import { escapeTerminalText, writeResult } from "../lib/output.js"; import { formatInstanceList, formatInstanceStatus, @@ -103,7 +103,7 @@ async function runWorkflows({ values, positionals, context }) { return; } - throw new CliError(`unknown workflows subcommand: ${subcommand}\n${usageText()}`); + throw new CliError(`unknown workflows subcommand: ${escapeTerminalText(subcommand)}\n${usageText()}`); } /** @@ -138,7 +138,7 @@ function requireInstanceRef(positionals, label) { */ function requireNoExtraPositionals(positionals, expected, label) { if (positionals.length > expected) { - throw new CliError(`${label} received unexpected argument: ${positionals[expected]}`); + throw unexpectedArgument(label, positionals[expected]); } } diff --git a/lib/command.js b/lib/command.js index 19abec4..bcc1b43 100644 --- a/lib/command.js +++ b/lib/command.js @@ -14,6 +14,7 @@ import { parseArgs } from "node:util"; import { controlFetch as defaultControlFetch } from "./control-fetch.js"; import { CliError, encodePath, optionParseOptions, printHelpIfRequested, readJsonOrFail, runCliMain, throwHttpErrorIfNotOk } from "./common.js"; import { resolveControlContext, resolveNamespace, warnIfInsecureControlUrl } from "./credentials.js"; +import { escapeTerminalText } from "./output.js"; /** * Flag-preset names accepted in a command's `options` list; each expands to @@ -129,11 +130,18 @@ export function defineCommand(spec) { // directly. It does NOT swallow errors — main() does that via runCliMain so // tests can still assert on the thrown CliError. async function runCommand(argv = process.argv.slice(2), deps = {}) { - const { values, positionals } = parseArgs({ - args: argv, - options: parseOptions, - allowPositionals: true, - }); + const { values, positionals } = (() => { + try { + return parseArgs({ + args: argv, + options: parseOptions, + allowPositionals: true, + }); + } catch (err) { + const message = err instanceof Error && err.message ? err.message : String(err); + throw new CliError(escapeTerminalText(message)); + } + })(); const context = buildContext(deps, defaults, values, positionals); diff --git a/lib/common.js b/lib/common.js index a9a300b..a5543e6 100644 --- a/lib/common.js +++ b/lib/common.js @@ -17,6 +17,15 @@ export class CliError extends Error { } } +/** + * @param {string} label + * @param {unknown} arg + * @returns {CliError} + */ +export function unexpectedArgument(label, arg) { + return new CliError(`${escapeTerminalText(label)} received unexpected argument: ${escapeTerminalText(arg)}`); +} + // The project's "set" predicate: a value is set only when it is a non-empty // string; "" or a non-string (undefined, a missing/boolean flag) counts as // absent. Centralized so the rule can't drift between its callers. @@ -219,7 +228,7 @@ export function handleCliError(err) { process.exit(err.exitCode); } if (isParseArgsError(err)) { - console.error(`error: ${err.message}`); + console.error(`error: ${escapeTerminalText(err.message)}`); process.exit(1); } throw err; diff --git a/lib/d1-files.js b/lib/d1-files.js index 6c1ed30..3adea59 100644 --- a/lib/d1-files.js +++ b/lib/d1-files.js @@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "n import path from "node:path"; import { CliError, isPathInside } from "./common.js"; +import { escapeTerminalText } from "./output.js"; /** * @param {Record} values Parsed flag values (`--sql`, `--file`). @@ -20,7 +21,7 @@ export function readSql(values, cwd = process.cwd()) { } // Keep --file inside the project, like the migrations-dir checks — a // relative/absolute path must not pull SQL from outside the repo. - if (!existsSync(cwd)) throw new CliError(`working directory ${cwd} does not exist`); + if (!existsSync(cwd)) throw new CliError(`working directory ${escapeTerminalText(cwd)} does not exist`); const root = realpathSync(cwd); const candidate = path.resolve(root, values.file); const resolved = existsSync(candidate) ? realpathSync(candidate) : candidate; @@ -32,7 +33,7 @@ export function readSql(values, cwd = process.cwd()) { sql = readFileSync(resolved, "utf8"); } catch (err) { const message = err instanceof Error && err.message ? err.message : String(err); - throw new CliError(`cannot read SQL file ${values.file}: ${message}`); + throw new CliError(`cannot read SQL file ${escapeTerminalText(values.file)}: ${escapeTerminalText(message)}`); } return requireSqlText(sql, "SQL file"); } @@ -70,33 +71,59 @@ export function readMigrationFiles(dir = "migrations") { entries = readdirSync(root, { withFileTypes: true }); } catch (err) { const message = err instanceof Error && err.message ? err.message : String(err); - throw new CliError(`cannot read migrations dir ${dir}: ${message}`); + throw new CliError(`cannot read migrations dir ${escapeTerminalText(dir)}: ${escapeTerminalText(message)}`); } /** @type {string[]} */ const filenames = []; for (const entry of entries) { if (!entry.name.endsWith(".sql")) continue; if (entry.isSymbolicLink()) { - throw new CliError(`d1 migrations: ${entry.name} is a symlink; copy the SQL file into the migrations dir`); + throw new CliError(`d1 migrations: ${escapeTerminalText(entry.name)} is a symlink; copy the SQL file into the migrations dir`); } if (entry.isFile()) filenames.push(entry.name); } return filenames .toSorted(compareMigrationFilenames) - .map((filename) => { - const file = path.join(root, filename); - if (!statSync(file).isFile()) return null; - const sql = readFileSync(file, "utf8"); - return { - id: filename, - name: filename.replace(/\.sql$/i, ""), - checksum: createHash("sha256").update(sql).digest("hex"), - sql, - }; - }) + .map((filename) => readMigrationFile(root, filename)) .filter(/** @returns {entry is MigrationFile} */ (entry) => entry != null); } +/** + * @param {string} root + * @param {string} filename + * @returns {MigrationFile | null} + */ +function readMigrationFile(root, filename) { + const file = path.join(root, filename); + try { + if (!statSync(file).isFile()) return null; + } catch (err) { + throw migrationFileReadError(filename, err); + } + let sql; + try { + sql = readFileSync(file, "utf8"); + } catch (err) { + throw migrationFileReadError(filename, err); + } + return { + id: filename, + name: filename.replace(/\.sql$/i, ""), + checksum: createHash("sha256").update(sql).digest("hex"), + sql, + }; +} + +/** + * @param {string} filename + * @param {unknown} err + * @returns {CliError} + */ +function migrationFileReadError(filename, err) { + const message = err instanceof Error && err.message ? err.message : String(err); + return new CliError(`cannot read migration file ${escapeTerminalText(filename)}: ${escapeTerminalText(message)}`); +} + // Order by the numeric prefix when both names have one ("2_x.sql" before // "10_y.sql" even without zero-padding); fall back to lexicographic so // non-numeric names keep plain string order. String-compare the trimmed diff --git a/tests/unit/cli-command.test.js b/tests/unit/cli-command.test.js index 95b1907..c7f0c2d 100644 --- a/tests/unit/cli-command.test.js +++ b/tests/unit/cli-command.test.js @@ -7,6 +7,8 @@ import { response } from "./helpers.js"; /** @typedef {Parameters[0]} CommandSpec */ /** @typedef {import("../../lib/command.js").CommandContext} CommandContext */ +const ESC = String.fromCharCode(27); + // Most tests don't care about name/summary; default them so each case only // states the fields it exercises. /** @param {Omit & { name?: string, summary?: string }} spec */ @@ -37,6 +39,28 @@ test("defineCommand exposes name/summary metadata and the parse schema", () => { assert.deepEqual(Object.keys(parseOptions).toSorted(), ["help", "ns"]); }); +test("defineCommand direct runner escapes parseArgs errors", async () => { + const bad = `--bad${ESC}[2J\nFORGED\rBAD`; + const cmd = define({ + options: ["ns"], + usage: () => "usage", + run: () => { + throw new Error("run body should not be called"); + }, + }); + + await assert.rejects( + () => cmd.run([bad]), + (err) => { + assert(err instanceof CliError); + assert.doesNotMatch(err.message, new RegExp(ESC), "raw ESC must not reach direct runner errors"); + assert.doesNotMatch(err.message, /\nFORGED|\rBAD/, "raw line controls must not forge direct runner errors"); + assert.match(err.message, /--bad\\u001b\[2J\\nFORGED\\rBAD/); + return true; + }, + ); +}); + test("defineCommand exposes autoloadEnv metadata", () => { const cmd = defineCommand({ name: "doctor", summary: "Check local state.", autoloadEnv: false, usage: () => "", run: () => {} }); const { parseOptions, ...meta } = cmd.meta; diff --git a/tests/unit/cli-d1.test.js b/tests/unit/cli-d1.test.js index 82eacde..6dfbf58 100644 --- a/tests/unit/cli-d1.test.js +++ b/tests/unit/cli-d1.test.js @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { runD1Command } from "../../commands/d1.js"; @@ -10,6 +10,20 @@ import { mockDeps as sharedMockDeps, response } from "./helpers.js"; /** @typedef {import("../../lib/control-fetch.js").ControlFetchInit} ControlFetchInit */ /** @typedef {import("./helpers.js").ControlCall} RecordedCall */ +const ESC = String.fromCharCode(27); + +/** + * @param {unknown} err + * @param {RegExp} expected + */ +function assertEscapedD1Error(err, expected) { + const message = /** @type {Error} */ (err).message; + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge error lines"); + assert.match(message, expected); + return true; +} + // Request bodies in these tests are always JSON strings; narrow the broader // `body` union before parsing. /** @@ -359,7 +373,38 @@ test("d1 migrations apply rejects symlinked SQL files", { skip: process.platform mkdirSync(migrations); const target = path.join(dir, "target.sql"); writeFileSync(target, "create table t (id integer);"); - symlinkSync(target, path.join(migrations, "001_link.sql")); + symlinkSync(target, path.join(migrations, `001_link${ESC}[2J\nFORGED\rBAD.sql`)); + + 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 () => { + throw new Error("controlFetch should not be called"); + }, + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge error lines"); + assert.match(message, /001_link\\u001b\[2J\\nFORGED\\rBAD\.sql is a symlink/); + return true; + } + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("d1 migrations apply escapes terminal controls in unreadable migration file errors", { skip: process.platform === "win32" }, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-migrations-unreadable-")); + const badName = `001_bad${ESC}[2J\nFORGED\rBAD.sql`; + const badPath = path.join(dir, "migrations", badName); + try { + const migrations = path.join(dir, "migrations"); + mkdirSync(migrations); + writeFileSync(badPath, "create table t (id integer);"); + chmodSync(badPath, 0o000); await assert.rejects( () => runD1Command(["migrations", "apply", "main", "--dir", "migrations", "--control-url", "http://ctl.test"], { @@ -369,9 +414,10 @@ test("d1 migrations apply rejects symlinked SQL files", { skip: process.platform throw new Error("controlFetch should not be called"); }, }), - /001_link\.sql is a symlink/ + (err) => assertEscapedD1Error(err, /cannot read migration file 001_bad\\u001b\[2J\\nFORGED\\rBAD\.sql:/) ); } finally { + try { chmodSync(badPath, 0o600); } catch {} rmSync(dir, { recursive: true, force: true }); } }); @@ -419,6 +465,35 @@ test("d1 migrations_dir from wrangler config cannot escape the project", async ( } }); +test("d1 migrations_dir errors escape terminal controls from config fields", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-config-escape-")); + const badMigrationsDir = `../outside${ESC}[2J\nFORGED\rBAD`; + try { + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: "src/index.js", + d1_databases: [{ + binding: "DB", + database_name: "main", + migrations_dir: badMigrationsDir, + }], + })); + + await assert.rejects( + () => runD1Command(["migrations", "apply", "main", "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), + (err) => assertEscapedD1Error(err, /migrations_dir must stay inside the project \(got "\.\.\/outside\\u001b\[2J\\nFORGED\\rBAD"\)/) + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("d1 migrations warns when multiple Wrangler configs exist", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-config-shadow-")); try { @@ -462,6 +537,83 @@ test("d1 migrations warns when multiple Wrangler configs exist", async () => { } }); +test("d1 migrations --dir display errors escape terminal controls", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-dir-display-")); + const badDir = `empty${ESC}[2J\nFORGED\rBAD`; + try { + mkdirSync(path.join(dir, badDir)); + await assert.rejects( + () => runD1Command(["migrations", "apply", "main", "--dir", badDir, "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), + (err) => assertEscapedD1Error(err, /no \.sql migration files found in empty\\u001b\[2J\\nFORGED\\rBAD/) + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("d1 migration config matching errors escape terminal controls", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-match-escape-")); + const badRef = `main${ESC}[2J\nFORGED\rBAD`; + try { + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: "src/index.js", + d1_databases: [{ + binding: "DB", + database_name: "other", + migrations_dir: "migrations", + }], + })); + + await assert.rejects( + () => runD1Command(["migrations", "apply", badRef, "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), + (err) => assertEscapedD1Error(err, /no matching \[\[d1_databases\]\] entry for "main\\u001b\[2J\\nFORGED\\rBAD"/) + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("d1 migration multiple-match errors escape terminal controls", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-multi-match-escape-")); + const badRef = `main${ESC}[2J\nFORGED\rBAD`; + try { + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: "src/index.js", + d1_databases: [ + { binding: "DB1", database_name: badRef, migrations_dir: "migrations" }, + { binding: "DB2", database_name: badRef, migrations_dir: "migrations" }, + ], + })); + + await assert.rejects( + () => runD1Command(["migrations", "apply", badRef, "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), + (err) => assertEscapedD1Error(err, /multiple \[\[d1_databases\]\] entries match "main\\u001b\[2J\\nFORGED\\rBAD"/) + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("d1 migrations --dir cannot escape the project", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-dir-contained-")); const outside = mkdtempSync(path.join(tmpdir(), "wdl-d1-dir-outside-")); @@ -593,6 +745,32 @@ test("d1 execute --file wraps missing file read errors", async () => { } }); +test("d1 execute --file escapes terminal controls in file read errors", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-file-missing-")); + const bad = `bad${ESC}[2J\nFORGED\rBAD.sql`; + try { + await assert.rejects( + () => runD1Command(["execute", "main", "--file", bad, "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge error lines"); + assert.match(message, /bad\\u001b\[2J\\nFORGED\\rBAD\.sql/); + return true; + }, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("d1 commands reject unexpected positional arguments", async () => { const deps = { env: { WDL_NS: "demo" }, @@ -619,6 +797,47 @@ test("d1 commands reject unexpected positional arguments", async () => { ); }); +test("d1 escapes terminal controls in unexpected positional errors", async () => { + const badAction = `apply${ESC}[2J\nFORGED\rBAD`; + const badArg = `bad${ESC}[2J\nFORGED\rBAD`; + const deps = { + env: { WDL_NS: "demo" }, + stdout: () => {}, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }; + await assert.rejects( + () => runD1Command(["migrations", badAction, "main", badArg, "--control-url", "http://ctl.test"], deps), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge error lines"); + assert.match(message, /d1 migrations apply\\u001b\[2J\\nFORGED\\rBAD received unexpected argument: bad\\u001b\[2J\\nFORGED\\rBAD/); + return true; + }, + ); +}); + +test("d1 escapes terminal controls in unknown subcommand errors", async () => { + const bad = `bogus${ESC}[2J\nFORGED\rBAD`; + const deps = { + env: { WDL_NS: "demo" }, + stdout: () => {}, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }; + await assert.rejects( + () => runD1Command([bad, "--control-url", "http://ctl.test"], deps), + (err) => assertEscapedD1Error(err, /unknown d1 subcommand: bogus\\u001b\[2J\\nFORGED\\rBAD/) + ); + await assert.rejects( + () => runD1Command(["migrations", bad, "main", "--control-url", "http://ctl.test"], deps), + (err) => assertEscapedD1Error(err, /unknown d1 migrations subcommand: bogus\\u001b\[2J\\nFORGED\\rBAD/) + ); +}); + test("d1 execute rejects an unknown --mode before calling control", async () => { let fetched = false; await assert.rejects( diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 55a8abe..0f86f7b 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -40,6 +40,8 @@ import { import { LONG_CONTROL_TIMEOUT_MS } from "../../lib/control-fetch.js"; import { response } from "./helpers.js"; +const ESC = String.fromCharCode(27); + /** * The options bag the deploy pipeline passes to its injected execFile dep. The * fakes record whichever subset each test asserts on; every field the deploy @@ -1740,6 +1742,25 @@ test("runDeployCommand rejects unexpected positional arguments", async () => { ); }); +test("runDeployCommand escapes terminal controls in unexpected positional errors", async () => { + const bad = `bad${ESC}[2J\nFORGED\rBAD`; + await assert.rejects( + () => runDeployCommand([".", bad, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + throw new Error("execFile should not be called"); + }, + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge error lines"); + assert.match(message, /bad\\u001b\[2J\\nFORGED\\rBAD/); + return true; + }, + ); +}); + test("runDeployCommand sanitizes wrangler.name via temp --config so mixed-case wdl names bundle", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-mixedcase-")); try { diff --git a/tests/unit/cli-init.test.js b/tests/unit/cli-init.test.js index a9434ad..555fac0 100644 --- a/tests/unit/cli-init.test.js +++ b/tests/unit/cli-init.test.js @@ -8,6 +8,7 @@ import { main, __test__ } from "../../commands/init.js"; const { parseArgs, validateNs, validateWorker, resolveWdlCliDep } = __test__; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const ESC = String.fromCharCode(27); /** @param {(dir: string) => Promise} fn */ async function withTempCwd(fn) { @@ -64,6 +65,31 @@ test("parseArgs rejects unknown flags", () => { assert.throws(() => parseArgs(["demo", "--unknown"]), /unknown flag/); }); +test("parseArgs escapes terminal controls in argv errors", () => { + const bad = `bad${ESC}[2J\nFORGED\rBAD`; + assert.throws( + () => parseArgs(["demo", bad]), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge error lines"); + assert.match(message, /bad\\u001b\[2J\\nFORGED\\rBAD/); + return true; + }, + ); +}); + +test("main escapes terminal controls in project name errors", async () => { + const bad = `bad${ESC}[2J\nFORGED\rBAD`; + await withTempCwd(async () => { + const { exitCode, errOutput } = await captureExit(() => main([bad])); + assert.equal(exitCode, 1); + assert.doesNotMatch(errOutput, new RegExp(ESC), "raw ESC must not reach project-name errors"); + assert.doesNotMatch(errOutput, /\nFORGED|\rBAD/, "raw line controls must not forge project-name errors"); + assert.match(errOutput, /project name "bad\\u001b\[2J\\nFORGED\\rBAD" must match/); + }); +}); + test("validateNs rejects names that fail the tenant grammar", () => { assert.throws(() => validateNs("ACME", "--ns"), /not a valid tenant namespace/); assert.throws(() => validateNs("admin", "--ns"), /not a valid tenant namespace/); @@ -73,6 +99,20 @@ test("validateNs rejects names that fail the tenant grammar", () => { assert.throws(() => validateNs("a".repeat(64), "--ns"), /1-63 lowercase/); }); +test("validateNs escapes terminal controls in rejected namespace values", () => { + const bad = `bad${ESC}[2J\nFORGED\rBAD`; + assert.throws( + () => validateNs(bad, "--ns"), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach --ns errors"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge --ns errors"); + assert.match(message, /--ns "bad\\u001b\[2J\\nFORGED\\rBAD" is not a valid tenant namespace/); + return true; + }, + ); +}); + test("validateNs accepts lowercase + digits + hyphens", () => { assert.doesNotThrow(() => validateNs("acme", "--ns")); assert.doesNotThrow(() => validateNs("acme-1", "--ns")); @@ -85,6 +125,20 @@ test("validateWorker rejects names that fail the worker grammar", () => { assert.throws(() => validateWorker("contains/slash", "--worker"), /must match/); }); +test("validateWorker escapes terminal controls in rejected worker values", () => { + const bad = `bad${ESC}[2J\nFORGED\rBAD`; + assert.throws( + () => validateWorker(bad, "--worker"), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach --worker errors"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge --worker errors"); + assert.match(message, /--worker "bad\\u001b\[2J\\nFORGED\\rBAD" must match/); + return true; + }, + ); +}); + test("validateWorker accepts letters digits underscores and hyphens", () => { assert.doesNotThrow(() => validateWorker("1starts-with-digit", "--worker")); assert.doesNotThrow(() => validateWorker("UpperCase", "--worker")); diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index 94a838c..fee502e 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -22,6 +22,17 @@ import { mockDeps, response } from "./helpers.js"; /** @typedef {import("./helpers.js").ControlCall} ControlCall */ +const ESC = String.fromCharCode(27); + +/** @param {unknown} err */ +function assertEscapedBadArg(err) { + const message = /** @type {Error} */ (err).message; + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach CLI errors"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge error lines"); + assert.match(message, /bad\\u001b\[2J\\nFORGED\\rBAD/); + return true; +} + /** * The options bag the dispatcher passes to an injected `loadEnv`. Matches the * third parameter of `loadCliDotEnv`. @@ -559,6 +570,33 @@ test("delete command rejects unexpected positional arguments", async () => { ); }); +test("commands escape terminal controls in unexpected positional errors", async () => { + const bad = `bad${ESC}[2J\nFORGED\rBAD`; + const deps = { + env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }; + + await assert.rejects( + () => runDeleteCommand(["version", "--ns", "demo", "api", "v1", bad], deps), + assertEscapedBadArg, + ); + await assert.rejects( + () => runSecretCommand(["list", "--ns", "demo", "--scope", "ns", bad], deps), + assertEscapedBadArg, + ); + await assert.rejects( + () => runR2Command(["buckets", "list", bad, "--ns", "demo"], deps), + assertEscapedBadArg, + ); + await assert.rejects( + () => runWorkflowsCommand(["list", "--ns", "demo", bad], deps), + assertEscapedBadArg, + ); +}); + test("secret list accepts flags before the subcommand", async () => { const { calls, deps } = mockDeps({ keys: [] }); @@ -1575,8 +1613,17 @@ test("wdl dispatcher skips dotenv for top-level help and unknown commands", asyn () => wdlMain(["bogus"], { loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ (() => calls.push("bogus"))) }), /exit:1/ ); + await assert.rejects( + () => wdlMain([`bad${ESC}[2J\nFORGED\rBAD`], { loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ (() => calls.push("bad"))) }), + /exit:1/ + ); assert.deepEqual(calls, []); assert.ok(errors.some((line) => line.includes("unknown command: bogus"))); + const escaped = errors.find((line) => line.includes("unknown command: bad")); + assert.ok(escaped); + assert.doesNotMatch(escaped, new RegExp(ESC), "raw ESC must not reach unknown-command errors"); + assert.doesNotMatch(escaped, /\nFORGED|\rBAD/, "raw line controls must not forge unknown-command errors"); + assert.match(escaped, /bad\\u001b\[2J\\nFORGED\\rBAD/); } finally { process.exit = oldExit; console.error = oldError; @@ -1597,7 +1644,7 @@ test("wdl dispatcher prints parseArgs errors without a Node stack", async () => try { await assert.rejects( - () => wdlMain(["tail", "--dsf"], { loadEnv: null }), + () => wdlMain(["tail", `--dsf${ESC}[2J\nFORGED\rBAD`], { loadEnv: null }), /exit:1/ ); } finally { @@ -1606,7 +1653,9 @@ test("wdl dispatcher prints parseArgs errors without a Node stack", async () => } assert.equal(errors.length, 1); - assert.match(errors[0], /error: Unknown option '--dsf'/); + assert.match(errors[0], /error: Unknown option '--dsf\\u001b\[2J\\nFORGED\\rBAD'/); + assert.doesNotMatch(errors[0], new RegExp(ESC), "raw ESC must not reach parseArgs errors"); + assert.doesNotMatch(errors[0], /\nFORGED|\rBAD/, "raw line controls must not forge parseArgs errors"); assert.doesNotMatch(errors[0], /TypeError|parse_args|Node\.js/); }); From 0124433e25be104994691011ecd6ca9018635998 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Thu, 9 Jul 2026 18:08:53 +0800 Subject: [PATCH 04/12] Harden CLI deploy compatibility checks Reject unmapped Wrangler runtime/deploy config instead of silently dropping it, tighten terminal-safe diagnostics, and sync deploy/env override docs with the effective CLI behavior. Cover CONTROL_CONNECT_HOST validation, deploy/delete/tail output escaping, env-specific DO migrations, and unsupported Wrangler runtime policy keys. Signed-off-by: Lu Zhang --- .claude/skills/wdl-deploy/SKILL.md | 8 +- GUIDE-zh.md | 2 +- GUIDE.md | 2 +- commands/deploy.js | 13 ++- commands/tail.js | 2 +- docs/deploy-zh.md | 2 +- docs/deploy.md | 4 + docs/env-overrides-zh.md | 4 +- docs/env-overrides.md | 10 +- lib/control-fetch.js | 28 ++++-- lib/delete-format.js | 69 ++++++++++--- lib/wrangler/config.js | 72 +++++++++++--- tests/integration/cli-live.test.js | 14 +-- tests/unit/cli-control-fetch.test.js | 49 ++++++++- tests/unit/cli-deploy.test.js | 143 ++++++++++++++++++++++++--- tests/unit/cli-lifecycle.test.js | 62 +++++++++++- 16 files changed, 398 insertions(+), 86 deletions(-) diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index ac00210..5d0a92a 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -50,9 +50,11 @@ target. WDL follows Wrangler config priority (`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`). The control plane is canonical for unsupported runtime shapes such as unsupported workerd experimental compatibility flags and WDL-reserved injected module names; the CLI still -fails fast for cheap local cases such as Python Workers modules and ambiguous -runtime `env` name collisions between `[vars]`, explicit bindings, and the -implicit `ASSETS` binding. +fails fast for cheap local cases such as Python Workers modules, unmapped +Wrangler runtime/deploy keys (`[site]`, `workers_dev`, +`pages_build_output_dir`, `observability`, `limits`, `placement`, etc.), and +ambiguous runtime `env` name collisions between `[vars]`, explicit bindings, +and the implicit `ASSETS` binding. Never recommend setting `CONTROL_CONNECT_HOST` outside local development: it overrides the TCP target the admin token connects to (Host header + TLS SNI diff --git a/GUIDE-zh.md b/GUIDE-zh.md index 378ef61..06add5e 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -246,7 +246,7 @@ Wrangler 能打包、但 WDL 不能运行的形状由 control plane 作为 canon | Durable Objects | 支持本 worker 内 class,要求 class 列在 `[[migrations]].new_classes` 或 `[[migrations]].new_sqlite_classes`;两种写法在 WDL 都映射到 SQLite-backed DO storage。`script_name`、rename/delete migration 暂未实现。`stub.fetch()`、JSON-structured `stub.method(...args)` DO RPC、同步 `ctx.storage.sql`、alarm shim、普通 WebSocket upgrade 和 native WebSocket hibernation API surface 可用;平台级 session/cursor 恢复仍由应用自己处理 | | `[[workflows]]` | 支持当前 Worker 内定义的 workflow class。可用 `WorkflowEntrypoint`、`env..create()`、`createBatch()`、`get()`、`status()`、`pause()`/`resume()`/`restart()`/`terminate()`、`sendEvent()`、`step.do()`/`sleep()`/`sleepUntil()`/`waitForEvent()`、retry、`NonRetryableError`、same-worker DO progress callback 和 runtime-observed parallel/DAG step。这是 WDL Workflows 支持,不是完整 Cloudflare Workflows parity。Instance payload、单 turn step fan-out 和并行 step 顺序都有上限;已启动的 step 必须 await。不支持 `script_name`、跨 worker workflow、跨 worker callback、service-binding callback 和 Cloudflare source-AST visualizer | | Analytics Engine | 暂不支持,部署时会拒绝 | -| 其他 Wrangler 绑定段(`ai`、`ai_search`、`ai_search_namespaces`、`browser`、`containers`、`data_blobs`、`dispatch_namespaces`、`hyperdrive`、`images`、`logfwdr`、`mtls_certificates`、`pipelines`、`secrets_store_secrets`、`send_email`、`tail_consumers`、`text_blobs`、`unsafe`、`vectorize`、`version_metadata`、`wasm_modules`) | 不支持;部署时显式报错,不会静默丢弃绑定。完整拒绝列表以 CLI 报错为准 | +| 其他未映射的 Wrangler 绑定/配置/策略段(例如 `ai`、`vectorize`、`hyperdrive`、`agent_memory`、`websearch`、`media`、`stream`、`ratelimits`、`vpc_services`、`cloudchamber`、`containers`、`wasm_modules`、`[site]`、`limits`、`placement`、`observability`、`workers_dev`、`pages_build_output_dir`) | 不支持;部署时显式报错,不会静默丢弃绑定/配置。CLI 报错会点名被拒字段;内部拒绝列表跟随打包的 Wrangler schema,这里不复刻完整清单 | Cron triggers 和 queue consumers 是运行时 dispatch 能力。除非管理方明确给了 reserved namespace,否则只应声明在 tenant namespace 里的可路由 Worker 上。通过 `[[platform_bindings]]` 选择的 Worker 是冷加载的平台能力,不是公开/runtime dispatch 目标,不能声明 cron triggers 或 queue consumers。 diff --git a/GUIDE.md b/GUIDE.md index c607dc0..228d4e0 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -353,7 +353,7 @@ binding metadata, or retained versions can fail with `worker_env_too_large`. | Durable Objects | Supported for local classes listed in `[[migrations]].new_classes` or `[[migrations]].new_sqlite_classes`; both map to SQLite-backed DO storage in WDL. `script_name` and renamed/deleted migrations are not supported yet. `stub.fetch()`, JSON-structured `stub.method(...args)` DO RPC, synchronous `ctx.storage.sql`, the alarm shim, ordinary WebSocket upgrade, and the native WebSocket hibernation API surface are available; platform-level session/cursor recovery remains application-owned | | `[[workflows]]` | Supported for workflow classes defined in the current Worker. `WorkflowEntrypoint`, `env..create()`, `createBatch()`, `get()`, `status()`, `pause()`/`resume()`/`restart()`/`terminate()`, `sendEvent()`, `step.do()`/`sleep()`/`sleepUntil()`/`waitForEvent()`, retries, `NonRetryableError`, same-worker DO progress callbacks, and runtime-observed parallel/DAG steps are available. This is WDL Workflows support, not full Cloudflare Workflows parity. Instance payloads, per-turn step fan-out, and parallel step ordering are bounded; started steps must be awaited. `script_name`, cross-worker workflows, cross-worker callbacks, service-binding callbacks, and Cloudflare source-AST visualizer are unsupported | | Analytics Engine | Not currently supported; deploy fails if configured | -| Other Wrangler binding sections (`ai`, `ai_search`, `ai_search_namespaces`, `browser`, `containers`, `data_blobs`, `dispatch_namespaces`, `hyperdrive`, `images`, `logfwdr`, `mtls_certificates`, `pipelines`, `secrets_store_secrets`, `send_email`, `tail_consumers`, `text_blobs`, `unsafe`, `vectorize`, `version_metadata`, `wasm_modules`) | Not supported; deploy fails loudly instead of silently dropping the binding. The CLI error is the authoritative list | +| Other unmapped Wrangler binding/config/policy sections (for example `ai`, `vectorize`, `hyperdrive`, `agent_memory`, `websearch`, `media`, `stream`, `ratelimits`, `vpc_services`, `cloudchamber`, `containers`, `wasm_modules`, `[site]`, `limits`, `placement`, `observability`, `workers_dev`, `pages_build_output_dir`) | Not supported; deploy fails loudly instead of silently dropping the binding/config. The CLI error names the rejected field; the internal rejection list tracks the bundled Wrangler schema and is not reproduced exhaustively here | Cron triggers and queue consumers are dispatch features. Declare them only on routeable Workers in tenant namespaces unless your operator gives you an diff --git a/commands/deploy.js b/commands/deploy.js index 4b29d6c..ab5ee90 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -5,7 +5,7 @@ import { execFileSync } from "node:child_process"; import { LONG_CONTROL_TIMEOUT_MS } from "../lib/control-fetch.js"; import { defineCommand } from "../lib/command.js"; import { CliError, defineCliOption, formatHelp, formatHttpError, isMain, optionHelp, readJsonOrFail, unexpectedArgument } from "../lib/common.js"; -import { escapeTerminalText, formatKnownWarning, writeStatusLine } from "../lib/output.js"; +import { escapeTerminalText, formatKnownWarning, shellSingleQuote, writeStatusLine } from "../lib/output.js"; import { isLocalDevHost } from "../lib/credentials.js"; import { packWranglerProject } from "../lib/wrangler-pack.js"; @@ -137,14 +137,16 @@ function renderDeployWarnings(warnings, { ns, workerName, stderr }) { // Control's deploy warnings are the only signal for several binding // misconfigurations — surface them so failures don't defer to runtime. if (!Array.isArray(warnings) || warnings.length === 0) return; + const nsArg = shellArg(ns); + const workerArg = shellArg(workerName); for (const w of warnings) { if (w && Array.isArray(w.missingCallerSecrets) && w.missingCallerSecrets.length) { const keys = escapeTerminalText(w.missingCallerSecrets.join(", ")); stderr( `warning: platform binding "${escapeTerminalText(w.binding)}" (platform="${escapeTerminalText(w.platform)}"): ` + `missing caller secrets ${keys}\n` + - ` ns-wide: wdl secret put --ns ${ns} --scope ns \n` + - ` per-worker: wdl secret put --ns ${ns} --worker ${workerName} ` + ` ns-wide: wdl secret put --ns ${nsArg} --scope ns \n` + + ` per-worker: wdl secret put --ns ${nsArg} --worker ${workerArg} ` ); } else { stderr(`warning: ${formatKnownWarning(w, DEPLOY_WARNING_KEYS)}`); @@ -152,6 +154,11 @@ function renderDeployWarnings(warnings, { ns, workerName, stderr }) { } } +/** @param {unknown} value */ +function shellArg(value) { + return escapeTerminalText(shellSingleQuote(value)); +} + /** * @param {string} text * @param {{ ns: string, workerName: string, stderr: (line: string) => void }} arg diff --git a/commands/tail.js b/commands/tail.js index 9482255..16c215e 100644 --- a/commands/tail.js +++ b/commands/tail.js @@ -259,7 +259,7 @@ async function runTail({ values, positionals, context: baseContext }) { const detail = transportErr instanceof Error ? `${transportErr.name}: ${transportErr.message}` : String(transportErr); - stderr(`tail transport error (${detail}); will reconnect`); + stderr(`tail transport error (${escapeTerminalText(detail)}); will reconnect`); } // Only stable sessions reset consecutiveAtCap. A flapping network can // establish TCP/TLS and still die quickly; keep backing off there. diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index c027810..5347ac8 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -97,7 +97,7 @@ wdl deploy . --env production **支持:** `name`、`main`、`compatibility_date`/`flags`、`[vars]`、`[[kv_namespaces]]`、`[[d1_databases]]`、`[[durable_objects.bindings]]`、`[[workflows]]`、`[[r2_buckets]]`、`[assets] directory`、`[triggers] crons`、`[[triggers.schedules]]`(带 timezone,平台扩展)、`[[queues.producers]]` / `[[queues.consumers]]`、`[[services]]`、`[[platform_bindings]]`、`[env.]`。 -**不支持(部署失败):** 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 experimental compatibility flags 和 WDL 保留注入模块名会在部署时被拒绝:CLI 会对本地 `.py` module fail-fast,workerd compatibility 与 bundle-shape policy 由 control plane canonical 判断。`assets.run_worker_first` 会被静默忽略。 +**不支持(部署失败):** 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 experimental compatibility flags 和 WDL 保留注入模块名会在部署时被拒绝:CLI 会对本地 `.py` module fail-fast,workerd compatibility 与 bundle-shape policy 由 control plane canonical 判断。WDL 会忽略、且无法映射进 manifest 的 Wrangler runtime/deploy 配置也会由 CLI 直接拒绝,包括 legacy `[site]` Workers Sites、`workers_dev`、`pages_build_output_dir`、`observability`、`limits`、`placement`,以及错误信息点名的其它 unsupported binding/config section。`assets.run_worker_first` 会被静默忽略。 Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在可路由的 tenant Worker 上。通过 `[[platform_bindings]]` 选择的 Worker 是冷加载的平台能力,不是 public/runtime dispatch 目标,不能声明 cron triggers 或 queue consumers。 diff --git a/docs/deploy.md b/docs/deploy.md index 8b30ba4..0165173 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -177,6 +177,10 @@ supported only when the operator enables them. Python Workers modules, workerd experimental compatibility flags, and WDL-reserved injected module names are rejected during deploy: the CLI fails fast on local `.py` modules, and the control plane is canonical for workerd compatibility and bundle-shape policy. +Unmapped Wrangler runtime/deploy config that WDL would otherwise ignore is also +rejected by the CLI, including legacy `[site]` Workers Sites, `workers_dev`, +`pages_build_output_dir`, `observability`, `limits`, `placement`, and other +unsupported binding/config sections named in the error. `assets.run_worker_first` is silently ignored. Cron triggers and queue consumers are runtime dispatch features; declare them diff --git a/docs/env-overrides-zh.md b/docs/env-overrides-zh.md index a3230d8..abd6174 100644 --- a/docs/env-overrides-zh.md +++ b/docs/env-overrides-zh.md @@ -96,9 +96,9 @@ 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 后,顶层同类配置不会回退进来。 -- Inheritable:`main`、`compatibility_date` / `compatibility_flags`、`route` / `routes`、`[assets]`、`[triggers]` 等。env 里没写时继续使用顶层值;env 里写了则覆盖顶层值。 +- Inheritable:`main`、`compatibility_date` / `compatibility_flags`、`route` / `routes`、`[[migrations]]`、`[assets]`、`[triggers]` 等。env 里没写时继续使用顶层值;env 里写了则覆盖顶层值。 -因此,共享的 `vars` 或 binding 不能只放顶层后期待所有 env 自动继承;每个 env 都需要声明自己要用的 runtime vars 和 bindings。共享的 assets / cron 等可放顶层,只在差异 env 下覆盖。 +因此,共享的 `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 a40de9a..ad54754 100644 --- a/docs/env-overrides.md +++ b/docs/env-overrides.md @@ -128,14 +128,14 @@ differ: `[[env..workflows]]`, 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`, `[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. + `routes`, `[[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. 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 -and bindings it uses. Shared assets / cron and the like can stay at the top -level, overridden only in the env that differs. +and bindings it uses. Shared DO migrations, assets / cron, and the like can stay +at the top level, overridden only in the env that differs. ## Anti-patterns diff --git a/lib/control-fetch.js b/lib/control-fetch.js index fa89b45..2468094 100644 --- a/lib/control-fetch.js +++ b/lib/control-fetch.js @@ -10,6 +10,7 @@ import https from "node:https"; import { isIP } from "node:net"; import { Transform } from "node:stream"; import { CliError } from "./common.js"; +import { escapeTerminalText } from "./output.js"; import { currentCliVersion } from "./package-info.js"; export const DEFAULT_CONTROL_TIMEOUT_MS = 30_000; @@ -205,7 +206,7 @@ export function validateControlHeaders(headers) { } } catch (err) { const message = err instanceof Error && err.message ? err.message : String(err); - throw new CliError(`control request failed: invalid HTTP header ${JSON.stringify(name)}: ${message}`); + throw new CliError(`control request failed: invalid HTTP header ${formatDiagnosticValue(name)}: ${escapeTerminalText(message)}`); } } } @@ -220,9 +221,9 @@ export function controlRequestError(err) { if (err instanceof CliError) return err; const codeValue = (/** @type {{ code?: unknown }} */ (/** @type {unknown} */ (err))).code; const code = typeof codeValue === "string" - ? `${codeValue} ` + ? `${escapeTerminalText(codeValue)} ` : ""; - const message = err.message || String(err); + const message = escapeTerminalText(err.message || String(err)); return new CliError(`control request failed: ${code}${message}`); } @@ -276,13 +277,20 @@ function controlConnectTarget(u, isHttps, env) { */ function parseConnectHost(raw, defaultPort) { const text = raw.trim(); + const display = formatDiagnosticValue(raw); + if (!text) throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}: host is required`); if (/^[a-z][a-z\d+.-]*:\/\//i.test(text)) { try { const parsed = new URL(text); - return { host: bareUrlHostname(parsed), port: validPort(parsed.port || defaultPort, raw) }; + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}: URL scheme must be http or https`); + } + const host = bareUrlHostname(parsed); + if (!host) throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}: host is required`); + return { host, port: validPort(parsed.port || defaultPort, raw) }; } catch (err) { if (err instanceof CliError) throw err; - throw new CliError(`Invalid CONTROL_CONNECT_HOST ${JSON.stringify(raw)}.`); + throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}.`); } } @@ -292,6 +300,9 @@ function parseConnectHost(raw, defaultPort) { } const hostPort = /^([^:]+):(\d+)$/.exec(text); if (hostPort) return { host: hostPort[1], port: validPort(hostPort[2], raw) }; + if (text.includes(":")) { + throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}: use host:port or [ipv6]:port`); + } return { host: text, port: defaultPort }; } @@ -302,11 +313,16 @@ function parseConnectHost(raw, defaultPort) { function validPort(value, raw) { const port = Number(value); if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new CliError(`Invalid CONTROL_CONNECT_HOST ${JSON.stringify(raw)}: port must be in [1, 65535]`); + throw new CliError(`Invalid CONTROL_CONNECT_HOST ${formatDiagnosticValue(raw)}: port must be in [1, 65535]`); } return port; } +/** @param {unknown} value */ +function formatDiagnosticValue(value) { + return escapeTerminalText(JSON.stringify(value)); +} + /** @param {URL} u */ function bareUrlHostname(u) { const match = /^\[(.*)\]$/.exec(u.hostname); diff --git a/lib/delete-format.js b/lib/delete-format.js index 8b73f25..a69461d 100644 --- a/lib/delete-format.js +++ b/lib/delete-format.js @@ -1,4 +1,4 @@ -import { formatKnownWarning } from "./output.js"; +import { escapeTerminalText, formatKnownWarning } from "./output.js"; const ASSET_WARNING_KEYS = [ "code", @@ -30,6 +30,14 @@ const ASSET_WARNING_KEYS = [ * @property {number} [crossNamespaceReferrerCount] */ +/** + * @typedef {object} DeleteWorkflowBlocker + * @property {string} [error] + * @property {string} [message] + * @property {number} [count] + * @property {Array<{ workflowKey?: string, instanceId?: string }>} [blockers] + */ + /** * @typedef {object} VersionDeleteBody * @property {string} [namespace] @@ -51,6 +59,7 @@ const ASSET_WARNING_KEYS = [ * @property {string[]} [affectedHosts] * @property {number} [queueConsumersRemoved] * @property {DeleteBlocker[]} [blockers] + * @property {DeleteWorkflowBlocker} [workflowBlocker] * @property {DeleteAssetsSummary} [assets] */ @@ -59,7 +68,7 @@ const ASSET_WARNING_KEYS = [ * @returns {string[]} */ export function formatVersionDelete(body) { - const lines = [`OK ${body.namespace}/${body.name}@${body.version} deleted`]; + const lines = [`OK ${field(body.namespace)}/${field(body.name)}@${field(body.version)} deleted`]; appendAssetsSummary(lines, body.assets); return lines; } @@ -71,18 +80,18 @@ export function formatVersionDelete(body) { export function formatWorkerDelete(body) { if (body.dryRun) return formatDryRun(body); if (!body.deleted) { - return [`(${body.namespace}/${body.name} had no worker-owned state)`]; + return [`(${field(body.namespace)}/${field(body.name)} had no worker-owned state)`]; } const versions = Array.isArray(body.versionsDeleted) && body.versionsDeleted.length - ? body.versionsDeleted.join(",") + ? body.versionsDeleted.map((version) => field(version)).join(",") : "-"; - const active = body.activeDeleted || "-"; + const active = field(body.activeDeleted); const lines = [ - `OK ${body.namespace}/${body.name} deleted active=${active} versions=${versions}`, + `OK ${field(body.namespace)}/${field(body.name)} deleted active=${active} versions=${versions}`, ]; if (Array.isArray(body.affectedHosts) && body.affectedHosts.length) { - lines.push(` affected hosts: ${body.affectedHosts.join(",")}`); + lines.push(` affected hosts: ${body.affectedHosts.map((host) => field(host)).join(",")}`); } if (Number.isFinite(body.queueConsumersRemoved) && Number(body.queueConsumersRemoved) > 0) { lines.push(` queue consumers removed: ${body.queueConsumersRemoved}`); @@ -97,21 +106,22 @@ export function formatWorkerDelete(body) { */ function formatDryRun(body) { const versions = Array.isArray(body.versionsDeleted) && body.versionsDeleted.length - ? body.versionsDeleted.join(",") + ? body.versionsDeleted.map((version) => field(version)).join(",") : "-"; - const active = body.activeDeleted || "-"; + const active = field(body.activeDeleted); const lines = [ - `DRY RUN ${body.namespace}/${body.name} wouldDelete=${body.deleted ? "yes" : "no"} active=${active} versions=${versions}`, + `DRY RUN ${field(body.namespace)}/${field(body.name)} wouldDelete=${body.deleted ? "yes" : "no"} active=${active} versions=${versions}`, ]; if (body.noop) lines.push(" no worker-owned state found"); if (body.hasWorkerSecrets) lines.push(" worker secrets would be deleted"); if (Array.isArray(body.affectedHosts) && body.affectedHosts.length) { - lines.push(` affected hosts: ${body.affectedHosts.join(",")}`); + lines.push(` affected hosts: ${body.affectedHosts.map((host) => field(host)).join(",")}`); } if (Number.isFinite(body.queueConsumersRemoved) && Number(body.queueConsumersRemoved) > 0) { lines.push(` queue consumers removed: ${body.queueConsumersRemoved}`); } appendBlockers(lines, body.blockers); + appendWorkflowBlocker(lines, body.workflowBlocker); return lines; } @@ -140,11 +150,11 @@ function appendBlockers(lines, blockers) { if (!Array.isArray(blockers) || blockers.length === 0) return; lines.push(" blockers:"); for (const blocker of blockers) { - lines.push(` version ${blocker.version}:`); + lines.push(` version ${field(blocker.version)}:`); const refs = Array.isArray(blocker.referrers) ? blocker.referrers : []; for (const ref of refs) { lines.push( - ` ${ref.callerNs}/${ref.callerWorker}@${ref.callerVersion} binding=${ref.binding}` + ` ${field(ref.callerNs)}/${field(ref.callerWorker)}@${field(ref.callerVersion)} binding=${field(ref.binding)}` ); } if (Number.isFinite(blocker.crossNamespaceReferrerCount) && @@ -153,3 +163,36 @@ function appendBlockers(lines, blockers) { } } } + +/** + * @param {string[]} lines + * @param {DeleteWorkflowBlocker | undefined} blocker + * @returns {void} + */ +function appendWorkflowBlocker(lines, blocker) { + if (!blocker) return; + lines.push(" workflow blocker:"); + if (blocker.error || blocker.message) { + lines.push( + ` ${field(blocker.error, "workflow_instances_active")}: ` + + `${field(blocker.message, "active workflow instances")}` + ); + } + if (Number.isFinite(blocker.count)) { + lines.push(` active instances: ${Number(blocker.count)}`); + } + const refs = Array.isArray(blocker.blockers) ? blocker.blockers : []; + for (const ref of refs) { + lines.push( + ` ${field(ref.workflowKey)} instance=${field(ref.instanceId)}` + ); + } +} + +/** + * @param {unknown} value + * @param {string} [fallback] + */ +function field(value, fallback = "-") { + return escapeTerminalText(value == null || value === "" ? fallback : String(value)); +} diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index 938f03f..e48e32b 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -4,50 +4,79 @@ import { parse as parseToml } from "smol-toml"; import { asRecord } from "./utils.js"; /** - * A parsed Wrangler config (`wrangler.toml`/`.jsonc`/`.json`). The CLI never - * trusts these fields' shapes: every binding parser re-validates the value it - * reads. Known sections (`name`, `main`, `kv_namespaces`, `d1_databases`, - * `r2_buckets`, `services`, `durable_objects`, `migrations`, `workflows`, - * `queues`, `exports`, `platform_bindings`, `vars`, `triggers`, `route`, - * `routes`, `assets`, `compatibility_date`, `compatibility_flags`, `env`, and - * the unsupported sections rejected by name) are read off this object and - * narrowed at the use site, so the honest value type is `unknown`. + * A parsed Wrangler config (`wrangler.json`, `wrangler.jsonc`, or + * `wrangler.toml`). The CLI never trusts these fields' shapes: every binding + * parser re-validates the value it reads. Known sections (`name`, `main`, + * `kv_namespaces`, `d1_databases`, `r2_buckets`, `services`, + * `durable_objects`, `migrations`, `workflows`, `queues`, `exports`, + * `platform_bindings`, `vars`, `triggers`, `route`, `routes`, `assets`, + * `compatibility_date`, `compatibility_flags`, `env`, and the unsupported + * sections rejected by name) are read off this object and narrowed at the use + * site, so the honest value type is `unknown`. * @typedef {Record} WranglerConfig */ const TOP_LEVEL_ONLY_ENV_KEYS = new Set([ "name", "keep_vars", - "migrations", "send_metrics", - "site", ]); -// Valid Wrangler binding sections the WDL manifest has no mapping for. -// Reject them loudly: wrangler dry-run bundles them happily, so a silent -// drop here would surface as `env. === undefined` at runtime. +// 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. const UNSUPPORTED_WRANGLER_KEYS = [ + "agent_memory", "ai", "ai_search", "ai_search_namespaces", "analytics_engine_datasets", + "artifacts", "browser", + "cache", + "cloudchamber", + "compliance_region", "containers", "data_blobs", "dispatch_namespaces", + "first_party_worker", + "flagship", "hyperdrive", "images", + "legacy_env", + "limits", "logfwdr", + "logpush", + "media", "mtls_certificates", + "observability", + "pages_build_output_dir", "pipelines", + "placement", + "preview_urls", + "previews", + "python_modules", + "ratelimits", "secrets_store_secrets", "send_email", + "site", + "stream", + "streaming_tail_consumers", "tail_consumers", "text_blobs", + "upload_source_maps", "unsafe", + "unsafe_hello_world", "vectorize", "version_metadata", + "vpc_networks", + "vpc_services", "wasm_modules", + "websearch", + "worker_loaders", + "workers_dev", ]; const SUPPORTED_WRANGLER_SUMMARY = @@ -99,7 +128,7 @@ export function loadWranglerConfig(dir) { throw new Error(`failed to parse ${name}: ${message}`, { cause: err }); } } - throw new Error(`no wrangler.{toml,jsonc,json} found in ${dir}`); + throw new Error(`no ${WRANGLER_CONFIG_CANDIDATES.join(", ")} found in ${dir}`); } /** @@ -123,12 +152,12 @@ export function validateUnsupportedWranglerConfig(rawCfg, envName, configRel = " envName && envTable ? asRecord(envTable[envName]) : null; for (const key of UNSUPPORTED_WRANGLER_KEYS) { - if (hasConfiguredValue(cfg?.[key])) { + if (hasConfiguredKey(cfg, key)) { throw new Error( `${configRel} uses [${key}] — not supported. ${SUPPORTED_WRANGLER_SUMMARY}` ); } - if (hasConfiguredValue(selectedEnvCfg?.[key])) { + if (hasConfiguredKey(selectedEnvCfg, key)) { throw new Error( `${configRel} env.${envName} uses [${key}] — not supported. ${SUPPORTED_WRANGLER_SUMMARY}` ); @@ -313,6 +342,17 @@ function listNamedEnvironments(cfg) { return Object.keys(env); } +/** + * @param {Record | null} record + * @param {string} key + * @returns {boolean} + */ +function hasConfiguredKey(record, key) { + if (!record || !Object.hasOwn(record, key)) return false; + const value = record[key]; + return value == null || Array.isArray(value) ? hasConfiguredValue(value) : true; +} + /** * @param {unknown} value * @returns {boolean} diff --git a/tests/integration/cli-live.test.js b/tests/integration/cli-live.test.js index e5f04ab..00481dc 100644 --- a/tests/integration/cli-live.test.js +++ b/tests/integration/cli-live.test.js @@ -280,11 +280,7 @@ test("live CLI integration covers command surface against a WDL control plane", if (!cleaned.appWorker) run(["delete", "worker", appWorker, "--yes", "--json"], { env: directTenantEnv }); }); cleanupStep("delete durable object worker", () => { - try { - run(["delete", "worker", doWorker, "--yes", "--json"], { env: directTenantEnv }); - } catch { - // The DO smoke may not have deployed if an earlier step failed. - } + run(["delete", "worker", doWorker, "--yes", "--json"], { env: directTenantEnv }); }); cleanupStep("delete workflow worker", () => { try { @@ -299,11 +295,7 @@ test("live CLI integration covers command surface against a WDL control plane", } }); cleanupStep("delete env worker", () => { - try { - run(["delete", "worker", envWorker, "--yes", "--json"], { env: directTenantEnv }); - } catch { - // The env smoke may not have deployed if an earlier step failed. - } + run(["delete", "worker", envWorker, "--yes", "--json"], { env: directTenantEnv }); }); await step("d1 commands create, migrate, list, execute", () => { @@ -371,7 +363,7 @@ test("live CLI integration covers command surface against a WDL control plane", assert.deepEqual(kvPut, { key: "counter", value: 1 }); }); - await step("tenant runtime exercises assets, queues, and cron registration", async () => { + await step("tenant runtime exercises assets and queue delivery", async () => { const assetUrl = /** @type {{ url?: string }} */ (await tenantJson(ctx, ns, appWorker, "/asset-url")); assert.match(String(assetUrl.url), /hello\.txt/); diff --git a/tests/unit/cli-control-fetch.test.js b/tests/unit/cli-control-fetch.test.js index d96388f..c34dc82 100644 --- a/tests/unit/cli-control-fetch.test.js +++ b/tests/unit/cli-control-fetch.test.js @@ -10,6 +10,8 @@ import { } from "../../lib/control-fetch.js"; import { currentCliVersion } from "../../lib/package-info.js"; +const ESC = String.fromCharCode(27); + /** * @param {{ statusCode?: number, headers?: import("node:http").IncomingHttpHeaders }} [init] */ @@ -106,6 +108,33 @@ test("controlFetch wraps request socket errors as CliError", async () => { ); }); +test("controlFetch escapes transport error details", async () => { + /** @type {import("../../lib/control-fetch.js").ControlTransport} */ + const transport = { + request() { + const req = Object.assign(new EventEmitter(), { + write() {}, + end() { + req.emit("error", Object.assign(new Error(`boom${ESC}[2J\nFORGED\rBAD`), { code: `E_BAD${ESC}` })); + }, + destroy() {}, + }); + return req; + }, + }; + + await assert.rejects( + () => controlFetch("http://ctl.test/whoami", { transport }), + (err) => { + assert(err instanceof CliError); + assert.match(err.message, /E_BAD\\u001b boom\\u001b\[2J\\nFORGED\\rBAD/); + assert.doesNotMatch(err.message, new RegExp(ESC), "raw ESC must not reach control request errors"); + assert.doesNotMatch(err.message, /\nFORGED|\rBAD/, "raw line controls must not forge control request errors"); + return true; + } + ); +}); + test("controlFetch rejects invalid header values before opening a socket", async () => { let opened = false; /** @type {import("../../lib/control-fetch.js").ControlTransport} */ @@ -402,10 +431,18 @@ test("controlFetch uses init env for CONTROL_CONNECT_HOST overrides", async () = assert.equal(opts.headers.Host, "admin.test:8080"); }); -test("controlFetch rejects invalid CONTROL_CONNECT_HOST ports before opening a socket", () => { +test("controlFetch rejects invalid CONTROL_CONNECT_HOST values before opening a socket", () => { const oldConnectHost = process.env.CONTROL_CONNECT_HOST; try { - for (const value of ["127.0.0.1:99999", "[::1]:99999", "http://127.0.0.1:99999"]) { + for (const value of [ + " ", + "file:///tmp/control.sock", + "127.0.0.1:abc", + "127.0.0.1:abc\u009b", + "127.0.0.1:99999", + "[::1]:99999", + "http://127.0.0.1:99999", + ]) { process.env.CONTROL_CONNECT_HOST = value; assert.throws( () => controlFetch("http://ctl.test/whoami", { @@ -415,8 +452,12 @@ test("controlFetch rejects invalid CONTROL_CONNECT_HOST ports before opening a s }, }, }), - (err) => err instanceof CliError && - err.message.includes(`Invalid CONTROL_CONNECT_HOST ${JSON.stringify(value)}`) + (err) => { + assert(err instanceof CliError); + assert.match(err.message, /Invalid CONTROL_CONNECT_HOST/); + assert.doesNotMatch(err.message, /\u009b/, "raw C1 controls must not reach CONTROL_CONNECT_HOST errors"); + return true; + } ); } } finally { diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 0f86f7b..8923942 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -1104,6 +1104,21 @@ test("resolveWranglerConfig: selected environment can override inherited assets" assert.deepEqual(cfg.assets, { directory: "./prod-public" }); }); +test("resolveWranglerConfig: selected environment can override durable object migrations", () => { + const { cfg } = resolveWranglerConfig({ + name: "demo", + main: "src/index.js", + migrations: [{ tag: "v1", new_classes: ["TopObject"] }], + env: { + prod: { + migrations: [{ tag: "v2", new_sqlite_classes: ["ProdObject"] }], + }, + }, + }, "prod", "wrangler.jsonc"); + + assert.deepEqual(cfg.migrations, [{ tag: "v2", new_sqlite_classes: ["ProdObject"] }]); +}); + test("resolveWranglerConfig: rejects unknown environment names", () => { assert.throws( () => resolveWranglerConfig({ @@ -1122,11 +1137,11 @@ test("resolveWranglerConfig: rejects top-level-only keys inside an environment", main: "src/index.js", env: { staging: { - migrations: [], + keep_vars: true, }, }, }, "staging", "wrangler.toml"), - /env\.staging\.migrations is top-level only/ + /env\.staging\.keep_vars is top-level only/ ); }); @@ -1213,17 +1228,93 @@ test("validateUnsupportedWranglerConfig: env-scoped allowed_callers is rejected ); }); -test("validateUnsupportedWranglerConfig rejects unmapped wrangler binding sections", () => { - for (const key of ["ai", "vectorize", "hyperdrive", "browser", "mtls_certificates"]) { +test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy keys", () => { + const objectShapeKeys = new Set([ + "ai", + "browser", + "cache", + "limits", + "observability", + "placement", + "previews", + "python_modules", + "site", + "unsafe_hello_world", + ]); + const booleanShapeKeys = new Set([ + "first_party_worker", + "legacy_env", + "preview_urls", + "upload_source_maps", + "workers_dev", + ]); + for (const key of [ + "agent_memory", + "ai", + "artifacts", + "browser", + "cache", + "cloudchamber", + "compliance_region", + "hyperdrive", + "first_party_worker", + "flagship", + "legacy_env", + "limits", + "logpush", + "media", + "mtls_certificates", + "observability", + "pages_build_output_dir", + "placement", + "preview_urls", + "previews", + "ratelimits", + "python_modules", + "site", + "stream", + "streaming_tail_consumers", + "unsafe_hello_world", + "upload_source_maps", + "vectorize", + "vpc_networks", + "vpc_services", + "websearch", + "worker_loaders", + "workers_dev", + ]) { assert.throws( () => validateUnsupportedWranglerConfig({ name: "demo", main: "src/index.js", - [key]: key === "ai" || key === "browser" ? { binding: "B" } : [{ binding: "B" }], + [key]: unsupportedWranglerFixtureValue(key, objectShapeKeys, booleanShapeKeys), }, null, "wrangler.toml"), new RegExp(`\\[${key}\\] — not supported`) ); } + + assert.throws( + () => validateUnsupportedWranglerConfig({ + name: "demo", + main: "src/index.js", + workers_dev: false, + }, null, "wrangler.toml"), + /\[workers_dev\] — not supported/ + ); + + assert.throws( + () => validateUnsupportedWranglerConfig({ + name: "demo", + main: "src/index.js", + env: { + staging: { + preview_urls: false, + }, + }, + }, "staging", "wrangler.toml"), + /env\.staging uses \[preview_urls\] — not supported/ + ); + // The rejection lists the actual supported surface. try { validateUnsupportedWranglerConfig( @@ -1240,6 +1331,20 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler binding sectio } }); +/** + * @param {string} key + * @param {Set} objectShapeKeys + * @param {Set} booleanShapeKeys + * @returns {unknown} + */ +function unsupportedWranglerFixtureValue(key, objectShapeKeys, booleanShapeKeys) { + if (objectShapeKeys.has(key)) return { binding: "B" }; + if (booleanShapeKeys.has(key)) return true; + if (key === "compliance_region") return "eu"; + if (key === "pages_build_output_dir") return "dist"; + return [{ binding: "B" }]; +} + test("validateUnsupportedWranglerConfig rejects module-binding and container sections", () => { assert.throws( () => validateUnsupportedWranglerConfig( @@ -2417,16 +2522,18 @@ test("runDeployCommand reports captured wrangler output only when dry-run fails" test("runDeployCommand warns with wdl secret hints for missing caller secrets", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-warning-")); + const badNs = `demo${ESC}[2J\nFORGED\rBAD`; + const badWorker = `api${ESC}[2J\nFORGED\rBAD`; try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ name: badWorker, main: "src/index.js" })); /** @type {string[]} */ const warnings = []; let fetchCount = 0; - await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, + await runDeployCommand([dir, "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok", WDL_NS: badNs }, stdout: () => {}, stderr: (/** @type {string} */ line) => warnings.push(/** @type {string} */ line), execFile: fakeWranglerExecFile, @@ -2449,8 +2556,10 @@ test("runDeployCommand warns with wdl secret hints for missing caller secrets", }); assert.equal(warnings.length, 1); - assert.match(warnings[0], /wdl secret put --ns demo --scope ns /); - assert.match(warnings[0], /wdl secret put --ns demo --worker api /); + assert.doesNotMatch(warnings[0], new RegExp(ESC), "raw ESC must not reach deploy warnings"); + assert.doesNotMatch(warnings[0], /\nFORGED|\rBAD/, "raw line controls must not forge deploy warnings"); + assert.match(warnings[0], /wdl secret put --ns 'demo\\u001b\[2J\\nFORGED\\rBAD' --scope ns /); + assert.match(warnings[0], /--worker 'api\\u001b\[2J\\nFORGED\\rBAD' /); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -2458,16 +2567,18 @@ test("runDeployCommand warns with wdl secret hints for missing caller secrets", test("runDeployCommand renders deploy warnings from error responses", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-error-warning-")); + const badNs = `demo${ESC}[2J\nFORGED\rBAD`; + const badWorker = `api${ESC}[2J\nFORGED\rBAD`; try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ name: badWorker, main: "src/index.js" })); /** @type {string[]} */ const warnings = []; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, + () => runDeployCommand([dir, "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok", WDL_NS: badNs }, stdout: () => {}, stderr: (/** @type {string} */ line) => warnings.push(/** @type {string} */ line), execFile: fakeWranglerExecFile, @@ -2485,8 +2596,10 @@ test("runDeployCommand renders deploy warnings from error responses", async () = ); assert.equal(warnings.length, 1); - assert.match(warnings[0], /wdl secret put --ns demo --scope ns /); - assert.match(warnings[0], /wdl secret put --ns demo --worker api /); + assert.doesNotMatch(warnings[0], new RegExp(ESC), "raw ESC must not reach deploy error warnings"); + assert.doesNotMatch(warnings[0], /\nFORGED|\rBAD/, "raw line controls must not forge deploy error warnings"); + assert.match(warnings[0], /wdl secret put --ns 'demo\\u001b\[2J\\nFORGED\\rBAD' --scope ns /); + assert.match(warnings[0], /--worker 'api\\u001b\[2J\\nFORGED\\rBAD' /); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index fee502e..5bb49e7 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -475,6 +475,50 @@ test("delete worker supports dry-run query and raw json output", async () => { assert.deepEqual(lines, [JSON.stringify(body, null, 2)]); }); +test("delete worker dry-run renders workflow blockers in human output", async () => { + const hostile = `bad${ESC}[2J\nFORGED\rBAD`; + const body = { + dryRun: true, + namespace: `demo-${hostile}`, + name: `api-${hostile}`, + deleted: false, + activeDeleted: `v2-${hostile}`, + versionsDeleted: [`v1-${hostile}`], + affectedHosts: [`host-${hostile}.example`], + blockers: [{ + version: `v1-${hostile}`, + referrers: [{ + callerNs: `ns-${hostile}`, + callerWorker: `worker-${hostile}`, + callerVersion: `version-${hostile}`, + binding: `binding-${hostile}`, + }], + }], + workflowBlocker: { + error: `workflow_instances_active-${hostile}`, + message: `demo/api has active workflow instances ${hostile}`, + count: 1, + blockers: [{ workflowKey: `wf-${hostile}`, instanceId: `inst-${hostile}` }], + }, + }; + const { lines, deps } = mockDeps(body); + + await runDeleteCommand( + ["worker", "--ns", "demo", "api", "--dry-run", "--control-url", "http://ctl.test"], + deps + ); + + const joined = lines.join("\n"); + assert.doesNotMatch(joined, new RegExp(ESC), "raw ESC must not reach delete dry-run output"); + assert.doesNotMatch(joined, /\nFORGED|\rBAD/, "raw line controls must not forge delete dry-run output"); + assert.ok(lines.some((line) => /workflow blocker/.test(line))); + assert.match(joined, /DRY RUN demo-bad\\u001b\[2J\\nFORGED\\rBAD\/api-bad\\u001b\[2J\\nFORGED\\rBAD/); + assert.match(joined, /affected hosts: host-bad\\u001b\[2J\\nFORGED\\rBAD\.example/); + assert.match(joined, /binding=binding-bad\\u001b\[2J\\nFORGED\\rBAD/); + assert.match(joined, /workflow_instances_active-bad\\u001b\[2J\\nFORGED\\rBAD/); + assert.match(joined, /wf-bad\\u001b\[2J\\nFORGED\\rBAD instance=inst-bad\\u001b\[2J\\nFORGED\\rBAD/); +}); + test("delete worker requires confirmation unless --yes or --dry-run is used", async () => { /** @type {ControlCall[]} */ const calls = []; @@ -1229,7 +1273,7 @@ test("r2 object get, head, and delete reject blank keys", async () => { test("r2 object key preserves empty path segments but rejects dot segments", async () => { /** @type {ControlCall[]} */ const calls = []; - await runR2Command(["objects", "head", "bkt", "a//b", "--ns", "demo", "--control-url", "http://ctl.test"], { + const deps = { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { @@ -1241,8 +1285,14 @@ test("r2 object key preserves empty path segments but rejects dot segments", asy text: async () => "", }; }, - }); + }; + await runR2Command(["objects", "head", "bkt", "a//b", "--ns", "demo", "--control-url", "http://ctl.test"], deps); + await runR2Command(["objects", "head", "bkt", "/a", "--ns", "demo", "--control-url", "http://ctl.test"], deps); + await runR2Command(["objects", "head", "bkt", "a/", "--ns", "demo", "--control-url", "http://ctl.test"], deps); + assert.equal(calls[0].url, "http://ctl.test/ns/demo/r2/buckets/bkt/objects/a//b"); + assert.equal(calls[1].url, "http://ctl.test/ns/demo/r2/buckets/bkt/objects//a"); + assert.equal(calls[2].url, "http://ctl.test/ns/demo/r2/buckets/bkt/objects/a/"); await assert.rejects( () => runR2Command(["objects", "get", "bkt", "a/./b", "--ns", "demo", "--control-url", "http://ctl.test"], { @@ -2320,7 +2370,7 @@ test("wdl tail reconnects with Last-Event-ID after transport errors", async () = ts: 1, })}\n\n`); setImmediate(() => { - res.emit("error", Object.assign(new Error("socket hang up"), { code: "ECONNRESET" })); + res.emit("error", Object.assign(new Error(`socket hang up${ESC}[2J\nFORGED\rBAD`), { code: "ECONNRESET" })); }); }); } else { @@ -2350,7 +2400,11 @@ test("wdl tail reconnects with Last-Event-ID after transport errors", async () = assert.ok(requestsSeen.length >= 2); assert.equal(requestsSeen[0].headers["last-event-id"], undefined); assert.equal(requestsSeen[1].headers["last-event-id"], "100-0"); - assert.ok(stderrLines.some((line) => /transport error/i.test(line))); + const transportLine = stderrLines.find((line) => /transport error/i.test(line)); + assert.ok(transportLine); + assert.match(transportLine, /socket hang up\\u001b\[2J\\nFORGED\\rBAD/); + assert.doesNotMatch(transportLine, new RegExp(ESC), "raw ESC must not reach tail transport diagnostics"); + assert.doesNotMatch(transportLine, /\nFORGED|\rBAD/, "raw line controls must not forge tail transport diagnostics"); }); test("wdl tail treats session recycle warnings as control-initiated reconnects", async () => { From 216e893d7efbdc291a797b016ece62e568760b91 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Thu, 9 Jul 2026 18:36:42 +0800 Subject: [PATCH 05/12] Polish CLI diagnostic output Avoid duplicate deploy warning context, report corrupt token stores in doctor, sync deploy docs for worker_code_too_large, and share small terminal-safe display helpers. Validation: npm run lint; npm run typecheck; npm test; git diff --check. Signed-off-by: Lu Zhang --- commands/deploy.js | 29 ++++++++++++++++++--------- commands/doctor.js | 15 +++++++++----- commands/token.js | 4 ++-- docs/deploy-zh.md | 1 + docs/deploy.md | 1 + lib/output.js | 10 +++++++--- lib/token-store.js | 4 ++-- tests/unit/cli-config-doctor.test.js | 30 ++++++++++++++++++++++++++++ tests/unit/cli-deploy.test.js | 9 ++++++++- tests/unit/cli-token-store.test.js | 10 ++++------ tests/unit/cli-token.test.js | 8 +++----- tests/unit/helpers.js | 8 ++++++++ 12 files changed, 96 insertions(+), 33 deletions(-) diff --git a/commands/deploy.js b/commands/deploy.js index ab5ee90..05ae6ea 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -5,7 +5,7 @@ import { execFileSync } from "node:child_process"; import { LONG_CONTROL_TIMEOUT_MS } from "../lib/control-fetch.js"; import { defineCommand } from "../lib/command.js"; import { CliError, defineCliOption, formatHelp, formatHttpError, isMain, optionHelp, readJsonOrFail, unexpectedArgument } from "../lib/common.js"; -import { escapeTerminalText, formatKnownWarning, shellSingleQuote, writeStatusLine } from "../lib/output.js"; +import { escapeTerminalText, formatKnownWarning, shellArgForDisplay, writeStatusLine } from "../lib/output.js"; import { isLocalDevHost } from "../lib/credentials.js"; import { packWranglerProject } from "../lib/wrangler-pack.js"; @@ -126,7 +126,7 @@ async function fetchDeployJson({ context, url, init, label, ns, workerName, stde if (res.ok) return await readJsonOrFail(res, label); const text = await res.text(); renderDeployWarningsFromErrorBody(text, { ns, workerName, stderr }); - throw new CliError(`${label} failed: ${formatHttpError(res.status, text, res.headers)}${deployErrorHint(text)}`); + throw new CliError(`${label} failed: ${formatHttpError(res.status, stripRenderedWarnings(text), res.headers)}${deployErrorHint(text)}`); } /** @@ -137,8 +137,8 @@ function renderDeployWarnings(warnings, { ns, workerName, stderr }) { // Control's deploy warnings are the only signal for several binding // misconfigurations — surface them so failures don't defer to runtime. if (!Array.isArray(warnings) || warnings.length === 0) return; - const nsArg = shellArg(ns); - const workerArg = shellArg(workerName); + const nsArg = shellArgForDisplay(ns); + const workerArg = shellArgForDisplay(workerName); for (const w of warnings) { if (w && Array.isArray(w.missingCallerSecrets) && w.missingCallerSecrets.length) { const keys = escapeTerminalText(w.missingCallerSecrets.join(", ")); @@ -154,11 +154,6 @@ function renderDeployWarnings(warnings, { ns, workerName, stderr }) { } } -/** @param {unknown} value */ -function shellArg(value) { - return escapeTerminalText(shellSingleQuote(value)); -} - /** * @param {string} text * @param {{ ns: string, workerName: string, stderr: (line: string) => void }} arg @@ -170,6 +165,22 @@ function renderDeployWarningsFromErrorBody(text, arg) { } catch {} } +/** @param {string} text */ +function stripRenderedWarnings(text) { + /** @type {unknown} */ + let body; + try { + body = JSON.parse(text); + } catch { + return text; + } + if (!body || typeof body !== "object" || Array.isArray(body)) return text; + const record = /** @type {Record} */ (body); + if (!Array.isArray(record.warnings)) return text; + const { warnings: _warnings, ...rest } = record; + return JSON.stringify(rest); +} + /** @param {string} text */ function deployErrorHint(text) { /** @type {unknown} */ diff --git a/commands/doctor.js b/commands/doctor.js index 1214997..3d3fa39 100644 --- a/commands/doctor.js +++ b/commands/doctor.js @@ -192,13 +192,18 @@ function checkTokenStore(state) { "only. A store file on disk, if any, stays readable by project build code.", }); } - let count = 0; + /** @type {ReturnType} */ + let store; try { - const store = readTokenStore(tokenStorePath(state.env)); - count = Object.keys(store.namespaces || {}).length; - } catch { - // A corrupt/unreadable store isn't a doctor failure; report it as absent. + store = readTokenStore(tokenStorePath(state.env)); + } catch (err) { + return check({ + ok: false, + label: "Token store", + detail: err instanceof Error && err.message ? err.message : String(err), + }); } + const count = Object.keys(store.namespaces || {}).length; if (count === 0) { return check({ ok: true, label: "Token store none" }); } diff --git a/commands/token.js b/commands/token.js index 3af6fb4..70a7b49 100644 --- a/commands/token.js +++ b/commands/token.js @@ -10,7 +10,7 @@ import { defineCommand } from "../lib/command.js"; import { CliError, defineCliOption, formatHelp, isMain, optionHelp } from "../lib/common.js"; import { flagSet, resolveControlUrl, warnIfInsecureControlUrl } from "../lib/credentials.js"; import { readSecretStdin } from "../lib/stdin.js"; -import { escapeTerminalText, maskToken, shellSingleQuote, writeResult, writeStatusLine } from "../lib/output.js"; +import { escapeTerminalText, maskToken, shellArgForDisplay, writeResult, writeStatusLine } from "../lib/output.js"; import { isAdminAcceptableNs } from "../lib/ns-pattern.js"; import { fetchWhoami, namespaceFromPrincipal } from "../lib/whoami.js"; import { readTokenStore, tokenStorePath, updateTokenStore } from "../lib/token-store.js"; @@ -220,7 +220,7 @@ function updateStoredNamespace(storePath, ns, command, mutate) { */ function throwMissingStoredNamespace(ns, command) { const escaped = escapeTerminalText(ns); - const quoted = escapeTerminalText(shellSingleQuote(ns)); + const quoted = shellArgForDisplay(ns); throw new CliError( command === "use" ? `no stored token for namespace "${escaped}" — run \`wdl token set --ns ${quoted}\` first` diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index 5347ac8..656a573 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -120,6 +120,7 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 | `experimental_compat_flag_unsupported` | 移除 experimental workerd compatibility flag。 | | `python_workers_unsupported` | WDL 不支持 Python Workers;移除 Python Worker modules。CLI 也会对本地 `.py` module fail-fast。 | | `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` 然后在那边修。 | | 部署成功但 promote 失败 | 自定义主机或服务绑定的目标校验问题;检查绑定目标。 | diff --git a/docs/deploy.md b/docs/deploy.md index 0165173..159dff9 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -211,6 +211,7 @@ Deleting a worker does **not** delete R2 data — see [r2.md](./r2.md). | `experimental_compat_flag_unsupported` | Remove the experimental workerd compatibility flag. | | `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. | | Deploy succeeds but promote fails | Custom host or service-binding target validation issue; check the binding targets. | diff --git a/lib/output.js b/lib/output.js index 4985094..642fdc2 100644 --- a/lib/output.js +++ b/lib/output.js @@ -123,6 +123,11 @@ export function shellSingleQuote(value) { return `'${String(value).replaceAll("'", `'\\''`)}'`; } +/** @param {unknown} value */ +export function shellArgForDisplay(value) { + return escapeTerminalText(shellSingleQuote(value)); +} + // Canonical machine-JSON output: one place defines the format so the JSON from // writeResult / writeJsonOr can't drift apart. /** @@ -144,9 +149,8 @@ export function writeResult(json, body, format, stdout) { writeJson(stdout, body); return; } - // Choke point: every human-readable command output flows through here, so - // neutralize control sequences in control-plane-derived fields once instead - // of per format helper. JSON (`--json`) is machine output and left raw. + // Choke point for tabular/list-style human output. One-off status lines use + // writeStatusLine; JSON (`--json`) is machine output and left raw. for (const line of format()) stdout(escapeTerminalLines(line)); } diff --git a/lib/token-store.js b/lib/token-store.js index 717fe97..c9c8b55 100644 --- a/lib/token-store.js +++ b/lib/token-store.js @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { CliError, hasErrorCode, isNonEmptyString } from "./common.js"; import { parseDotEnvSection, parseDotEnvValue, quoteValue } from "./dotenv.js"; -import { escapeTerminalText, shellSingleQuote } from "./output.js"; +import { escapeTerminalText, shellArgForDisplay } from "./output.js"; // The global credential store is the lowest-precedence layer of the same // credential model as a project `.env`: a `dotenv`/INI-subset file in the @@ -155,7 +155,7 @@ export function assertStoreDirSecure(storeDir, platform = process.platform) { if (platform === "win32") return; if ((statSync(storeDir).mode & 0o022) !== 0) { const escapedDir = escapeTerminalText(storeDir); - const quotedDir = escapeTerminalText(shellSingleQuote(storeDir)); + const quotedDir = shellArgForDisplay(storeDir); throw new CliError( `refusing to write credentials: ${escapedDir} is group/world-writable; restrict it with \`chmod 700 ${quotedDir}\`` ); diff --git a/tests/unit/cli-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index e1ce2ed..b2ca3a8 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -399,6 +399,36 @@ test("doctor reports the token store namespace count and the build-readable cave }); }); +test("doctor reports a corrupt token store as a failed check", async () => { + await withTempDir(async (cwd) => { + writeFileSync(path.join(cwd, "wrangler.jsonc"), "{}"); + const xdg = path.join(cwd, "xdg"); + const storePath = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + mkdirSync(path.dirname(storePath), { recursive: true }); + writeFileSync(storePath, "[demo]\nADMIN_TOKEN=\"unterminated\n"); + /** @type {string[]} */ + const lines = []; + + await assert.rejects( + () => runDoctorCommand(["--strict", "--ns", "demo", "--control-url", "http://ctl.test", "--token", "secret-token"], { + cwd, + env: { XDG_CONFIG_HOME: xdg }, + execFile: () => "4.94.0\n", + /** @param {string} line */ + stdout: (line) => lines.push(line), + controlFetch: async () => + response({ ok: true, principal: { kind: "ns", ns: "demo" }, urls: { control: "http://ctl.test" } }), + }), + /doctor checks failed/ + ); + + const out = lines.join("\n"); + assert.match(out, /✗ Token store/); + assert.match(out, /Invalid \.env value: missing closing quote/); + assert.doesNotMatch(out, /Token store none/); + }); +}); + test("doctor honors --no-token-store: reports the store disabled without reading it", async () => { await withTempDir(async (cwd) => { const xdg = path.join(cwd, "xdg"); diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 8923942..5721acf 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -2589,10 +2589,17 @@ test("runDeployCommand renders deploy warnings from error responses", async () = binding: "PAYMENT", platform: "STRIPE", missingCallerSecrets: ["API_KEY"], + internalTaskId: "task-secret", }], }, 400), }), - /deploy failed: 400 deploy_failed: missing caller secrets/ + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /deploy failed: 400 deploy_failed: missing caller secrets/); + assert.doesNotMatch(message, /warnings=/); + assert.doesNotMatch(message, /task-secret/); + return true; + } ); assert.equal(warnings.length, 1); diff --git a/tests/unit/cli-token-store.test.js b/tests/unit/cli-token-store.test.js index 44c3af9..e33704e 100644 --- a/tests/unit/cli-token-store.test.js +++ b/tests/unit/cli-token-store.test.js @@ -12,6 +12,7 @@ import { updateTokenStore, writeTokenStore, } from "../../lib/token-store.js"; +import { assertNoRawTerminalControls } from "./helpers.js"; /** * @template T @@ -658,8 +659,7 @@ test("assertStoreDirSecure refuses a group/world-writable store dir", () => { (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /bad\\u001bdir\\nFORGED\\rBAD/); - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not reach the error"); + assertNoRawTerminalControls(message, "the error"); return true; } ); @@ -684,8 +684,7 @@ test("updateTokenStore escapes write-side filesystem errors", () => { const message = /** @type {Error} */ (err).message; assert.match(message, /failed to update credential store/); assert.match(message, /bad\\u001bdir\\nFORGED\\rBAD/); - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not reach the error"); + assertNoRawTerminalControls(message, "the error"); return true; } ); @@ -704,8 +703,7 @@ test("writeTokenStore escapes write-side filesystem errors", () => { const message = /** @type {Error} */ (err).message; assert.match(message, /failed to write credential store/); assert.match(message, /bad\\u001bdir\\nFORGED\\rBAD/); - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not reach the error"); + assertNoRawTerminalControls(message, "the error"); return true; } ); diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index b0a182a..8be076a 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { runTokenCommand } from "../../commands/token.js"; import { readTokenStore, tokenStorePath, writeTokenStore } from "../../lib/token-store.js"; -import { response } from "./helpers.js"; +import { assertNoRawTerminalControls, response } from "./helpers.js"; const ESC = String.fromCharCode(27); @@ -361,8 +361,7 @@ test("token list escapes terminal controls inside table cells", async () => { await runTokenCommand(["list"], d); const out = lines.join("\n"); - assert.doesNotMatch(out, new RegExp(ESC), "raw ESC must not reach token list output"); - assert.doesNotMatch(out, /\nFORGED|\rBAD/, "raw line controls must not forge table rows"); + assertNoRawTerminalControls(out, "token list output"); assert.match(out, /prod\\u001b\[2J\\nFORGED/); assert.match(out, /https:\/\/api\.example\\nFORGED\\rBAD/); }); @@ -379,8 +378,7 @@ test("token list escapes credential-store read errors", async () => { const message = /** @type {Error} */ (err).message; assert.match(message, /failed to read credential store/); assert.match(message, /bad\\u001bdir\\nFORGED\\rBAD/); - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not reach the error"); + assertNoRawTerminalControls(message, "the error"); return true; } ); diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js index c5e3da4..e344b41 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -1,12 +1,20 @@ // Shared fixtures for the CLI unit tests. Not a test file itself (the test // runner only globs cli-*.test.js). +import assert from "node:assert/strict"; + /** * A recorded control-plane call: the URL and the init passed to controlFetch. * Shared by the tests that assert on what mockDeps recorded. * @typedef {{ url: string, init: import("../../lib/control-fetch.js").ControlFetchInit }} ControlCall */ +/** @param {string} text @param {string} [target] */ +export function assertNoRawTerminalControls(text, target = "output") { + assert.doesNotMatch(text, new RegExp(String.fromCharCode(27)), `raw ESC must not reach ${target}`); + assert.doesNotMatch(text, /\nFORGED|\rBAD/, `raw line controls must not reach ${target}`); +} + // A minimal fetch Response stand-in. Accepts an object (JSON) or string body // and exposes json()/text()/arrayBuffer() so it works for control-plane JSON // responses and R2 streaming/byte tests alike. json() parses the text From adbe1ac1ada697c717da006c5bb6364d778804f8 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Thu, 9 Jul 2026 20:53:48 +0800 Subject: [PATCH 06/12] Close CLI branch review findings Escape remaining config diagnostics, restore dispatcher/version fallbacks, handle token-store doctor failures, and align secret-envelope deploy/docs guidance. Validation: git diff --cached --check; npm run typecheck; npm run lint; npm test; npm audit --audit-level=moderate; npm pack --dry-run. Signed-off-by: Lu Zhang --- CHANGELOG.md | 4 +- GUIDE-zh.md | 1 + GUIDE.md | 4 ++ bin/wdl.js | 9 +++-- commands/deploy.js | 4 ++ commands/doctor.js | 24 ++++++++--- commands/secret.js | 14 ++----- docs/secrets-zh.md | 4 +- docs/secrets.md | 8 +++- lib/config-state.js | 29 +++++++++++++- lib/control-fetch.js | 3 +- lib/credentials.js | 11 ++++-- lib/package-info.js | 13 +++++- lib/secret-envelope-errors.js | 9 +++++ lib/wrangler-pack.js | 2 +- lib/wrangler/config.js | 32 +++++++++------ tests/unit/cli-config-doctor.test.js | 34 ++++++++++++++++ tests/unit/cli-control-fetch.test.js | 34 +++++++++++++++- tests/unit/cli-credentials.test.js | 35 +++++++++++++++++ tests/unit/cli-deploy.test.js | 59 ++++++++++++++++++++++++++++ tests/unit/cli-lifecycle.test.js | 21 +++++++++- 21 files changed, 307 insertions(+), 47 deletions(-) create mode 100644 lib/secret-envelope-errors.js diff --git a/CHANGELOG.md b/CHANGELOG.md index dc5751b..c248887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ remains canonical for runtime/workerd bundle errors, while the CLI still rejects cheap local cases like Python Worker modules and ambiguous runtime env name collisions between `[vars]`, explicit bindings, and the implicit - `ASSETS` binding. + `ASSETS` binding. Deploy also fails fast on unmapped Wrangler runtime/deploy + keys such as `[site]`, `workers_dev`, `pages_build_output_dir`, + `observability`, `limits`, and `placement` instead of silently dropping them. - `wdl tail` now recognizes control-initiated `session_idle` / `session_expired` stream recycling and reconnects without presenting it as an unknown warning. diff --git a/GUIDE-zh.md b/GUIDE-zh.md index 06add5e..aa8f849 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -505,6 +505,7 @@ printf '%s' "$DATABASE_URL" | wdl secret put --scope ns DATABASE_URL - 已有线上版本的 Worker 修改 worker-level secret 时,平台会自动创建并 promote 一个新版本,因此新流量会 cold-load 更新后的 secret。已经加载的历史版本可能继续持有旧值,直到 runtime eviction 或 recycle。 - worker-level secret 修改是原子的。如果 mutation 期间 active version 变化,control 会返回 `secret_mutation_contention`,CLI 会要求重试,而不是留下"已存储但未 promote"的半成功状态。 +- `secret_encryption_unconfigured`、`secret_decrypt_failed`、`invalid_envelope`、`unsupported_envelope`、`unknown_kid` 或 `secret_not_encrypted` 这类 secret-envelope 错误表示 mutation 没有写入;等运维侧修复 envelope 配置或已存储数据后再重试。 - worker-level secret 可以在第一次部署前设置;第一次部署会读取这些 secret。 - namespace-level secret 会共享给 namespace 下的所有 Worker,但不会批量 bump 所有 Worker。它会在下一次自然 cold-load 时生效,例如新部署、runtime recycle 或 isolate eviction。 - secret key 必须符合环境变量命名规则,例如 `STRIPE_KEY`;value 最大 64 KiB,并且和 `[vars]` 一样计入 workerLoader env budget。 diff --git a/GUIDE.md b/GUIDE.md index 228d4e0..236cf7f 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -727,6 +727,10 @@ Effect timing: - Worker-level secret changes are atomic. If the active version changes during the mutation, control returns `secret_mutation_contention` and the CLI asks you to retry instead of leaving a stored-but-not-promoted partial update. +- Secret-envelope errors such as `secret_encryption_unconfigured`, + `secret_decrypt_failed`, `invalid_envelope`, `unsupported_envelope`, + `unknown_kid`, or `secret_not_encrypted` mean the mutation was not written; + retry after the operator repairs envelope configuration or stored data. - Worker-level secrets can be set before the first deploy; the first deploy will pick them up. - Namespace-level secret changes are shared by every Worker in the namespace, diff --git a/bin/wdl.js b/bin/wdl.js index 0407aff..3d82f37 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -30,7 +30,8 @@ const REGISTRY = [initCmd, deployCmd, secretCmd, workersCmd, deleteCmd, d1Cmd, r const ALIASES = { secrets: "secret" }; /** @type {Record} */ -const COMMANDS = Object.fromEntries(REGISTRY.map((c) => [c.meta.name, c])); +const COMMANDS = Object.create(null); +for (const c of REGISTRY) COMMANDS[c.meta.name] = c; for (const [alias, target] of Object.entries(ALIASES)) COMMANDS[alias] = COMMANDS[target]; // The pre-scan below needs each command's flag schema; a missing one would @@ -56,7 +57,7 @@ for (const c of REGISTRY) { export async function main(argv = process.argv.slice(2), deps = {}) { const [command, ...rest] = argv; - if (command === "help" && rest.length === 1 && COMMANDS[rest[0]]) { + if (command === "help" && rest.length === 1 && Object.hasOwn(COMMANDS, rest[0])) { return await COMMANDS[rest[0]].main(["--help"]); } if (!command || command === "-h" || command === "--help" || command === "help") { @@ -67,12 +68,12 @@ export async function main(argv = process.argv.slice(2), deps = {}) { console.log(currentCliVersion()); return; } - const commandModule = COMMANDS[command]; - if (!commandModule) { + if (!Object.hasOwn(COMMANDS, command)) { console.error(`error: unknown command: ${escapeTerminalText(command)}`); usage(1); return; } + const commandModule = COMMANDS[command]; const env = deps.env || process.env; const scanned = scanCommandArgs(commandModule, rest); diff --git a/commands/deploy.js b/commands/deploy.js index 05ae6ea..0ecb503 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -7,6 +7,7 @@ import { defineCommand } from "../lib/command.js"; import { CliError, defineCliOption, formatHelp, formatHttpError, isMain, optionHelp, readJsonOrFail, unexpectedArgument } from "../lib/common.js"; import { escapeTerminalText, formatKnownWarning, shellArgForDisplay, writeStatusLine } from "../lib/output.js"; import { isLocalDevHost } from "../lib/credentials.js"; +import { isSecretEnvelopeErrorCode } from "../lib/secret-envelope-errors.js"; import { packWranglerProject } from "../lib/wrangler-pack.js"; export const DEPLOY_JSON_BODY_MAX_BYTES = 32 * 1024 * 1024; @@ -207,6 +208,9 @@ function deployErrorHint(text) { if (error === "experimental_compat_flag_unsupported") { return "; remove the unsupported workerd experimental compatibility flag."; } + if (isSecretEnvelopeErrorCode(error)) { + return "; secret-envelope configuration or stored secret data needs operator repair before retrying."; + } return ""; } diff --git a/commands/doctor.js b/commands/doctor.js index 3d3fa39..93c6c50 100644 --- a/commands/doctor.js +++ b/commands/doctor.js @@ -6,8 +6,8 @@ import { CliError, defineCliOption, formatHelp, isMain, isNonEmptyString, option import { warnIfInsecureControlUrl } from "../lib/credentials.js"; import { writeResult } from "../lib/output.js"; import { readTokenStore, tokenStorePath } from "../lib/token-store.js"; -import { resolveCliConfigState } from "../lib/config-state.js"; -import { CLI_ROOT, readCliPackageJson } from "../lib/package-info.js"; +import { TokenStoreConfigError, resolveCliConfigState } from "../lib/config-state.js"; +import { CLI_ROOT, currentCliVersion, readCliPackageJson } from "../lib/package-info.js"; import { ensureControlContextFromConfigState, fetchWhoami, @@ -55,7 +55,21 @@ async function runDoctor({ values, positionals, context: baseContext }) { if (positionals.length > 0) throw new CliError(usageText()); const context = /** @type {DoctorContext} */ (baseContext); - const state = resolveCliConfigState({ values, env: context.env, cwd: context.cwd, warn: context.warn }); + let tokenStoreError = null; + let state; + try { + state = resolveCliConfigState({ values, env: context.env, cwd: context.cwd, warn: context.warn }); + } catch (err) { + if (!(err instanceof TokenStoreConfigError)) throw err; + tokenStoreError = err.message; + state = resolveCliConfigState({ + values, + env: context.env, + cwd: context.cwd, + readStore: () => ({}), + warn: context.warn, + }); + } const checks = [ checkNode(), checkCliVersion(), @@ -63,7 +77,7 @@ async function runDoctor({ values, positionals, context: baseContext }) { checkControlUrl(state), checkToken(state), checkNamespace(state), - checkTokenStore(state), + tokenStoreError ? check({ ok: false, label: "Token store", detail: tokenStoreError }) : checkTokenStore(state), checkWranglerConfig(context.cwd), ]; const remote = await checkRemoteWhoami({ @@ -105,7 +119,7 @@ function checkNode() { function checkCliVersion() { return check({ ok: true, - label: `wdl-cli ${readCliPackageJson().version}`, + label: `wdl-cli ${currentCliVersion()}`, }); } diff --git a/commands/secret.js b/commands/secret.js index c35a0c6..487d7fd 100644 --- a/commands/secret.js +++ b/commands/secret.js @@ -6,6 +6,7 @@ import { defineCommand } from "../lib/command.js"; import { CliError, defineCliOption, formatHelp, formatHttpError, isMain, isNonEmptyString, optionHelp, readJsonOrFail, unexpectedArgument } from "../lib/common.js"; import { confirmAction, readSecretStdin } from "../lib/stdin.js"; import { escapeTerminalText, writeJsonOr, writeStatusLine } from "../lib/output.js"; +import { isSecretEnvelopeErrorCode } from "../lib/secret-envelope-errors.js"; const SECRET_OPTIONS = [ defineCliOption("worker", { type: "string" }, "--worker ", "Use worker-level secret scope."), @@ -160,21 +161,12 @@ function secretMutationHint(text) { if (error === "secret_mutation_contention" || error === "namespace_secret_mutation_contention") { return "; secret mutation was not written. Retry after concurrent worker metadata updates settle."; } - if (isSecretEnvelopeError(error)) { - return "; secret mutation was not written. A stored secret envelope needs operator repair before retrying."; + if (isSecretEnvelopeErrorCode(error)) { + return "; secret mutation was not written. Secret-envelope configuration or stored secret data needs operator repair before retrying."; } return ""; } -/** @param {unknown} error */ -function isSecretEnvelopeError(error) { - return error === "invalid_envelope" || - error === "secret_decrypt_failed" || - error === "secret_not_encrypted" || - error === "unsupported_envelope" || - error === "unknown_kid"; -} - function usageText() { return formatHelp({ usage: [ diff --git a/docs/secrets-zh.md b/docs/secrets-zh.md index cb1f639..bad89f0 100644 --- a/docs/secrets-zh.md +++ b/docs/secrets-zh.md @@ -10,7 +10,7 @@ - API key、token、签名密钥,凡是不该出现在 git diff 或构建产物里的东西。 -非敏感配置(问候字符串、功能开关、公开 URL)放 `wrangler.jsonc` / `wrangler.toml` 的 `[vars]`。 +非敏感配置(问候字符串、功能开关、公开 URL)放 `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` 的 `[vars]`。 ## 设置 @@ -50,6 +50,8 @@ worker 级 secret > 命名空间级 secret > [vars] Worker 级 secret mutation 是原子的:如果更新期间 active version 变化,control 会返回 `secret_mutation_contention`,CLI 会要求重试,而不是留下"已存储但未 promote"的半成功状态。Namespace secret mutation 在 retained worker metadata 持续变化时也可能返回 `namespace_secret_mutation_contention`。 +如果 secret mutation 返回 `secret_encryption_unconfigured`、`secret_decrypt_failed`、`invalid_envelope`、`unsupported_envelope`、`unknown_kid` 或 `secret_not_encrypted`,这次 mutation 没有写入。这类错误需要运维侧修复 secret-envelope 配置或已存储 envelope 数据;等运维确认修复后再重试。 + ## 约束 - Key 必须符合环境变量命名规范:`[A-Z_][A-Z0-9_]*` —— 例如 `STRIPE_KEY`、`API_TOKEN`、`SIGNING_SECRET`。 diff --git a/docs/secrets.md b/docs/secrets.md index 290d761..6c69255 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -15,7 +15,7 @@ visible to anyone with read access. or build output. Put non-sensitive configuration (greeting strings, feature flags, public URLs) -in `[vars]` in `wrangler.jsonc` / `wrangler.toml`. +in `[vars]` in `wrangler.json` / `wrangler.jsonc` / `wrangler.toml`. ## Set @@ -67,6 +67,12 @@ retry instead of leaving a stored-but-not-promoted partial update. Namespace secret mutations can similarly return `namespace_secret_mutation_contention` when retained worker metadata keeps changing. +If a secret mutation returns `secret_encryption_unconfigured`, +`secret_decrypt_failed`, `invalid_envelope`, `unsupported_envelope`, +`unknown_kid`, or `secret_not_encrypted`, the mutation was not written. These +are operator-side secret-envelope configuration or stored-data repair problems; +retry after the operator reports the envelope issue repaired. + ## Constraints - Keys must follow environment-variable grammar: `[A-Z_][A-Z0-9_]*` — e.g. diff --git a/lib/config-state.js b/lib/config-state.js index fe8439a..4a02efa 100644 --- a/lib/config-state.js +++ b/lib/config-state.js @@ -4,6 +4,14 @@ import { flagSet, isTokenStoreDisabled, loadCliControlEnv, protectedEnvKeys, res import { maskToken } from "./output.js"; import { tokenStoreReader } from "./token-store.js"; +export class TokenStoreConfigError extends CliError { + /** @param {string} message */ + constructor(message) { + super(message); + this.name = "TokenStoreConfigError"; + } +} + /** * A resolved config field with its display value and provenance. * @typedef {{ value: string | null, display: string, source: string, error: string | null }} ConfigEntry @@ -15,10 +23,11 @@ import { tokenStoreReader } from "./token-store.js"; * env?: NodeJS.ProcessEnv, * cwd?: string, * dotenvPath?: string, + * readStore?: (env: NodeJS.ProcessEnv) => import("./token-store.js").TokenStore, * warn?: (line: string) => void, * }} [options] */ -export function resolveCliConfigState({ values = {}, env = process.env, cwd = process.cwd(), dotenvPath = ".env", warn = () => {} } = {}) { +export function resolveCliConfigState({ values = {}, env = process.env, cwd = process.cwd(), dotenvPath = ".env", readStore, warn = () => {} } = {}) { const workingEnv = { ...env }; // The loader's helper, not a raw Object.keys set: diagnostics must resolve what // an operating command would. See protectedEnvKeys. @@ -47,13 +56,14 @@ export function resolveCliConfigState({ values = {}, env = process.env, cwd = pr // Resolved before the .env load (so WDL_TOKEN_STORE comes from the process env — // shell/CI — not a project .env) and surfaced so doctor reuses it. const tokenStoreDisabled = isTokenStoreDisabled(workingEnv, values["no-token-store"] === true); + const storeReader = readStore || tokenStoreReader(tokenStoreDisabled); loadCliControlEnv(workingEnv, { dotenvPath: resolvedDotenvPath, nsFromFlag: /** @type {string | undefined} */ (values.ns), tokenFromFlag: flagSet(values, "token"), controlUrlFromFlag: flagSet(values, "control-url"), protectedKeys, - readStore: tokenStoreReader(tokenStoreDisabled), + readStore: wrapTokenStoreReader(storeReader), onLoad: recordDotenvLoad, warn: () => {}, onCrossOrigin: warn, @@ -76,6 +86,21 @@ export function resolveCliConfigState({ values = {}, env = process.env, cwd = pr }; } +/** + * @param {(env: NodeJS.ProcessEnv) => import("./token-store.js").TokenStore} readStore + */ +function wrapTokenStoreReader(readStore) { + /** @param {NodeJS.ProcessEnv} env */ + return (env) => { + try { + return readStore(env); + } catch (err) { + const message = err instanceof Error && err.message ? err.message : String(err); + throw new TokenStoreConfigError(message); + } + }; +} + /** * @param {Record} values * @param {NodeJS.ProcessEnv} env diff --git a/lib/control-fetch.js b/lib/control-fetch.js index 2468094..a069839 100644 --- a/lib/control-fetch.js +++ b/lib/control-fetch.js @@ -249,7 +249,7 @@ export function controlRequestOptions(u, env = process.env) { // u.host carries the port when it isn't the scheme default (RFC 9112). headers: { Host: u.host, - "User-Agent": `wdl-cli/${currentCliVersion() || "unknown"}`, + "User-Agent": `wdl-cli/${currentCliVersion()}`, }, agent: false, }; @@ -300,6 +300,7 @@ function parseConnectHost(raw, defaultPort) { } const hostPort = /^([^:]+):(\d+)$/.exec(text); if (hostPort) return { host: hostPort[1], port: validPort(hostPort[2], raw) }; + if (isIP(text) === 6) return { host: text, port: defaultPort }; if (text.includes(":")) { throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}: use host:port or [ipv6]:port`); } diff --git a/lib/credentials.js b/lib/credentials.js index 45dd1de..28962e8 100644 --- a/lib/credentials.js +++ b/lib/credentials.js @@ -34,10 +34,10 @@ export function resolveControlUrl(values, env = process.env) { try { parsed = new URL(normalized); } catch { - throw new CliError(`Invalid control URL ${JSON.stringify(raw)}.`); + throw new CliError(`Invalid control URL ${formatDiagnosticValue(raw)}.`); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new CliError(`Invalid control URL ${JSON.stringify(raw)}: expected http:// or https://.`); + throw new CliError(`Invalid control URL ${formatDiagnosticValue(raw)}: expected http:// or https://.`); } return normalized; } @@ -233,7 +233,7 @@ export function loadCliDotEnv( if (nextSection !== null) { section = nextSection; if (!isAdminAcceptableNs(section)) { - throw new CliError(`Invalid .env line ${idx + 1}: invalid section name "${section}"`); + throw new CliError(`Invalid .env line ${idx + 1}: invalid section name "${escapeTerminalText(section)}"`); } continue; } @@ -388,6 +388,11 @@ export function isTokenStoreDisabled(env, flag = false) { return isNonEmptyString(env.WDL_TOKEN_STORE) && env.WDL_TOKEN_STORE.toLowerCase() === "off"; } +/** @param {unknown} value */ +function formatDiagnosticValue(value) { + return escapeTerminalText(JSON.stringify(value)); +} + // Only the control-plane endpoint and token are materialized into env from a // store section; LABEL is store-only metadata for `wdl token list`. /** @type {readonly ["CONTROL_URL", "ADMIN_TOKEN"]} */ diff --git a/lib/package-info.js b/lib/package-info.js index 4cb87bc..b6e397a 100644 --- a/lib/package-info.js +++ b/lib/package-info.js @@ -3,13 +3,22 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; export const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +/** @type {string | null} */ +let cachedCliVersion = null; /** @returns {{ version?: string, [key: string]: unknown }} */ export function readCliPackageJson() { return JSON.parse(readFileSync(path.join(CLI_ROOT, "package.json"), "utf8")); } -/** @returns {string | undefined} */ +/** @returns {string} */ export function currentCliVersion() { - return readCliPackageJson().version; + if (cachedCliVersion !== null) return cachedCliVersion; + try { + const version = readCliPackageJson().version; + cachedCliVersion = typeof version === "string" && version ? version : "unknown"; + } catch { + cachedCliVersion = "unknown"; + } + return cachedCliVersion; } diff --git a/lib/secret-envelope-errors.js b/lib/secret-envelope-errors.js new file mode 100644 index 0000000..88c210c --- /dev/null +++ b/lib/secret-envelope-errors.js @@ -0,0 +1,9 @@ +/** @param {unknown} error */ +export function isSecretEnvelopeErrorCode(error) { + return error === "invalid_envelope" || + error === "secret_decrypt_failed" || + error === "secret_encryption_unconfigured" || + error === "secret_not_encrypted" || + error === "unsupported_envelope" || + error === "unknown_kid"; +} diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index c08487f..3804d71 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -374,7 +374,7 @@ export function collectRoutes(cfg, configRel) { if (typeof r === "string") collected.push(r); else if (r && typeof r === "object" && typeof (/** @type {Record} */ (r).pattern) === "string") { collected.push(/** @type {string} */ (/** @type {Record} */ (r).pattern)); - } else throw new CliError(`unsupported ${source} entry: ${JSON.stringify(r)}`); + } else throw new CliError(`unsupported ${source} entry: ${escapeTerminalText(JSON.stringify(r))}`); }; if (cfg.route !== undefined && cfg.routes !== undefined) { throw new CliError(`${configRel}: specify either "route" or "routes", not both`); diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index e48e32b..0145459 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { parse as parseToml } from "smol-toml"; +import { escapeTerminalText } from "../output.js"; import { asRecord } from "./utils.js"; /** @@ -125,10 +126,10 @@ export function loadWranglerConfig(dir) { return { path: p, cfg: JSON.parse(raw), shadowed }; } catch (err) { const message = err instanceof Error && err.message ? err.message : String(err); - throw new Error(`failed to parse ${name}: ${message}`, { cause: err }); + throw new Error(`failed to parse ${escapeTerminalText(name)}: ${escapeTerminalText(message)}`, { cause: err }); } } - throw new Error(`no ${WRANGLER_CONFIG_CANDIDATES.join(", ")} found in ${dir}`); + throw new Error(`no ${WRANGLER_CONFIG_CANDIDATES.join(", ")} found in ${escapeTerminalText(dir)}`); } /** @@ -150,16 +151,18 @@ export function validateUnsupportedWranglerConfig(rawCfg, envName, configRel = " const envTable = cfg ? asRecord(cfg.env) : null; const selectedEnvCfg = envName && envTable ? asRecord(envTable[envName]) : null; + const shownConfig = escapeTerminalText(configRel); + const shownEnv = escapeTerminalText(envName ?? ""); for (const key of UNSUPPORTED_WRANGLER_KEYS) { if (hasConfiguredKey(cfg, key)) { throw new Error( - `${configRel} uses [${key}] — not supported. ${SUPPORTED_WRANGLER_SUMMARY}` + `${shownConfig} uses [${key}] — not supported. ${SUPPORTED_WRANGLER_SUMMARY}` ); } if (hasConfiguredKey(selectedEnvCfg, key)) { throw new Error( - `${configRel} env.${envName} uses [${key}] — not supported. ${SUPPORTED_WRANGLER_SUMMARY}` + `${shownConfig} env.${shownEnv} uses [${key}] — not supported. ${SUPPORTED_WRANGLER_SUMMARY}` ); } } @@ -170,10 +173,10 @@ export function validateUnsupportedWranglerConfig(rawCfg, envName, configRel = " 'Authorize cross-namespace service-binding callers on the target via ' + '[[exports]] (entrypoint = "default", allowed_callers = [...]).'; if (hasConfiguredValue(cfg?.allowed_callers)) { - throw new Error(`${configRel} uses top-level allowed_callers — removed. ${allowedCallersHint}`); + throw new Error(`${shownConfig} uses top-level allowed_callers — removed. ${allowedCallersHint}`); } if (hasConfiguredValue(selectedEnvCfg?.allowed_callers)) { - throw new Error(`${configRel} env.${envName} uses top-level allowed_callers — removed. ${allowedCallersHint}`); + throw new Error(`${shownConfig} env.${shownEnv} uses top-level allowed_callers — removed. ${allowedCallersHint}`); } } @@ -184,16 +187,19 @@ export function validateUnsupportedWranglerConfig(rawCfg, envName, configRel = " * @returns {{ cfg: WranglerConfig, envName: string | null }} */ export function resolveWranglerConfig(rawCfg, envName, configRel = "wrangler config") { + const shownConfig = escapeTerminalText(configRel); + const shownEnv = escapeTerminalText(envName ?? ""); if (!rawCfg || typeof rawCfg !== "object" || Array.isArray(rawCfg)) { - throw new Error(`${configRel}: config must be an object`); + throw new Error(`${shownConfig}: config must be an object`); } const cfg = /** @type {WranglerConfig} */ (rawCfg); const availableEnvs = listNamedEnvironments(cfg); + const shownAvailableEnvs = availableEnvs.map(escapeTerminalText).join(", "); if (!envName) { if (availableEnvs.length) { throw new Error( - `${configRel}: named environments found (${availableEnvs.join(", ")}); ` + + `${shownConfig}: named environments found (${shownAvailableEnvs}); ` + `pass --env or set CLOUDFLARE_ENV` ); } @@ -201,25 +207,25 @@ export function resolveWranglerConfig(rawCfg, envName, configRel = "wrangler con } if (!availableEnvs.length) { - throw new Error(`${configRel}: environment "${envName}" requested but no [env] config exists`); + throw new Error(`${shownConfig}: environment "${shownEnv}" requested but no [env] config exists`); } const envTable = asRecord(cfg.env); if (!envTable || !Object.hasOwn(envTable, envName)) { throw new Error( - `${configRel}: environment "${envName}" not found ` + - `(available: ${availableEnvs.join(", ")})` + `${shownConfig}: environment "${shownEnv}" not found ` + + `(available: ${shownAvailableEnvs})` ); } const envCfg = asRecord(envTable[envName]); if (!envCfg) { - throw new Error(`${configRel}: env.${envName} must be an object/table`); + throw new Error(`${shownConfig}: env.${shownEnv} must be an object/table`); } for (const key of Object.keys(envCfg)) { if (TOP_LEVEL_ONLY_ENV_KEYS.has(key)) { - throw new Error(`${configRel}: env.${envName}.${key} is top-level only`); + throw new Error(`${shownConfig}: env.${shownEnv}.${key} is top-level only`); } } diff --git a/tests/unit/cli-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index b2ca3a8..956c927 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -429,6 +429,40 @@ test("doctor reports a corrupt token store as a failed check", async () => { }); }); +test("doctor reports a corrupt token store even when it blocks credential resolution", async () => { + await withTempDir(async (cwd) => { + writeFileSync(path.join(cwd, "wrangler.jsonc"), "{}"); + const xdg = path.join(cwd, "xdg"); + const storePath = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + mkdirSync(path.dirname(storePath), { recursive: true }); + writeFileSync(storePath, "[demo]\nADMIN_TOKEN=\"unterminated\n"); + /** @type {string[]} */ + const lines = []; + + await assert.rejects( + () => runDoctorCommand(["--strict"], { + cwd, + env: { XDG_CONFIG_HOME: xdg, WDL_NS: "demo" }, + execFile: () => "4.94.0\n", + /** @param {string} line */ + stdout: (line) => lines.push(line), + controlFetch: async () => { + throw new Error("whoami should be skipped without resolved credentials"); + }, + }), + /doctor checks failed/ + ); + + const out = lines.join("\n"); + assert.match(out, /✗ Token store/); + assert.match(out, /Invalid \.env value: missing closing quote/); + assert.match(out, /✗ ADMIN_TOKEN/); + assert.match(out, /✗ CONTROL_URL/); + assert.doesNotMatch(out, /Token store disabled/); + assert.doesNotMatch(out, /Control \/whoami/); + }); +}); + test("doctor honors --no-token-store: reports the store disabled without reading it", async () => { await withTempDir(async (cwd) => { const xdg = path.join(cwd, "xdg"); diff --git a/tests/unit/cli-control-fetch.test.js b/tests/unit/cli-control-fetch.test.js index c34dc82..28bc1f7 100644 --- a/tests/unit/cli-control-fetch.test.js +++ b/tests/unit/cli-control-fetch.test.js @@ -40,7 +40,7 @@ test("readControlResponse rejects oversized response bodies", async () => { await assert.rejects(promise, /control response exceeded 3 bytes/); }); -test("default buffered control response cap matches platform response cap", () => { +test("default buffered control response cap stays conservative", () => { assert.equal(DEFAULT_CONTROL_MAX_BODY_BYTES, 16 * 1024 * 1024); }); @@ -431,6 +431,38 @@ test("controlFetch uses init env for CONTROL_CONNECT_HOST overrides", async () = assert.equal(opts.headers.Host, "admin.test:8080"); }); +test("controlFetch accepts bare IPv6 CONTROL_CONNECT_HOST overrides", async () => { + /** @type {import("node:https").RequestOptions | null} */ + let seen = null; + /** @type {import("../../lib/control-fetch.js").ControlTransport} */ + const transport = { + request(opts, onResponse) { + seen = opts; + return Object.assign(new EventEmitter(), { + write() {}, + end() { + const res = fakeResponse(); + onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); + res.emit("data", Buffer.from("{}")); + res.emit("end"); + }, + destroy() {}, + }); + }, + }; + + await controlFetch("https://ctl.example/whoami", { + env: { CONTROL_CONNECT_HOST: "::1" }, + transport, + }); + + assert.ok(seen); + const opts = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen); + assert.equal(opts.host, "::1"); + assert.equal(opts.port, 443); + assert.equal(opts.headers.Host, "ctl.example"); +}); + test("controlFetch rejects invalid CONTROL_CONNECT_HOST values before opening a socket", () => { const oldConnectHost = process.env.CONTROL_CONNECT_HOST; try { diff --git a/tests/unit/cli-credentials.test.js b/tests/unit/cli-credentials.test.js index f7a2676..3a80a78 100644 --- a/tests/unit/cli-credentials.test.js +++ b/tests/unit/cli-credentials.test.js @@ -5,6 +5,8 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { isTokenStoreDisabled, loadCliControlEnv, loadCliDotEnv, protectedEnvKeys, resolveControlContext, resolveControlUrl, resolveNamespace, warnIfInsecureControlUrl } from "../../lib/credentials.js"; +const ESC = String.fromCharCode(27); + test("isTokenStoreDisabled honors the flag and WDL_TOKEN_STORE=off", () => { assert.equal(isTokenStoreDisabled({}, false), false); assert.equal(isTokenStoreDisabled({}, true), true); @@ -33,6 +35,19 @@ test("resolveControlUrl requires a configured endpoint", () => { assert.throws(() => resolveControlUrl({}, {}), /No control URL configured/); }); +test("resolveControlUrl escapes invalid endpoint diagnostics", () => { + assert.throws( + () => resolveControlUrl({ "control-url": `ftp://ctl.test/${ESC}[2J\u009b` }, {}), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /Invalid control URL/); + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach control URL errors"); + assert.doesNotMatch(message, /\u009b/, "raw C1 controls must not reach control URL errors"); + return true; + } + ); +}); + test("resolveControlUrl accepts bare control hosts as https URLs", () => { assert.equal( resolveControlUrl({ "control-url": "ctl.example" }, {}), @@ -190,6 +205,26 @@ test("loadCliDotEnv rejects malformed quoted values", () => { } }); +test("loadCliDotEnv escapes invalid section names", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-env-section-")); + const file = path.join(dir, ".env"); + try { + writeFileSync(file, `[BAD${ESC}[2J\u009b]\nADMIN_TOKEN=tok\n`); + assert.throws( + () => loadCliDotEnv(emptyEnv(), file), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /invalid section name/); + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach section errors"); + assert.doesNotMatch(message, /\u009b/, "raw C1 controls must not reach section errors"); + return true; + } + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("loadCliDotEnv ignores non-WDL dotenv lines it cannot parse", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-env-")); const file = path.join(dir, ".env"); diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 5721acf..486c7d3 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -483,6 +483,16 @@ test("collectRoutes: accepts strings and { pattern } tables, rejects non-arrays" () => collectRoutes({ route: "a", routes: ["b"] }, "wrangler.toml"), /specify either "route" or "routes"/ ); + assert.throws( + () => collectRoutes({ routes: [{ bad: `x${ESC}[2J\nFORGED\rBAD` }] }, "wrangler.toml"), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /unsupported routes entry/); + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach route errors"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge route errors"); + return true; + } + ); }); test("parseKvNamespacesFromCfg: validates shape and non-empty string binding/id", () => { @@ -992,6 +1002,25 @@ test("loadWranglerConfig: parses JSONC when TOML is absent", () => { } }); +test("loadWranglerConfig: escapes parser diagnostics from config files", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-config-bad-")); + try { + writeFileSync(path.join(dir, "wrangler.toml"), `name = "bad${ESC}[2J\nFORGED\rBAD"\nmain =\n`); + assert.throws( + () => loadWranglerConfig(dir), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /failed to parse wrangler\.toml/); + assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach config parse errors"); + assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge config parse errors"); + return true; + } + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("installTempFileCleanup removes temp files on process exit and signals", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-temp-cleanup-")); try { @@ -2644,6 +2673,36 @@ test("runDeployCommand explains deploy env-budget failures at the command layer" } }); +test("runDeployCommand explains secret-envelope deploy failures at the command layer", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-secret-envelope-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); + + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => response({ + error: "secret_encryption_unconfigured", + message: "provider missing", + }, 503), + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /secret_encryption_unconfigured/); + assert.match(message, /Secret-envelope configuration or stored secret data needs operator repair/i); + return true; + } + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("runDeployCommand keeps worker_code_invalid hints generic", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-code-invalid-")); try { diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index 5bb49e7..e643216 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -845,7 +845,21 @@ test("secret mutation errors explain retry and operator-repair cases", async () }, 503), } ), - /stored secret envelope needs operator repair/ + /Secret-envelope configuration or stored secret data needs operator repair/ + ); + await assert.rejects( + () => runSecretCommand( + ["put", "--ns", "demo", "--worker", "api", "KEY", "--control-url", "http://ctl.test"], + { + env: { ADMIN_TOKEN: "tok" }, + stdin: stdinFrom("secret-value\n"), + controlFetch: async () => response({ + error: "secret_encryption_unconfigured", + message: "provider missing", + }, 503), + } + ), + /Secret-envelope configuration or stored secret data needs operator repair/ ); }); @@ -1492,6 +1506,11 @@ test("wdl dispatcher routes documented commands and rejects unknown commands", a await assert.rejects(() => wdlMain(["worker-list"], { loadEnv: null }), /exit:1/); assert.ok(seen.some((line) => line.includes("unknown command: worker-list"))); + + await assert.rejects(() => wdlMain(["toString"], { loadEnv: null }), /exit:1/); + assert.ok(seen.some((line) => line.includes("unknown command: toString"))); + await assert.rejects(() => wdlMain(["help", "toString"], { loadEnv: null }), /exit:0/); + assert.doesNotMatch(seen.join("\n"), /TypeError|COMMANDS\[|\.main/); } finally { process.exit = oldExit; console.error = oldError; From 0aece3a8009e3681b813f2f4cf21f2312e1f38da Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Fri, 10 Jul 2026 00:29:38 +0800 Subject: [PATCH 07/12] Harden CLI diagnostics for WDL adapter Adapt the CLI to the WDL 20260701/audit baseline by keeping control-canonical behavior where appropriate, centralizing terminal-safe diagnostic formatting, escaping control/config/filesystem-derived diagnostics across the touched paths, syncing help/docs coverage, and adding focused regression tests. Signed-off-by: Lu Zhang --- GUIDE-zh.md | 2 + GUIDE.md | 3 + README-zh.md | 2 +- README.md | 2 +- lib/control-fetch.js | 7 +- lib/credentials.js | 7 +- lib/output.js | 5 + lib/wrangler-pack.js | 18 +- lib/wrangler/assets.js | 133 +++++++++-- lib/wrangler/bindings.js | 119 ++++++---- lib/wrangler/command.js | 8 +- lib/wrangler/modules.js | 42 +++- tests/unit/cli-control-fetch.test.js | 149 ++++-------- tests/unit/cli-credentials.test.js | 9 +- tests/unit/cli-d1.test.js | 16 +- tests/unit/cli-deploy.test.js | 324 ++++++++++++++++++++++++++- tests/unit/cli-lifecycle.test.js | 16 +- tests/unit/helpers.js | 13 +- 18 files changed, 630 insertions(+), 245 deletions(-) diff --git a/GUIDE-zh.md b/GUIDE-zh.md index aa8f849..ce50241 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -50,6 +50,8 @@ npm link node /path/to/cli/bin/wdl.js deploy ./my-worker ``` +查看单个命令帮助时,可以运行 `wdl --help` 或 `wdl help `。 + ### 配置默认值 推荐的做法是 `wdl token` 存储(见下文);凭证也可以来自本机或 CI 的 shell: diff --git a/GUIDE.md b/GUIDE.md index 236cf7f..050ba43 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -58,6 +58,9 @@ If you do not want to link the CLI globally, call the entrypoint directly: node /path/to/cli/bin/wdl.js deploy ./my-worker ``` +For command-specific help, run `wdl --help` or +`wdl help `. + ### Configure Defaults The recommended setup is the `wdl token` store (see below); credentials can also diff --git a/README-zh.md b/README-zh.md index 1471661..518e5c0 100644 --- a/README-zh.md +++ b/README-zh.md @@ -77,7 +77,7 @@ wdl r2 buckets list / wdl r2 objects ... wdl workflows ... wdl delete worker [--dry-run] / wdl delete version wdl config explain / wdl doctor / wdl whoami [--json] -wdl --version / wdl --help +wdl --version / wdl --help / wdl help ``` 破坏性命令默认提示确认;只有自动化已核对过目标时才传 `--yes`。 diff --git a/README.md b/README.md index 26b10ec..f3d2e03 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ wdl r2 buckets list / wdl r2 objects ... wdl workflows ... wdl delete worker [--dry-run] / wdl delete version wdl config explain / wdl doctor / wdl whoami [--json] -wdl --version / wdl --help +wdl --version / wdl --help / wdl help ``` Destructive commands prompt for confirmation; pass `--yes` only in automation diff --git a/lib/control-fetch.js b/lib/control-fetch.js index a069839..22b81f5 100644 --- a/lib/control-fetch.js +++ b/lib/control-fetch.js @@ -10,7 +10,7 @@ import https from "node:https"; import { isIP } from "node:net"; import { Transform } from "node:stream"; import { CliError } from "./common.js"; -import { escapeTerminalText } from "./output.js"; +import { escapeTerminalText, formatDiagnosticValue } from "./output.js"; import { currentCliVersion } from "./package-info.js"; export const DEFAULT_CONTROL_TIMEOUT_MS = 30_000; @@ -319,11 +319,6 @@ function validPort(value, raw) { return port; } -/** @param {unknown} value */ -function formatDiagnosticValue(value) { - return escapeTerminalText(JSON.stringify(value)); -} - /** @param {URL} u */ function bareUrlHostname(u) { const match = /^\[(.*)\]$/.exec(u.hostname); diff --git a/lib/credentials.js b/lib/credentials.js index 28962e8..7ac5ec7 100644 --- a/lib/credentials.js +++ b/lib/credentials.js @@ -6,7 +6,7 @@ import { readFileSync } from "node:fs"; import { CliError, hasErrorCode, isNonEmptyString } from "./common.js"; import { CLI_DOTENV_KEYS, parseDotEnvSection, parseDotEnvValue } from "./dotenv.js"; import { isAdminAcceptableNs } from "./ns-pattern.js"; -import { escapeTerminalText } from "./output.js"; +import { escapeTerminalText, formatDiagnosticValue } from "./output.js"; // Control-plane endpoint keys: where the admin token gets sent. A cwd .env // must not redirect these for a token that came from the shell/--token. @@ -388,11 +388,6 @@ export function isTokenStoreDisabled(env, flag = false) { return isNonEmptyString(env.WDL_TOKEN_STORE) && env.WDL_TOKEN_STORE.toLowerCase() === "off"; } -/** @param {unknown} value */ -function formatDiagnosticValue(value) { - return escapeTerminalText(JSON.stringify(value)); -} - // Only the control-plane endpoint and token are materialized into env from a // store section; LABEL is store-only metadata for `wdl token list`. /** @type {readonly ["CONTROL_URL", "ADMIN_TOKEN"]} */ diff --git a/lib/output.js b/lib/output.js index 642fdc2..fa5df29 100644 --- a/lib/output.js +++ b/lib/output.js @@ -54,6 +54,11 @@ export function escapeTerminalText(value) { return escapeControlChars(text, false); } +/** @param {unknown} value */ +export function formatDiagnosticValue(value) { + return escapeTerminalText(JSON.stringify(value)); +} + // Mask a token for display: `****` plus the last 4 chars, but only when that // reveals at most half the token (short tokens show no suffix). "(unset)" for // an empty/absent token. diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index 3804d71..beff520 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto"; import { rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { CliError } from "./common.js"; -import { escapeTerminalText } from "./output.js"; +import { escapeTerminalText, formatDiagnosticValue } from "./output.js"; import { RESERVED_OBJECT_KEYS, WDL_RESERVED_BINDING_RE, @@ -109,13 +109,13 @@ function normalizeVars(vars) { const normalized = manifestMap(); for (const [name, value] of Object.entries(vars)) { if (WDL_RESERVED_BINDING_RE.test(name)) { - throw new CliError(`[vars] ${name}: name is reserved for runtime-internal bindings`); + throw new CliError(`[vars] ${escapeTerminalText(name)}: name is reserved for runtime-internal bindings`); } if (RESERVED_OBJECT_KEYS.has(name)) { - throw new CliError(`[vars] ${name}: name is a reserved Object.prototype key`); + throw new CliError(`[vars] ${escapeTerminalText(name)}: name is a reserved Object.prototype key`); } if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { - throw new CliError(`[vars] ${name}: only string/number/boolean values are supported`); + throw new CliError(`[vars] ${escapeTerminalText(name)}: only string/number/boolean values are supported`); } normalized[name] = value; } @@ -171,7 +171,7 @@ export async function packWranglerProject({ const claimedBindings = new Set(); /** @param {string} name */ const claimBinding = (name) => { - if (claimedBindings.has(name)) throw new CliError(`binding name collision: ${name}`); + if (claimedBindings.has(name)) throw new CliError(`binding name collision: ${escapeTerminalText(name)}`); claimedBindings.add(name); }; @@ -252,8 +252,8 @@ export async function packWranglerProject({ const wrangler = resolveWranglerCommand({ absProject, env }); checkWranglerVersion({ execFile, cwd: absProject, env, wrangler }); stdout( - `[1/3] bundling via wrangler${envName ? ` (env=${envName})` : ""} → ` + - `${path.relative(cwd, outDir)}` + `[1/3] bundling via wrangler${envName ? ` (env=${escapeTerminalText(envName)})` : ""} → ` + + `${escapeTerminalText(path.relative(cwd, outDir))}` ); const tmpConfigPath = path.join(absProject, `.wrangler.wdl-tmp-${randomUUID()}.json`); // resolveWranglerConfig already verified rawCfg is a plain object. @@ -297,7 +297,8 @@ export async function packWranglerProject({ const entryName = path.basename(String(cfg.main)).replace(/\.(ts|tsx|jsx|mjs|cjs|mts|cts)$/, ".js"); if (!hasOwn(modules, entryName)) { throw new CliError( - `wrangler output doesn't contain expected entry "${entryName}" (from main="${cfg.main}"). Found: ${Object.keys(modules).join(", ")}` + `wrangler output doesn't contain expected entry ${formatDiagnosticValue(entryName)} ` + + `(from main=${formatDiagnosticValue(cfg.main)}). Found: ${Object.keys(modules).map(escapeTerminalText).join(", ")}` ); } @@ -364,6 +365,7 @@ export async function packWranglerProject({ * @returns {string[]} */ export function collectRoutes(cfg, configRel) { + configRel = escapeTerminalText(configRel); /** @type {string[]} */ const collected = []; /** diff --git a/lib/wrangler/assets.js b/lib/wrangler/assets.js index 5c9451b..7ad6bd1 100644 --- a/lib/wrangler/assets.js +++ b/lib/wrangler/assets.js @@ -1,5 +1,6 @@ import { existsSync, lstatSync, readFileSync, realpathSync, readdirSync } from "node:fs"; import path from "node:path"; +import { escapeTerminalText, formatDiagnosticValue } from "../output.js"; import { manifestMap } from "./utils.js"; export const MAX_ASSET_FILE_BYTES = 25 * 1024 * 1024; @@ -24,6 +25,39 @@ const DEFAULT_ASSET_IGNORE_PATTERNS = [ "**/.env.*", ]; +/** @param {string} configRel */ +function formatConfigRel(configRel) { + return escapeTerminalText(configRel); +} + +/** + * @param {string} action + * @param {string} relPath + * @param {unknown} err + * @returns {Error} + */ +function assetFsError(action, relPath, err) { + const message = err instanceof Error ? err.message : String(err); + return new Error(`assets: failed to ${action} ${formatDiagnosticValue(relPath)}: ${escapeTerminalText(message)}`, { + cause: err, + }); +} + +/** + * @param {string} configRel + * @param {string} action + * @param {unknown} assetsDirRel + * @param {unknown} err + * @returns {Error} + */ +function assetsDirFsError(configRel, action, assetsDirRel, err) { + const message = err instanceof Error ? err.message : String(err); + return new Error( + `${configRel} assets.directory ${formatDiagnosticValue(assetsDirRel)} failed to ${action}: ${escapeTerminalText(message)}`, + { cause: err } + ); +} + /** * @param {string} absProject * @param {unknown} assetsDirRel Raw config value; validated as a non-empty string here. @@ -31,6 +65,7 @@ const DEFAULT_ASSET_IGNORE_PATTERNS = [ * @returns {string} */ export function resolveAssetsDir(absProject, assetsDirRel, configRel = "wrangler config") { + configRel = formatConfigRel(configRel); // Fail loudly on a malformed `assets.directory` instead of letting a non-string // hit path.resolve as a low-level TypeError, or an empty string be ignored. if (typeof assetsDirRel !== "string" || assetsDirRel.trim() === "") { @@ -38,23 +73,38 @@ export function resolveAssetsDir(absProject, assetsDirRel, configRel = "wrangler } const assetsDir = path.resolve(absProject, assetsDirRel); if (!existsSync(assetsDir)) { - throw new Error(`${configRel} assets.directory "${assetsDirRel}" not found`); + throw new Error(`${configRel} assets.directory ${formatDiagnosticValue(assetsDirRel)} not found`); + } + let dirStat; + try { + dirStat = lstatSync(assetsDir); + } catch (err) { + throw assetsDirFsError(configRel, "stat", assetsDirRel, err); } - const dirStat = lstatSync(assetsDir); if (dirStat.isSymbolicLink()) { throw new Error( - `${configRel} assets.directory "${assetsDirRel}" must not be a symlink` + `${configRel} assets.directory ${formatDiagnosticValue(assetsDirRel)} must not be a symlink` ); } if (!dirStat.isDirectory()) { - throw new Error(`${configRel} assets.directory "${assetsDirRel}" is not a directory`); + throw new Error(`${configRel} assets.directory ${formatDiagnosticValue(assetsDirRel)} is not a directory`); + } + let projectReal; + try { + projectReal = realpathSync(absProject); + } catch (err) { + throw assetsDirFsError(configRel, "resolve project root", assetsDirRel, err); + } + let assetsReal; + try { + assetsReal = realpathSync(assetsDir); + } catch (err) { + throw assetsDirFsError(configRel, "resolve", assetsDirRel, err); } - const projectReal = realpathSync(absProject); - const assetsReal = realpathSync(assetsDir); const rel = path.relative(projectReal, assetsReal); if (rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel)) { throw new Error( - `${configRel} assets.directory "${assetsDirRel}" resolves outside the project root` + `${configRel} assets.directory ${formatDiagnosticValue(assetsDirRel)} resolves outside the project root` ); } return assetsDir; @@ -66,19 +116,41 @@ export function resolveAssetsDir(absProject, assetsDirRel, configRel = "wrangler * @returns {Record} */ export function collectAssets(dir, { onIgnore = null } = {}) { - const rootReal = realpathSync(dir); + let rootReal; + try { + rootReal = realpathSync(dir); + } catch (err) { + throw assetFsError("resolve", ".", err); + } const ignoreFile = path.join(dir, ASSETS_IGNORE_FILENAME); - const userPatterns = existsSync(ignoreFile) - ? parseAssetIgnorePatterns(readFileSync(ignoreFile, "utf8")) - : []; + let ignoreText = ""; + if (existsSync(ignoreFile)) { + try { + ignoreText = readFileSync(ignoreFile, "utf8"); + } catch (err) { + throw assetFsError("read", ASSETS_IGNORE_FILENAME, err); + } + } + const userPatterns = ignoreText ? parseAssetIgnorePatterns(ignoreText) : []; const matcher = createAssetIgnoreMatcher([...DEFAULT_ASSET_IGNORE_PATTERNS, ...userPatterns]); const out = manifestMap(); let totalBytes = 0; (function walk(cur, rel) { - for (const entry of readdirSync(cur)) { + let entries; + try { + entries = readdirSync(cur); + } catch (err) { + throw assetFsError("read directory", rel || ".", err); + } + for (const entry of entries) { const full = path.join(cur, entry); const relPath = rel ? `${rel}/${entry}` : entry; - const st = lstatSync(full); + let st; + try { + st = lstatSync(full); + } catch (err) { + throw assetFsError("stat", relPath, err); + } // Ignore check runs first so an ignored symlink (e.g. a node_modules // link) prunes silently instead of failing the symlink rule below. const isDir = st.isDirectory(); @@ -90,9 +162,10 @@ export function collectAssets(dir, { onIgnore = null } = {}) { continue; } if (st.isSymbolicLink()) { + const shownRelPath = formatDiagnosticValue(relPath); throw new Error( - `assets: symlink not allowed at "${relPath}" ` + - `(add "${relPath}" to .assetsignore to skip it; patterns ending in "/" match only real directories, not symlinks)` + `assets: symlink not allowed at ${shownRelPath} ` + + `(add ${shownRelPath} to .assetsignore to skip it; patterns ending in "/" match only real directories, not symlinks)` ); } if (st.isDirectory()) { @@ -100,26 +173,37 @@ export function collectAssets(dir, { onIgnore = null } = {}) { continue; } if (!st.isFile()) { - throw new Error(`assets: unsupported entry type at "${relPath}"`); + throw new Error(`assets: unsupported entry type at ${formatDiagnosticValue(relPath)}`); + } + let realFull; + try { + realFull = realpathSync(full); + } catch (err) { + throw assetFsError("resolve", relPath, err); } - const realFull = realpathSync(full); const inside = path.relative(rootReal, realFull); if (inside === ".." || inside.startsWith(".." + path.sep) || path.isAbsolute(inside)) { - throw new Error(`assets: "${relPath}" resolves outside the assets root`); + throw new Error(`assets: ${formatDiagnosticValue(relPath)} resolves outside the assets root`); } if (st.size > MAX_ASSET_FILE_BYTES) { throw new Error( - `assets: "${relPath}" is ${st.size} bytes, exceeds ${MAX_ASSET_FILE_BYTES} per-file cap` + `assets: ${formatDiagnosticValue(relPath)} is ${st.size} bytes, exceeds ${MAX_ASSET_FILE_BYTES} per-file cap` ); } totalBytes += st.size; if (totalBytes > MAX_ASSETS_TOTAL_BYTES) { throw new Error( `assets: cumulative size exceeds ${MAX_ASSETS_TOTAL_BYTES} bytes ` + - `(hit at "${relPath}")` + `(hit at ${formatDiagnosticValue(relPath)})` ); } - out[relPath] = readFileSync(full).toString("base64"); + let bytes; + try { + bytes = readFileSync(full); + } catch (err) { + throw assetFsError("read", relPath, err); + } + out[relPath] = bytes.toString("base64"); } })(dir, ""); return out; @@ -196,9 +280,10 @@ function compileAssetIgnoreRule(pattern) { regex = new RegExp(`^${prefix}${assetGlobToRegex(pattern)}$`); } catch (err) { const message = err instanceof Error ? err.message : String(err); - throw new Error(`assets: invalid .assetsignore pattern ${JSON.stringify(originalPattern)}: ${message}`, { - cause: err, - }); + throw new Error( + `assets: invalid .assetsignore pattern ${formatDiagnosticValue(originalPattern)}: ${escapeTerminalText(message)}`, + { cause: err } + ); } return { regex, diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index bde94d6..696d057 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -10,6 +10,7 @@ import { isAdminAcceptableNs, isValidJsIdentifier, } from "../ns-pattern.js"; +import { escapeTerminalText, formatDiagnosticValue } from "../output.js"; import { asRecord } from "./utils.js"; @@ -22,6 +23,21 @@ const MAX_QUEUE_DELAY_SECONDS = 86_400; // than binding names to read as registered identifiers. const PLATFORM_KEY_RE = /^[A-Z_][A-Z0-9_]*$/; +/** @param {unknown} value */ +function formatConfigLabel(value) { + return escapeTerminalText(String(value)); +} + +/** @param {string} configRel */ +function formatConfigRel(configRel) { + return formatConfigLabel(configRel); +} + +/** @param {Iterable} keys */ +function formatConfigKeyList(keys) { + return [...keys].map(formatConfigLabel).join(", "); +} + /** * The caller may hand any value: these helpers validate via regex, which * stringifies its argument, so `binding` is the unvalidated config value. @@ -30,9 +46,10 @@ const PLATFORM_KEY_RE = /^[A-Z_][A-Z0-9_]*$/; * @param {unknown} binding */ export function assertNotRuntimeReservedBinding(configRel, scope, binding) { + configRel = formatConfigRel(configRel); if (WDL_RESERVED_BINDING_RE.test(String(binding))) { throw new Error( - `${configRel}: ${scope} ${binding}: binding name is reserved for runtime-internal bindings` + `${configRel}: ${scope} ${formatConfigLabel(binding)}: binding name is reserved for runtime-internal bindings` ); } } @@ -47,8 +64,9 @@ export function assertNotRuntimeReservedBinding(configRel, scope, binding) { * @param {unknown} binding Unvalidated config value; regex `.test` stringifies it. */ export function assertValidBindingName(configRel, scope, binding) { + configRel = formatConfigRel(configRel); if (!BINDING_NAME_RE.test(String(binding))) { - throw new Error(`${configRel}: ${scope} ${binding}: binding must match ${BINDING_NAME_RE}`); + throw new Error(`${configRel}: ${scope} ${formatConfigLabel(binding)}: binding must match ${BINDING_NAME_RE}`); } } @@ -58,6 +76,7 @@ export function assertValidBindingName(configRel, scope, binding) { * @returns {Array<{ cron: string, timezone: string }>} */ export function parseTriggers(triggers, configRel = "wrangler config") { + configRel = formatConfigRel(configRel); if (triggers == null) return []; const triggersTable = asRecord(triggers); if (!triggersTable) { @@ -121,6 +140,7 @@ export function parseTriggers(triggers, configRel = "wrangler config") { * @returns {{ producers: QueueProducer[], consumers: QueueConsumer[] }} */ export function parseQueues(queues, configRel = "wrangler config") { + configRel = formatConfigRel(configRel); if (queues == null) return { producers: [], consumers: [] }; const queuesTable = asRecord(queues); if (!queuesTable) { @@ -151,7 +171,7 @@ export function parseQueues(queues, configRel = "wrangler config") { producer.deliveryDelaySeconds = normalizeQueueDelayConfig( p.delivery_delay, configRel, - `[[queues.producers]] ${p.binding}.delivery_delay` + `[[queues.producers]] ${formatConfigLabel(p.binding)}.delivery_delay` ); } producers.push(producer); @@ -173,7 +193,7 @@ export function parseQueues(queues, configRel = "wrangler config") { } if (c.max_concurrency != null) { throw new Error( - `${configRel}: [[queues.consumers]] ${c.queue}: max_concurrency not supported` + `${configRel}: [[queues.consumers]] ${formatConfigLabel(c.queue)}: max_concurrency not supported` ); } /** @type {QueueConsumer} */ @@ -183,7 +203,7 @@ export function parseQueues(queues, configRel = "wrangler config") { entry.maxBatchTimeoutMs = normalizeQueueDelayConfig( c.max_batch_timeout, configRel, - `[[queues.consumers]] ${c.queue}.max_batch_timeout` + `[[queues.consumers]] ${formatConfigLabel(c.queue)}.max_batch_timeout` ) * 1000; } if (c.max_retries != null) entry.maxRetries = c.max_retries; @@ -191,7 +211,7 @@ export function parseQueues(queues, configRel = "wrangler config") { entry.retryDelaySeconds = normalizeQueueDelayConfig( c.retry_delay, configRel, - `[[queues.consumers]] ${c.queue}.retry_delay` + `[[queues.consumers]] ${formatConfigLabel(c.queue)}.retry_delay` ); } if (c.dead_letter_queue != null) entry.deadLetterQueue = c.dead_letter_queue; @@ -207,6 +227,7 @@ export function parseQueues(queues, configRel = "wrangler config") { * @returns {Array<{ binding: string, id: string }>} */ export function parseKvNamespacesFromCfg(cfg, configRel = "wrangler config") { + configRel = formatConfigRel(configRel); if (cfg.kv_namespaces == null) return []; if (!Array.isArray(cfg.kv_namespaces)) { throw new Error(`${configRel}: [[kv_namespaces]] must be an array of tables`); @@ -225,7 +246,7 @@ export function parseKvNamespacesFromCfg(cfg, configRel = "wrangler config") { const unknownKeys = Object.keys(entry).filter((key) => !allowedKeys.has(key)); if (unknownKeys.length > 0) { throw new Error( - `${configRel}: [[kv_namespaces]] contains unknown field(s): ${unknownKeys.join(", ")}` + `${configRel}: [[kv_namespaces]] contains unknown field(s): ${formatConfigKeyList(unknownKeys)}` ); } if (typeof entry.binding !== "string" || !entry.binding.trim()) { @@ -234,7 +255,7 @@ export function parseKvNamespacesFromCfg(cfg, configRel = "wrangler config") { assertNotRuntimeReservedBinding(configRel, "[[kv_namespaces]]", entry.binding); assertValidBindingName(configRel, "[[kv_namespaces]]", entry.binding); if (typeof entry.id !== "string" || !entry.id.trim()) { - throw new Error(`${configRel}: [[kv_namespaces]] ${entry.binding}: 'id' must be a non-empty string`); + throw new Error(`${configRel}: [[kv_namespaces]] ${formatConfigLabel(entry.binding)}: 'id' must be a non-empty string`); } // Store trimmed values like the d1/r2 parsers, so a stray "abc " id isn't // forwarded with whitespace. @@ -249,6 +270,7 @@ export function parseKvNamespacesFromCfg(cfg, configRel = "wrangler config") { * @returns {Array<{ binding: string, databaseId: string }>} */ export function parseD1DatabasesFromCfg(cfg, configRel = "wrangler config") { + configRel = formatConfigRel(configRel); if (cfg.d1_databases == null) return []; if (!Array.isArray(cfg.d1_databases)) { throw new Error(`${configRel}: [[d1_databases]] must be an array of tables`); @@ -272,7 +294,7 @@ export function parseD1DatabasesFromCfg(cfg, configRel = "wrangler config") { const unknownKeys = Object.keys(entry).filter((key) => !allowedKeys.has(key)); if (unknownKeys.length > 0) { throw new Error( - `${configRel}: [[d1_databases]] contains unknown field(s): ${unknownKeys.join(", ")}` + `${configRel}: [[d1_databases]] contains unknown field(s): ${formatConfigKeyList(unknownKeys)}` ); } if (typeof entry.binding !== "string" || !entry.binding.trim()) { @@ -285,7 +307,7 @@ export function parseD1DatabasesFromCfg(cfg, configRel = "wrangler config") { ? entry.database_id.trim() : entry.database_name; if (typeof databaseRef !== "string" || !databaseRef.trim()) { - throw new Error(`${configRel}: [[d1_databases]] ${entry.binding}: database_name or database_id is required`); + throw new Error(`${configRel}: [[d1_databases]] ${formatConfigLabel(entry.binding)}: database_name or database_id is required`); } out.push({ binding: entry.binding.trim(), databaseId: databaseRef.trim() }); } @@ -298,6 +320,7 @@ export function parseD1DatabasesFromCfg(cfg, configRel = "wrangler config") { * @returns {Array<{ binding: string, bucketName: string }>} */ export function parseR2BucketsFromCfg(cfg, configRel = "wrangler config") { + configRel = formatConfigRel(configRel); if (cfg.r2_buckets == null) return []; if (!Array.isArray(cfg.r2_buckets)) { throw new Error(`${configRel}: [[r2_buckets]] must be an array of tables`); @@ -313,7 +336,7 @@ export function parseR2BucketsFromCfg(cfg, configRel = "wrangler config") { const unknownKeys = Object.keys(entry).filter((key) => !allowedKeys.has(key)); if (unknownKeys.length > 0) { throw new Error( - `${configRel}: [[r2_buckets]] contains unknown field(s): ${unknownKeys.join(", ")}` + `${configRel}: [[r2_buckets]] contains unknown field(s): ${formatConfigKeyList(unknownKeys)}` ); } if (typeof entry.binding !== "string" || !entry.binding.trim()) { @@ -322,22 +345,22 @@ export function parseR2BucketsFromCfg(cfg, configRel = "wrangler config") { assertNotRuntimeReservedBinding(configRel, "[[r2_buckets]]", entry.binding); assertValidBindingName(configRel, "[[r2_buckets]]", entry.binding); if (typeof entry.bucket_name !== "string" || !entry.bucket_name.trim()) { - throw new Error(`${configRel}: [[r2_buckets]] ${entry.binding}: bucket_name is required`); + throw new Error(`${configRel}: [[r2_buckets]] ${formatConfigLabel(entry.binding)}: bucket_name is required`); } if (entry.preview_bucket_name != null) { throw new Error( - `${configRel}: [[r2_buckets]] ${entry.binding}: preview_bucket_name is not supported by WDL R2` + `${configRel}: [[r2_buckets]] ${formatConfigLabel(entry.binding)}: preview_bucket_name is not supported by WDL R2` ); } if (entry.jurisdiction != null) { throw new Error( - `${configRel}: [[r2_buckets]] ${entry.binding}: jurisdiction is not supported by WDL R2` + `${configRel}: [[r2_buckets]] ${formatConfigLabel(entry.binding)}: jurisdiction is not supported by WDL R2` ); } const bucketName = entry.bucket_name.trim(); if (!R2_BUCKET_NAME_RE.test(bucketName)) { throw new Error( - `${configRel}: [[r2_buckets]] ${entry.binding}: bucket_name must match ${R2_BUCKET_NAME_RE}` + `${configRel}: [[r2_buckets]] ${formatConfigLabel(entry.binding)}: bucket_name must match ${R2_BUCKET_NAME_RE}` ); } out.push({ binding: entry.binding.trim(), bucketName }); @@ -362,6 +385,7 @@ export function parseR2BucketsFromCfg(cfg, configRel = "wrangler config") { * @returns {ServiceBinding[]} */ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { + configRel = formatConfigRel(configRel); if (cfg.services == null) return []; if (!Array.isArray(cfg.services)) { throw new Error(`${configRel}: [[services]] must be an array of tables`); @@ -382,10 +406,10 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { // the manifest, and a non-string `binding` (e.g. ["AB"]) would be silently // String()-coerced past the BINDING_NAME_RE check below. if (typeof entry.binding !== "string" || !entry.binding.trim()) { - throw new Error(`${configRel}: [[services]] binding must be a non-empty string, got ${JSON.stringify(entry.binding)}`); + throw new Error(`${configRel}: [[services]] binding must be a non-empty string, got ${formatDiagnosticValue(entry.binding)}`); } if (typeof entry.service !== "string" || !entry.service.trim()) { - throw new Error(`${configRel}: [[services]] ${entry.binding}: service must be a non-empty string, got ${JSON.stringify(entry.service)}`); + throw new Error(`${configRel}: [[services]] ${formatConfigLabel(entry.binding)}: service must be a non-empty string, got ${formatDiagnosticValue(entry.service)}`); } assertNotRuntimeReservedBinding(configRel, "[[services]]", entry.binding); assertValidBindingName(configRel, "[[services]]", entry.binding); @@ -396,12 +420,12 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { if (entry.entrypoint != null) { if (!isValidJsIdentifier(entry.entrypoint)) { throw new Error( - `${configRel}: [[services]] ${entry.binding}: entrypoint must be a JS identifier, got ${JSON.stringify(entry.entrypoint)}` + `${configRel}: [[services]] ${formatConfigLabel(entry.binding)}: entrypoint must be a JS identifier, got ${formatDiagnosticValue(entry.entrypoint)}` ); } if (WDL_RESERVED_ENTRYPOINT_RE.test(entry.entrypoint)) { throw new Error( - `${configRel}: [[services]] ${entry.binding}: entrypoint ${JSON.stringify(entry.entrypoint)} is reserved for runtime-injected entrypoints` + `${configRel}: [[services]] ${formatConfigLabel(entry.binding)}: entrypoint ${formatDiagnosticValue(entry.entrypoint)} is reserved for runtime-injected entrypoints` ); } entrypoint = entry.entrypoint; @@ -411,7 +435,7 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { if (entry.ns != null) { if (!isAdminAcceptableNs(entry.ns)) { throw new Error( - `${configRel}: [[services]] ${entry.binding}: ns must match ${NS_PATTERN} or an operator-reserved namespace, got ${JSON.stringify(entry.ns)}` + `${configRel}: [[services]] ${formatConfigLabel(entry.binding)}: ns must match ${NS_PATTERN} or an operator-reserved namespace, got ${formatDiagnosticValue(entry.ns)}` ); } ns = entry.ns; @@ -434,6 +458,7 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { * @returns {Array<{ binding: string, className: string }>} */ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { + configRel = formatConfigRel(configRel); if (cfg.durable_objects == null) return []; const durableObjects = asRecord(cfg.durable_objects); if (!durableObjects) { @@ -469,12 +494,12 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { for (const className of classNames) { if (!isValidJsClassDeclarationName(className)) { throw new Error( - `${configRel}: [[migrations]].${key} entries must be valid JS class declaration names, got ${JSON.stringify(className)}` + `${configRel}: [[migrations]].${key} entries must be valid JS class declaration names, got ${formatDiagnosticValue(className)}` ); } if (WDL_RESERVED_ENTRYPOINT_RE.test(String(className))) { throw new Error( - `${configRel}: [[migrations]].${key} entry ${JSON.stringify(className)} is reserved for runtime-injected entrypoints` + `${configRel}: [[migrations]].${key} entry ${formatDiagnosticValue(className)} is reserved for runtime-injected entrypoints` ); } newClasses.add(className); @@ -490,7 +515,7 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { throw new Error(`${configRel}: [[durable_objects.bindings]] entry must be a table`); } if (entry.script_name != null) { - throw new Error(`${configRel}: [[durable_objects.bindings]] ${entry.name}: script_name is not supported by WDL Durable Objects yet`); + throw new Error(`${configRel}: [[durable_objects.bindings]] ${formatConfigLabel(entry.name)}: script_name is not supported by WDL Durable Objects yet`); } if (typeof entry.name !== "string" || !entry.name.trim()) { throw new Error(`${configRel}: [[durable_objects.bindings]].name is required`); @@ -499,17 +524,17 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { assertValidBindingName(configRel, "[[durable_objects.bindings]]", entry.name); if (!isValidJsClassDeclarationName(entry.class_name)) { throw new Error( - `${configRel}: [[durable_objects.bindings]] ${entry.name}: class_name must be a valid JS class declaration name, got ${JSON.stringify(entry.class_name)}` + `${configRel}: [[durable_objects.bindings]] ${formatConfigLabel(entry.name)}: class_name must be a valid JS class declaration name, got ${formatDiagnosticValue(entry.class_name)}` ); } if (WDL_RESERVED_ENTRYPOINT_RE.test(String(entry.class_name))) { throw new Error( - `${configRel}: [[durable_objects.bindings]] ${entry.name}: class_name ${JSON.stringify(entry.class_name)} is reserved for runtime-injected entrypoints` + `${configRel}: [[durable_objects.bindings]] ${formatConfigLabel(entry.name)}: class_name ${formatDiagnosticValue(entry.class_name)} is reserved for runtime-injected entrypoints` ); } if (!newClasses.has(entry.class_name)) { throw new Error( - `${configRel}: [[durable_objects.bindings]] ${entry.name}: class_name ${entry.class_name} must be listed in [[migrations]].new_classes or [[migrations]].new_sqlite_classes` + `${configRel}: [[durable_objects.bindings]] ${formatConfigLabel(entry.name)}: class_name ${formatConfigLabel(entry.class_name)} must be listed in [[migrations]].new_classes or [[migrations]].new_sqlite_classes` ); } out.push({ binding: entry.name.trim(), className: entry.class_name }); @@ -523,6 +548,7 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { * @returns {Array<{ name: string, binding: string, className: string }>} */ export function parseWorkflowsFromCfg(cfg, configRel = "wrangler config") { + configRel = formatConfigRel(configRel); if (cfg.workflows == null) return []; if (!Array.isArray(cfg.workflows)) { throw new Error(`${configRel}: [[workflows]] must be an array of tables`); @@ -539,41 +565,41 @@ export function parseWorkflowsFromCfg(cfg, configRel = "wrangler config") { throw new Error(`${configRel}: [[workflows]] entry must be a table`); } if (entry.script_name != null) { - throw new Error(`${configRel}: [[workflows]] ${entry.name}: script_name is not supported by WDL Workflows`); + throw new Error(`${configRel}: [[workflows]] ${formatConfigLabel(entry.name)}: script_name is not supported by WDL Workflows`); } if (typeof entry.name !== "string" || !WORKFLOW_NAME_RE.test(entry.name)) { throw new Error( - `${configRel}: [[workflows]].name must match ${WORKFLOW_NAME_RE}, got ${JSON.stringify(entry.name)}` + `${configRel}: [[workflows]].name must match ${WORKFLOW_NAME_RE}, got ${formatDiagnosticValue(entry.name)}` ); } if (RESERVED_OBJECT_KEYS.has(entry.name)) { - throw new Error(`${configRel}: [[workflows]] ${entry.name}: name is a reserved Object.prototype key`); + throw new Error(`${configRel}: [[workflows]] ${formatConfigLabel(entry.name)}: name is a reserved Object.prototype key`); } if (seenNames.has(entry.name)) { - throw new Error(`${configRel}: [[workflows]] duplicate name ${JSON.stringify(entry.name)}`); + throw new Error(`${configRel}: [[workflows]] duplicate name ${formatDiagnosticValue(entry.name)}`); } seenNames.add(entry.name); if (typeof entry.binding !== "string" || !BINDING_NAME_RE.test(entry.binding)) { throw new Error( - `${configRel}: [[workflows]] ${entry.name}: binding must match ${BINDING_NAME_RE}, got ${JSON.stringify(entry.binding)}` + `${configRel}: [[workflows]] ${formatConfigLabel(entry.name)}: binding must match ${BINDING_NAME_RE}, got ${formatDiagnosticValue(entry.binding)}` ); } assertNotRuntimeReservedBinding(configRel, "[[workflows]]", entry.binding); if (RESERVED_OBJECT_KEYS.has(entry.binding)) { - throw new Error(`${configRel}: [[workflows]] ${entry.name}: binding is a reserved Object.prototype key`); + throw new Error(`${configRel}: [[workflows]] ${formatConfigLabel(entry.name)}: binding is a reserved Object.prototype key`); } if (seenBindings.has(entry.binding)) { - throw new Error(`${configRel}: [[workflows]] duplicate binding ${JSON.stringify(entry.binding)}`); + throw new Error(`${configRel}: [[workflows]] duplicate binding ${formatDiagnosticValue(entry.binding)}`); } seenBindings.add(entry.binding); if (!isValidJsClassDeclarationName(entry.class_name)) { throw new Error( - `${configRel}: [[workflows]] ${entry.name}: class_name must be a valid JS class declaration name, got ${JSON.stringify(entry.class_name)}` + `${configRel}: [[workflows]] ${formatConfigLabel(entry.name)}: class_name must be a valid JS class declaration name, got ${formatDiagnosticValue(entry.class_name)}` ); } if (WDL_RESERVED_ENTRYPOINT_RE.test(String(entry.class_name))) { throw new Error( - `${configRel}: [[workflows]] ${entry.name}: class_name ${JSON.stringify(entry.class_name)} is reserved for runtime-injected entrypoints` + `${configRel}: [[workflows]] ${formatConfigLabel(entry.name)}: class_name ${formatDiagnosticValue(entry.class_name)} is reserved for runtime-injected entrypoints` ); } out.push({ @@ -599,6 +625,7 @@ export function parseWorkflowsFromCfg(cfg, configRel = "wrangler config") { * @returns {ExportEntry[]} */ export function parseExportsFromCfg(cfg, configRel = "wrangler config") { + configRel = formatConfigRel(configRel); if (cfg.exports == null) return []; if (!Array.isArray(cfg.exports)) { throw new Error(`${configRel}: [[exports]] must be an array of tables`); @@ -612,24 +639,24 @@ export function parseExportsFromCfg(cfg, configRel = "wrangler config") { } if (entry.entrypoint !== "default" && !isValidJsClassDeclarationName(entry.entrypoint)) { throw new Error( - `${configRel}: [[exports]].entrypoint must be a valid JS class declaration name or "default", got ${JSON.stringify(entry.entrypoint)}` + `${configRel}: [[exports]].entrypoint must be a valid JS class declaration name or "default", got ${formatDiagnosticValue(entry.entrypoint)}` ); } if (WDL_RESERVED_ENTRYPOINT_RE.test(String(entry.entrypoint))) { throw new Error( - `${configRel}: [[exports]] ${entry.entrypoint}: entrypoint is reserved for runtime-injected entrypoints` + `${configRel}: [[exports]] ${formatConfigLabel(entry.entrypoint)}: entrypoint is reserved for runtime-injected entrypoints` ); } const allowedCallers = entry.allowed_callers; if (!Array.isArray(allowedCallers)) { throw new Error( - `${configRel}: [[exports]] ${entry.entrypoint}: allowed_callers must be an array of strings` + `${configRel}: [[exports]] ${formatConfigLabel(entry.entrypoint)}: allowed_callers must be an array of strings` ); } for (const c of allowedCallers) { if (typeof c !== "string" || (c !== "*" && !NS_RE.test(c))) { throw new Error( - `${configRel}: [[exports]] ${entry.entrypoint}: allowed_callers entries must be "*" or match ${NS_PATTERN}, got ${JSON.stringify(c)}` + `${configRel}: [[exports]] ${formatConfigLabel(entry.entrypoint)}: allowed_callers entries must be "*" or match ${NS_PATTERN}, got ${formatDiagnosticValue(c)}` ); } } @@ -641,7 +668,7 @@ export function parseExportsFromCfg(cfg, configRel = "wrangler config") { if (entry.as !== undefined) { if (typeof entry.as !== "string" || !PLATFORM_KEY_RE.test(entry.as)) { throw new Error( - `${configRel}: [[exports]] ${entry.entrypoint}: as must match ${PLATFORM_KEY_RE}, got ${JSON.stringify(entry.as)}` + `${configRel}: [[exports]] ${formatConfigLabel(entry.entrypoint)}: as must match ${PLATFORM_KEY_RE}, got ${formatDiagnosticValue(entry.as)}` ); } wire.as = entry.as; @@ -650,13 +677,13 @@ export function parseExportsFromCfg(cfg, configRel = "wrangler config") { const requiredCallerSecrets = entry.required_caller_secrets; if (!Array.isArray(requiredCallerSecrets)) { throw new Error( - `${configRel}: [[exports]] ${entry.entrypoint}: required_caller_secrets must be an array` + `${configRel}: [[exports]] ${formatConfigLabel(entry.entrypoint)}: required_caller_secrets must be an array` ); } for (const k of requiredCallerSecrets) { if (typeof k !== "string" || !PLATFORM_KEY_RE.test(k)) { throw new Error( - `${configRel}: [[exports]] ${entry.entrypoint}: required_caller_secrets entries must match ${PLATFORM_KEY_RE}, got ${JSON.stringify(k)}` + `${configRel}: [[exports]] ${formatConfigLabel(entry.entrypoint)}: required_caller_secrets entries must match ${PLATFORM_KEY_RE}, got ${formatDiagnosticValue(k)}` ); } } @@ -673,6 +700,7 @@ export function parseExportsFromCfg(cfg, configRel = "wrangler config") { * @returns {Array<{ binding: string, platform: string }>} */ export function parsePlatformBindingsFromCfg(cfg, configRel = "wrangler config") { + configRel = formatConfigRel(configRel); if (cfg.platform_bindings == null) return []; if (!Array.isArray(cfg.platform_bindings)) { throw new Error(`${configRel}: [[platform_bindings]] must be an array of tables`); @@ -686,14 +714,14 @@ export function parsePlatformBindingsFromCfg(cfg, configRel = "wrangler config") } if (typeof entry.binding !== "string" || !PLATFORM_KEY_RE.test(entry.binding)) { throw new Error( - `${configRel}: [[platform_bindings]].binding must match ${PLATFORM_KEY_RE}, got ${JSON.stringify(entry.binding)}` + `${configRel}: [[platform_bindings]].binding must match ${PLATFORM_KEY_RE}, got ${formatDiagnosticValue(entry.binding)}` ); } assertNotRuntimeReservedBinding(configRel, "[[platform_bindings]]", entry.binding); const platform = entry.platform == null ? entry.binding : entry.platform; if (typeof platform !== "string" || !PLATFORM_KEY_RE.test(platform)) { throw new Error( - `${configRel}: [[platform_bindings]] ${entry.binding}: platform must match ${PLATFORM_KEY_RE}, got ${JSON.stringify(entry.platform)}` + `${configRel}: [[platform_bindings]] ${formatConfigLabel(entry.binding)}: platform must match ${PLATFORM_KEY_RE}, got ${formatDiagnosticValue(entry.platform)}` ); } out.push({ binding: entry.binding, platform }); @@ -708,8 +736,9 @@ export function parsePlatformBindingsFromCfg(cfg, configRel = "wrangler config") * @returns {number} */ function normalizeQueueDelayConfig(value, configRel, field) { + configRel = formatConfigRel(configRel); if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_QUEUE_DELAY_SECONDS) { - throw new Error(`${configRel}: ${field} must be an integer in [0, ${MAX_QUEUE_DELAY_SECONDS}]`); + throw new Error(`${configRel}: ${formatConfigLabel(field)} must be an integer in [0, ${MAX_QUEUE_DELAY_SECONDS}]`); } return value; } diff --git a/lib/wrangler/command.js b/lib/wrangler/command.js index e00e3f5..27bdb4e 100644 --- a/lib/wrangler/command.js +++ b/lib/wrangler/command.js @@ -5,6 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { CliError } from "../common.js"; import { WRANGLER_SCRUB_KEYS } from "../dotenv.js"; +import { escapeTerminalText, formatDiagnosticValue } from "../output.js"; const CLI_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const resolveFromHere = createRequire(import.meta.url); @@ -106,7 +107,7 @@ export function checkWranglerVersion({ execFile = execFileSync, cwd, env, wrangl const parsed = parseWranglerMajorVersion(output); if (parsed == null) { throw new CliError( - `wrangler version check failed: could not parse version from ${JSON.stringify(String(output).trim())}` + `wrangler version check failed: could not parse version from ${formatDiagnosticValue(String(output).trim())}` ); } if (parsed < MIN_WRANGLER_MAJOR) { @@ -244,14 +245,15 @@ function pathWrangler(env, platform) { */ function formatWranglerVersionFailure(rawErr) { const err = asExecFailure(rawErr); - const reason = err.status ?? err.signal ?? err.message ?? "unknown"; + const reason = escapeTerminalText(err.status ?? err.signal ?? err.message ?? "unknown"); const output = [err.stdout, err.stderr] .map(toText) .filter(Boolean) .join("\n") .trim(); + const shownOutput = escapeTerminalText(truncateOutput(output)); let message = output - ? `wrangler version check failed (${reason})\n${truncateOutput(output)}` + ? `wrangler version check failed (${reason})\n${shownOutput}` : `wrangler version check failed (${reason})`; if (err.code === "ENOENT") { message += diff --git a/lib/wrangler/modules.js b/lib/wrangler/modules.js index 0bd7e4a..c87a3a0 100644 --- a/lib/wrangler/modules.js +++ b/lib/wrangler/modules.js @@ -1,9 +1,22 @@ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { inferType, toWireModule } from "../bundle-modules.js"; -import { escapeTerminalText } from "../output.js"; +import { escapeTerminalText, formatDiagnosticValue } from "../output.js"; import { manifestMap } from "./utils.js"; +/** + * @param {string} action + * @param {string} relPath + * @param {unknown} err + * @returns {Error} + */ +function moduleFsError(action, relPath, err) { + const message = err instanceof Error ? err.message : String(err); + return new Error(`wrangler output: failed to ${action} ${formatDiagnosticValue(relPath)}: ${escapeTerminalText(message)}`, { + cause: err, + }); +} + // Skip only the known incidentals (.map, README.md). Dropping any other // artifact silently would crash the worker at runtime. /** @@ -11,16 +24,27 @@ import { manifestMap } from "./utils.js"; * @returns {Record} */ export function collectModules(dir) { - if (!existsSync(dir)) throw new Error(`wrangler produced no output at ${dir}`); + if (!existsSync(dir)) throw new Error(`wrangler produced no output at ${formatDiagnosticValue(dir)}`); const out = manifestMap(); const skip = new Set(["README.md"]); (function walk(cur, rel) { - for (const entry of readdirSync(cur)) { + let entries; + try { + entries = readdirSync(cur); + } catch (err) { + throw moduleFsError("read directory", rel || ".", err); + } + for (const entry of entries) { const full = path.join(cur, entry); const relPath = rel ? `${rel}/${entry}` : entry; - const st = lstatSync(full); + let st; + try { + st = lstatSync(full); + } catch (err) { + throw moduleFsError("stat", relPath, err); + } if (st.isSymbolicLink()) { - throw new Error(`wrangler output contains a symlink (${relPath}); refusing to follow`); + throw new Error(`wrangler output contains a symlink (${formatDiagnosticValue(relPath)}); refusing to follow`); } if (st.isDirectory()) { walk(full, relPath); @@ -32,7 +56,13 @@ export function collectModules(dir) { if (type === "py") { throw new Error(`Python Workers modules are not supported by WDL (${escapeTerminalText(relPath)})`); } - out[relPath] = toWireModule(readFileSync(full), type); + let bytes; + try { + bytes = readFileSync(full); + } catch (err) { + throw moduleFsError("read", relPath, err); + } + out[relPath] = toWireModule(bytes, type); } })(dir, ""); return out; diff --git a/tests/unit/cli-control-fetch.test.js b/tests/unit/cli-control-fetch.test.js index 28bc1f7..0a2bc3e 100644 --- a/tests/unit/cli-control-fetch.test.js +++ b/tests/unit/cli-control-fetch.test.js @@ -9,8 +9,7 @@ import { readControlResponse, } from "../../lib/control-fetch.js"; import { currentCliVersion } from "../../lib/package-info.js"; - -const ESC = String.fromCharCode(27); +import { ESC, assertNoRawTerminalControls } from "./helpers.js"; /** * @param {{ statusCode?: number, headers?: import("node:http").IncomingHttpHeaders }} [init] @@ -19,6 +18,28 @@ function fakeResponse({ statusCode = 200, headers = {} } = {}) { return Object.assign(new EventEmitter(), { statusCode, headers }); } +function captureSuccessfulRequestOptions() { + /** @type {import("node:https").RequestOptions[]} */ + const seen = []; + /** @type {import("../../lib/control-fetch.js").ControlTransport} */ + const transport = { + request(opts, onResponse) { + seen.push(opts); + return Object.assign(new EventEmitter(), { + write() {}, + end() { + const res = fakeResponse(); + onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); + res.emit("data", Buffer.from("{}")); + res.emit("end"); + }, + destroy() {}, + }); + }, + }; + return { seen, transport }; +} + test("readControlResponse parses bounded response bodies", async () => { const res = fakeResponse({ headers: { "content-type": "application/json" } }); const promise = readControlResponse(res, { maxBodyBytes: 64 }); @@ -323,59 +344,28 @@ test("controlFetch forwards streaming source errors to the consumer", async () = }); test("controlFetch carries the URL port in Host and strips IPv6 brackets for the socket", async () => { - /** @type {Array} */ - const seen = []; - /** @type {import("../../lib/control-fetch.js").ControlTransport} */ - const transport = { - request(opts, onResponse) { - seen.push(/** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (opts)); - return Object.assign(new EventEmitter(), { - write() {}, - end() { - const res = fakeResponse(); - onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); - res.emit("data", Buffer.from("{}")); - res.emit("end"); - }, - destroy() {}, - }); - }, - }; + const { seen, transport } = captureSuccessfulRequestOptions(); await controlFetch("http://[::1]:8080/whoami", { transport }); await controlFetch("http://127.0.0.1:9090/whoami", { transport }); await controlFetch("http://ctl.test/whoami", { transport }); - assert.equal(seen[0].host, "::1"); - assert.equal(seen[0].port, 8080); - assert.equal(seen[0].headers.Host, "[::1]:8080"); - assert.equal(seen[1].headers.Host, "127.0.0.1:9090"); + const first = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[0]); + const second = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[1]); + const third = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[2]); + assert.equal(first.host, "::1"); + assert.equal(first.port, 8080); + assert.equal(first.headers.Host, "[::1]:8080"); + assert.equal(second.headers.Host, "127.0.0.1:9090"); // No explicit port: Host stays the bare hostname. - assert.equal(seen[2].headers.Host, "ctl.test"); - assert.equal(seen[2].headers["User-Agent"], `wdl-cli/${currentCliVersion()}`); + assert.equal(third.headers.Host, "ctl.test"); + assert.equal(third.headers["User-Agent"], `wdl-cli/${currentCliVersion()}`); }); test("controlFetch parses CONTROL_CONNECT_HOST host:port overrides", async () => { const oldConnectHost = process.env.CONTROL_CONNECT_HOST; process.env.CONTROL_CONNECT_HOST = "[::1]:18443"; - /** @type {import("node:https").RequestOptions | null} */ - let seen = null; - /** @type {import("../../lib/control-fetch.js").ControlTransport} */ - const transport = { - request(opts, onResponse) { - seen = opts; - return Object.assign(new EventEmitter(), { - write() {}, - end() { - const res = fakeResponse(); - onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); - res.emit("data", Buffer.from("{}")); - res.emit("end"); - }, - destroy() {}, - }); - }, - }; + const { seen, transport } = captureSuccessfulRequestOptions(); try { await controlFetch("https://ctl.example/whoami", { transport }); @@ -384,8 +374,7 @@ test("controlFetch parses CONTROL_CONNECT_HOST host:port overrides", async () => else process.env.CONTROL_CONNECT_HOST = oldConnectHost; } - assert.ok(seen); - const opts = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen); + const opts = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[0]); assert.equal(opts.host, "::1"); assert.equal(opts.port, 18443); assert.equal(opts.headers.Host, "ctl.example"); @@ -395,24 +384,7 @@ test("controlFetch parses CONTROL_CONNECT_HOST host:port overrides", async () => test("controlFetch uses init env for CONTROL_CONNECT_HOST overrides", async () => { const oldConnectHost = process.env.CONTROL_CONNECT_HOST; process.env.CONTROL_CONNECT_HOST = "process.example:19000"; - /** @type {import("node:https").RequestOptions | null} */ - let seen = null; - /** @type {import("../../lib/control-fetch.js").ControlTransport} */ - const transport = { - request(opts, onResponse) { - seen = opts; - return Object.assign(new EventEmitter(), { - write() {}, - end() { - const res = fakeResponse(); - onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); - res.emit("data", Buffer.from("{}")); - res.emit("end"); - }, - destroy() {}, - }); - }, - }; + const { seen, transport } = captureSuccessfulRequestOptions(); try { await controlFetch("http://admin.test:8080/whoami", { @@ -424,40 +396,21 @@ test("controlFetch uses init env for CONTROL_CONNECT_HOST overrides", async () = else process.env.CONTROL_CONNECT_HOST = oldConnectHost; } - assert.ok(seen); - const opts = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen); + const opts = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[0]); assert.equal(opts.host, "127.0.0.1"); assert.equal(opts.port, 18080); assert.equal(opts.headers.Host, "admin.test:8080"); }); test("controlFetch accepts bare IPv6 CONTROL_CONNECT_HOST overrides", async () => { - /** @type {import("node:https").RequestOptions | null} */ - let seen = null; - /** @type {import("../../lib/control-fetch.js").ControlTransport} */ - const transport = { - request(opts, onResponse) { - seen = opts; - return Object.assign(new EventEmitter(), { - write() {}, - end() { - const res = fakeResponse(); - onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); - res.emit("data", Buffer.from("{}")); - res.emit("end"); - }, - destroy() {}, - }); - }, - }; + const { seen, transport } = captureSuccessfulRequestOptions(); await controlFetch("https://ctl.example/whoami", { env: { CONTROL_CONNECT_HOST: "::1" }, transport, }); - assert.ok(seen); - const opts = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen); + const opts = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[0]); assert.equal(opts.host, "::1"); assert.equal(opts.port, 443); assert.equal(opts.headers.Host, "ctl.example"); @@ -487,7 +440,7 @@ test("controlFetch rejects invalid CONTROL_CONNECT_HOST values before opening a (err) => { assert(err instanceof CliError); assert.match(err.message, /Invalid CONTROL_CONNECT_HOST/); - assert.doesNotMatch(err.message, /\u009b/, "raw C1 controls must not reach CONTROL_CONNECT_HOST errors"); + assertNoRawTerminalControls(err.message, "CONTROL_CONNECT_HOST errors"); return true; } ); @@ -499,29 +452,11 @@ test("controlFetch rejects invalid CONTROL_CONNECT_HOST values before opening a }); test("controlFetch omits TLS SNI for HTTPS IP literals", async () => { - /** @type {import("node:https").RequestOptions | null} */ - let seen = null; - /** @type {import("../../lib/control-fetch.js").ControlTransport} */ - const transport = { - request(opts, onResponse) { - seen = opts; - return Object.assign(new EventEmitter(), { - write() {}, - end() { - const res = fakeResponse(); - onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); - res.emit("data", Buffer.from("{}")); - res.emit("end"); - }, - destroy() {}, - }); - }, - }; + const { seen, transport } = captureSuccessfulRequestOptions(); await controlFetch("https://127.0.0.1/whoami", { transport }); - assert.ok(seen); - const opts = /** @type {import("node:https").RequestOptions} */ (seen); + const opts = /** @type {import("node:https").RequestOptions} */ (seen[0]); assert.equal(opts.host, "127.0.0.1"); assert.equal(opts.port, 443); assert.equal(opts.servername, undefined); diff --git a/tests/unit/cli-credentials.test.js b/tests/unit/cli-credentials.test.js index 3a80a78..c391e12 100644 --- a/tests/unit/cli-credentials.test.js +++ b/tests/unit/cli-credentials.test.js @@ -4,8 +4,7 @@ import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { isTokenStoreDisabled, loadCliControlEnv, loadCliDotEnv, protectedEnvKeys, resolveControlContext, resolveControlUrl, resolveNamespace, warnIfInsecureControlUrl } from "../../lib/credentials.js"; - -const ESC = String.fromCharCode(27); +import { ESC, assertNoRawTerminalControls } from "./helpers.js"; test("isTokenStoreDisabled honors the flag and WDL_TOKEN_STORE=off", () => { assert.equal(isTokenStoreDisabled({}, false), false); @@ -41,8 +40,7 @@ test("resolveControlUrl escapes invalid endpoint diagnostics", () => { (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /Invalid control URL/); - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach control URL errors"); - assert.doesNotMatch(message, /\u009b/, "raw C1 controls must not reach control URL errors"); + assertNoRawTerminalControls(message, "control URL errors"); return true; } ); @@ -215,8 +213,7 @@ test("loadCliDotEnv escapes invalid section names", () => { (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /invalid section name/); - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach section errors"); - assert.doesNotMatch(message, /\u009b/, "raw C1 controls must not reach section errors"); + assertNoRawTerminalControls(message, "section errors"); return true; } ); diff --git a/tests/unit/cli-d1.test.js b/tests/unit/cli-d1.test.js index 6dfbf58..3fb89fc 100644 --- a/tests/unit/cli-d1.test.js +++ b/tests/unit/cli-d1.test.js @@ -5,21 +5,18 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { runD1Command } from "../../commands/d1.js"; import { LONG_CONTROL_TIMEOUT_MS } from "../../lib/control-fetch.js"; -import { mockDeps as sharedMockDeps, response } from "./helpers.js"; +import { ESC, assertNoRawTerminalControls, mockDeps as sharedMockDeps, response } from "./helpers.js"; /** @typedef {import("../../lib/control-fetch.js").ControlFetchInit} ControlFetchInit */ /** @typedef {import("./helpers.js").ControlCall} RecordedCall */ -const ESC = String.fromCharCode(27); - /** * @param {unknown} err * @param {RegExp} expected */ function assertEscapedD1Error(err, expected) { const message = /** @type {Error} */ (err).message; - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge error lines"); + assertNoRawTerminalControls(message, "the error"); assert.match(message, expected); return true; } @@ -385,8 +382,7 @@ test("d1 migrations apply rejects symlinked SQL files", { skip: process.platform }), (err) => { const message = /** @type {Error} */ (err).message; - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge error lines"); + assertNoRawTerminalControls(message, "the error"); assert.match(message, /001_link\\u001b\[2J\\nFORGED\\rBAD\.sql is a symlink/); return true; } @@ -760,8 +756,7 @@ test("d1 execute --file escapes terminal controls in file read errors", async () }), (err) => { const message = /** @type {Error} */ (err).message; - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge error lines"); + assertNoRawTerminalControls(message, "the error"); assert.match(message, /bad\\u001b\[2J\\nFORGED\\rBAD\.sql/); return true; }, @@ -811,8 +806,7 @@ test("d1 escapes terminal controls in unexpected positional errors", async () => () => runD1Command(["migrations", badAction, "main", badArg, "--control-url", "http://ctl.test"], deps), (err) => { const message = /** @type {Error} */ (err).message; - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge error lines"); + assertNoRawTerminalControls(message, "the error"); assert.match(message, /d1 migrations apply\\u001b\[2J\\nFORGED\\rBAD received unexpected argument: bad\\u001b\[2J\\nFORGED\\rBAD/); return true; }, diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 486c7d3..15a36cd 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -22,6 +22,7 @@ import { parseExportsFromCfg, parseJsonc, parseKvNamespacesFromCfg, + packWranglerProject, parsePlatformBindingsFromCfg, parseQueues, parseR2BucketsFromCfg, @@ -38,9 +39,25 @@ import { wranglerChildEnv, } from "../../lib/wrangler-pack.js"; import { LONG_CONTROL_TIMEOUT_MS } from "../../lib/control-fetch.js"; -import { response } from "./helpers.js"; +import { checkWranglerVersion } from "../../lib/wrangler/command.js"; +import { ESC, assertNoRawTerminalControls, response } from "./helpers.js"; -const ESC = String.fromCharCode(27); +/** + * @param {() => unknown} fn + * @param {RegExp} expected + * @param {string} target + */ +function assertThrowsNoRawTerminalControls(fn, expected, target) { + assert.throws( + fn, + (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, target); + assert.match(message, expected); + return true; + } + ); +} /** * The options bag the deploy pipeline passes to its injected execFile dep. The @@ -483,13 +500,17 @@ test("collectRoutes: accepts strings and { pattern } tables, rejects non-arrays" () => collectRoutes({ route: "a", routes: ["b"] }, "wrangler.toml"), /specify either "route" or "routes"/ ); + assertThrowsNoRawTerminalControls( + () => collectRoutes({ route: "a", routes: ["b"] }, `wrangler${ESC}[2J\nFORGED\rBAD.toml`), + /specify either "route" or "routes"/, + "route config label" + ); assert.throws( () => collectRoutes({ routes: [{ bad: `x${ESC}[2J\nFORGED\rBAD` }] }, "wrangler.toml"), (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /unsupported routes entry/); - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach route errors"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge route errors"); + assertNoRawTerminalControls(message, "route errors"); return true; } ); @@ -620,6 +641,54 @@ test("parseServicesFromCfg: rejects runtime-reserved entrypoint names (__Wdl…_ ); }); +test("wrangler binding parser diagnostics escape terminal controls", () => { + const bad = `bad${ESC}[2J\nFORGED\rBAD`; + const badConfigRel = `wrangler${ESC}[2J\nFORGED\rBAD.json`; + assertThrowsNoRawTerminalControls( + () => parseQueues({ consumers: [{ queue: "jobs", max_concurrency: 4 }] }, badConfigRel), + /wrangler\\u001b\[2J\\nFORGED\\rBAD\.json/, + "config path diagnostics" + ); + assertThrowsNoRawTerminalControls( + () => parseQueues({ consumers: [{ queue: bad, max_concurrency: 4 }] }), + /max_concurrency not supported/, + "queue diagnostics" + ); + assertThrowsNoRawTerminalControls( + () => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "x", [bad]: true }] }), + /unknown field\(s\): bad\\u001b\[2J\\nFORGED\\rBAD/, + "KV diagnostics" + ); + assertThrowsNoRawTerminalControls( + () => parseServicesFromCfg({ services: [{ binding: bad, service: 123 }] }), + /service must be a non-empty string/, + "service diagnostics" + ); + assertThrowsNoRawTerminalControls( + () => parseDurableObjectsFromCfg({ + durable_objects: { bindings: [{ name: bad, class_name: "Room", script_name: "other" }] }, + migrations: [{ tag: "v1", new_classes: ["Room"] }], + }), + /script_name is not supported/, + "Durable Object diagnostics" + ); + assertThrowsNoRawTerminalControls( + () => parseWorkflowsFromCfg({ workflows: [{ name: bad, binding: "WF", class_name: "Flow", script_name: "other" }] }), + /script_name is not supported/, + "workflow diagnostics" + ); + assertThrowsNoRawTerminalControls( + () => parseExportsFromCfg({ exports: [{ entrypoint: "Public", allowed_callers: [bad] }] }), + /allowed_callers entries must be/, + "export diagnostics" + ); + assertThrowsNoRawTerminalControls( + () => parsePlatformBindingsFromCfg({ platform_bindings: [{ binding: "PAYMENT", platform: bad }] }), + /platform must match/, + "platform binding diagnostics" + ); +}); + test("collectModules: drops only top-level README, keeps nested ones", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-collect-")); try { @@ -656,11 +725,20 @@ 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"); const outside = path.join(parent, "secret"); + const bad = `evil${ESC}[2J\nFORGED\rBAD.js`; try { mkdirSync(outdir, { recursive: true }); writeFileSync(outside, "leak"); - symlinkSync(outside, path.join(outdir, "evil.js")); - assert.throws(() => collectModules(outdir), /symlink/); + symlinkSync(outside, path.join(outdir, bad)); + assert.throws( + () => collectModules(outdir), + (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "module symlink diagnostics"); + assert.match(message, /evil\\u001b\[2J\\nFORGED\\rBAD\.js/); + return true; + } + ); } finally { rmSync(parent, { recursive: true, force: true }); } @@ -868,6 +946,62 @@ test("collectAssets reports invalid .assetsignore patterns with context", () => } }); +test("collectAssets escapes terminal controls in asset diagnostics", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-assets-diagnostic-escape-")); + const bad = `bad${ESC}[2J\nFORGED\rBAD`; + const badPattern = `bad${ESC}[2J\u009b\rBAD`; + try { + writeFileSync(path.join(dir, ".assetsignore"), `${badPattern}[z-a]\n`); + assert.throws( + () => collectAssets(dir), + (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "asset ignore diagnostics"); + assert.match(message, /bad\\u001b\[2J\\u009b\\rBAD/); + return true; + } + ); + + writeFileSync(path.join(dir, ".assetsignore"), ""); + writeFileSync(path.join(dir, "real.txt"), "x"); + symlinkSync(path.join(dir, "real.txt"), path.join(dir, bad)); + assert.throws( + () => collectAssets(dir), + (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "asset path diagnostics"); + assert.match(message, /bad\\u001b\[2J\\nFORGED\\rBAD/); + return true; + } + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("collectAssets wraps native filesystem errors with escaped asset paths", { skip: process.platform === "win32" }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-assets-fs-escape-")); + const bad = `blocked${ESC}[2J\nFORGED\rBAD.txt`; + const file = path.join(dir, bad); + try { + writeFileSync(file, "secret"); + chmodSync(file, 0); + assert.throws( + () => collectAssets(dir), + (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "asset filesystem diagnostics"); + assert.match(message, /failed to read/); + assert.match(message, /blocked\\u001b\[2J\\nFORGED\\rBAD\.txt/); + return true; + } + ); + } finally { + if (existsSync(file)) chmodSync(file, 0o600); + rmSync(dir, { recursive: true, force: true }); + } +}); + test("collectAssets skips crash-leftover wdl temp configs by default", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-assets-tmpcfg-")); try { @@ -910,6 +1044,26 @@ test("resolveAssetsDir: rejects a missing, empty, or non-string assets.directory } }); +test("resolveAssetsDir: escapes terminal controls in diagnostics", () => { + const project = mkdtempSync(path.join(tmpdir(), "wdl-assets-dir-escape-")); + const bad = `missing${ESC}[2J\nFORGED\rBAD`; + const badConfigRel = `wrangler${ESC}[2J\nFORGED\rBAD.json`; + try { + assert.throws( + () => resolveAssetsDir(project, bad, badConfigRel), + (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "assets.directory diagnostics"); + assert.match(message, /wrangler\\u001b\[2J\\nFORGED\\rBAD\.json/); + assert.match(message, /missing\\u001b\[2J\\nFORGED\\rBAD/); + return true; + } + ); + } finally { + rmSync(project, { recursive: true, force: true }); + } +}); + test("resolveAssetsDir: rejects assets.directory that escapes project root", () => { const parent = mkdtempSync(path.join(tmpdir(), "wdl-assets-escape-")); const project = path.join(parent, "proj"); @@ -1011,8 +1165,7 @@ test("loadWranglerConfig: escapes parser diagnostics from config files", () => { (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /failed to parse wrangler\.toml/); - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach config parse errors"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge config parse errors"); + assertNoRawTerminalControls(message, "config parse errors"); return true; } ); @@ -1551,6 +1704,50 @@ test("parseWranglerMajorVersion accepts common wrangler --version output", () => assert.equal(parseWranglerMajorVersion("not a version"), null); }); +test("checkWranglerVersion escapes unparsable version diagnostics", () => { + const execFile = /** @type {typeof import("node:child_process").execFileSync} */ ( + /** @type {unknown} */ (() => `bad\u009b31m\nFORGED\rBAD`) + ); + assertThrowsNoRawTerminalControls( + () => checkWranglerVersion({ + execFile, + cwd: "/tmp/project", + env: {}, + wrangler: { command: "wrangler", args: [] }, + }), + /could not parse version/, + "wrangler version parse" + ); +}); + +test("checkWranglerVersion escapes failed version probe diagnostics", () => { + const execFile = /** @type {typeof import("node:child_process").execFileSync} */ ( + /** @type {unknown} */ (() => { + const err = new Error(`boom${ESC}[2J\nFORGED\rBAD\u009b`); + Object.assign(err, { + stdout: `out${ESC}[2J\nFORGED\rBAD`, + stderr: "err\u009b31m", + }); + throw err; + }) + ); + assert.throws( + () => checkWranglerVersion({ + execFile, + cwd: "/tmp/project", + env: {}, + wrangler: { command: "wrangler", args: [] }, + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "wrangler version failure"); + assert.match(message, /boom\\u001b\[2J\\nFORGED\\rBAD\\u009b/); + assert.match(message, /out\\u001b\[2J\\nFORGED\\rBAD\\nerr\\u009b31m/); + return true; + } + ); +}); + test("resolveWranglerCommand prefers explicit WDL_WRANGLER_BIN", () => { assert.deepEqual( resolveWranglerCommand({ @@ -2234,6 +2431,42 @@ test("runDeployCommand rejects non-scalar vars before bundling", async () => { } }); +test("runDeployCommand escapes terminal controls in [vars] diagnostics", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-vars-escape-")); + const bad = `BAD${ESC}[2J\nFORGED\rBAD`; + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: "src/index.js", + vars: { + [bad]: { nested: true }, + }, + })); + + let execCalled = false; + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + execCalled = true; + throw new Error("execFile should not be called"); + }, + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "[vars] diagnostics"); + assert.match(message, /BAD\\u001b\[2J\\nFORGED\\rBAD/); + return true; + } + ); + assert.equal(execCalled, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("runDeployCommand rejects runtime-internal vars before bundling", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-vars-")); try { @@ -2454,6 +2687,79 @@ test("runDeployCommand rejects vars that collide with empty declared assets", as } }); +test("packWranglerProject escapes progress output fields", async () => { + const root = mkdtempSync(path.join(tmpdir(), "wdl-pack-progress-escape-")); + const badEnv = `prod${ESC}[2J\nFORGED\rBAD`; + const projectDir = `app${ESC}[2J\nFORGED\rBAD`; + const dir = path.join(root, projectDir); + try { + mkdirSync(dir); + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: "src/index.js", + env: { [badEnv]: {} }, + })); + + /** @type {string[]} */ + const stdoutLines = []; + await packWranglerProject({ + cwd: root, + projectDir, + envName: badEnv, + stdout: (line = "") => { + stdoutLines.push(line); + }, + execFile: /** @type {typeof import("node:child_process").execFileSync} */ ((/** @type {string} */ _cmd, /** @type {readonly string[]} */ args = []) => { + 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 }); + writeFileSync(path.join(outDir, "index.js"), "export default {}"); + }), + }); + + const progress = stdoutLines.find((line) => line.includes("bundling via wrangler")); + assert.ok(progress); + assertNoRawTerminalControls(progress, "wrangler progress output"); + assert.match(progress, /env=prod\\u001b\[2J\\nFORGED\\rBAD/); + assert.match(progress, /app\\u001b\[2J\\nFORGED\\rBAD/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("packWranglerProject escapes missing entry diagnostics", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-pack-entry-escape-")); + const badMain = `src/bad${ESC}[2J\nFORGED\rBAD.ts`; + try { + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: badMain, + })); + + await assert.rejects( + () => packWranglerProject({ + cwd: dir, + projectDir: ".", + stdout: () => {}, + execFile: /** @type {typeof import("node:child_process").execFileSync} */ ((/** @type {string} */ _cmd, /** @type {readonly string[]} */ args = []) => { + 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 }); + writeFileSync(path.join(outDir, "other.js"), "export default {}"); + }), + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "missing entry diagnostics"); + assert.match(message, /bad\\u001b\[2J\\nFORGED\\rBAD/); + return true; + } + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("runDeployCommand passes through wrangler output in verbose mode", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-verbose-")); try { diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index e643216..559aea0 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -18,17 +18,14 @@ import { LONG_CONTROL_TIMEOUT_MS, UNLIMITED_CONTROL_BODY_BYTES, } from "../../lib/control-fetch.js"; -import { mockDeps, response } from "./helpers.js"; +import { ESC, assertNoRawTerminalControls, mockDeps, response } from "./helpers.js"; /** @typedef {import("./helpers.js").ControlCall} ControlCall */ -const ESC = String.fromCharCode(27); - /** @param {unknown} err */ function assertEscapedBadArg(err) { const message = /** @type {Error} */ (err).message; - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach CLI errors"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge error lines"); + assertNoRawTerminalControls(message, "CLI errors"); assert.match(message, /bad\\u001b\[2J\\nFORGED\\rBAD/); return true; } @@ -1690,8 +1687,7 @@ test("wdl dispatcher skips dotenv for top-level help and unknown commands", asyn assert.ok(errors.some((line) => line.includes("unknown command: bogus"))); const escaped = errors.find((line) => line.includes("unknown command: bad")); assert.ok(escaped); - assert.doesNotMatch(escaped, new RegExp(ESC), "raw ESC must not reach unknown-command errors"); - assert.doesNotMatch(escaped, /\nFORGED|\rBAD/, "raw line controls must not forge unknown-command errors"); + assertNoRawTerminalControls(escaped, "unknown-command errors"); assert.match(escaped, /bad\\u001b\[2J\\nFORGED\\rBAD/); } finally { process.exit = oldExit; @@ -1723,8 +1719,7 @@ test("wdl dispatcher prints parseArgs errors without a Node stack", async () => assert.equal(errors.length, 1); assert.match(errors[0], /error: Unknown option '--dsf\\u001b\[2J\\nFORGED\\rBAD'/); - assert.doesNotMatch(errors[0], new RegExp(ESC), "raw ESC must not reach parseArgs errors"); - assert.doesNotMatch(errors[0], /\nFORGED|\rBAD/, "raw line controls must not forge parseArgs errors"); + assertNoRawTerminalControls(errors[0], "parseArgs errors"); assert.doesNotMatch(errors[0], /TypeError|parse_args|Node\.js/); }); @@ -2422,8 +2417,7 @@ test("wdl tail reconnects with Last-Event-ID after transport errors", async () = const transportLine = stderrLines.find((line) => /transport error/i.test(line)); assert.ok(transportLine); assert.match(transportLine, /socket hang up\\u001b\[2J\\nFORGED\\rBAD/); - assert.doesNotMatch(transportLine, new RegExp(ESC), "raw ESC must not reach tail transport diagnostics"); - assert.doesNotMatch(transportLine, /\nFORGED|\rBAD/, "raw line controls must not forge tail transport diagnostics"); + assertNoRawTerminalControls(transportLine, "tail transport diagnostics"); }); test("wdl tail treats session recycle warnings as control-initiated reconnects", async () => { diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js index e344b41..8421b5d 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -3,15 +3,26 @@ import assert from "node:assert/strict"; +export const ESC = String.fromCharCode(27); + /** * A recorded control-plane call: the URL and the init passed to controlFetch. * Shared by the tests that assert on what mockDeps recorded. * @typedef {{ url: string, init: import("../../lib/control-fetch.js").ControlFetchInit }} ControlCall */ +// Human output may intentionally contain LF/TAB for layout; hostile fixtures use +// sentinel text after those bytes so the helper still catches forged lines. /** @param {string} text @param {string} [target] */ export function assertNoRawTerminalControls(text, target = "output") { - assert.doesNotMatch(text, new RegExp(String.fromCharCode(27)), `raw ESC must not reach ${target}`); + for (const ch of text) { + if (ch === "\n" || ch === "\t") continue; + const code = ch.charCodeAt(0); + if ((code >= 0x00 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f)) { + assert.fail(`raw terminal control U+${code.toString(16).padStart(4, "0")} must not reach ${target}`); + } + } + assert.doesNotMatch(text, new RegExp(ESC), `raw ESC must not reach ${target}`); assert.doesNotMatch(text, /\nFORGED|\rBAD/, `raw line controls must not reach ${target}`); } From de12b867c336e1437eadf146fa20e5afadc6b40e Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Fri, 10 Jul 2026 00:49:33 +0800 Subject: [PATCH 08/12] Harden CLI diagnostics for WDL adapter Adapt the CLI to the WDL 20260701/audit baseline by keeping control-canonical behavior where appropriate, centralizing terminal-safe diagnostic formatting, escaping control/config/filesystem-derived diagnostics across the touched paths, fixing Wrangler fallback and unsupported-key rejection, syncing help/docs coverage, and adding focused regression tests. Signed-off-by: Lu Zhang --- lib/wrangler-pack.js | 20 ++++++++++++++++---- lib/wrangler/command.js | 5 +++-- lib/wrangler/config.js | 4 +--- tests/unit/cli-deploy.test.js | 24 ++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index beff520..2b3537d 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -408,8 +408,19 @@ export function installTempFileCleanup( terminate = (signal) => { process.kill(process.pid, signal); } ) { let active = true; - const cleanup = () => rmSync(filePath, { force: true }); - const onExit = () => cleanup(); + /** @param {{ ignoreErrors: boolean }} options */ + const cleanup = ({ ignoreErrors }) => { + try { + rmSync(filePath, { force: true }); + } catch (err) { + if (!ignoreErrors) throw err; + } + }; + const onExit = () => { + // Best effort: this runs during process exit and must not mask the original + // failure mode. + cleanup({ ignoreErrors: true }); + }; const uninstall = () => { if (!active) return; active = false; @@ -418,7 +429,8 @@ export function installTempFileCleanup( processLike.off("SIGTERM", onSigterm); }; const handleSignal = (/** @type {"SIGINT" | "SIGTERM"} */ signal) => { - cleanup(); + // Best effort: do not interrupt signal propagation if cleanup itself fails. + cleanup({ ignoreErrors: true }); uninstall(); terminate(signal); }; @@ -428,7 +440,7 @@ export function installTempFileCleanup( processLike.once("SIGINT", onSigint); processLike.once("SIGTERM", onSigterm); return () => { - cleanup(); + cleanup({ ignoreErrors: false }); uninstall(); }; } diff --git a/lib/wrangler/command.js b/lib/wrangler/command.js index 27bdb4e..1d70389 100644 --- a/lib/wrangler/command.js +++ b/lib/wrangler/command.js @@ -207,8 +207,9 @@ function wranglerScript(dir) { function bundledWrangler() { try { - const script = resolveFromHere.resolve("wrangler/bin/wrangler.js"); - return { command: process.execPath, args: [script] }; + const packageJson = resolveFromHere.resolve("wrangler/package.json"); + const script = path.join(path.dirname(packageJson), "bin", "wrangler.js"); + return existsSync(script) ? { command: process.execPath, args: [script] } : null; } catch { return null; } diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index 0145459..f4206d2 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -354,9 +354,7 @@ function listNamedEnvironments(cfg) { * @returns {boolean} */ function hasConfiguredKey(record, key) { - if (!record || !Object.hasOwn(record, key)) return false; - const value = record[key]; - return value == null || Array.isArray(value) ? hasConfiguredValue(value) : true; + return Boolean(record && Object.hasOwn(record, key)); } /** diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 15a36cd..4375adf 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -1199,6 +1199,21 @@ test("installTempFileCleanup removes temp files on process exit and signals", () } }); +test("installTempFileCleanup only swallows cleanup errors during process handlers", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-temp-cleanup-error-")); + try { + const exitProcess = /** @type {EventEmitter & { off(event: string, listener: () => void): EventEmitter }} */ (new EventEmitter()); + installTempFileCleanup(dir, exitProcess); + assert.doesNotThrow(() => exitProcess.emit("exit")); + + const cleanupProcess = /** @type {EventEmitter & { off(event: string, listener: () => void): EventEmitter }} */ (new EventEmitter()); + const cleanup = installTempFileCleanup(dir, cleanupProcess); + assert.throws(() => cleanup(), /EISDIR|directory/i); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("resolveWranglerConfig: named environments require explicit selection", () => { assert.throws( () => resolveWranglerConfig({ @@ -1484,6 +1499,15 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy /\[workers_dev\] — not supported/ ); + assert.throws( + () => validateUnsupportedWranglerConfig({ + name: "demo", + main: "src/index.js", + vectorize: [], + }, null, "wrangler.toml"), + /\[vectorize\] — not supported/ + ); + assert.throws( () => validateUnsupportedWranglerConfig({ name: "demo", From 1a4d0038be2565b7bd3aba5578a717d88e502bfa Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Fri, 10 Jul 2026 01:40:01 +0800 Subject: [PATCH 09/12] Harden CLI diagnostics and Wrangler config handling Share CONTROL_CONNECT_HOST parsing and tighten Wrangler config rejection. Escape local diagnostic output from Wrangler, D1, assets, and token flows. Preserve D1 migration status canonical 413 behavior and sync docs/tests. Signed-off-by: Lu Zhang --- .claude/skills/wdl-deploy/SKILL.md | 7 +- README-zh.md | 4 +- README.md | 4 +- bin/wdl.js | 15 ++- commands/d1.js | 19 ++- commands/doctor.js | 9 +- commands/init.js | 3 +- docs/deploy-zh.md | 4 +- docs/deploy.md | 13 +- docs/secrets-zh.md | 2 +- docs/secrets.md | 2 +- lib/common.js | 7 +- lib/control-connect-host.js | 71 +++++++++++ lib/control-fetch.js | 58 +-------- lib/credentials.js | 18 +-- lib/d1-files.js | 9 +- lib/wrangler-pack.js | 23 ++-- lib/wrangler/assets.js | 3 +- lib/wrangler/command.js | 29 +++-- lib/wrangler/config.js | 47 ++++--- templates/AGENTS.md | 20 +-- tests/unit/cli-command.test.js | 7 +- tests/unit/cli-control-fetch.test.js | 3 +- tests/unit/cli-d1.test.js | 35 ++++-- tests/unit/cli-deploy.test.js | 176 ++++++++++++++++++++++----- tests/unit/cli-init.test.js | 14 +-- tests/unit/cli-lifecycle.test.js | 3 +- tests/unit/cli-token-store.test.js | 42 +++---- tests/unit/cli-token.test.js | 10 +- 29 files changed, 409 insertions(+), 248 deletions(-) create mode 100644 lib/control-connect-host.js diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index 5d0a92a..92a08b3 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, workers, delete, d1, r2, workflows). Trigger when the user asks to scaffold or deploy a Worker, inspect resolved CLI configuration, identify the active control token/principal, run diagnostics, tail live logs, configure KV / Queues / Durable Objects / Workflows bindings, manage D1 / R2 / secrets through `wdl`, or troubleshoot wdl CLI output. Works with `wrangler.toml` / `wrangler.jsonc` 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, workers, delete, d1, r2, workflows). Trigger when the user asks to scaffold or deploy a Worker, inspect resolved CLI configuration, identify the active control token/principal, run diagnostics, tail live logs, configure KV / Queues / Durable Objects / Workflows bindings, manage D1 / R2 / secrets through `wdl`, or troubleshoot wdl CLI output. Works with `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` projects pinned to wrangler@^4. --- # WDL CLI deploy skill @@ -51,8 +51,9 @@ target. WDL follows Wrangler config priority (`wrangler.json`, then unsupported runtime shapes such as unsupported workerd experimental compatibility flags and WDL-reserved injected module names; the CLI still fails fast for cheap local cases such as Python Workers modules, unmapped -Wrangler runtime/deploy keys (`[site]`, `workers_dev`, -`pages_build_output_dir`, `observability`, `limits`, `placement`, etc.), and +top-level or selected-env Wrangler runtime/deploy keys (`[site]`, +`workers_dev`, `pages_build_output_dir`, `observability`, `limits`, +`placement`, etc.), and ambiguous runtime `env` name collisions between `[vars]`, explicit bindings, and the implicit `ASSETS` binding. diff --git a/README-zh.md b/README-zh.md index 518e5c0..4aaf6f2 100644 --- a/README-zh.md +++ b/README-zh.md @@ -8,7 +8,7 @@ ## 与 Cloudflare Workers 的关系 -- 你写的就是标准 module worker(`export default { fetch }`),配普通的 `wrangler.toml` / `wrangler.jsonc`,pin 在 `wrangler@^4`。 +- 你写的就是标准 module worker(`export default { fetch }`),配普通的 `wrangler.json` / `wrangler.jsonc` / `wrangler.toml`,pin 在 `wrangler@^4`。 - `wdl deploy` 只用 `wrangler deploy --dry-run` 做**本地打包**——不会向 Cloudflare 发送任何东西。在 WDL 平台上不要用 `wrangler deploy` 发布,真实发布走 `wdl deploy`。 - Worker 通过平台域名上带路径前缀的 URL 提供服务: @@ -131,7 +131,7 @@ Worker/项目目录名:[如果已知就填,例如 hello-counter;不知道 `wdl init && cd && npm install` (给 `wdl init` 加 `--ns ` 可把 namespace 烤进 deploy 脚本;否则部署期从 `wdl token` 默认或 `--ns` 解析。) 4. 立刻打开并阅读新目录里的 `AGENTS.md`,再根据我的功能打开 `node_modules/@wdl-dev/cli/docs/` 下相关文档和示例。注意:session 中新生成的 `AGENTS.md` 不会自动加载,必须显式读取。 -5. 根据功能修改 `wrangler.jsonc` 和 `src/`。需要第三方 API 鉴权 secret 时用 `wdl secret put --worker ` 写入,不要把 token 放进源码、`wrangler.jsonc` 或 `.env`。 +5. 根据功能修改 `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` 和 `src/`。需要第三方 API 鉴权 secret 时用 `wdl secret put --worker ` 写入,不要把 token 放进源码、Wrangler config 或 `.env`。 6. 先跑 `npm run dry-run` 修复本地 bundle 问题,再跑 `npm run deploy` 部署。 7. 部署成功后给我 Worker URL(形态 `https://.//`)、本次改了哪些文件,以及我该如何验证。 ``` diff --git a/README.md b/README.md index f3d2e03..3139d9f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Workflows, secrets, and live logs — inside your own namespace. ## How it relates to Cloudflare Workers - You write standard module workers (`export default { fetch }`) with a normal - `wrangler.toml` / `wrangler.jsonc`, pinned to `wrangler@^4`. + `wrangler.json` / `wrangler.jsonc` / `wrangler.toml`, pinned to `wrangler@^4`. - `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`. @@ -169,7 +169,7 @@ Steps: `wdl init && cd && npm install` (add `--ns ` to `wdl init` to bake the namespace into the deploy script; otherwise it resolves from the `wdl token` default or `--ns` at deploy time.) 4. Immediately open and read `AGENTS.md` in the new directory, then open the relevant docs and examples under `node_modules/@wdl-dev/cli/docs/` for my feature. Note: a freshly generated `AGENTS.md` is not loaded automatically mid-session — read it explicitly. -5. Edit `wrangler.jsonc` and `src/` for the feature. Push third-party API secrets with `wdl secret put --worker `; never put tokens in source, `wrangler.jsonc`, or `.env`. +5. Edit `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` and `src/` for the feature. Push third-party API secrets with `wdl secret put --worker `; never put tokens in source, Wrangler config, or `.env`. 6. Run `npm run dry-run` first and fix local bundle issues, then deploy with `npm run deploy`. 7. After a successful deploy, give me the Worker URL (shape `https://.//`), the files you changed, and how I should verify. ``` diff --git a/bin/wdl.js b/bin/wdl.js index 3d82f37..1055a61 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -57,10 +57,19 @@ for (const c of REGISTRY) { export async function main(argv = process.argv.slice(2), deps = {}) { const [command, ...rest] = argv; - if (command === "help" && rest.length === 1 && Object.hasOwn(COMMANDS, rest[0])) { - return await COMMANDS[rest[0]].main(["--help"]); + if (command === "help") { + if (rest.length === 0) { + usage(0); + return; + } + if (rest.length === 1 && Object.hasOwn(COMMANDS, rest[0])) { + return await COMMANDS[rest[0]].main(["--help"]); + } + console.error(`error: unknown help topic: ${escapeTerminalText(rest.join(" "))}`); + usage(1); + return; } - if (!command || command === "-h" || command === "--help" || command === "help") { + if (!command || command === "-h" || command === "--help") { usage(command ? 0 : 1); return; } diff --git a/commands/d1.js b/commands/d1.js index 7704730..3dcd8fc 100644 --- a/commands/d1.js +++ b/commands/d1.js @@ -18,7 +18,7 @@ import { LONG_CONTROL_TIMEOUT_MS } from "../lib/control-fetch.js"; import { defineCommand } from "../lib/command.js"; import { CliError, defineCliOption, formatHelp, isMain, isPathInside, optionHelp, unexpectedArgument } from "../lib/common.js"; import { confirmAction } from "../lib/stdin.js"; -import { escapeTerminalText, writeResult } from "../lib/output.js"; +import { escapeTerminalText, formatDiagnosticValue, writeResult } from "../lib/output.js"; const D1_EXECUTE_MODES = ["all", "raw", "run", "exec"]; @@ -203,7 +203,7 @@ async function runMigrationsCommand({ action, databaseRef, context }) { await context.fetchJson(`${migrationsBase}/status`, { method: "POST", headers, - body: JSON.stringify({ migrations: migrations.map(({ sql: _sql, ...rest }) => rest) }), + body: serializeMigrationStatusRequest(migrations), }, "show d1 migration status") ); writeResult(values.json === true, body, () => formatD1MigrationStatus(body), stdout); @@ -224,6 +224,15 @@ async function runMigrationsCommand({ action, databaseRef, context }) { return; } } + +/** + * @param {import("../lib/d1-files.js").MigrationFile[]} migrations + * @returns {string} + */ +export function serializeMigrationStatusRequest(migrations) { + return JSON.stringify({ migrations: migrations.map(({ sql: _sql, ...rest }) => rest) }); +} + /** * @typedef {{ values: D1Flags, env: NodeJS.ProcessEnv, cwd: string, databaseRef: string, warn?: (line: string) => void }} MigrationsDirArgs */ @@ -313,11 +322,11 @@ function resolveMigrationsDir({ values, env, cwd, databaseRef, warn }) { const matches = byId.length > 0 ? byId : byName; if (matches.length > 1) { - throw new CliError(`${escapeTerminalText(configRel)}: multiple [[d1_databases]] entries match ${escapeTerminalText(JSON.stringify(databaseRef))}`); + throw new CliError(`${escapeTerminalText(configRel)}: multiple [[d1_databases]] entries match ${formatDiagnosticValue(databaseRef)}`); } if (matches.length === 0) { throw new CliError( - `${escapeTerminalText(configRel)}: no matching [[d1_databases]] entry for ${escapeTerminalText(JSON.stringify(databaseRef))}; ` + + `${escapeTerminalText(configRel)}: no matching [[d1_databases]] entry for ${formatDiagnosticValue(databaseRef)}; ` + "use a configured database_name/database_id or pass --dir explicitly" ); } @@ -361,7 +370,7 @@ function resolveConfiguredMigrationsDir({ configDir, migrationsDir, configRel, b if (!isPathInside(root, resolved)) { throw new CliError( `${escapeTerminalText(configRel)}: [[d1_databases]] ${escapeTerminalText(binding)}: ` + - `migrations_dir must stay inside the project (got ${escapeTerminalText(JSON.stringify(migrationsDir))})` + `migrations_dir must stay inside the project (got ${formatDiagnosticValue(migrationsDir)})` ); } return resolved; diff --git a/commands/doctor.js b/commands/doctor.js index 93c6c50..95969fa 100644 --- a/commands/doctor.js +++ b/commands/doctor.js @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import path from "node:path"; import { defineCommand } from "../lib/command.js"; import { CliError, defineCliOption, formatHelp, isMain, isNonEmptyString, optionHelp } from "../lib/common.js"; @@ -20,7 +20,7 @@ import { resolveWranglerCommand, wranglerChildEnv, } from "../lib/wrangler/command.js"; -import { WRANGLER_CONFIG_CANDIDATES } from "../lib/wrangler/config.js"; +import { selectWranglerConfigFiles } from "../lib/wrangler/config.js"; const DOCTOR_OPTIONS = [ defineCliOption("strict", { type: "boolean" }, "--strict", "Exit non-zero if any check fails."), @@ -232,9 +232,8 @@ function checkTokenStore(state) { /** @param {string} cwd */ function checkWranglerConfig(cwd) { - const found = WRANGLER_CONFIG_CANDIDATES.filter((candidate) => existsSync(path.join(cwd, candidate))); - const name = found[0]; - const shadowed = found.slice(1); + const { selected, shadowed } = selectWranglerConfigFiles(cwd); + const name = selected?.name; return check({ ok: Boolean(name), label: name ? `Wrangler config ${name}` : "Wrangler config", diff --git a/commands/init.js b/commands/init.js index 5252f6e..55b834c 100644 --- a/commands/init.js +++ b/commands/init.js @@ -6,6 +6,7 @@ import { isHelpAlias } from "../lib/command.js"; import { CliError, defineCliOption, formatHelp, handleCliError, isMain, isNonEmptyString, optionHelp, optionParseOptions } from "../lib/common.js"; import { NS_PATTERN, RESERVED_TENANT_NS, isReservedNs } from "../lib/ns-pattern.js"; import { escapeTerminalText } from "../lib/output.js"; +import { WRANGLER_WDL_TMP_PREFIX } from "../lib/wrangler/config.js"; const NAME_REGEX = /^[A-Za-z][A-Za-z0-9-]*$/; const WORKER_NAME_REGEX = /^[A-Za-z0-9][A-Za-z0-9_-]{0,254}$/; @@ -222,7 +223,7 @@ async function writeStarter(targetDir, { packageName, workerName, ns }) { `node_modules/ .deploy-dist/ .wrangler/ -.wrangler.wdl-tmp*.json +${WRANGLER_WDL_TMP_PREFIX}*.json *.log # Never commit tenant credentials diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index 656a573..351c388 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -95,9 +95,9 @@ wdl deploy . --env production 如果同时存在多个 Wrangler 配置文件,CLI 跟随 Wrangler 的优先级:`wrangler.json`,然后 `wrangler.jsonc`,最后 `wrangler.toml`。 -**支持:** `name`、`main`、`compatibility_date`/`flags`、`[vars]`、`[[kv_namespaces]]`、`[[d1_databases]]`、`[[durable_objects.bindings]]`、`[[workflows]]`、`[[r2_buckets]]`、`[assets] directory`、`[triggers] crons`、`[[triggers.schedules]]`(带 timezone,平台扩展)、`[[queues.producers]]` / `[[queues.consumers]]`、`[[services]]`、`[[platform_bindings]]`、`[env.]`。 +**支持:** `name`、`main`、`compatibility_date` / `compatibility_flags`、`[vars]`、`[[kv_namespaces]]`、`[[d1_databases]]`、`[[durable_objects.bindings]]`、`[[workflows]]`、`[[r2_buckets]]`、`[assets] directory`、`[triggers] crons`、`[[triggers.schedules]]`(带 timezone,平台扩展)、`[[queues.producers]]` / `[[queues.consumers]]`、`[[services]]`、`[[platform_bindings]]`、`[[exports]]`、`[env.]`。 -**不支持(部署失败):** 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 experimental compatibility flags 和 WDL 保留注入模块名会在部署时被拒绝:CLI 会对本地 `.py` module fail-fast,workerd compatibility 与 bundle-shape policy 由 control plane canonical 判断。WDL 会忽略、且无法映射进 manifest 的 Wrangler runtime/deploy 配置也会由 CLI 直接拒绝,包括 legacy `[site]` Workers Sites、`workers_dev`、`pages_build_output_dir`、`observability`、`limits`、`placement`,以及错误信息点名的其它 unsupported binding/config section。`assets.run_worker_first` 会被静默忽略。 +**不支持(部署失败):** 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 experimental 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、`workers_dev`、`pages_build_output_dir`、`observability`、`limits`、`placement`,以及错误信息点名的其它 unsupported binding/config field 或 section。`assets.run_worker_first` 会被静默忽略。 Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在可路由的 tenant Worker 上。通过 `[[platform_bindings]]` 选择的 Worker 是冷加载的平台能力,不是 public/runtime dispatch 目标,不能声明 cron triggers 或 queue consumers。 diff --git a/docs/deploy.md b/docs/deploy.md index 159dff9..5699543 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -160,12 +160,12 @@ names like `my-worker-preview`; WDL does not append that suffix. See When multiple Wrangler config files exist, the CLI follows Wrangler's priority: `wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`. -**Supported:** `name`, `main`, `compatibility_date`/`flags`, `[vars]`, +**Supported:** `name`, `main`, `compatibility_date` / `compatibility_flags`, `[vars]`, `[[kv_namespaces]]`, `[[d1_databases]]`, `[[durable_objects.bindings]]`, `[[workflows]]`, `[[r2_buckets]]`, `[assets] directory`, `[triggers] crons`, `[[triggers.schedules]]` (with timezone, a platform extension), `[[queues.producers]]` / `[[queues.consumers]]`, `[[services]]`, -`[[platform_bindings]]`, `[env.]`. +`[[platform_bindings]]`, `[[exports]]`, `[env.]`. **Unsupported (deploy fails):** Analytics Engine. Durable Objects supports same-worker classes only; `script_name` and rename/delete migrations are not @@ -177,10 +177,11 @@ supported only when the operator enables them. Python Workers modules, workerd experimental compatibility flags, and WDL-reserved injected module names are rejected during deploy: the CLI fails fast on local `.py` modules, and the control plane is canonical for workerd compatibility and bundle-shape policy. -Unmapped Wrangler runtime/deploy config that WDL would otherwise ignore is also -rejected by the CLI, including legacy `[site]` Workers Sites, `workers_dev`, -`pages_build_output_dir`, `observability`, `limits`, `placement`, and other -unsupported binding/config sections named in the error. +Top-level or selected-environment Wrangler runtime/deploy config fields and +sections that WDL would otherwise ignore are also rejected by the CLI, including +legacy `[site]` Workers Sites, `workers_dev`, `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. Cron triggers and queue consumers are runtime dispatch features; declare them diff --git a/docs/secrets-zh.md b/docs/secrets-zh.md index bad89f0..9b2f054 100644 --- a/docs/secrets-zh.md +++ b/docs/secrets-zh.md @@ -72,7 +72,7 @@ export default { ## 反模式 - ❌ `[vars] = { STRIPE_KEY = "sk_live_..." }`。`[vars]` 会进 bundle。用 `wdl secret put`。 -- ❌ 把第三方 API token 硬编码到 `.env` 或 `wrangler.jsonc`。用 `wdl secret put` 推送。 +- ❌ 把第三方 API token 硬编码到 `.env` 或 Wrangler config。用 `wdl secret put` 推送。 - ❌ 在没有先 `wdl secret list` 并跟用户确认时,给 `wdl secret delete` 加 `--yes`。 - ❌ 用 `echo "$VAL" |` 而不是 `printf '%s' "$VAL" |`。`echo` 会在末尾加换行符,被一并写进 secret 值里。 - ❌ 期望命名空间级 secret 立即给每个 worker 生效。它不会 —— 下次冷启动时才生效。需要"立刻生效"用 worker 级 secret。 diff --git a/docs/secrets.md b/docs/secrets.md index 6c69255..4e8c4d3 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -97,7 +97,7 @@ export default { - ❌ `[vars] = { STRIPE_KEY = "sk_live_..." }`. `[vars]` goes into the bundle. Use `wdl secret put`. -- ❌ Hardcoding third-party API tokens in `.env` or `wrangler.jsonc`. Push them +- ❌ Hardcoding third-party API tokens in `.env` or Wrangler config. Push them with `wdl secret put`. - ❌ Adding `--yes` to `wdl secret delete` without running `wdl secret list` first and confirming with the user. diff --git a/lib/common.js b/lib/common.js index a5543e6..9d286f2 100644 --- a/lib/common.js +++ b/lib/common.js @@ -1,9 +1,7 @@ import { realpathSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { escapeTerminalText } from "./output.js"; - - +import { escapeTerminalText, formatDiagnosticValue } from "./output.js"; export class CliError extends Error { /** @@ -355,8 +353,7 @@ export function formatHttpError(status, text, headers = {}) { continue; } if (ARRAY_CONTEXT_KEYS.has(key) && Array.isArray(value) && value.length > 0) { - // JSON.stringify escapes C0 controls but leaves C1 bytes raw. - context.push(`${safeKey}=${escapeTerminalText(JSON.stringify(value))}`); + context.push(`${safeKey}=${formatDiagnosticValue(value)}`); } } if (context.length > 0) parts.push(context.join(" ")); diff --git a/lib/control-connect-host.js b/lib/control-connect-host.js new file mode 100644 index 0000000..f745f0d --- /dev/null +++ b/lib/control-connect-host.js @@ -0,0 +1,71 @@ +import { isIP } from "node:net"; +import { CliError } from "./common.js"; +import { formatDiagnosticValue } from "./output.js"; + +/** + * @param {string} raw + * @param {number} defaultPort + * @returns {{ host: string, port: number }} + */ +export function parseControlConnectHost(raw, defaultPort) { + const text = raw.trim(); + const display = formatDiagnosticValue(raw); + if (!text) throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}: host is required`); + if (/^[a-z][a-z\d+.-]*:\/\//i.test(text)) { + try { + const parsed = new URL(text); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}: URL scheme must be http or https`); + } + const host = bareUrlHostname(parsed); + if (!host) throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}: host is required`); + return { host, port: validPort(parsed.port || defaultPort, raw) }; + } catch (err) { + if (err instanceof CliError) throw err; + throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}.`); + } + } + + const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(text); + if (bracketed) { + return { host: bracketed[1], port: validPort(bracketed[2] || defaultPort, raw) }; + } + const hostPort = /^([^:]+):(\d+)$/.exec(text); + if (hostPort) return { host: hostPort[1], port: validPort(hostPort[2], raw) }; + if (isIP(text) === 6) return { host: text, port: defaultPort }; + if (text.includes(":")) { + throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}: use host:port or [ipv6]:port`); + } + return { host: text, port: defaultPort }; +} + +/** + * Warning paths should not make a malformed override fail before the request + * path reports the canonical validation error. + * @param {string} raw + */ +export function controlConnectHostForWarning(raw) { + try { + return parseControlConnectHost(raw, 1).host; + } catch { + return raw.trim(); + } +} + +/** @param {URL} u */ +export function bareUrlHostname(u) { + const match = /^\[(.*)\]$/.exec(u.hostname); + return match ? match[1] : u.hostname; +} + +/** + * @param {string | number} value + * @param {string} raw + */ +function validPort(value, raw) { + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new CliError(`Invalid CONTROL_CONNECT_HOST ${formatDiagnosticValue(raw)}: port must be in [1, 65535]`); + } + return port; +} diff --git a/lib/control-fetch.js b/lib/control-fetch.js index 22b81f5..d40fb47 100644 --- a/lib/control-fetch.js +++ b/lib/control-fetch.js @@ -10,6 +10,7 @@ import https from "node:https"; import { isIP } from "node:net"; import { Transform } from "node:stream"; import { CliError } from "./common.js"; +import { bareUrlHostname, parseControlConnectHost } from "./control-connect-host.js"; import { escapeTerminalText, formatDiagnosticValue } from "./output.js"; import { currentCliVersion } from "./package-info.js"; @@ -267,62 +268,7 @@ function controlConnectTarget(u, isHttps, env) { const defaultPort = Number(u.port) || (isHttps ? 443 : 80); const override = env.CONTROL_CONNECT_HOST; if (!override) return { host: bareHostname(u), port: defaultPort }; - return parseConnectHost(override, defaultPort); -} - -/** - * @param {string} raw - * @param {number} defaultPort - * @returns {{ host: string, port: number }} - */ -function parseConnectHost(raw, defaultPort) { - const text = raw.trim(); - const display = formatDiagnosticValue(raw); - if (!text) throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}: host is required`); - if (/^[a-z][a-z\d+.-]*:\/\//i.test(text)) { - try { - const parsed = new URL(text); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}: URL scheme must be http or https`); - } - const host = bareUrlHostname(parsed); - if (!host) throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}: host is required`); - return { host, port: validPort(parsed.port || defaultPort, raw) }; - } catch (err) { - if (err instanceof CliError) throw err; - throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}.`); - } - } - - const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(text); - if (bracketed) { - return { host: bracketed[1], port: validPort(bracketed[2] || defaultPort, raw) }; - } - const hostPort = /^([^:]+):(\d+)$/.exec(text); - if (hostPort) return { host: hostPort[1], port: validPort(hostPort[2], raw) }; - if (isIP(text) === 6) return { host: text, port: defaultPort }; - if (text.includes(":")) { - throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}: use host:port or [ipv6]:port`); - } - return { host: text, port: defaultPort }; -} - -/** - * @param {string | number} value - * @param {string} raw - */ -function validPort(value, raw) { - const port = Number(value); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new CliError(`Invalid CONTROL_CONNECT_HOST ${formatDiagnosticValue(raw)}: port must be in [1, 65535]`); - } - return port; -} - -/** @param {URL} u */ -function bareUrlHostname(u) { - const match = /^\[(.*)\]$/.exec(u.hostname); - return match ? match[1] : u.hostname; + return parseControlConnectHost(override, defaultPort); } // URL.hostname keeps IPv6 literals bracketed ("[::1]"), but the socket layer diff --git a/lib/credentials.js b/lib/credentials.js index 7ac5ec7..e2d1440 100644 --- a/lib/credentials.js +++ b/lib/credentials.js @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import { CliError, hasErrorCode, isNonEmptyString } from "./common.js"; +import { controlConnectHostForWarning } from "./control-connect-host.js"; import { CLI_DOTENV_KEYS, parseDotEnvSection, parseDotEnvValue } from "./dotenv.js"; import { isAdminAcceptableNs } from "./ns-pattern.js"; import { escapeTerminalText, formatDiagnosticValue } from "./output.js"; @@ -116,7 +117,7 @@ function insecureControlUrlReason(controlUrl, env) { } const connectHost = env.CONTROL_CONNECT_HOST; if (!isNonEmptyString(connectHost)) return null; - const actualHost = controlConnectHostname(connectHost); + const actualHost = controlConnectHostForWarning(connectHost); if (!isLocalDevHost(actualHost)) { return `control URL ${escapeTerminalText(controlUrl)} is plain http and ` + `CONTROL_CONNECT_HOST=${escapeTerminalText(connectHost)} is non-local`; @@ -139,21 +140,6 @@ export function isLocalDevHost(host) { ); } -/** @param {string} value */ -function controlConnectHostname(value) { - const text = value.trim(); - if (/^[a-z][a-z\d+.-]*:\/\//i.test(text)) { - try { - return new URL(text).hostname; - } catch {} - } - const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(text); - if (bracketed) return bracketed[1]; - const hostPort = /^([^:]+):\d+$/.exec(text); - if (hostPort) return hostPort[1]; - return text; -} - /** * @param {Record} values * @param {NodeJS.ProcessEnv} [env] diff --git a/lib/d1-files.js b/lib/d1-files.js index 3adea59..21426ce 100644 --- a/lib/d1-files.js +++ b/lib/d1-files.js @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs"; import path from "node:path"; import { CliError, isPathInside } from "./common.js"; @@ -96,8 +96,13 @@ export function readMigrationFiles(dir = "migrations") { function readMigrationFile(root, filename) { const file = path.join(root, filename); try { - if (!statSync(file).isFile()) return null; + const st = lstatSync(file); + if (st.isSymbolicLink()) { + throw new CliError(`d1 migrations: ${escapeTerminalText(filename)} is a symlink; copy the SQL file into the migrations dir`); + } + if (!st.isFile()) return null; } catch (err) { + if (err instanceof CliError) throw err; throw migrationFileReadError(filename, err); } let sql; diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index 2b3537d..de2343b 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -35,6 +35,7 @@ import { loadWranglerConfig, resolveWranglerConfig, validateUnsupportedWranglerConfig, + WRANGLER_WDL_TMP_PREFIX, } from "./wrangler/config.js"; import { collectModules } from "./wrangler/modules.js"; import { asRecord, hasOwn, manifestMap } from "./wrangler/utils.js"; @@ -70,9 +71,12 @@ export { loadWranglerConfig, parseJsonc, resolveWranglerConfig, + selectWranglerConfigFiles, stripJsonComments, stripTrailingCommas, validateUnsupportedWranglerConfig, + WRANGLER_WDL_TMP_IGNORE_PATTERN, + WRANGLER_WDL_TMP_PREFIX, } from "./wrangler/config.js"; export { collectModules } from "./wrangler/modules.js"; @@ -255,13 +259,14 @@ export async function packWranglerProject({ `[1/3] bundling via wrangler${envName ? ` (env=${escapeTerminalText(envName)})` : ""} → ` + `${escapeTerminalText(path.relative(cwd, outDir))}` ); - const tmpConfigPath = path.join(absProject, `.wrangler.wdl-tmp-${randomUUID()}.json`); + const tmpConfigPath = path.join(absProject, `${WRANGLER_WDL_TMP_PREFIX}-${randomUUID()}.json`); // resolveWranglerConfig already verified rawCfg is a plain object. const rawCfgObject = /** @type {Record} */ (rawCfg); writeFileSync(tmpConfigPath, JSON.stringify({ ...rawCfgObject, name: "wdl-bundle-tmp" }), { flag: "wx", }); const cleanupTempConfig = installTempFileCleanup(tmpConfigPath); + let bundlingFailed = false; try { const wranglerArgs = [ ...wrangler.args, @@ -288,9 +293,10 @@ export async function packWranglerProject({ execFile(wrangler.command, wranglerArgs, wranglerOpts); stdout(" bundled by wrangler"); } catch (err) { + bundlingFailed = true; throw new CliError(formatWranglerFailure(err)); } finally { - cleanupTempConfig(); + cleanupTempConfig({ ignoreErrors: bundlingFailed }); } const modules = wrapCli(() => collectModules(outDir)); @@ -376,7 +382,7 @@ export function collectRoutes(cfg, configRel) { if (typeof r === "string") collected.push(r); else if (r && typeof r === "object" && typeof (/** @type {Record} */ (r).pattern) === "string") { collected.push(/** @type {string} */ (/** @type {Record} */ (r).pattern)); - } else throw new CliError(`unsupported ${source} entry: ${escapeTerminalText(JSON.stringify(r))}`); + } else throw new CliError(`unsupported ${source} entry: ${formatDiagnosticValue(r)}`); }; if (cfg.route !== undefined && cfg.routes !== undefined) { throw new CliError(`${configRel}: specify either "route" or "routes", not both`); @@ -400,7 +406,7 @@ export function collectRoutes(cfg, configRel) { * off(event: string, listener: () => void): unknown, * }} [processLike] * @param {(signal: "SIGINT" | "SIGTERM") => void} [terminate] - * @returns {() => void} + * @returns {(options?: { ignoreErrors?: boolean }) => void} */ export function installTempFileCleanup( filePath, @@ -439,9 +445,12 @@ export function installTempFileCleanup( processLike.once("exit", onExit); processLike.once("SIGINT", onSigint); processLike.once("SIGTERM", onSigterm); - return () => { - cleanup({ ignoreErrors: false }); - uninstall(); + return ({ ignoreErrors = false } = {}) => { + try { + cleanup({ ignoreErrors }); + } finally { + uninstall(); + } }; } diff --git a/lib/wrangler/assets.js b/lib/wrangler/assets.js index 7ad6bd1..d32f23b 100644 --- a/lib/wrangler/assets.js +++ b/lib/wrangler/assets.js @@ -1,6 +1,7 @@ import { existsSync, lstatSync, readFileSync, realpathSync, readdirSync } from "node:fs"; import path from "node:path"; import { escapeTerminalText, formatDiagnosticValue } from "../output.js"; +import { WRANGLER_WDL_TMP_IGNORE_PATTERN } from "./config.js"; import { manifestMap } from "./utils.js"; export const MAX_ASSET_FILE_BYTES = 25 * 1024 * 1024; @@ -20,7 +21,7 @@ const DEFAULT_ASSET_IGNORE_PATTERNS = [ "**/.DS_Store", "/.wrangler", "/.deploy-dist", - "/.wrangler.wdl-tmp*.json", + WRANGLER_WDL_TMP_IGNORE_PATTERN, "**/.env", "**/.env.*", ]; diff --git a/lib/wrangler/command.js b/lib/wrangler/command.js index 1d70389..46f577c 100644 --- a/lib/wrangler/command.js +++ b/lib/wrangler/command.js @@ -5,7 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { CliError } from "../common.js"; import { WRANGLER_SCRUB_KEYS } from "../dotenv.js"; -import { escapeTerminalText, formatDiagnosticValue } from "../output.js"; +import { escapeTerminalLines, escapeTerminalText, formatDiagnosticValue } from "../output.js"; const CLI_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const resolveFromHere = createRequire(import.meta.url); @@ -50,16 +50,18 @@ export function resolveWranglerCommand({ return { command: env.WDL_WRANGLER_BIN, args: [], source: "WDL_WRANGLER_BIN" }; } - const searchDirs = uniquePaths([absProject, ...packageDirs]); - for (const dir of searchDirs) { - if (!dir) continue; - const local = localWrangler(dir, platform); - if (local) return { ...local, source: "local" }; + if (absProject) { + const projectLocal = localWrangler(path.resolve(absProject), platform); + if (projectLocal) return { ...projectLocal, source: "project" }; } - if (searchDirs.includes(CLI_DIR)) { - const bundled = bundledWrangler(); - if (bundled) return { ...bundled, source: "package" }; + for (const dir of uniquePaths(packageDirs)) { + if (dir === CLI_DIR) { + const bundled = bundledWrangler(); + if (bundled) return { ...bundled, source: "package" }; + } + const local = localWrangler(dir, platform); + if (local) return { ...local, source: "package" }; } const fromPath = pathWrangler(env, platform); @@ -137,14 +139,14 @@ export function wranglerChildEnv(env) { */ export function formatWranglerFailure(rawErr) { const err = asExecFailure(rawErr); - const reason = err.status ?? err.signal ?? err.message ?? "unknown"; + const reason = escapeTerminalText(err.status ?? err.signal ?? err.message ?? "unknown"); const output = [err.stdout, err.stderr] .map(toText) .filter(Boolean) .join("\n") .trim(); if (!output) return `wrangler build failed (${reason})`; - return `wrangler build failed (${reason})\n${truncateOutput(output)}`; + return `wrangler build failed (${reason})\n${escapeTerminalLines(truncateOutput(output))}`; } /** @@ -252,14 +254,15 @@ function formatWranglerVersionFailure(rawErr) { .filter(Boolean) .join("\n") .trim(); - const shownOutput = escapeTerminalText(truncateOutput(output)); + const shownOutput = escapeTerminalLines(truncateOutput(output)); let message = output ? `wrangler version check failed (${reason})\n${shownOutput}` : `wrangler version check failed (${reason})`; if (err.code === "ENOENT") { message += "\nNo runnable wrangler found. Install wrangler@^4 in the Worker project " + - "(npm i -D wrangler), or set WDL_WRANGLER_BIN to a runnable wrangler entry."; + "(npm i -D wrangler), set WDL_WRANGLER_BIN to a runnable wrangler entry, " + + "or set WDL_ALLOW_NPX_WRANGLER=1."; } return message; } diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index f4206d2..7bd06f1 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { parse as parseToml } from "smol-toml"; -import { escapeTerminalText } from "../output.js"; +import { escapeTerminalText, formatDiagnosticValue } from "../output.js"; import { asRecord } from "./utils.js"; /** @@ -105,25 +105,40 @@ const NON_INHERITABLE_ENV_KEYS = new Set([ ]); export const WRANGLER_CONFIG_CANDIDATES = Object.freeze(["wrangler.json", "wrangler.jsonc", "wrangler.toml"]); +export const WRANGLER_WDL_TMP_PREFIX = ".wrangler.wdl-tmp"; +export const WRANGLER_WDL_TMP_IGNORE_PATTERN = `/${WRANGLER_WDL_TMP_PREFIX}*.json`; /** * @typedef {{ path: string, cfg: unknown, shadowed: string[] }} LoadedWranglerConfig */ +/** + * @typedef {{ selected: { name: string, path: string } | null, shadowed: string[] }} WranglerConfigSelection + */ + +/** + * @param {string} dir + * @returns {WranglerConfigSelection} + */ +export function selectWranglerConfigFiles(dir) { + const found = WRANGLER_CONFIG_CANDIDATES.filter((name) => existsSync(path.join(dir, name))); + const selected = found[0] ? { name: found[0], path: path.join(dir, found[0]) } : null; + return { selected, shadowed: found.slice(1) }; +} + /** * @param {string} dir * @returns {LoadedWranglerConfig} */ export function loadWranglerConfig(dir) { - const found = WRANGLER_CONFIG_CANDIDATES.filter((name) => existsSync(path.join(dir, name))); - for (const name of found) { - const p = path.join(dir, name); + const selection = selectWranglerConfigFiles(dir); + if (selection.selected) { + const { name, path: p } = selection.selected; const raw = readFileSync(p, "utf8"); try { - const shadowed = found.slice(1); - if (name.endsWith(".toml")) return { path: p, cfg: parseToml(raw), shadowed }; - if (name.endsWith(".jsonc")) return { path: p, cfg: parseJsonc(raw), shadowed }; - return { path: p, cfg: JSON.parse(raw), shadowed }; + if (name.endsWith(".toml")) return { path: p, cfg: parseToml(raw), shadowed: selection.shadowed }; + if (name.endsWith(".jsonc")) return { path: p, cfg: parseJsonc(raw), shadowed: selection.shadowed }; + return { path: p, cfg: JSON.parse(raw), shadowed: selection.shadowed }; } catch (err) { const message = err instanceof Error && err.message ? err.message : String(err); throw new Error(`failed to parse ${escapeTerminalText(name)}: ${escapeTerminalText(message)}`, { cause: err }); @@ -157,12 +172,12 @@ export function validateUnsupportedWranglerConfig(rawCfg, envName, configRel = " for (const key of UNSUPPORTED_WRANGLER_KEYS) { if (hasConfiguredKey(cfg, key)) { throw new Error( - `${shownConfig} uses [${key}] — not supported. ${SUPPORTED_WRANGLER_SUMMARY}` + `${shownConfig} uses unsupported Wrangler field ${formatDiagnosticValue(key)}. ${SUPPORTED_WRANGLER_SUMMARY}` ); } if (hasConfiguredKey(selectedEnvCfg, key)) { throw new Error( - `${shownConfig} env.${shownEnv} uses [${key}] — not supported. ${SUPPORTED_WRANGLER_SUMMARY}` + `${shownConfig} env.${shownEnv} uses unsupported Wrangler field ${formatDiagnosticValue(key)}. ${SUPPORTED_WRANGLER_SUMMARY}` ); } } @@ -172,10 +187,10 @@ export function validateUnsupportedWranglerConfig(rawCfg, envName, configRel = " const allowedCallersHint = 'Authorize cross-namespace service-binding callers on the target via ' + '[[exports]] (entrypoint = "default", allowed_callers = [...]).'; - if (hasConfiguredValue(cfg?.allowed_callers)) { + if (hasConfiguredKey(cfg, "allowed_callers")) { throw new Error(`${shownConfig} uses top-level allowed_callers — removed. ${allowedCallersHint}`); } - if (hasConfiguredValue(selectedEnvCfg?.allowed_callers)) { + if (hasConfiguredKey(selectedEnvCfg, "allowed_callers")) { throw new Error(`${shownConfig} env.${shownEnv} uses top-level allowed_callers — removed. ${allowedCallersHint}`); } } @@ -356,11 +371,3 @@ function listNamedEnvironments(cfg) { function hasConfiguredKey(record, key) { return Boolean(record && Object.hasOwn(record, key)); } - -/** - * @param {unknown} value - * @returns {boolean} - */ -function hasConfiguredValue(value) { - return Array.isArray(value) ? value.length > 0 : Boolean(value); -} diff --git a/templates/AGENTS.md b/templates/AGENTS.md index a60a78e..af028d7 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -34,9 +34,9 @@ package. | Storing control-plane tokens locally | `token.md` | | Deploy / dry-run / list and delete workers | `deploy.md` | -Open the relevant doc before editing `wrangler.jsonc` / `wrangler.toml` or -`src/`. When combining features (say "cron + KV + assets"), read each matching -doc and merge their wrangler config snippets. +Open the relevant doc before editing `wrangler.json` / `wrangler.jsonc` / +`wrangler.toml` or `src/`. When combining features (say "cron + KV + assets"), +read each matching doc and merge their wrangler config snippets. New Wrangler configs should use `compatibility_date = "2026-06-17"` unless a project feature requires a newer target or the operator gives a different @@ -44,9 +44,11 @@ target. WDL follows Wrangler config priority (`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`). The control plane is canonical for unsupported runtime shapes such as unsupported workerd experimental compatibility flags and WDL-reserved injected module names; the -CLI still fails fast for cheap local cases such as Python Workers modules and -ambiguous runtime `env` name collisions between `[vars]`, explicit bindings, -and the implicit `ASSETS` binding. +CLI still fails fast for cheap local cases such as Python Workers modules, +unmapped top-level or selected-env Wrangler runtime/deploy keys (`[site]`, +`workers_dev`, `pages_build_output_dir`, etc.), and ambiguous runtime `env` +name collisions between `[vars]`, explicit bindings, and the implicit `ASSETS` +binding. ## Runnable end-to-end examples @@ -67,10 +69,10 @@ When a snippet is not enough and you need a complete working file tree: ## Project-level anti-patterns -- ❌ Hardcoding third-party API tokens or keys into code, `.env`, or - `wrangler.jsonc`. Push them with `wdl secret put --worker ` — the +- ❌ Hardcoding third-party API tokens or keys into code, `.env`, or Wrangler + config. Push them with `wdl secret put --worker ` — the secret value is read from stdin (type it interactively, or pipe / redirect it - in, e.g. `echo -n "$VALUE" | wdl secret put --worker `); it is + in, e.g. `printf '%s' "$VALUE" | wdl secret put --worker `); it is deliberately not a command-line argument so it stays out of shell history. - ❌ Testing platform bindings with `wrangler dev` — `[[platform_bindings]]` never resolves in any local runtime; the binding is `undefined` locally and diff --git a/tests/unit/cli-command.test.js b/tests/unit/cli-command.test.js index c7f0c2d..629696c 100644 --- a/tests/unit/cli-command.test.js +++ b/tests/unit/cli-command.test.js @@ -2,13 +2,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { defineCommand } from "../../lib/command.js"; import { CliError, defineCliOption } from "../../lib/common.js"; -import { response } from "./helpers.js"; +import { ESC, assertNoRawTerminalControls, response } from "./helpers.js"; /** @typedef {Parameters[0]} CommandSpec */ /** @typedef {import("../../lib/command.js").CommandContext} CommandContext */ -const ESC = String.fromCharCode(27); - // Most tests don't care about name/summary; default them so each case only // states the fields it exercises. /** @param {Omit & { name?: string, summary?: string }} spec */ @@ -53,8 +51,7 @@ test("defineCommand direct runner escapes parseArgs errors", async () => { () => cmd.run([bad]), (err) => { assert(err instanceof CliError); - assert.doesNotMatch(err.message, new RegExp(ESC), "raw ESC must not reach direct runner errors"); - assert.doesNotMatch(err.message, /\nFORGED|\rBAD/, "raw line controls must not forge direct runner errors"); + assertNoRawTerminalControls(err.message, "direct runner errors"); assert.match(err.message, /--bad\\u001b\[2J\\nFORGED\\rBAD/); return true; }, diff --git a/tests/unit/cli-control-fetch.test.js b/tests/unit/cli-control-fetch.test.js index 0a2bc3e..6e15229 100644 --- a/tests/unit/cli-control-fetch.test.js +++ b/tests/unit/cli-control-fetch.test.js @@ -149,8 +149,7 @@ test("controlFetch escapes transport error details", async () => { (err) => { assert(err instanceof CliError); assert.match(err.message, /E_BAD\\u001b boom\\u001b\[2J\\nFORGED\\rBAD/); - assert.doesNotMatch(err.message, new RegExp(ESC), "raw ESC must not reach control request errors"); - assert.doesNotMatch(err.message, /\nFORGED|\rBAD/, "raw line controls must not forge control request errors"); + assertNoRawTerminalControls(err.message, "control request errors"); return true; } ); diff --git a/tests/unit/cli-d1.test.js b/tests/unit/cli-d1.test.js index 3fb89fc..44fac09 100644 --- a/tests/unit/cli-d1.test.js +++ b/tests/unit/cli-d1.test.js @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { runD1Command } from "../../commands/d1.js"; +import { runD1Command, serializeMigrationStatusRequest } from "../../commands/d1.js"; import { LONG_CONTROL_TIMEOUT_MS } from "../../lib/control-fetch.js"; import { ESC, assertNoRawTerminalControls, mockDeps as sharedMockDeps, response } from "./helpers.js"; @@ -312,18 +312,33 @@ test("d1 migrations apply surfaces control request body size errors", async () = } }); +test("d1 migrations status can send oversized metadata bodies to control", () => { + const migrations = Array.from({ length: 2_200 }, (_, i) => { + const stem = `${String(i).padStart(4, "0")}_${"m".repeat(220)}`; + return { + id: `${stem}.sql`, + name: stem, + checksum: "a".repeat(64), + sql: "select 1;", + }; + }); + const body = serializeMigrationStatusRequest(migrations); + assert.ok( + body.length > 1_048_576, + "oversized local migration metadata is sent to control for canonical rejection" + ); + assert.equal(body.includes("select 1;"), false); + const parsed = /** @type {{ migrations: Array> }} */ (parseBody(body)); + assert.equal(parsed.migrations.length, migrations.length); + assert.equal(Object.hasOwn(parsed.migrations[0], "sql"), false); +}); + test("d1 migrations status surfaces control request body size errors", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-status-413-")); try { const migrations = path.join(dir, "migrations"); mkdirSync(migrations); - for (let i = 0; i < 4_600; i += 1) { - const suffix = String(i).padStart(4, "0"); - writeFileSync( - path.join(migrations, `001_${suffix}_${"x".repeat(170)}.sql`), - "select 1;" - ); - } + writeFileSync(path.join(migrations, "001_init.sql"), "select 1;"); /** @type {RecordedCall[]} */ const calls = []; @@ -354,10 +369,6 @@ test("d1 migrations status surfaces control request body size errors", async () assert.equal(calls.length, 1); assert.equal(calls[0].url, "http://ctl.test/ns/demo/d1/databases/main/migrations/status"); - assert.ok( - typeof calls[0].init.body === "string" && calls[0].init.body.length > 1_048_576, - "oversized local migration status bodies are sent to control for canonical rejection" - ); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 4375adf..703cd0d 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -39,7 +39,7 @@ import { wranglerChildEnv, } from "../../lib/wrangler-pack.js"; import { LONG_CONTROL_TIMEOUT_MS } from "../../lib/control-fetch.js"; -import { checkWranglerVersion } from "../../lib/wrangler/command.js"; +import { checkWranglerVersion, formatWranglerFailure } from "../../lib/wrangler/command.js"; import { ESC, assertNoRawTerminalControls, response } from "./helpers.js"; /** @@ -99,14 +99,31 @@ function fakeWranglerExecFile(_cmd, args) { writeFileSync(path.join(outDir, "index.js"), "export default {}"); } -/** @param {string} cmd */ +/** + * @param {string} cmd + */ function assertWranglerCommand(cmd) { assert.ok( - cmd === "wrangler" || path.basename(cmd) === (process.platform === "win32" ? "wrangler.cmd" : "wrangler"), + cmd === "wrangler" || + cmd === process.execPath || + path.basename(cmd) === (process.platform === "win32" ? "wrangler.cmd" : "wrangler"), `expected wrangler command, got ${cmd}` ); } +/** + * @param {{ cmd: string, args: readonly string[] }} call + */ +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"]); +} + test("stripJsonComments removes line and block comments", () => { const raw = `{ // line @@ -1199,7 +1216,7 @@ test("installTempFileCleanup removes temp files on process exit and signals", () } }); -test("installTempFileCleanup only swallows cleanup errors during process handlers", () => { +test("installTempFileCleanup only swallows cleanup errors when explicitly requested or during process handlers", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-temp-cleanup-error-")); try { const exitProcess = /** @type {EventEmitter & { off(event: string, listener: () => void): EventEmitter }} */ (new EventEmitter()); @@ -1209,6 +1226,7 @@ test("installTempFileCleanup only swallows cleanup errors during process handler const cleanupProcess = /** @type {EventEmitter & { off(event: string, listener: () => void): EventEmitter }} */ (new EventEmitter()); const cleanup = installTempFileCleanup(dir, cleanupProcess); assert.throws(() => cleanup(), /EISDIR|directory/i); + assert.doesNotThrow(() => cleanup({ ignoreErrors: true })); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -1384,7 +1402,7 @@ test("validateUnsupportedWranglerConfig: rejects unsupported top-level config ev analytics_engine_datasets: [{ binding: "AE" }], env: { staging: {} }, }, "staging", "wrangler.toml"), - /\[analytics_engine_datasets\] — not supported/ + /unsupported Wrangler field "analytics_engine_datasets"/ ); }); @@ -1399,7 +1417,7 @@ test("validateUnsupportedWranglerConfig: rejects unsupported config inside the s }, }, }, "staging", "wrangler.toml"), - /env\.staging uses \[analytics_engine_datasets\] — not supported/ + /env\.staging uses unsupported Wrangler field "analytics_engine_datasets"/ ); }); @@ -1414,6 +1432,19 @@ test("validateUnsupportedWranglerConfig: top-level allowed_callers is rejected w ); }); +test("validateUnsupportedWranglerConfig: empty top-level allowed_callers is still rejected by presence", () => { + for (const value of [[], null, false, ""]) { + assert.throws( + () => validateUnsupportedWranglerConfig({ + name: "demo", + main: "src/index.js", + allowed_callers: value, + }, null, "wrangler.toml"), + /top-level allowed_callers — removed/ + ); + } +}); + test("validateUnsupportedWranglerConfig: env-scoped allowed_callers is rejected too", () => { assert.throws( () => validateUnsupportedWranglerConfig({ @@ -1425,6 +1456,19 @@ test("validateUnsupportedWranglerConfig: env-scoped allowed_callers is rejected ); }); +test("validateUnsupportedWranglerConfig: empty env-scoped allowed_callers is still rejected by presence", () => { + for (const value of [[], null, false, ""]) { + assert.throws( + () => validateUnsupportedWranglerConfig({ + name: "demo", + main: "src/index.js", + env: { staging: { allowed_callers: value } }, + }, "staging", "wrangler.toml"), + /env\.staging uses top-level allowed_callers — removed/ + ); + } +}); + test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy keys", () => { const objectShapeKeys = new Set([ "ai", @@ -1486,7 +1530,7 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy main: "src/index.js", [key]: unsupportedWranglerFixtureValue(key, objectShapeKeys, booleanShapeKeys), }, null, "wrangler.toml"), - new RegExp(`\\[${key}\\] — not supported`) + new RegExp(`unsupported Wrangler field "${key}"`) ); } @@ -1496,7 +1540,7 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy main: "src/index.js", workers_dev: false, }, null, "wrangler.toml"), - /\[workers_dev\] — not supported/ + /unsupported Wrangler field "workers_dev"/ ); assert.throws( @@ -1505,7 +1549,7 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy main: "src/index.js", vectorize: [], }, null, "wrangler.toml"), - /\[vectorize\] — not supported/ + /unsupported Wrangler field "vectorize"/ ); assert.throws( @@ -1518,7 +1562,7 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy }, }, }, "staging", "wrangler.toml"), - /env\.staging uses \[preview_urls\] — not supported/ + /env\.staging uses unsupported Wrangler field "preview_urls"/ ); // The rejection lists the actual supported surface. @@ -1558,7 +1602,7 @@ test("validateUnsupportedWranglerConfig rejects module-binding and container sec null, "wrangler.toml" ), - /\[wasm_modules\] — not supported/ + /unsupported Wrangler field "wasm_modules"/ ); assert.throws( () => validateUnsupportedWranglerConfig( @@ -1566,7 +1610,7 @@ test("validateUnsupportedWranglerConfig rejects module-binding and container sec null, "wrangler.toml" ), - /\[containers\] — not supported/ + /unsupported Wrangler field "containers"/ ); }); @@ -1749,7 +1793,7 @@ test("checkWranglerVersion escapes failed version probe diagnostics", () => { /** @type {unknown} */ (() => { const err = new Error(`boom${ESC}[2J\nFORGED\rBAD\u009b`); Object.assign(err, { - stdout: `out${ESC}[2J\nFORGED\rBAD`, + stdout: `out${ESC}[2J\nline\rBAD`, stderr: "err\u009b31m", }); throw err; @@ -1766,12 +1810,41 @@ test("checkWranglerVersion escapes failed version probe diagnostics", () => { const message = /** @type {Error} */ (err).message; assertNoRawTerminalControls(message, "wrangler version failure"); assert.match(message, /boom\\u001b\[2J\\nFORGED\\rBAD\\u009b/); - assert.match(message, /out\\u001b\[2J\\nFORGED\\rBAD\\nerr\\u009b31m/); + assert.match(message, /out\\u001b\[2J\nline\\rBAD\nerr\\u009b31m/); return true; } ); }); +test("checkWranglerVersion ENOENT hint mentions the npx opt-in", () => { + const execFile = /** @type {typeof import("node:child_process").execFileSync} */ ( + /** @type {unknown} */ (() => { + const err = new Error("spawn wrangler ENOENT"); + Object.assign(err, { code: "ENOENT" }); + throw err; + }) + ); + assert.throws( + () => checkWranglerVersion({ + execFile, + cwd: "/tmp/project", + env: {}, + wrangler: { command: "wrangler", args: [] }, + }), + /WDL_ALLOW_NPX_WRANGLER=1/ + ); +}); + +test("formatWranglerFailure escapes captured dry-run diagnostics", () => { + const message = formatWranglerFailure(Object.assign(new Error(`boom${ESC}[2J\nFORGED\rBAD\u009b`), { + stdout: `out${ESC}[2J\nline\rBAD`, + stderr: "err\u009b31m", + })); + assertNoRawTerminalControls(message, "wrangler build failure"); + assert.match(message, /boom\\u001b\[2J\\nFORGED\\rBAD\\u009b/); + assert.match(message, /out\\u001b\[2J\nline\\rBAD\nerr\\u009b31m/); +}); + test("resolveWranglerCommand prefers explicit WDL_WRANGLER_BIN", () => { assert.deepEqual( resolveWranglerCommand({ @@ -1797,7 +1870,7 @@ test("resolveWranglerCommand prefers project-local wrangler without npx", () => env: { WDL_ALLOW_NPX_WRANGLER: "1" }, packageDirs: [], }), - { command: bin, args: [], source: "local" } + { command: bin, args: [], source: "project" } ); } finally { rmSync(dir, { recursive: true, force: true }); @@ -1815,6 +1888,18 @@ test("resolveWranglerCommand uses PATH wrangler by default", () => { ); }); +test("resolveWranglerCommand labels the CLI package wrangler as package", () => { + const resolved = resolveWranglerCommand({ + absProject: "/project", + env: { PATH: "" }, + }); + assert.equal(resolved.source, "package"); + assert.ok( + resolved.command.includes("node") || resolved.command.includes("wrangler"), + "package resolver should return a runnable wrangler command" + ); +}); + test("resolveWranglerCommand prefers PATH wrangler before npx", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-wrangler-path-")); try { @@ -1866,7 +1951,7 @@ test("resolveWranglerCommand ignores unrelated cwd local wrangler", () => { env: {}, packageDirs: [packageDir], }), - { command: packageBin, args: [], source: "local" } + { command: packageBin, args: [], source: "package" } ); } finally { process.chdir(originalCwd); @@ -1888,7 +1973,7 @@ test("resolveWranglerCommand on win32 runs the wrangler JS entry via node instea assert.deepEqual( resolveWranglerCommand({ absProject: dir, env: {}, packageDirs: [], platform: "win32" }), - { command: process.execPath, args: [script], source: "local" } + { command: process.execPath, args: [script], source: "project" } ); } finally { rmSync(dir, { recursive: true, force: true }); @@ -2039,8 +2124,7 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a ); assert.equal(execCalls.length, 2); - assertWranglerCommand(execCalls[0].cmd); - assert.deepEqual(execCalls[0].args, ["--version"]); + assertWranglerVersionProbe(execCalls[0]); assert.equal(execCalls[0].opts.cwd, dir); assert.deepEqual(execCalls[0].opts.stdio, ["ignore", "pipe", "pipe"]); assert.equal(execCalls[0].opts.encoding, "utf8"); @@ -2108,8 +2192,7 @@ test("runDeployCommand escapes terminal controls in unexpected positional errors }), (err) => { const message = /** @type {Error} */ (err).message; - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge error lines"); + assertNoRawTerminalControls(message, "deploy positional errors"); assert.match(message, /bad\\u001b\[2J\\nFORGED\\rBAD/); return true; }, @@ -2216,6 +2299,42 @@ test("runDeployCommand removes the sanitized temp config when wrangler exec fail } }); +test("runDeployCommand does not mask a wrangler failure when temp config cleanup fails", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-cleanup-mask-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: "src/index.js", + })); + + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { + if (args.includes("--version")) return "wrangler 4.94.0"; + const cfgIdx = args.indexOf("--config"); + rmSync(/** @type {string} */ (args[cfgIdx + 1]), { force: true }); + mkdirSync(/** @type {string} */ (args[cfgIdx + 1])); + throw Object.assign(new Error("wrangler boom"), { + status: 1, + stderr: "fake wrangler failure", + }); + }, + controlFetch: async () => { + throw new Error("control should not be hit when bundling fails"); + }, + }), + /wrangler build failed/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("runDeployCommand preserves prototype-shaped binding keys for control validation", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-proto-")); try { @@ -2808,8 +2927,7 @@ test("runDeployCommand passes through wrangler output in verbose mode", async () }); assert.equal(execCalls.length, 2); - assertWranglerCommand(execCalls[0].cmd); - assert.deepEqual(execCalls[0].args, ["--version"]); + assertWranglerVersionProbe(execCalls[0]); assert.equal(execCalls[1].opts.stdio, "inherit"); assert.equal(Object.hasOwn(execCalls[1].opts, "encoding"), false); } finally { @@ -2842,8 +2960,7 @@ test("runDeployCommand rejects wrangler v3 before dry-run", async () => { /requires Wrangler v4 \(wrangler@\^4\); found v3/ ); assert.equal(execCalls.length, 1); - assertWranglerCommand(execCalls[0].cmd); - assert.deepEqual(execCalls[0].args, ["--version"]); + assertWranglerVersionProbe(execCalls[0]); assert.deepEqual(lines, []); } finally { rmSync(dir, { recursive: true, force: true }); @@ -2915,8 +3032,7 @@ test("runDeployCommand warns with wdl secret hints for missing caller secrets", }); assert.equal(warnings.length, 1); - assert.doesNotMatch(warnings[0], new RegExp(ESC), "raw ESC must not reach deploy warnings"); - assert.doesNotMatch(warnings[0], /\nFORGED|\rBAD/, "raw line controls must not forge deploy warnings"); + assertNoRawTerminalControls(warnings[0], "deploy warnings"); assert.match(warnings[0], /wdl secret put --ns 'demo\\u001b\[2J\\nFORGED\\rBAD' --scope ns /); assert.match(warnings[0], /--worker 'api\\u001b\[2J\\nFORGED\\rBAD' /); } finally { @@ -2962,8 +3078,8 @@ test("runDeployCommand renders deploy warnings from error responses", async () = ); assert.equal(warnings.length, 1); - assert.doesNotMatch(warnings[0], new RegExp(ESC), "raw ESC must not reach deploy error warnings"); - assert.doesNotMatch(warnings[0], /\nFORGED|\rBAD/, "raw line controls must not forge deploy error warnings"); + assertNoRawTerminalControls(warnings[0], "deploy error warnings"); + assert.doesNotMatch(warnings[0], /task-secret/); assert.match(warnings[0], /wdl secret put --ns 'demo\\u001b\[2J\\nFORGED\\rBAD' --scope ns /); assert.match(warnings[0], /--worker 'api\\u001b\[2J\\nFORGED\\rBAD' /); } finally { @@ -3433,7 +3549,7 @@ test("runDeployCommand escapes a control-supplied version before printing", asyn }); const out = stdoutLines.join("\n"); - assert.ok(!out.includes("\u001b"), "raw ESC byte must not reach stdout"); + assertNoRawTerminalControls(out, "deploy success output"); assert.ok(out.includes("promoting v1\\u001b[2J")); assert.ok(out.includes("@v1\\u001b[2J live")); } finally { diff --git a/tests/unit/cli-init.test.js b/tests/unit/cli-init.test.js index 555fac0..fdce4b7 100644 --- a/tests/unit/cli-init.test.js +++ b/tests/unit/cli-init.test.js @@ -5,10 +5,10 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { main, __test__ } from "../../commands/init.js"; +import { ESC, assertNoRawTerminalControls } from "./helpers.js"; const { parseArgs, validateNs, validateWorker, resolveWdlCliDep } = __test__; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const ESC = String.fromCharCode(27); /** @param {(dir: string) => Promise} fn */ async function withTempCwd(fn) { @@ -71,8 +71,7 @@ test("parseArgs escapes terminal controls in argv errors", () => { () => parseArgs(["demo", bad]), (err) => { const message = /** @type {Error} */ (err).message; - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach the error"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge error lines"); + assertNoRawTerminalControls(message, "the error"); assert.match(message, /bad\\u001b\[2J\\nFORGED\\rBAD/); return true; }, @@ -84,8 +83,7 @@ test("main escapes terminal controls in project name errors", async () => { await withTempCwd(async () => { const { exitCode, errOutput } = await captureExit(() => main([bad])); assert.equal(exitCode, 1); - assert.doesNotMatch(errOutput, new RegExp(ESC), "raw ESC must not reach project-name errors"); - assert.doesNotMatch(errOutput, /\nFORGED|\rBAD/, "raw line controls must not forge project-name errors"); + assertNoRawTerminalControls(errOutput, "project-name errors"); assert.match(errOutput, /project name "bad\\u001b\[2J\\nFORGED\\rBAD" must match/); }); }); @@ -105,8 +103,7 @@ test("validateNs escapes terminal controls in rejected namespace values", () => () => validateNs(bad, "--ns"), (err) => { const message = /** @type {Error} */ (err).message; - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach --ns errors"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge --ns errors"); + assertNoRawTerminalControls(message, "--ns errors"); assert.match(message, /--ns "bad\\u001b\[2J\\nFORGED\\rBAD" is not a valid tenant namespace/); return true; }, @@ -131,8 +128,7 @@ test("validateWorker escapes terminal controls in rejected worker values", () => () => validateWorker(bad, "--worker"), (err) => { const message = /** @type {Error} */ (err).message; - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not reach --worker errors"); - assert.doesNotMatch(message, /\nFORGED|\rBAD/, "raw line controls must not forge --worker errors"); + assertNoRawTerminalControls(message, "--worker errors"); assert.match(message, /--worker "bad\\u001b\[2J\\nFORGED\\rBAD" must match/); return true; }, diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index 559aea0..1f49d61 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -1506,7 +1506,8 @@ test("wdl dispatcher routes documented commands and rejects unknown commands", a await assert.rejects(() => wdlMain(["toString"], { loadEnv: null }), /exit:1/); assert.ok(seen.some((line) => line.includes("unknown command: toString"))); - await assert.rejects(() => wdlMain(["help", "toString"], { loadEnv: null }), /exit:0/); + await assert.rejects(() => wdlMain(["help", "toString"], { loadEnv: null }), /exit:1/); + assert.ok(seen.some((line) => line.includes("unknown help topic: toString"))); assert.doesNotMatch(seen.join("\n"), /TypeError|COMMANDS\[|\.main/); } finally { process.exit = oldExit; diff --git a/tests/unit/cli-token-store.test.js b/tests/unit/cli-token-store.test.js index e33704e..1436a8d 100644 --- a/tests/unit/cli-token-store.test.js +++ b/tests/unit/cli-token-store.test.js @@ -12,7 +12,7 @@ import { updateTokenStore, writeTokenStore, } from "../../lib/token-store.js"; -import { assertNoRawTerminalControls } from "./helpers.js"; +import { ESC, assertNoRawTerminalControls } from "./helpers.js"; /** * @template T @@ -37,7 +37,7 @@ function writeLockOwner(lockDir, owner) { } const TOKEN_STORE_MODULE_URL = new URL("../../lib/token-store.js", import.meta.url).href; -const ESC = String.fromCharCode(27); +const POSIX_ONLY = { skip: process.platform === "win32" ? "POSIX-only filesystem behavior" : false }; test("tokenStoreDir honors XDG_CONFIG_HOME, then falls back to ~/.config", () => { assert.equal( @@ -141,7 +141,8 @@ test("updateTokenStore handles concurrent released-lock recovery", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-token-store-")); try { const p = path.join(dir, "wdl", "credentials"); - const workers = Array.from({ length: 32 }, (_, index) => { + const workerCount = 8; + const workers = Array.from({ length: workerCount }, (_, index) => { const ns = `ns-${index}`; const token = `tok-${index}`; const code = ` @@ -184,8 +185,8 @@ updateTokenStore(${JSON.stringify(p)}, (store) => { } const store = readTokenStore(p); - assert.equal(Object.keys(store.namespaces).length, 32); - for (let index = 0; index < 32; index += 1) { + assert.equal(Object.keys(store.namespaces).length, workerCount); + for (let index = 0; index < workerCount; index += 1) { assert.equal(store.namespaces[`ns-${index}`]?.ADMIN_TOKEN, `tok-${index}`); } } finally { @@ -264,8 +265,7 @@ test("updateTokenStore takeover clears stale temp files inside the lock", () => }); }); -test("updateTokenStore creates a usable lock under a restrictive umask", () => { - if (process.platform === "win32") return; +test("updateTokenStore creates a usable lock under a restrictive umask", POSIX_ONLY, () => { withTempDir((dir) => { const p = path.join(dir, "wdl", "credentials"); const oldUmask = process.umask(0o777); @@ -325,8 +325,7 @@ test("updateTokenStore recovers an old lock whose owner pid appears alive", () = }); }); -test("updateTokenStore recovers a stale lock with an unreadable owner file", () => { - if (process.platform === "win32") return; +test("updateTokenStore recovers a stale lock with an unreadable owner file", POSIX_ONLY, () => { withTempDir((dir) => { const p = path.join(dir, "wdl", "credentials"); const lockDir = `${p}.lock`; @@ -347,8 +346,7 @@ test("updateTokenStore recovers a stale lock with an unreadable owner file", () }); }); -test("updateTokenStore recovers a stale lock with an unreadable release marker", () => { - if (process.platform === "win32") return; +test("updateTokenStore recovers a stale lock with an unreadable release marker", POSIX_ONLY, () => { withTempDir((dir) => { const p = path.join(dir, "wdl", "credentials"); const lockDir = `${p}.lock`; @@ -396,8 +394,7 @@ test("updateTokenStore recovers a stale regular-file lock", () => { }); }); -test("updateTokenStore recovers a stale dangling-symlink lock", () => { - if (process.platform === "win32") return; +test("updateTokenStore recovers a dangling-symlink lock at a zero stale threshold", POSIX_ONLY, () => { withTempDir((dir) => { const p = path.join(dir, "wdl", "credentials"); const lockDir = `${p}.lock`; @@ -415,8 +412,7 @@ test("updateTokenStore recovers a stale dangling-symlink lock", () => { }); }); -test("updateTokenStore recovers a stale symlink lock without chmodding its target", () => { - if (process.platform === "win32") return; +test("updateTokenStore recovers a symlink lock at a zero stale threshold without chmodding its target", POSIX_ONLY, () => { withTempDir((dir) => { const p = path.join(dir, "wdl", "credentials"); const lockDir = `${p}.lock`; @@ -430,6 +426,10 @@ test("updateTokenStore recovers a stale symlink lock without chmodding its targe }, { lockTimeoutMs: 0, staleLockMs: 0 }); assert.equal((statSync(target).mode & 0o777), 0o600); + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "tok-acme" } }, + }); assert.deepEqual( readdirSync(path.dirname(p)).filter((name) => name.startsWith(`${path.basename(p)}.lock.recovered-`)), [] @@ -437,8 +437,7 @@ test("updateTokenStore recovers a stale symlink lock without chmodding its targe }); }); -test("updateTokenStore does not spin on a fresh dangling-symlink lock", () => { - if (process.platform === "win32") return; +test("updateTokenStore does not spin on a fresh dangling-symlink lock", POSIX_ONLY, () => { withTempDir((dir) => { const p = path.join(dir, "wdl", "credentials"); const lockDir = `${p}.lock`; @@ -452,8 +451,7 @@ test("updateTokenStore does not spin on a fresh dangling-symlink lock", () => { }); }); -test("updateTokenStore recovers a stale lock with an unreadable directory", () => { - if (process.platform === "win32") return; +test("updateTokenStore recovers a stale lock with an unreadable directory", POSIX_ONLY, () => { withTempDir((dir) => { const p = path.join(dir, "wdl", "credentials"); const lockDir = `${p}.lock`; @@ -606,8 +604,7 @@ test("writeTokenStore writes canonical sorted output with a managed-by header", }); }); -test("writeTokenStore sets 0600 file and 0700 dir permissions", () => { - if (process.platform === "win32") return; +test("writeTokenStore sets 0600 file and 0700 dir permissions", POSIX_ONLY, () => { withTempDir((dir) => { const p = path.join(dir, "credentials"); writeTokenStore(p, { namespaces: { acme: { ADMIN_TOKEN: "t" } } }); @@ -634,8 +631,7 @@ test("writeTokenStore sets 0600 file and 0700 dir permissions", () => { }); }); -test("assertStoreDirSecure refuses a group/world-writable store dir", () => { - if (process.platform === "win32") return; +test("assertStoreDirSecure refuses a group/world-writable store dir", POSIX_ONLY, () => { /** @type {string[]} */ const made = []; /** @param {number} mode */ diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index 8be076a..edf6e00 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -6,9 +6,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { runTokenCommand } from "../../commands/token.js"; import { readTokenStore, tokenStorePath, writeTokenStore } from "../../lib/token-store.js"; -import { assertNoRawTerminalControls, response } from "./helpers.js"; - -const ESC = String.fromCharCode(27); +import { ESC, assertNoRawTerminalControls, response } from "./helpers.js"; /** * @template T @@ -270,7 +268,7 @@ test("token set escapes terminal controls in a principal-mismatch error", async ), (err) => { const message = /** @type {Error} */ (err).message; - assert.doesNotMatch(message, new RegExp(ESC), "raw ESC must not be in the error"); + assertNoRawTerminalControls(message, "principal-mismatch errors"); assert.match(message, /token principal is namespace/); return true; } @@ -283,7 +281,7 @@ test("token set escapes a masked token suffix containing terminal controls", asy const { lines, deps: d } = deps(xdg, { stdin: stdinFrom(`tok-secret${ESC}[2J\n`) }); await runTokenCommand(["set", "--ns", "acme", "--control-url", "https://api.example"], d); const out = lines.join("\n"); - assert.doesNotMatch(out, new RegExp(ESC), "raw ESC must not reach stdout via the masked suffix"); + assertNoRawTerminalControls(out, "masked token suffix output"); assert.match(out, /Stored token for acme/); }); }); @@ -433,7 +431,7 @@ test("token use/rm escape terminal controls in the not-found error", async () => const bad = `ghost${ESC}[2J`; /** @param {unknown} err */ const noEsc = (err) => { - assert.doesNotMatch(/** @type {Error} */ (err).message, new RegExp(ESC), "raw ESC must not reach the error"); + assertNoRawTerminalControls(/** @type {Error} */ (err).message, "token not-found errors"); return true; }; await assert.rejects(() => runTokenCommand(["use", bad], deps(xdg).deps), noEsc); From 46b5415293fcf94cc972e4d2a64ff50b236d9022 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Fri, 10 Jul 2026 05:39:08 +0800 Subject: [PATCH 10/12] Polish final CLI audit findings Handle tail scalar SSE payloads and unreadable project .env files as CLI errors. Render reserved module arrays from control errors and lock remaining deploy/secret hints. Update changelog with control transport and Wrangler resolution behavior. Signed-off-by: Lu Zhang --- CHANGELOG.md | 13 ++- commands/tail.js | 14 ++- lib/common.js | 1 + lib/credentials.js | 3 +- tests/unit/cli-command.test.js | 23 +++++ tests/unit/cli-credentials.test.js | 22 ++++- tests/unit/cli-deploy.test.js | 45 ++++++++++ tests/unit/cli-lifecycle.test.js | 137 ++++++++++++++++++++--------- 8 files changed, 208 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c248887..9e2d64f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ `ASSETS` binding. Deploy also fails fast on unmapped Wrangler runtime/deploy keys such as `[site]`, `workers_dev`, `pages_build_output_dir`, `observability`, `limits`, and `placement` instead of silently dropping them. +- `wdl deploy` resolves Wrangler from `WDL_WRANGLER_BIN`, the Worker project's + local install, the CLI package's bundled dependency, then `PATH`; `npx` stays + opt-in via `WDL_ALLOW_NPX_WRANGLER=1`. +- Control requests now include a `wdl-cli/` User-Agent and buffer JSON + control responses up to 16 MiB by default. +- `CONTROL_CONNECT_HOST` now accepts `host:port`, bracketed or bare IPv6, and + `http://` / `https://` override URLs, while rejecting blank hosts, non-http + schemes, and invalid ports before opening a control connection. - `wdl tail` now recognizes control-initiated `session_idle` / `session_expired` stream recycling and reconnects without presenting it as an unknown warning. @@ -33,8 +41,9 @@ ### Fixed - Control connection failures, invalid 2xx JSON responses, unreadable - `wdl d1 execute --file` inputs, and unexpected positional arguments now fail - with CLI errors instead of raw Node errors or silently ignored input. + project `.env` files, unreadable `wdl d1 execute --file` inputs, and + unexpected positional arguments now fail with CLI errors instead of raw Node + errors or silently ignored input. - `wdl d1 migrations apply` now rejects symlinked `.sql` migration files instead of silently dropping them. - `wdl deploy` now renders deploy warnings attached to failed upload responses, diff --git a/commands/tail.js b/commands/tail.js index 16c215e..163b2f4 100644 --- a/commands/tail.js +++ b/commands/tail.js @@ -12,7 +12,7 @@ import { validateControlHeaders, } from "../lib/control-fetch.js"; import { CliError, defineCliOption, formatHelp, isMain, isNonEmptyString, optionHelp } from "../lib/common.js"; -import { escapeTerminalLines, escapeTerminalText } from "../lib/output.js"; +import { escapeTerminalLines, escapeTerminalText, formatDiagnosticValue } from "../lib/output.js"; const RECONNECT_INITIAL_MS = 1_000; const RECONNECT_MAX_MS = 5_000; @@ -144,7 +144,7 @@ async function runTail({ values, positionals, context: baseContext }) { const raw = values["max-reconnects"]; if (!/^\d+$/.test(raw)) { throw new CliError( - `--max-reconnects must be a non-negative integer (got ${JSON.stringify(raw)}); ` + + `--max-reconnects must be a non-negative integer (got ${formatDiagnosticValue(raw)}); ` + `use 0 to disable the cap`, ); } @@ -518,8 +518,14 @@ export class SseParser { function renderEvent({ event, raw, stdout, stderr, isMultiWorker }) { /** @type {TailPayload} */ let payload; - try { payload = JSON.parse(event.data); } - catch { payload = { event: event.event, raw: event.data }; } + try { + const decoded = JSON.parse(event.data); + payload = decoded && typeof decoded === "object" && !Array.isArray(decoded) + ? /** @type {TailPayload} */ (decoded) + : { event: event.event, raw: decoded }; + } catch { + payload = { event: event.event, raw: event.data }; + } const eventType = payload.event || event.event; const isServerRecycle = eventType === "tail_warning" && diff --git a/lib/common.js b/lib/common.js index 9d286f2..2c1a9a2 100644 --- a/lib/common.js +++ b/lib/common.js @@ -301,6 +301,7 @@ const ERROR_SUMMARY_KEYS = new Set([ const ARRAY_CONTEXT_KEYS = new Set([ "blockers", "referrers", + "reserved_modules", "warnings", ]); diff --git a/lib/credentials.js b/lib/credentials.js index e2d1440..97f66ff 100644 --- a/lib/credentials.js +++ b/lib/credentials.js @@ -196,7 +196,8 @@ export function loadCliDotEnv( text = readFileSync(path, "utf8"); } catch (err) { if (hasErrorCode(err) && err.code === "ENOENT") return []; - throw err; + const message = err instanceof Error && err.message ? err.message : String(err); + throw new CliError(`Cannot read .env file ${formatDiagnosticValue(path)}: ${escapeTerminalText(message)}`); } const { diff --git a/tests/unit/cli-command.test.js b/tests/unit/cli-command.test.js index 629696c..a701720 100644 --- a/tests/unit/cli-command.test.js +++ b/tests/unit/cli-command.test.js @@ -226,6 +226,29 @@ test("context.fetchJson escapes structured error context keys", async () => { ); }); +test("context.fetchJson renders reserved module arrays from control errors", async () => { + const cmd = define({ + options: [], + usage: () => "", + run: ({ context }) => context.fetchJson("http://x", {}, "deploy"), + }); + await assert.rejects( + () => cmd.run([], { + env: {}, + controlFetch: async () => response({ + error: "worker_code_invalid", + message: "reserved injected module name", + reserved_modules: ["_wdl-wrapper.js", "_wdl-init.js"], + }, 400), + }), + (err) => { + assert(err instanceof CliError); + assert.match(err.message, /reserved_modules=\["_wdl-wrapper\.js","_wdl-init\.js"\]/); + return true; + }, + ); +}); + test("context.fetchStream returns the raw response after a status check", async () => { const ok = response("bytes"); const cmd = define({ diff --git a/tests/unit/cli-credentials.test.js b/tests/unit/cli-credentials.test.js index c391e12..d336ee9 100644 --- a/tests/unit/cli-credentials.test.js +++ b/tests/unit/cli-credentials.test.js @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { isTokenStoreDisabled, loadCliControlEnv, loadCliDotEnv, protectedEnvKeys, resolveControlContext, resolveControlUrl, resolveNamespace, warnIfInsecureControlUrl } from "../../lib/credentials.js"; @@ -553,6 +553,26 @@ test("loadCliDotEnv ignores a missing file", () => { assert.deepEqual(env, {}); }); +test("loadCliDotEnv wraps unreadable .env filesystem errors", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-env-unreadable-")); + try { + const file = path.join(dir, `.env${ESC}[2J\nFORGED\rBAD`); + mkdirSync(file); + assert.throws( + () => loadCliDotEnv(emptyEnv(), file), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /Cannot read \.env file/); + assert.match(message, /\.env\\u001b\[2J\\nFORGED\\rBAD/); + assertNoRawTerminalControls(message, ".env read errors"); + return true; + } + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("loadCliControlEnv drops a .env control endpoint when the token is from the shell", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-crossorigin-")); try { diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 703cd0d..9a586ea 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -99,6 +99,37 @@ function fakeWranglerExecFile(_cmd, args) { writeFileSync(path.join(outDir, "index.js"), "export default {}"); } +/** + * @param {unknown} body + * @param {number} [status] + * @returns {Promise} + */ +async function rejectDeployWithControlBody(body, status = 400) { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-control-error-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); + /** @type {unknown} */ + let rejected; + try { + await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => response(body, status), + }); + } catch (err) { + rejected = err; + } + assert(rejected instanceof Error); + return rejected; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + /** * @param {string} cmd */ @@ -3119,6 +3150,20 @@ test("runDeployCommand explains deploy env-budget failures at the command layer" } }); +test("runDeployCommand explains worker code size and Python module failures", async () => { + for (const { error, status, expected } of [ + { error: "worker_code_too_large", status: 413, expected: /reduce generated Worker code size or split the worker/ }, + { error: "python_workers_unsupported", status: 400, expected: /Python Workers modules are not supported by WDL/ }, + ]) { + const err = await rejectDeployWithControlBody({ + error, + message: "control rejected deploy", + }, status); + assert.match(err.message, new RegExp(error)); + assert.match(err.message, expected); + } +}); + test("runDeployCommand explains secret-envelope deploy failures at the command layer", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-secret-envelope-")); try { diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index 1f49d61..30b9b12 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -818,46 +818,43 @@ test("secret put explains env-budget failures as unwritten mutations", async () }); test("secret mutation errors explain retry and operator-repair cases", async () => { - await assert.rejects( - () => runSecretCommand( - ["delete", "--ns", "demo", "--worker", "api", "KEY", "--yes", "--control-url", "http://ctl.test"], - { - env: { ADMIN_TOKEN: "tok" }, - controlFetch: async () => response({ - error: "secret_mutation_contention", - message: "active version changed", - }, 503), - } - ), - /Retry after concurrent worker metadata updates settle/ - ); - await assert.rejects( - () => runSecretCommand( - ["delete", "--ns", "demo", "--scope", "ns", "KEY", "--yes", "--control-url", "http://ctl.test"], - { - env: { ADMIN_TOKEN: "tok" }, - controlFetch: async () => response({ - error: "secret_decrypt_failed", - message: "bad envelope", - }, 503), - } - ), - /Secret-envelope configuration or stored secret data needs operator repair/ - ); - await assert.rejects( - () => runSecretCommand( - ["put", "--ns", "demo", "--worker", "api", "KEY", "--control-url", "http://ctl.test"], - { - env: { ADMIN_TOKEN: "tok" }, - stdin: stdinFrom("secret-value\n"), - controlFetch: async () => response({ - error: "secret_encryption_unconfigured", - message: "provider missing", - }, 503), - } - ), - /Secret-envelope configuration or stored secret data needs operator repair/ - ); + for (const error of ["secret_mutation_contention", "namespace_secret_mutation_contention"]) { + await assert.rejects( + () => runSecretCommand( + ["delete", "--ns", "demo", "--worker", "api", "KEY", "--yes", "--control-url", "http://ctl.test"], + { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => response({ + error, + message: "active version changed", + }, 503), + } + ), + /Retry after concurrent worker metadata updates settle/ + ); + } + for (const error of [ + "invalid_envelope", + "secret_decrypt_failed", + "secret_encryption_unconfigured", + "secret_not_encrypted", + "unsupported_envelope", + "unknown_kid", + ]) { + await assert.rejects( + () => runSecretCommand( + ["delete", "--ns", "demo", "--scope", "ns", "KEY", "--yes", "--control-url", "http://ctl.test"], + { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => response({ + error, + message: "bad envelope", + }, 503), + } + ), + /Secret-envelope configuration or stored secret data needs operator repair/ + ); + } }); test("secret put and delete support raw json output", async () => { @@ -1805,13 +1802,20 @@ test("wdl tail rejects --since for multi-worker sessions", async () => { }); test("wdl tail rejects invalid max-reconnects input", async () => { + const bad = `forever${ESC}[2J\nFORGED\rBAD\u009b`; await assert.rejects( () => runTailCommand( - ["foo", "--max-reconnects", "forever", "--ns", "demo", "--token", "t", + ["foo", "--max-reconnects", bad, "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, stdout: () => {}, stderr: () => {} } ), - /--max-reconnects must be a non-negative integer/ + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /--max-reconnects must be a non-negative integer/); + assert.match(message, /forever\\u001b\[2J\\nFORGED\\rBAD\\u009b/); + assertNoRawTerminalControls(message, "--max-reconnects errors"); + return true; + } ); }); @@ -2535,6 +2539,55 @@ test("wdl tail --raw still treats session recycle warnings as control-initiated }); }); +test("wdl tail --raw treats non-object SSE JSON payloads as raw values", async () => { + /** @type {string[]} */ + const stdoutLines = []; + let requestCount = 0; + const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} _opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ + request(_opts, cb) { + requestCount += 1; + const req = fakeHttpReq(); + setImmediate(() => { + const res = fakeHttpRes(); + cb(res); + if (requestCount === 1) { + setImmediate(() => { + res.emit("data", "data: null\n\n"); + res.emit("end"); + }); + } else { + setImmediate(() => { + res.emit("error", new CliError("test stop")); + }); + } + }); + return req; + }, + }; + + await assert.rejects( + () => runTailCommand( + ["foo", "--raw", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], + { + env: {}, + stdout: (/** @type {string} */ line) => stdoutLines.push(line), + stderr: () => {}, + transport: fakeTransport, + sleepFn: async () => {}, + } + ), + { message: "test stop" } + ); + + assert.deepEqual(stdoutLines.map((line) => JSON.parse(line)), [ + { event: "message", raw: null }, + ]); +}); + test("wdl tail increases backoff until a stable session resets it", async () => { /** @type {number[]} */ const sleepCalls = []; From 93de69350d65fd1d737f19b9e6fb957eff028abb Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Fri, 10 Jul 2026 05:57:22 +0800 Subject: [PATCH 11/12] Honor effective env in control requests Pass the command effective environment through shared JSON/stream control helpers and the deploy/secret direct controlFetch paths so CONTROL_CONNECT_HOST behaves consistently. Wrap Wrangler config read failures as escaped user-facing diagnostics. Signed-off-by: Lu Zhang --- commands/deploy.js | 2 +- commands/secret.js | 2 +- lib/command.js | 4 ++-- lib/wrangler/config.js | 9 ++++++++- tests/unit/cli-command.test.js | 16 +++++++++++++--- tests/unit/cli-deploy.test.js | 23 +++++++++++++++++++++++ tests/unit/cli-lifecycle.test.js | 3 ++- 7 files changed, 50 insertions(+), 9 deletions(-) diff --git a/commands/deploy.js b/commands/deploy.js index 0ecb503..f37a1b4 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -123,7 +123,7 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, * }} arg */ async function fetchDeployJson({ context, url, init, label, ns, workerName, stderr }) { - const res = await context.controlFetch(url, init); + const res = await context.controlFetch(url, { ...init, env: init.env ?? context.env }); if (res.ok) return await readJsonOrFail(res, label); const text = await res.text(); renderDeployWarningsFromErrorBody(text, { ns, workerName, stderr }); diff --git a/commands/secret.js b/commands/secret.js index 487d7fd..9b2cc14 100644 --- a/commands/secret.js +++ b/commands/secret.js @@ -138,7 +138,7 @@ async function runSecret({ values, positionals, context }) { * @param {string} label */ async function fetchSecretMutationJson(context, url, init, label) { - const res = await context.controlFetch(url, init); + const res = await context.controlFetch(url, { ...init, env: init.env ?? context.env }); if (res.ok) return await readJsonOrFail(res, label); const text = await res.text(); throw new CliError(`${label} failed: ${formatHttpError(res.status, text, res.headers)}${secretMutationHint(text)}`); diff --git a/lib/command.js b/lib/command.js index bcc1b43..4518908 100644 --- a/lib/command.js +++ b/lib/command.js @@ -230,7 +230,7 @@ function buildContext(deps, commandDefaults, values, positionals) { * @param {string} label */ context.fetchJson = async (url, init, label) => { - const res = await controlFetch(url, init); + const res = await controlFetch(url, { ...init, env: init.env ?? env }); return readJsonOrFail(res, label); }; @@ -242,7 +242,7 @@ function buildContext(deps, commandDefaults, values, positionals) { * @param {string} label */ context.fetchStream = async (url, init, label) => { - const res = await controlFetch(url, init); + const res = await controlFetch(url, { ...init, env: init.env ?? env }); await throwHttpErrorIfNotOk(res, label); return res; }; diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index 7bd06f1..29457c8 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -134,7 +134,14 @@ export function loadWranglerConfig(dir) { const selection = selectWranglerConfigFiles(dir); if (selection.selected) { const { name, path: p } = selection.selected; - const raw = readFileSync(p, "utf8"); + /** @type {string} */ + let raw; + try { + raw = readFileSync(p, "utf8"); + } catch (err) { + const message = err instanceof Error && err.message ? err.message : String(err); + throw new Error(`failed to read ${escapeTerminalText(name)}: ${escapeTerminalText(message)}`, { cause: err }); + } try { if (name.endsWith(".toml")) return { path: p, cfg: parseToml(raw), shadowed: selection.shadowed }; if (name.endsWith(".jsonc")) return { path: p, cfg: parseJsonc(raw), shadowed: selection.shadowed }; diff --git a/tests/unit/cli-command.test.js b/tests/unit/cli-command.test.js index a701720..63a7ccf 100644 --- a/tests/unit/cli-command.test.js +++ b/tests/unit/cli-command.test.js @@ -181,13 +181,13 @@ test("context.fetchJson fetches with the given init and parses JSON", async () = run: ({ context }) => context.fetchJson("http://x/y", { headers: { a: "b" } }, "do thing"), }); const body = await cmd.run([], { - env: {}, + env: { CONTROL_CONNECT_HOST: "127.0.0.1:18080" }, /** @param {string} url @param {import("../../lib/control-fetch.js").ControlFetchInit} init */ controlFetch: async (url, init) => { got = { url, init }; return response({ ok: 1 }); }, }); assert.deepEqual(body, { ok: 1 }); assert.equal(got.url, "http://x/y"); - assert.deepEqual(got.init, { headers: { a: "b" } }); + assert.deepEqual(got.init, { headers: { a: "b" }, env: { CONTROL_CONNECT_HOST: "127.0.0.1:18080" } }); }); test("context.fetchJson throws a CliError on a non-2xx response", async () => { @@ -251,13 +251,23 @@ test("context.fetchJson renders reserved module arrays from control errors", asy test("context.fetchStream returns the raw response after a status check", async () => { const ok = response("bytes"); + let got = /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ ( + /** @type {unknown} */ (null) + ); const cmd = define({ options: [], usage: () => "", run: ({ context }) => context.fetchStream("http://x", { method: "HEAD" }, "get"), }); - const res = await cmd.run([], { env: {}, controlFetch: async () => ok }); + const res = await cmd.run([], { + env: { CONTROL_CONNECT_HOST: "127.0.0.1:18080" }, + controlFetch: async (/** @type {string} */ _url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init) => { + got = init; + return ok; + }, + }); assert.equal(res, ok); + assert.deepEqual(got, { method: "HEAD", env: { CONTROL_CONNECT_HOST: "127.0.0.1:18080" } }); const bad = define({ options: [], diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 9a586ea..b08a337 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -1222,6 +1222,26 @@ test("loadWranglerConfig: escapes parser diagnostics from config files", () => { } }); +test("loadWranglerConfig: escapes config read errors", () => { + const root = mkdtempSync(path.join(tmpdir(), "wdl-config-read-")); + const dir = path.join(root, `bad${ESC}[2J\nFORGED\rBAD`); + try { + mkdirSync(dir); + mkdirSync(path.join(dir, "wrangler.json")); + assert.throws( + () => loadWranglerConfig(dir), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /failed to read wrangler\.json/); + assertNoRawTerminalControls(message, "config read errors"); + return true; + } + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("installTempFileCleanup removes temp files on process exit and signals", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-temp-cleanup-")); try { @@ -2128,6 +2148,7 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a { env: { ADMIN_TOKEN: "tok", + CONTROL_CONNECT_HOST: "127.0.0.1:18080", WDL_NS: "demo space", CLOUDFLARE_API_TOKEN: "real-cf-token", }, @@ -2172,6 +2193,7 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a assert.equal(fetchCalls[0].url, "http://ctl.test/ns/demo%20space/worker/api/deploy"); assert.equal(fetchCalls[0].init.method, "POST"); assert.equal(fetchCalls[0].init.timeoutMs, LONG_CONTROL_TIMEOUT_MS); + assert.equal(fetchCalls[0].init.env?.CONTROL_CONNECT_HOST, "127.0.0.1:18080"); assert.deepEqual(fetchCalls[0].init.headers, { "content-type": "application/json", "x-admin-token": "tok", @@ -2192,6 +2214,7 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a assert.equal(fetchCalls[1].url, "http://ctl.test/ns/demo%20space/worker/api/promote"); assert.equal(fetchCalls[1].init.method, "POST"); + assert.equal(fetchCalls[1].init.env?.CONTROL_CONNECT_HOST, "127.0.0.1:18080"); assert.deepEqual(JSON.parse(/** @type {string} */ (fetchCalls[1].init.body)), { version: "v1" }); assert.ok(lines.includes(" bundled by wrangler")); assert.ok(lines.includes("✓ demo space/api@v1 live")); diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index 30b9b12..0303d41 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -706,7 +706,7 @@ test("secret put reads stdin, trims one newline, and encodes key", async () => { await runSecretCommand( ["put", "--ns", "demo", "--scope", "ns", "KEY/ONE", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, + env: { ADMIN_TOKEN: "tok", CONTROL_CONNECT_HOST: "127.0.0.1:18080" }, stdin: stdinFrom("secret-value\n"), stdout: (/** @type {string} */ line) => lines.push(line), controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { @@ -719,6 +719,7 @@ test("secret put reads stdin, trims one newline, and encodes key", async () => { assert.equal(calls.length, 1); assert.equal(calls[0].url, "http://ctl.test/ns/demo/secrets/KEY%2FONE"); assert.equal(calls[0].init.method, "PUT"); + assert.equal(calls[0].init.env?.CONTROL_CONNECT_HOST, "127.0.0.1:18080"); assert.equal(calls[0].init.body, JSON.stringify({ value: "secret-value" })); assert.deepEqual(lines, ["✓ demo (ns)/KEY/ONE set — effect on next natural cold-load"]); }); From b8378a5a26db09b200403b3dc05170150e720274 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Fri, 10 Jul 2026 06:58:29 +0800 Subject: [PATCH 12/12] Align Wrangler JSONC and connect-host parsing Parse wrangler.json and wrangler.jsonc with jsonc-parser while preserving safe object handling. Use URL schemes for CONTROL_CONNECT_HOST default ports and document the transport distinction. Cover both contracts with focused regression tests. Signed-off-by: Lu Zhang --- .claude/skills/wdl-deploy/SKILL.md | 20 +++-- CHANGELOG.md | 18 ++-- GUIDE-zh.md | 4 +- GUIDE.md | 6 +- docs/deploy-zh.md | 2 + docs/deploy.md | 2 + lib/control-connect-host.js | 3 +- lib/wrangler-pack.js | 2 - lib/wrangler/config.js | 113 +++++++------------------ package-lock.json | 7 ++ package.json | 1 + tests/unit/cli-control-fetch.test.js | 21 +++++ tests/unit/cli-deploy.test.js | 120 ++++++++++++++------------- 13 files changed, 157 insertions(+), 162 deletions(-) diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index 92a08b3..7885e49 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -47,20 +47,22 @@ 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. WDL follows Wrangler config priority (`wrangler.json`, then -`wrangler.jsonc`, then `wrangler.toml`). The control plane is canonical for -unsupported runtime shapes such as unsupported workerd experimental -compatibility flags and WDL-reserved injected module names; 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]`, +`wrangler.jsonc`, then `wrangler.toml`); both JSON filenames use Wrangler's +JSONC syntax, including comments and trailing commas. The control plane is +canonical for unsupported runtime shapes such as unsupported workerd +experimental compatibility flags and WDL-reserved injected module names; 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]`, `workers_dev`, `pages_build_output_dir`, `observability`, `limits`, -`placement`, etc.), and -ambiguous runtime `env` name collisions between `[vars]`, explicit bindings, -and the implicit `ASSETS` binding. +`placement`, etc.), and ambiguous runtime `env` name collisions between +`[vars]`, explicit bindings, and the implicit `ASSETS` binding. 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. GUIDE covers the details. +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. `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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2d64f..b6ed040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,14 @@ - `wdl init`, examples, and docs now default new Wrangler configs to `compatibility_date = "2026-06-17"` for the documented feature baseline. - `wdl deploy` now follows Wrangler config priority - (`wrangler.json`, `wrangler.jsonc`, `wrangler.toml`); the control plane - remains canonical for runtime/workerd bundle errors, while the CLI still - rejects cheap local cases like Python Worker modules and ambiguous runtime env - name collisions between `[vars]`, explicit bindings, and the implicit - `ASSETS` binding. Deploy also fails fast on unmapped Wrangler runtime/deploy - keys such as `[site]`, `workers_dev`, `pages_build_output_dir`, - `observability`, `limits`, and `placement` instead of silently dropping them. + (`wrangler.json`, `wrangler.jsonc`, `wrangler.toml`) and parses both JSON + filenames with Wrangler's JSONC syntax; the control plane remains canonical + for runtime/workerd bundle errors, while the CLI still rejects cheap local + cases like Python Worker modules and ambiguous runtime env name collisions + between `[vars]`, explicit bindings, and the implicit `ASSETS` binding. + Deploy also fails fast on unmapped Wrangler runtime/deploy keys such as + `[site]`, `workers_dev`, `pages_build_output_dir`, `observability`, `limits`, + and `placement` instead of silently dropping them. - `wdl deploy` resolves Wrangler from `WDL_WRANGLER_BIN`, the Worker project's local install, the CLI package's bundled dependency, then `PATH`; `npx` stays opt-in via `WDL_ALLOW_NPX_WRANGLER=1`. @@ -21,7 +22,8 @@ control responses up to 16 MiB by default. - `CONTROL_CONNECT_HOST` now accepts `host:port`, bracketed or bare IPv6, and `http://` / `https://` override URLs, while rejecting blank hosts, non-http - schemes, and invalid ports before opening a control connection. + schemes, and invalid ports before opening a control connection. URL schemes + choose the default TCP port; request transport still follows `CONTROL_URL`. - `wdl tail` now recognizes control-initiated `session_idle` / `session_expired` stream recycling and reconnects without presenting it as an unknown warning. diff --git a/GUIDE-zh.md b/GUIDE-zh.md index ce50241..958d487 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -87,7 +87,7 @@ ADMIN_TOKEN= CLI 只会从 `.env` 读取 WDL 平台变量:`ADMIN_TOKEN`、`CONTROL_URL`、`CONTROL_CONNECT_HOST`、`WDL_NS`。优先级是 `CLI flag > shell/CI env > [resolved-ns] section > base .env > wdl token store`,都没有提供时命令直接报错——没有内置默认值。namespace 解析顺序是 `--ns`,然后是 shell 或 base `.env` 里的 `WDL_NS`,再然后是 token store 的默认 namespace。section 名可以是 `[acme]` 这类 tenant namespace,也可以是 `[__name__]` 这种运维保留的不透明 section。Tenant Wrangler 配置默认仍使用普通 tenant namespace 语法,除非运维方明确给了这种 namespace token;否则不要把 `__name__` 形态写进 `[[services]].ns`、`allowed_callers` 或命令示例。如果没有解析出 namespace,section 会全部跳过;后续命令如果需要 namespace 或 token,会按正常校验报错。只有临时切换 namespace 时才需要显式传 `--ns`。不带 scheme 的生产 control host(例如 `api.wdl.dev`)默认补 `https://`;`localhost:8080` 或 `*.test:8080` 这类本地开发地址默认补 `http://`。任何不带 scheme 的 `:8080` control URL 都会按本地 HTTP 处理。需要强制使用其它协议时,显式写 scheme。 -`CONTROL_CONNECT_HOST` 是本地开发 / 调试用的覆盖开关:它改变请求实际连接的 TCP 目标,而 HTTP Host header 和 TLS SNI 仍跟随 `CONTROL_URL`(所以 HTTPS 下控制面证书仍会拒绝被重定向的连接;纯 http 没有这层保护)。只在本地开发用 —— 不要在 CI 或生产 shell 中持久设置,残留值可能把 admin token 路由到非预期目标。 +`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` 决定。 推荐的做法是把这些凭证放进托管存储,而不是 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 ` 切换默认。详见 [token-zh.md](./docs/token-zh.md)。 @@ -150,6 +150,8 @@ APP_NAME = "hello" 如果同时存在多个 Wrangler 配置文件,WDL 跟随 Wrangler 的优先级:`wrangler.json`,然后 `wrangler.jsonc`,最后 `wrangler.toml`。 +`wrangler.json` 和 `wrangler.jsonc` 都按 Wrangler 的 JSONC 语法解析,支持注释和尾随逗号。 + 配置好 CLI 默认值后执行: ```bash diff --git a/GUIDE.md b/GUIDE.md index 050ba43..f5239c1 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -125,7 +125,9 @@ 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. +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`. 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 ` @@ -229,6 +231,8 @@ related build behavior still follow Wrangler. When several Wrangler config files exist, WDL follows Wrangler's priority: `wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`. +Both JSON filenames use Wrangler's JSONC syntax, including comments and +trailing commas. After configuring the CLI defaults: diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index 351c388..7c77e93 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -95,6 +95,8 @@ wdl deploy . --env production 如果同时存在多个 Wrangler 配置文件,CLI 跟随 Wrangler 的优先级:`wrangler.json`,然后 `wrangler.jsonc`,最后 `wrangler.toml`。 +`wrangler.json` 和 `wrangler.jsonc` 都按 Wrangler 的 JSONC 语法解析,支持注释和尾随逗号。 + **支持:** `name`、`main`、`compatibility_date` / `compatibility_flags`、`[vars]`、`[[kv_namespaces]]`、`[[d1_databases]]`、`[[durable_objects.bindings]]`、`[[workflows]]`、`[[r2_buckets]]`、`[assets] directory`、`[triggers] crons`、`[[triggers.schedules]]`(带 timezone,平台扩展)、`[[queues.producers]]` / `[[queues.consumers]]`、`[[services]]`、`[[platform_bindings]]`、`[[exports]]`、`[env.]`。 **不支持(部署失败):** 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 experimental 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、`workers_dev`、`pages_build_output_dir`、`observability`、`limits`、`placement`,以及错误信息点名的其它 unsupported binding/config field 或 section。`assets.run_worker_first` 会被静默忽略。 diff --git a/docs/deploy.md b/docs/deploy.md index 5699543..83ee720 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -159,6 +159,8 @@ names like `my-worker-preview`; WDL does not append that suffix. See When multiple Wrangler config files exist, the CLI follows Wrangler's priority: `wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`. +Both JSON filenames use Wrangler's JSONC syntax, including comments and +trailing commas. **Supported:** `name`, `main`, `compatibility_date` / `compatibility_flags`, `[vars]`, `[[kv_namespaces]]`, `[[d1_databases]]`, `[[durable_objects.bindings]]`, diff --git a/lib/control-connect-host.js b/lib/control-connect-host.js index f745f0d..4a80092 100644 --- a/lib/control-connect-host.js +++ b/lib/control-connect-host.js @@ -19,7 +19,8 @@ export function parseControlConnectHost(raw, defaultPort) { } const host = bareUrlHostname(parsed); if (!host) throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}: host is required`); - return { host, port: validPort(parsed.port || defaultPort, raw) }; + const port = parsed.port || (parsed.protocol === "http:" ? 80 : 443); + return { host, port: validPort(port, raw) }; } catch (err) { if (err instanceof CliError) throw err; throw new CliError(`Invalid CONTROL_CONNECT_HOST ${display}.`); diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index de2343b..6309595 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -72,8 +72,6 @@ export { parseJsonc, resolveWranglerConfig, selectWranglerConfigFiles, - stripJsonComments, - stripTrailingCommas, validateUnsupportedWranglerConfig, WRANGLER_WDL_TMP_IGNORE_PATTERN, WRANGLER_WDL_TMP_PREFIX, diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index 29457c8..07c92b1 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; +import { getNodeValue, parseTree, printParseErrorCode } from "jsonc-parser"; import { parse as parseToml } from "smol-toml"; import { escapeTerminalText, formatDiagnosticValue } from "../output.js"; import { asRecord } from "./utils.js"; @@ -144,8 +145,7 @@ export function loadWranglerConfig(dir) { } try { if (name.endsWith(".toml")) return { path: p, cfg: parseToml(raw), shadowed: selection.shadowed }; - if (name.endsWith(".jsonc")) return { path: p, cfg: parseJsonc(raw), shadowed: selection.shadowed }; - return { path: p, cfg: JSON.parse(raw), shadowed: selection.shadowed }; + return { path: p, cfg: parseJsonc(raw), shadowed: selection.shadowed }; } catch (err) { const message = err instanceof Error && err.message ? err.message : String(err); throw new Error(`failed to parse ${escapeTerminalText(name)}: ${escapeTerminalText(message)}`, { cause: err }); @@ -269,95 +269,42 @@ export function resolveWranglerConfig(rawCfg, envName, configRel = "wrangler con /** * @param {string} src - * @returns {string} + * @returns {unknown} */ -export function stripJsonComments(src) { - let out = ""; - let i = 0; - const n = src.length; - while (i < n) { - const c = src[i]; - const next = src[i + 1]; - if (c === '"') { - const start = i; - i++; - while (i < n) { - if (src[i] === "\\") { i += 2; continue; } - if (src[i] === '"') { i++; break; } - i++; - } - out += src.slice(start, i); - continue; - } - if (c === "/" && next === "/") { - i += 2; - while (i < n && src[i] !== "\n") i++; - continue; - } - if (c === "/" && next === "*") { - i += 2; - while (i < n && !(src[i] === "*" && src[i + 1] === "/")) i++; - i += 2; - continue; - } - out += c; - i++; +export function parseJsonc(src) { + // Wrangler strips a UTF-8 BOM before parsing both .json and .jsonc files. + const input = src.charCodeAt(0) === 0xfeff ? src.slice(1) : src; + /** @type {import("jsonc-parser").ParseError[]} */ + const errors = []; + const tree = parseTree(input, errors, { allowTrailingComma: true }); + if (errors[0]) { + throw new SyntaxError(printParseErrorCode(errors[0].error)); } - return out; + if (!tree) throw new SyntaxError("ValueExpected"); + return materializeJsonValue(getNodeValue(tree)); } /** - * @param {string} src - * @returns {string} + * Convert jsonc-parser's null-prototype objects to the plain JSON objects the + * previous JSON.parse path returned, without invoking the __proto__ setter. + * @param {unknown} value + * @returns {unknown} */ -export function stripTrailingCommas(src) { - let out = ""; - let i = 0; - let inString = false; - - while (i < src.length) { - const c = src[i]; - if (inString) { - out += c; - if (c === "\\") { - i++; - if (i < src.length) out += src[i]; - } else if (c === '"') { - inString = false; - } - i++; - continue; - } - - if (c === '"') { - inString = true; - out += c; - i++; - continue; - } +function materializeJsonValue(value) { + if (Array.isArray(value)) return value.map(materializeJsonValue); + if (!value || typeof value !== "object") return value; - if (c === ",") { - let j = i + 1; - while (j < src.length && /\s/.test(src[j])) j++; - if (src[j] === "}" || src[j] === "]") { - i++; - continue; - } - } - - out += c; - i++; + /** @type {Record} */ + const result = {}; + for (const [key, item] of Object.entries(value)) { + Object.defineProperty(result, key, { + configurable: true, + enumerable: true, + value: materializeJsonValue(item), + writable: true, + }); } - - return out; -} - -/** - * @param {string} src - * @returns {unknown} - */ -export function parseJsonc(src) { - return JSON.parse(stripTrailingCommas(stripJsonComments(src))); + return result; } /** diff --git a/package-lock.json b/package-lock.json index 1a59353..12f237a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.3.1", "license": "Apache-2.0", "dependencies": { + "jsonc-parser": "3.2.0", "smol-toml": "^1.3.1", "wrangler": "^4.102.0" }, @@ -1904,6 +1905,12 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/package.json b/package.json index 11ea3ac..9dca56d 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "GUIDE-zh.md" ], "dependencies": { + "jsonc-parser": "3.2.0", "smol-toml": "^1.3.1", "wrangler": "^4.102.0" }, diff --git a/tests/unit/cli-control-fetch.test.js b/tests/unit/cli-control-fetch.test.js index 6e15229..5a4404d 100644 --- a/tests/unit/cli-control-fetch.test.js +++ b/tests/unit/cli-control-fetch.test.js @@ -380,6 +380,27 @@ test("controlFetch parses CONTROL_CONNECT_HOST host:port overrides", async () => assert.equal(opts.servername, "ctl.example"); }); +test("controlFetch derives CONTROL_CONNECT_HOST URL default ports from the override scheme", async () => { + const { seen, transport } = captureSuccessfulRequestOptions(); + + await controlFetch("https://ctl.example/whoami", { + env: { CONTROL_CONNECT_HOST: "http://127.0.0.1" }, + transport, + }); + await controlFetch("https://ctl.example/whoami", { + env: { CONTROL_CONNECT_HOST: "http://127.0.0.1:80" }, + transport, + }); + await controlFetch("http://ctl.example/whoami", { + env: { CONTROL_CONNECT_HOST: "https://127.0.0.1" }, + transport, + }); + + assert.equal(seen[0].port, 80); + assert.equal(seen[1].port, 80); + assert.equal(seen[2].port, 443); +}); + test("controlFetch uses init env for CONTROL_CONNECT_HOST overrides", async () => { const oldConnectHost = process.env.CONTROL_CONNECT_HOST; process.env.CONTROL_CONNECT_HOST = "process.example:19000"; diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index b08a337..d0a2aef 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -33,8 +33,6 @@ import { resolveAssetsDir, resolveWranglerCommand, resolveWranglerConfig, - stripJsonComments, - stripTrailingCommas, validateUnsupportedWranglerConfig, wranglerChildEnv, } from "../../lib/wrangler-pack.js"; @@ -155,42 +153,6 @@ function assertWranglerVersionProbe(call) { assert.deepEqual(call.args, ["--version"]); } -test("stripJsonComments removes line and block comments", () => { - const raw = `{ - // line - "name": "demo", /* block */ - "value": "keep // inside string" - }`; - const out = stripJsonComments(raw); - assert.ok(!out.includes("// line")); - assert.ok(!out.includes("/* block */")); - assert.ok(out.includes('"value": "keep // inside string"')); -}); - -test("stripTrailingCommas removes trailing commas outside strings", () => { - const raw = `{ - "arr": [1, 2,], - "obj": { "x": 1, }, - "literal": ",}" - }`; - const out = stripTrailingCommas(raw); - assert.ok(out.includes('"literal": ",}"')); - assert.ok(out.includes('"arr": [1, 2]')); - assert.ok(out.includes('"obj": { "x": 1 }')); -}); - -test("stripTrailingCommas keeps escaped quotes and backslashes inside strings", () => { - const raw = `{ - "quoted": "say \\"hi\\"", - "path": "C:\\\\tmp\\\\,}", - "arr": [1,], - }`; - const out = stripTrailingCommas(raw); - assert.ok(out.includes('"quoted": "say \\"hi\\""')); - assert.ok(out.includes('"path": "C:\\\\tmp\\\\,}"')); - assert.ok(out.includes('"arr": [1]')); -}); - test("parseJsonc accepts comments and trailing commas", () => { const cfg = parseJsonc(`{ // hello @@ -205,6 +167,27 @@ test("parseJsonc accepts comments and trailing commas", () => { }); }); +test("parseJsonc matches Wrangler handling of BOM and CR-only comments", () => { + const cfg = parseJsonc('\ufeff{\r// comment\r"name": "demo",\r}\r'); + assert.deepEqual(cfg, { name: "demo" }); +}); + +test("parseJsonc rejects comments that splice JSON tokens", () => { + assert.throws(() => parseJsonc('{"value": 1/* comment */2}'), /CommaExpected/); +}); + +test("parseJsonc rejects unterminated block comments", () => { + assert.throws(() => parseJsonc('{"name": "demo"} /*'), /UnexpectedEndOfComment/); +}); + +test("parseJsonc preserves reserved keys without changing object prototypes", () => { + const cfg = parseJsonc('{"__proto__": {"polluted": true}, "name": "demo"}'); + assert.ok(cfg && typeof cfg === "object" && !Array.isArray(cfg)); + assert.equal(Object.getPrototypeOf(cfg), Object.prototype); + assert.equal(Object.hasOwn(cfg, "__proto__"), true); + assert.equal(/** @type {Record} */ (cfg).polluted, undefined); +}); + test("parseTriggers: missing/empty yields []", () => { assert.deepEqual(parseTriggers(undefined), []); assert.deepEqual(parseTriggers(null), []); @@ -1181,26 +1164,49 @@ test("loadWranglerConfig: prefers wrangler.json when multiple config files exist } }); -test("loadWranglerConfig: parses JSONC when TOML is absent", () => { - const dir = mkdtempSync(path.join(tmpdir(), "wdl-config-jsonc-")); - try { - writeFileSync( - path.join(dir, "wrangler.jsonc"), - `{ - // comment - "name": "jsonc-demo", - "main": "src/index.js", - }` - ); +test("loadWranglerConfig: parses JSONC syntax in JSON config formats", () => { + for (const name of ["wrangler.json", "wrangler.jsonc"]) { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-config-jsonc-")); + try { + writeFileSync( + path.join(dir, name), + `{ + // comment + "name": "jsonc-demo", + "main": "src/index.js", + }` + ); - const loaded = loadWranglerConfig(dir); - const cfg = /** @type {{ name?: string, main?: string }} */ (loaded.cfg); - assert.equal(loaded.path, path.join(dir, "wrangler.jsonc")); - assert.equal(cfg.name, "jsonc-demo"); - assert.equal(cfg.main, "src/index.js"); - assert.deepEqual(loaded.shadowed, []); - } finally { - rmSync(dir, { recursive: true, force: true }); + const loaded = loadWranglerConfig(dir); + const cfg = /** @type {{ name?: string, main?: string }} */ (loaded.cfg); + assert.equal(loaded.path, path.join(dir, name)); + assert.equal(cfg.name, "jsonc-demo"); + assert.equal(cfg.main, "src/index.js"); + assert.deepEqual(loaded.shadowed, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test("loadWranglerConfig: rejects invalid JSONC in JSON config formats", () => { + const invalidCases = [ + ['{"value": 1/* comment */2}', "CommaExpected"], + ['{"name": "demo"} /*', "UnexpectedEndOfComment"], + ]; + for (const name of ["wrangler.json", "wrangler.jsonc"]) { + for (const [source, expected] of invalidCases) { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-config-jsonc-invalid-")); + try { + writeFileSync(path.join(dir, name), source); + assert.throws( + () => loadWranglerConfig(dir), + new RegExp(`failed to parse ${name.replace(".", "\\.")}: ${expected}`) + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } } });