diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index 8e8cb72..7885e49 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 @@ -44,10 +44,25 @@ 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`); 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. + 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/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..b6ed040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,88 @@ # 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 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`. +- 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. 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. +- `wdl doctor --strict` now exits non-zero when any local or remote readiness + check fails, while the default `wdl doctor` remains report-only. +- `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. +- 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 + 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, + 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. +- `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 + 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..958d487 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: @@ -85,13 +87,13 @@ 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)。 `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 +138,20 @@ 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`。 + +`wrangler.json` 和 `wrangler.jsonc` 都按 Wrangler 的 JSONC 语法解析,支持注释和尾随逗号。 + 配置好 CLI 默认值后执行: ```bash @@ -199,6 +205,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 +230,8 @@ https://.// ## 支持的 wrangler 配置 +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`。 + | 配置 | 支持情况 | | --- | --- | | `name` / `main` / `compatibility_date` / `compatibility_flags` | 支持 | @@ -240,7 +250,7 @@ https://.// | 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。 @@ -248,6 +258,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 +382,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 +395,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 +445,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 +508,11 @@ 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。 +- secret key 必须符合环境变量命名规则,例如 `STRIPE_KEY`;value 最大 64 KiB,并且和 `[vars]` 一样计入 workerLoader env budget。 ### Queues @@ -553,7 +575,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 | 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 的完整示例。 @@ -708,7 +730,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 +738,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 +813,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..f5239c1 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 @@ -122,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 ` @@ -148,10 +153,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 +213,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 +229,11 @@ 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`. +Both JSON filenames use Wrangler's JSONC syntax, including comments and +trailing commas. + After configuring the CLI defaults: ```bash @@ -289,6 +300,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 +333,15 @@ for you. ## Supported Wrangler Configuration +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 | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` / `main` / `compatibility_date` / `compatibility_flags` | Supported | @@ -334,7 +360,7 @@ for you. | 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 @@ -361,6 +387,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 +558,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 +577,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 +590,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 +643,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 +731,21 @@ 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. +- 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, 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 +813,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 | 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, @@ -954,11 +1010,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 +1024,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 +1107,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..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 提供服务: @@ -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)。 ## 命令 @@ -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`。 @@ -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 26b10ec..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`. @@ -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 @@ -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 fb79a90..1055a61 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"; @@ -29,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 @@ -55,7 +57,19 @@ for (const c of REGISTRY) { export async function main(argv = process.argv.slice(2), deps = {}) { const [command, ...rest] = argv; - if (!command || command === "-h" || command === "--help" || command === "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") { usage(command ? 0 : 1); return; } @@ -63,12 +77,12 @@ export async function main(argv = process.argv.slice(2), deps = {}) { console.log(currentCliVersion()); return; } - const commandModule = COMMANDS[command]; - if (!commandModule) { - console.error(`error: unknown command: ${command}`); + 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); @@ -136,10 +150,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..3dcd8fc 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, @@ -15,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, formatDiagnosticValue, writeResult } from "../lib/output.js"; const D1_EXECUTE_MODES = ["all", "raw", "run", "exec"]; @@ -67,19 +68,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 unexpectedArgument(`d1 migrations ${action}`, extraArg); await runMigrationsCommand({ action, databaseRef, context }); return; } if (subcommand === "list") { + 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") ); @@ -90,6 +93,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 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"), { method: "POST", @@ -108,6 +113,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 unexpectedArgument("d1 delete", positionals[2]); + const { headers } = context.resolveControl(); await confirmAction({ yes: values.yes === true, stdin, @@ -130,6 +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 unexpectedArgument("d1 execute", positionals[2]); const sql = readSql(values, context.cwd); const mode = values.mode || "all"; if (!D1_EXECUTE_MODES.includes(mode)) { @@ -151,6 +159,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", @@ -167,13 +176,16 @@ 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 */ 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: ${escapeTerminalText(action)}`); + } const { headers } = context.resolveControl(); const migrationsBase = context.nsUrl("d1", "databases", databaseRef, "migrations"); @@ -186,12 +198,12 @@ 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 body = /** @type {Parameters[0]} */ ( 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); @@ -199,7 +211,7 @@ 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 body = /** @type {Parameters[0]} */ ( await context.fetchJson(`${migrationsBase}/apply`, { method: "POST", @@ -211,12 +223,18 @@ async function runMigrationsCommand({ action, databaseRef, context }) { writeResult(values.json === true, body, () => formatD1MigrationApply(body), stdout); return; } +} - throw new CliError(`unknown d1 migrations subcommand: ${action}`); +/** + * @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 }} MigrationsDirArgs + * @typedef {{ values: D1Flags, env: NodeJS.ProcessEnv, cwd: string, databaseRef: string, warn?: (line: string) => void }} MigrationsDirArgs */ /** @@ -231,11 +249,11 @@ 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}`); + throw new CliError(`no .sql migration files found in ${escapeTerminalText(display)}`); } return migrations; } @@ -244,7 +262,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 +286,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 }} */ @@ -288,7 +309,7 @@ function resolveMigrationsDir({ values, env, cwd, databaseRef }) { 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); @@ -301,11 +322,11 @@ function resolveMigrationsDir({ values, env, cwd, databaseRef }) { 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 ${formatDiagnosticValue(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 ${formatDiagnosticValue(databaseRef)}; ` + "use a configured database_name/database_id or pass --dir explicitly" ); } @@ -348,7 +369,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 ${formatDiagnosticValue(migrationsDir)})` ); } return resolved; diff --git a/commands/delete.js b/commands/delete.js index fe48cf7..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,17 +38,19 @@ 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()}`); } - 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 unexpectedArgument("delete version", 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 unexpectedArgument("delete worker", 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..f37a1b4 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -4,9 +4,10 @@ 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 { escapeTerminalText, formatKnownWarning, writeStatusLine } from "../lib/output.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; @@ -65,34 +66,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 +108,112 @@ 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, env: init.env ?? context.env }); + 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, stripRenderedWarnings(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; + 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(", ")); + stderr( + `warning: platform binding "${escapeTerminalText(w.binding)}" (platform="${escapeTerminalText(w.platform)}"): ` + + `missing caller secrets ${keys}\n` + + ` 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)}`); + } + } +} + +/** + * @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 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} */ + 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 "; fix the Worker bundle shape reported by the control plane."; + } + 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."; + } + if (isSecretEnvelopeErrorCode(error)) { + return "; secret-envelope configuration or stored secret data needs operator repair before retrying."; + } + return ""; +} + /** * @param {unknown} manifest * @param {number} [maxBytes] @@ -171,9 +266,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 unexpectedArgument("deploy", 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..95969fa 100644 --- a/commands/doctor.js +++ b/commands/doctor.js @@ -1,13 +1,13 @@ 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, 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"; -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, @@ -20,8 +20,15 @@ import { resolveWranglerCommand, wranglerChildEnv, } from "../lib/wrangler/command.js"; +import { selectWranglerConfigFiles } 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,12 +50,26 @@ 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()); 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(), @@ -56,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({ @@ -68,6 +89,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"); + } } /** @@ -95,7 +119,7 @@ function checkNode() { function checkCliVersion() { return check({ ok: true, - label: `wdl-cli ${readCliPackageJson().version}`, + label: `wdl-cli ${currentCliVersion()}`, }); } @@ -182,13 +206,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" }); } @@ -203,13 +232,16 @@ 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 { selected, shadowed } = selectWranglerConfigFiles(cwd); + const name = selected?.name; 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 +263,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..55b834c 100644 --- a/commands/init.js +++ b/commands/init.js @@ -5,12 +5,14 @@ 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"; +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}$/; 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 = [ @@ -45,7 +47,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 +88,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 +97,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 +135,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 +148,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 +168,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.`, ); @@ -221,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/commands/r2.js b/commands/r2.js index 95aea55..5705e26 100644 --- a/commands/r2.js +++ b/commands/r2.js @@ -7,16 +7,16 @@ 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 = [ 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 unexpectedArgument("r2 buckets list", 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 unexpectedArgument("r2 objects list", 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 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 "); + } + 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 unexpectedArgument("r2 objects head", 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 unexpectedArgument("r2 objects delete", extraArg); const objectKey = requireR2ObjectKey(key); + const { headers } = context.resolveControl(); await confirmAction({ yes: values.yes === true, stdin, @@ -150,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()}`); } /** @@ -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..9b2cc14 100644 --- a/commands/secret.js +++ b/commands/secret.js @@ -3,9 +3,10 @@ // "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, 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"; +import { isSecretEnvelopeErrorCode } from "../lib/secret-envelope-errors.js"; const SECRET_OPTIONS = [ defineCliOption("worker", { type: "string" }, "--worker ", "Use worker-level secret scope."), @@ -29,14 +30,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 +38,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 +45,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 +66,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 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; const keys = Array.isArray(body.keys) ? body.keys : []; @@ -88,22 +82,20 @@ async function runSecret({ values, positionals, context }) { if (subcommand === "put") { if (!keyArg) throw new CliError("put requires a KEY argument"); + 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, { 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 +107,8 @@ async function runSecret({ values, positionals, context }) { if (subcommand === "delete") { if (!keyArg) throw new CliError("delete requires a KEY argument"); + if (extraArg) throw unexpectedArgument("secret delete", extraArg); + const { headers } = context.resolveControl(); await confirmAction({ yes: values.yes === true, stdin, @@ -122,37 +116,55 @@ 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`); return; } - throw new CliError(`unknown subcommand: ${subcommand}`); + throw new CliError(`unknown subcommand: ${escapeTerminalText(subcommand)}`); } /** - * @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, 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)}`); +} + +/** @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 (isSecretEnvelopeErrorCode(error)) { + return "; secret mutation was not written. Secret-envelope configuration or stored secret data needs operator repair before retrying."; + } + return ""; } function usageText() { diff --git a/commands/tail.js b/commands/tail.js index 051f514..163b2f4 100644 --- a/commands/tail.js +++ b/commands/tail.js @@ -6,9 +6,13 @@ 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"; +import { escapeTerminalLines, escapeTerminalText, formatDiagnosticValue } from "../lib/output.js"; const RECONNECT_INITIAL_MS = 1_000; const RECONNECT_MAX_MS = 5_000; @@ -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,19 @@ function isExpectedAbortError(err) { return false; } +/** @param {unknown} err */ +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.", @@ -100,7 +119,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 */ /** @@ -125,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`, ); } @@ -197,12 +216,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 +244,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; @@ -236,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. @@ -293,71 +316,115 @@ 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); }); - onAbort = () => { req.destroy(); }; + onAbort = () => { + clearConnectTimer(); + req.destroy(tailAbortError()); + }; 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 +437,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 +452,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 +498,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`); + } + } } /** @@ -441,12 +518,22 @@ 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" && + (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 +541,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..70a7b49 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, shellArgForDisplay, 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)."), @@ -93,7 +94,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 +109,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) { @@ -119,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).`); @@ -156,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).`); } @@ -185,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 = shellArgForDisplay(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 @@ -213,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/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..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, @@ -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, @@ -98,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()}`); } /** @@ -133,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/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..7c77e93 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,8 @@ Worker 看到的路径是**剥掉 `/` 之后的路径**。除非运 Deploy 上传给 control 的 manifest JSON 最大 32 MiB。Assets 在部署时会嵌进这个 JSON 请求;如果静态文件集合较大,可能先撞到 control request cap。大体积或频繁变化的文件用 R2,不要放进 assets。 +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 +93,13 @@ wdl deploy . --env production ## 支持 / 不支持的 wrangler 配置 -**支持:** `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.]`。 +如果同时存在多个 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` 仅在运维方启用时支持。`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。 @@ -112,10 +118,17 @@ 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]`、显式 bindings 或隐式 `ASSETS` binding 复用了 runtime env 名称;重命名其中一个。 | +| `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 失败 | 自定义主机或服务绑定的目标校验问题;检查绑定目标。 | | 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..83ee720 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,11 @@ 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. +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,12 +157,17 @@ names like `my-worker-preview`; WDL does not append that suffix. See ## Supported / unsupported wrangler configuration -**Supported:** `name`, `main`, `compatibility_date`/`flags`, `[vars]`, +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]]`, `[[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 @@ -164,8 +175,16 @@ 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, 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. +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 only on routeable tenant Workers. Workers selected through @@ -191,10 +210,17 @@ 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: ` | `[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. 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. | | 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..abd6174 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": { @@ -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 6955094..ad54754 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": { @@ -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/docs/queues-zh.md b/docs/queues-zh.md index 97292bb..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` 会被解析保存,但当前不要依赖它做完整的等待聚合;实际 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 f788221..20b2185 100644 --- a/docs/queues.md +++ b/docs/queues.md @@ -66,7 +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 +- 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. 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..9b2f054 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]`。 ## 设置 @@ -48,10 +48,15 @@ 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`。 + +如果 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`。 - 值上限 64 KiB。 +- Secrets 会和 `[vars]`、binding metadata 一起计入 workerLoader env budget。如果 mutation 返回 `worker_env_too_large`,减少 env payload;如果错误点名 retained version,redeploy/delete 该版本。 ## Worker 端读取 @@ -67,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 be6c6ad..4e8c4d3 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 @@ -61,11 +61,26 @@ 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. + +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. `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 @@ -82,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/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..de2ea9e 100644 --- a/lib/bundle-modules.js +++ b/lib/bundle-modules.js @@ -14,7 +14,8 @@ export function inferType(filePath) { 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 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`. @@ -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..4518908 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); @@ -195,7 +203,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; @@ -222,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); }; @@ -234,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/common.js b/lib/common.js index b0cf885..2c1a9a2 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 { /** @@ -17,6 +15,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 +226,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; @@ -266,7 +273,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 +287,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([ @@ -289,27 +301,30 @@ const ERROR_SUMMARY_KEYS = new Set([ const ARRAY_CONTEXT_KEYS = new Set([ "blockers", "referrers", + "reserved_modules", "warnings", ]); /** * @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 +339,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; @@ -338,14 +354,31 @@ function formatHttpError(status, text) { 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(" ")); 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 +396,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/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-connect-host.js b/lib/control-connect-host.js new file mode 100644 index 0000000..4a80092 --- /dev/null +++ b/lib/control-connect-host.js @@ -0,0 +1,72 @@ +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`); + 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}.`); + } + } + + 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 ddb40b4..d40fb47 100644 --- a/lib/control-fetch.js +++ b/lib/control-fetch.js @@ -7,12 +7,16 @@ 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 { bareUrlHostname, parseControlConnectHost } from "./control-connect-host.js"; +import { escapeTerminalText, formatDiagnosticValue } from "./output.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 +40,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 +74,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 +93,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 +117,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 +153,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 +192,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 ${formatDiagnosticValue(name)}: ${escapeTerminalText(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" + ? `${escapeTerminalText(codeValue)} ` + : ""; + const message = escapeTerminalText(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 +235,47 @@ 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()}`, + }, 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 parseControlConnectHost(override, defaultPort); +} + // 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..97f66ff 100644 --- a/lib/credentials.js +++ b/lib/credentials.js @@ -4,8 +4,10 @@ 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"; // 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. @@ -33,10 +35,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; } @@ -88,23 +90,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; + } + 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 = controlConnectHostForWarning(connectHost); + if (!isLocalDevHost(actualHost)) { + return `control URL ${escapeTerminalText(controlUrl)} is plain http and ` + + `CONTROL_CONNECT_HOST=${escapeTerminalText(connectHost)} is non-local`; } - return parsed.protocol === "http:" && !isLocalDevHost(parsed.hostname); + return null; } // Loopback / dev-TLD hosts, shared by the bare-URL scheme default and the @@ -178,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 { @@ -201,7 +220,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; } @@ -209,14 +228,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 +258,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..21426ce 100644 --- a/lib/d1-files.js +++ b/lib/d1-files.js @@ -1,8 +1,9 @@ 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"; +import { escapeTerminalText } from "./output.js"; /** * @param {Record} values Parsed flag values (`--sql`, `--file`). @@ -20,14 +21,21 @@ 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; 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 ${escapeTerminalText(values.file)}: ${escapeTerminalText(message)}`); + } + return requireSqlText(sql, "SQL file"); } throw new CliError("d1 execute requires --sql or --file "); } @@ -63,26 +71,64 @@ 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: ${escapeTerminalText(entry.name)} is a symlink; copy the SQL file into the migrations dir`); + } + if (entry.isFile()) filenames.push(entry.name); } - return entries - .filter((entry) => entry.isFile() && entry.name.endsWith(".sql")) - .map((entry) => 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 { + 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; + 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/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/output.js b/lib/output.js index 4985094..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. @@ -123,6 +128,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 +154,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/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/token-store.js b/lib/token-store.js index 57f9fe3..c9c8b55 100644 --- a/lib/token-store.js +++ b/lib/token-store.js @@ -1,8 +1,10 @@ -import { chmodSync, mkdirSync, readFileSync, 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, 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 @@ -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 = shellArgForDisplay(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,17 +215,451 @@ 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 = 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); + 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 { - chmodSync(storePath, 0o600); + return readFileSync(lockOwnerPath(lockDir), "utf8"); } catch (err) { - if (!hasErrorCode(err) || err.code !== "ENOENT") throw 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. } - 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..6309595 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, @@ -31,9 +31,11 @@ import { wranglerChildEnv, } from "./wrangler/command.js"; import { + formatWranglerConfigShadowWarning, 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"; @@ -65,12 +67,14 @@ export { wranglerChildEnv, } from "./wrangler/command.js"; export { + formatWranglerConfigShadowWarning, loadWranglerConfig, parseJsonc, resolveWranglerConfig, - stripJsonComments, - stripTrailingCommas, + selectWranglerConfigFiles, validateUnsupportedWranglerConfig, + WRANGLER_WDL_TMP_IGNORE_PATTERN, + WRANGLER_WDL_TMP_PREFIX, } from "./wrangler/config.js"; export { collectModules } from "./wrangler/modules.js"; @@ -107,13 +111,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; } @@ -148,6 +152,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); @@ -167,7 +173,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); }; @@ -233,6 +239,9 @@ export async function packWranglerProject({ } const vars = normalizeVars(cfg.vars); + 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. @@ -245,15 +254,17 @@ 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`); + 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, @@ -280,16 +291,18 @@ export async function packWranglerProject({ execFile(wrangler.command, wranglerArgs, wranglerOpts); stdout(" bundled by wrangler"); } catch (err) { + bundlingFailed = true; throw new CliError(formatWranglerFailure(err)); } finally { - rmSync(tmpConfigPath, { force: true }); + cleanupTempConfig({ ignoreErrors: bundlingFailed }); } const modules = wrapCli(() => collectModules(outDir)); 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(", ")}` ); } @@ -343,7 +356,8 @@ export async function packWranglerProject({ `defaults and .assetsignore rules apply — re-include with a !pattern line` ); } - if (Object.keys(assets).length) manifest.assets = assets; + claimBinding("ASSETS"); + manifest.assets = assets; } return { absProject, workerName: cfg.name, manifest }; @@ -355,6 +369,7 @@ export async function packWranglerProject({ * @returns {string[]} */ export function collectRoutes(cfg, configRel) { + configRel = escapeTerminalText(configRel); /** @type {string[]} */ const collected = []; /** @@ -365,7 +380,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: ${formatDiagnosticValue(r)}`); }; if (cfg.route !== undefined && cfg.routes !== undefined) { throw new CliError(`${configRel}: specify either "route" or "routes", not both`); @@ -382,6 +397,61 @@ 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 {(options?: { ignoreErrors?: boolean }) => void} + */ +export function installTempFileCleanup( + filePath, + processLike = process, + terminate = (signal) => { process.kill(process.pid, signal); } +) { + let active = true; + /** @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; + processLike.off("exit", onExit); + processLike.off("SIGINT", onSigint); + processLike.off("SIGTERM", onSigterm); + }; + const handleSignal = (/** @type {"SIGINT" | "SIGTERM"} */ signal) => { + // Best effort: do not interrupt signal propagation if cleanup itself fails. + cleanup({ ignoreErrors: true }); + uninstall(); + terminate(signal); + }; + const onSigint = () => handleSignal("SIGINT"); + const onSigterm = () => handleSignal("SIGTERM"); + processLike.once("exit", onExit); + processLike.once("SIGINT", onSigint); + processLike.once("SIGTERM", onSigterm); + return ({ ignoreErrors = false } = {}) => { + try { + cleanup({ ignoreErrors }); + } finally { + uninstall(); + } + }; +} + /** * @template T * @param {() => T} fn diff --git a/lib/wrangler/assets.js b/lib/wrangler/assets.js index f038e63..d32f23b 100644 --- a/lib/wrangler/assets.js +++ b/lib/wrangler/assets.js @@ -1,5 +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; @@ -19,11 +21,44 @@ const DEFAULT_ASSET_IGNORE_PATTERNS = [ "**/.DS_Store", "/.wrangler", "/.deploy-dist", - "/.wrangler.wdl-tmp*.json", + WRANGLER_WDL_TMP_IGNORE_PATTERN, "**/.env", "**/.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 +66,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 +74,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 +117,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 +163,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 +174,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; @@ -172,6 +257,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 +276,18 @@ 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 ${formatDiagnosticValue(originalPattern)}: ${escapeTerminalText(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..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 e35ad78..46f577c 100644 --- a/lib/wrangler/command.js +++ b/lib/wrangler/command.js @@ -1,11 +1,14 @@ 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"; +import { escapeTerminalLines, escapeTerminalText, formatDiagnosticValue } from "../output.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,10 +50,18 @@ export function resolveWranglerCommand({ return { command: env.WDL_WRANGLER_BIN, args: [], source: "WDL_WRANGLER_BIN" }; } - for (const dir of uniquePaths([absProject, ...packageDirs])) { - if (!dir) continue; + if (absProject) { + const projectLocal = localWrangler(path.resolve(absProject), platform); + if (projectLocal) return { ...projectLocal, source: "project" }; + } + + 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: "local" }; + if (local) return { ...local, source: "package" }; } const fromPath = pathWrangler(env, platform); @@ -98,7 +109,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) { @@ -128,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))}`; } /** @@ -196,6 +207,16 @@ function wranglerScript(dir) { return existsSync(script) ? script : null; } +function bundledWrangler() { + try { + 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; + } +} + /** * @param {NodeJS.ProcessEnv} env * @param {NodeJS.Platform} platform @@ -227,19 +248,21 @@ 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 = escapeTerminalLines(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 += "\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 2dfed5b..07c92b1 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -1,53 +1,84 @@ 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"; /** - * 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 = @@ -74,26 +105,62 @@ const NON_INHERITABLE_ENV_KEYS = new Set([ "secrets_store_secrets", ]); +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 {{ path: string, cfg: unknown }} + * @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 candidates = ["wrangler.toml", "wrangler.jsonc", "wrangler.json"]; - for (const name of candidates) { - const p = path.join(dir, name); - if (!existsSync(p)) continue; - const raw = readFileSync(p, "utf8"); + const selection = selectWranglerConfigFiles(dir); + if (selection.selected) { + const { name, path: p } = selection.selected; + /** @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) }; - if (name.endsWith(".jsonc")) return { path: p, cfg: parseJsonc(raw) }; - return { path: p, cfg: JSON.parse(raw) }; + if (name.endsWith(".toml")) return { path: p, cfg: parseToml(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 ${name}: ${message}`, { cause: err }); + throw new Error(`failed to parse ${escapeTerminalText(name)}: ${escapeTerminalText(message)}`, { cause: err }); } } - throw new Error(`no wrangler.{toml,jsonc,json} found in ${dir}`); + throw new Error(`no ${WRANGLER_CONFIG_CANDIDATES.join(", ")} found in ${escapeTerminalText(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(", ")}`; } /** @@ -106,16 +173,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 (hasConfiguredValue(cfg?.[key])) { + if (hasConfiguredKey(cfg, key)) { throw new Error( - `${configRel} uses [${key}] — not supported. ${SUPPORTED_WRANGLER_SUMMARY}` + `${shownConfig} uses unsupported Wrangler field ${formatDiagnosticValue(key)}. ${SUPPORTED_WRANGLER_SUMMARY}` ); } - if (hasConfiguredValue(selectedEnvCfg?.[key])) { + if (hasConfiguredKey(selectedEnvCfg, key)) { throw new Error( - `${configRel} env.${envName} uses [${key}] — not supported. ${SUPPORTED_WRANGLER_SUMMARY}` + `${shownConfig} env.${shownEnv} uses unsupported Wrangler field ${formatDiagnosticValue(key)}. ${SUPPORTED_WRANGLER_SUMMARY}` ); } } @@ -125,11 +194,11 @@ 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)) { - throw new Error(`${configRel} uses top-level allowed_callers — removed. ${allowedCallersHint}`); + if (hasConfiguredKey(cfg, "allowed_callers")) { + 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}`); + if (hasConfiguredKey(selectedEnvCfg, "allowed_callers")) { + throw new Error(`${shownConfig} env.${shownEnv} uses top-level allowed_callers — removed. ${allowedCallersHint}`); } } @@ -140,16 +209,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` ); } @@ -157,25 +229,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`); } } @@ -197,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; - } +function materializeJsonValue(value) { + if (Array.isArray(value)) return value.map(materializeJsonValue); + if (!value || typeof value !== "object") return value; - if (c === '"') { - inString = true; - out += c; - i++; - continue; - } - - 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; } /** @@ -299,9 +318,10 @@ function listNamedEnvironments(cfg) { } /** - * @param {unknown} value + * @param {Record | null} record + * @param {string} key * @returns {boolean} */ -function hasConfiguredValue(value) { - return Array.isArray(value) ? value.length > 0 : Boolean(value); +function hasConfiguredKey(record, key) { + return Boolean(record && Object.hasOwn(record, key)); } diff --git a/lib/wrangler/modules.js b/lib/wrangler/modules.js index ea1006c..c87a3a0 100644 --- a/lib/wrangler/modules.js +++ b/lib/wrangler/modules.js @@ -1,8 +1,22 @@ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { inferType, toWireModule } from "../bundle-modules.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. /** @@ -10,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); @@ -27,7 +52,17 @@ export function collectModules(dir) { } if (!st.isFile()) continue; if (entry.endsWith(".map") || skip.has(relPath)) continue; - out[relPath] = toWireModule(readFileSync(full), inferType(entry)); + const type = inferType(entry); + if (type === "py") { + throw new Error(`Python Workers modules are not supported by WDL (${escapeTerminalText(relPath)})`); + } + let bytes; + try { + bytes = readFileSync(full); + } catch (err) { + throw moduleFsError("read", relPath, err); + } + out[relPath] = toWireModule(bytes, type); } })(dir, ""); return out; 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/templates/AGENTS.md b/templates/AGENTS.md index a94c6ce..af028d7 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -34,9 +34,21 @@ 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 +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]`, +`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 @@ -57,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/integration/cli-live.test.js b/tests/integration/cli-live.test.js index 0a74e28..00481dc 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,9 @@ 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", () => { + run(["delete", "worker", doWorker, "--yes", "--json"], { env: directTenantEnv }); + }); cleanupStep("delete workflow worker", () => { try { run(["delete", "worker", wfWorker, "--yes", "--json"], { env: directTenantEnv }); @@ -283,6 +294,9 @@ test("live CLI integration covers command surface against a WDL control plane", throw err; } }); + cleanupStep("delete env worker", () => { + run(["delete", "worker", envWorker, "--yes", "--json"], { env: directTenantEnv }); + }); await step("d1 commands create, migrate, list, execute", () => { const createdDb = /** @type {D1CreateResult} */ (runJson(["d1", "create", dbName, "--json"], { env: storeEnv })); @@ -349,6 +363,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 and queue delivery", 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 +406,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 +733,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 +748,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 +763,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 +852,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 +873,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 +1033,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..3ca2e01 100644 --- a/tests/unit/cli-bundle-modules.test.js +++ b/tests/unit/cli-bundle-modules.test.js @@ -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-command.test.js b/tests/unit/cli-command.test.js index 95b1907..63a7ccf 100644 --- a/tests/unit/cli-command.test.js +++ b/tests/unit/cli-command.test.js @@ -2,7 +2,7 @@ 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 */ @@ -37,6 +37,27 @@ 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); + assertNoRawTerminalControls(err.message, "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; @@ -160,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 () => { @@ -205,15 +226,48 @@ 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"); + 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-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index fcfec19..956c927 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/); }); }); @@ -353,6 +399,70 @@ 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 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"); @@ -535,3 +645,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..5a4404d 100644 --- a/tests/unit/cli-control-fetch.test.js +++ b/tests/unit/cli-control-fetch.test.js @@ -2,7 +2,14 @@ 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"; +import { ESC, assertNoRawTerminalControls } from "./helpers.js"; /** * @param {{ statusCode?: number, headers?: import("node:http").IncomingHttpHeaders }} [init] @@ -11,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 }); @@ -32,6 +61,10 @@ test("readControlResponse rejects oversized response bodies", async () => { await assert.rejects(promise, /control response exceeded 3 bytes/); }); +test("default buffered control response cap stays conservative", () => { + 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 +107,90 @@ 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 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/); + assertNoRawTerminalControls(err.message, "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} */ + 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, @@ -226,33 +343,141 @@ 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(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"; + const { seen, transport } = captureSuccessfulRequestOptions(); + + 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; + } + + 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"); + 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"; + const { seen, transport } = captureSuccessfulRequestOptions(); + + 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; + } + + 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 () => { + const { seen, transport } = captureSuccessfulRequestOptions(); + + await controlFetch("https://ctl.example/whoami", { + env: { CONTROL_CONNECT_HOST: "::1" }, + transport, + }); + + 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"); +}); + +test("controlFetch rejects invalid CONTROL_CONNECT_HOST values before opening a socket", () => { + const oldConnectHost = process.env.CONTROL_CONNECT_HOST; + try { + 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", { + transport: { + request() { + throw new Error("request should not be opened"); + }, + }, + }), + (err) => { + assert(err instanceof CliError); + assert.match(err.message, /Invalid CONTROL_CONNECT_HOST/); + assertNoRawTerminalControls(err.message, "CONTROL_CONNECT_HOST errors"); + return true; + } + ); + } + } 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 () => { + const { seen, transport } = captureSuccessfulRequestOptions(); + + await controlFetch("https://127.0.0.1/whoami", { transport }); + + 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 1f60061..d336ee9 100644 --- a/tests/unit/cli-credentials.test.js +++ b/tests/unit/cli-credentials.test.js @@ -1,9 +1,10 @@ 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 } from "../../lib/credentials.js"; +import { isTokenStoreDisabled, loadCliControlEnv, loadCliDotEnv, protectedEnvKeys, resolveControlContext, resolveControlUrl, resolveNamespace, warnIfInsecureControlUrl } from "../../lib/credentials.js"; +import { ESC, assertNoRawTerminalControls } from "./helpers.js"; test("isTokenStoreDisabled honors the flag and WDL_TOKEN_STORE=off", () => { assert.equal(isTokenStoreDisabled({}, false), false); @@ -33,6 +34,18 @@ 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/); + assertNoRawTerminalControls(message, "control URL errors"); + return true; + } + ); +}); + test("resolveControlUrl accepts bare control hosts as https URLs", () => { assert.equal( resolveControlUrl({ "control-url": "ctl.example" }, {}), @@ -82,6 +95,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 +203,46 @@ 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/); + assertNoRawTerminalControls(message, "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"); + 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"); @@ -471,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-d1.test.js b/tests/unit/cli-d1.test.js index 7caedff..44fac09 100644 --- a/tests/unit/cli-d1.test.js +++ b/tests/unit/cli-d1.test.js @@ -1,15 +1,26 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, 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"; +import { runD1Command, serializeMigrationStatusRequest } 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 */ +/** + * @param {unknown} err + * @param {RegExp} expected + */ +function assertEscapedD1Error(err, expected) { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "the error"); + assert.match(message, expected); + return true; +} + // Request bodies in these tests are always JSON strings; narrow the broader // `body` union before parsing. /** @@ -256,6 +267,168 @@ 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 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); + writeFileSync(path.join(migrations, "001_init.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"); + } 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 { + 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${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; + assertNoRawTerminalControls(message, "the error"); + 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"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), + (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 }); + } +}); + 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 +472,155 @@ 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 { + 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 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-")); @@ -411,6 +733,116 @@ 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 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; + assertNoRawTerminalControls(message, "the error"); + 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" }, + 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 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; + assertNoRawTerminalControls(message, "the error"); + 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 3599974..d0a2aef 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { EventEmitter } from "node:events"; +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -12,6 +13,8 @@ import { collectAssets, collectModules, collectRoutes, + formatWranglerConfigShadowWarning, + installTempFileCleanup, loadWranglerConfig, MAX_ASSET_FILE_BYTES, parseD1DatabasesFromCfg, @@ -19,6 +22,7 @@ import { parseExportsFromCfg, parseJsonc, parseKvNamespacesFromCfg, + packWranglerProject, parsePlatformBindingsFromCfg, parseQueues, parseR2BucketsFromCfg, @@ -29,13 +33,29 @@ import { resolveAssetsDir, resolveWranglerCommand, resolveWranglerConfig, - stripJsonComments, - stripTrailingCommas, validateUnsupportedWranglerConfig, wranglerChildEnv, } from "../../lib/wrangler-pack.js"; import { LONG_CONTROL_TIMEOUT_MS } from "../../lib/control-fetch.js"; -import { response } from "./helpers.js"; +import { checkWranglerVersion, formatWranglerFailure } from "../../lib/wrangler/command.js"; +import { ESC, assertNoRawTerminalControls, response } from "./helpers.js"; + +/** + * @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 @@ -77,49 +97,61 @@ function fakeWranglerExecFile(_cmd, args) { writeFileSync(path.join(outDir, "index.js"), "export default {}"); } -/** @param {string} cmd */ +/** + * @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 + */ 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}` ); } -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]')); -}); +/** + * @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("parseJsonc accepts comments and trailing commas", () => { const cfg = parseJsonc(`{ @@ -135,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), []); @@ -229,6 +282,13 @@ test("parseQueues: consumers normalize max_batch_timeout and retry_delay", () => ]); }); +test("parseQueues: forwards platform-range batch timeouts for control-side validation", () => { + assert.deepEqual( + parseQueues({ consumers: [{ queue: "orders", max_batch_timeout: 61 }] }).consumers, + [{ queue: "orders", maxBatchTimeoutMs: 61_000 }] + ); +}); + test("parseQueues: omits optional consumer fields when absent", () => { assert.deepEqual( parseQueues({ consumers: [{ queue: "orders" }] }), @@ -471,6 +531,20 @@ 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/); + assertNoRawTerminalControls(message, "route errors"); + return true; + } + ); }); test("parseKvNamespacesFromCfg: validates shape and non-empty string binding/id", () => { @@ -598,6 +672,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 { @@ -634,16 +756,38 @@ 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 }); } }); +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 { @@ -823,6 +967,72 @@ 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 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 { @@ -865,6 +1075,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"); @@ -907,10 +1137,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,30 +1152,138 @@ 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 }); } }); -test("loadWranglerConfig: parses JSONC when TOML is absent", () => { - const dir = mkdtempSync(path.join(tmpdir(), "wdl-config-jsonc-")); +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, 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 }); + } + } + } +}); + +test("loadWranglerConfig: escapes parser diagnostics from config files", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-config-bad-")); try { - writeFileSync( - path.join(dir, "wrangler.jsonc"), - `{ - // comment - "name": "jsonc-demo", - "main": "src/index.js", - }` + 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/); + assertNoRawTerminalControls(message, "config parse errors"); + return true; + } ); + } 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, "wrangler.jsonc")); - assert.equal(cfg.name, "jsonc-demo"); - assert.equal(cfg.main, "src/index.js"); +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 { + 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 }); + } +}); + +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()); + 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); + assert.doesNotThrow(() => cleanup({ ignoreErrors: true })); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -962,7 +1304,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 +1318,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 * * * *"] }); @@ -1034,6 +1376,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({ @@ -1052,11 +1409,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/ ); }); @@ -1102,7 +1459,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"/ ); }); @@ -1117,7 +1474,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"/ ); }); @@ -1132,6 +1489,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({ @@ -1143,17 +1513,115 @@ 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: 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", + "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`) + new RegExp(`unsupported Wrangler field "${key}"`) ); } + + assert.throws( + () => validateUnsupportedWranglerConfig({ + name: "demo", + main: "src/index.js", + workers_dev: false, + }, null, "wrangler.toml"), + /unsupported Wrangler field "workers_dev"/ + ); + + assert.throws( + () => validateUnsupportedWranglerConfig({ + name: "demo", + main: "src/index.js", + vectorize: [], + }, null, "wrangler.toml"), + /unsupported Wrangler field "vectorize"/ + ); + + assert.throws( + () => validateUnsupportedWranglerConfig({ + name: "demo", + main: "src/index.js", + env: { + staging: { + preview_urls: false, + }, + }, + }, "staging", "wrangler.toml"), + /env\.staging uses unsupported Wrangler field "preview_urls"/ + ); + // The rejection lists the actual supported surface. try { validateUnsupportedWranglerConfig( @@ -1170,6 +1638,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( @@ -1177,7 +1659,7 @@ test("validateUnsupportedWranglerConfig rejects module-binding and container sec null, "wrangler.toml" ), - /\[wasm_modules\] — not supported/ + /unsupported Wrangler field "wasm_modules"/ ); assert.throws( () => validateUnsupportedWranglerConfig( @@ -1185,7 +1667,7 @@ test("validateUnsupportedWranglerConfig rejects module-binding and container sec null, "wrangler.toml" ), - /\[containers\] — not supported/ + /unsupported Wrangler field "containers"/ ); }); @@ -1347,6 +1829,79 @@ 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\nline\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\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({ @@ -1372,7 +1927,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 }); @@ -1390,6 +1945,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 { @@ -1441,7 +2008,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); @@ -1463,7 +2030,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 }); @@ -1546,7 +2113,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"', @@ -1587,6 +2154,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", }, @@ -1614,8 +2182,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"); @@ -1632,6 +2199,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", @@ -1648,10 +2216,11 @@ 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"); + 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")); @@ -1660,6 +2229,36 @@ 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 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; + assertNoRawTerminalControls(message, "deploy positional errors"); + 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 { @@ -1671,15 +2270,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 +2313,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 }); } @@ -1756,8 +2359,44 @@ test("runDeployCommand removes the sanitized temp config when wrangler exec fail } }); -test("runDeployCommand preserves prototype-shaped binding keys for control validation", async () => { - const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-proto-")); +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 { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); @@ -1850,7 +2489,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 = []; @@ -1995,6 +2634,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 { @@ -2052,6 +2727,242 @@ 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-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: "kv-cache" }], + vars: { CACHE: "shadow" }, + })); + + let execCalled = false; + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: () => { execCalled = true; }, + controlFetch: async () => response({}), + }), + /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"), ""); + 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 }); + } +}); + +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"), ""); + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: "src/index.js", + assets: { directory: "public" }, + kv_namespaces: [{ binding: "ASSETS", id: "kv-assets" }], + })); + + 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 }); + } +}); + +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", + assets: { directory: "public" }, + })); + + /** @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" }); + }, + }); + + const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); + assert.deepEqual(manifest.assets, {}); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +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 { + 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 }); + } +}); + +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 { @@ -2076,8 +2987,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 { @@ -2110,8 +3020,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 }); @@ -2149,16 +3058,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, @@ -2181,8 +3092,252 @@ 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 /); + 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 { + rmSync(dir, { recursive: true, force: true }); + } +}); + +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.json"), JSON.stringify({ name: badWorker, main: "src/index.js" })); + + /** @type {string[]} */ + const warnings = []; + await assert.rejects( + () => 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, + controlFetch: async () => response({ + error: "deploy_failed", + message: "missing caller secrets", + warnings: [{ + binding: "PAYMENT", + platform: "STRIPE", + missingCallerSecrets: ["API_KEY"], + internalTaskId: "task-secret", + }], + }, 400), + }), + (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); + 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 { + 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 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 { + 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 { + 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 }); } @@ -2234,6 +3389,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 { @@ -2434,7 +3623,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 f53a67b..fdce4b7 100644 --- a/tests/unit/cli-init.test.js +++ b/tests/unit/cli-init.test.js @@ -5,6 +5,7 @@ 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)), "../.."); @@ -64,6 +65,29 @@ 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; + assertNoRawTerminalControls(message, "the error"); + 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); + assertNoRawTerminalControls(errOutput, "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 +97,19 @@ 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; + assertNoRawTerminalControls(message, "--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 +122,19 @@ 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; + assertNoRawTerminalControls(message, "--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")); @@ -134,7 +184,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..0303d41 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"; @@ -18,10 +18,18 @@ 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 */ +/** @param {unknown} err */ +function assertEscapedBadArg(err) { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "CLI errors"); + 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`. @@ -170,6 +178,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 +245,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 () => { @@ -422,6 +472,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 = []; @@ -500,6 +594,50 @@ 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("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: [] }); @@ -568,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 = {}) => { @@ -581,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"]); }); @@ -653,6 +792,72 @@ 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 () => { + 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 () => { /** @type {ControlCall[]} */ const calls = []; @@ -705,6 +910,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 +991,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 +1009,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 +1159,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 +1209,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 +1259,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 +1279,58 @@ 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 = []; + const deps = { + 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 () => "", + }; + }, + }; + 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"], { + () => 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 +1442,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 +1457,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 +1471,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 +1479,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/); @@ -1174,12 +1501,33 @@ 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:1/); + assert.ok(seen.some((line) => line.includes("unknown help topic: toString"))); + assert.doesNotMatch(seen.join("\n"), /TypeError|COMMANDS\[|\.main/); } 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 () => { const oldLog = console.log; /** @type {string[]} */ @@ -1292,7 +1640,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 +1657,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 +1667,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( @@ -1328,11 +1678,20 @@ 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); + assertNoRawTerminalControls(escaped, "unknown-command errors"); + assert.match(escaped, /bad\\u001b\[2J\\nFORGED\\rBAD/); } finally { process.exit = oldExit; console.error = oldError; + console.log = oldLog; } }); @@ -1349,7 +1708,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 { @@ -1358,7 +1717,8 @@ 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'/); + assertNoRawTerminalControls(errors[0], "parseArgs errors"); assert.doesNotMatch(errors[0], /TypeError|parse_args|Node\.js/); }); @@ -1395,6 +1755,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( @@ -1406,13 +1803,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; + } ); }); @@ -1669,6 +2073,131 @@ 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 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 = []; @@ -1861,7 +2390,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 { @@ -1891,7 +2420,173 @@ 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/); + assertNoRawTerminalControls(transportLine, "tail transport diagnostics"); +}); + +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 --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 () => { @@ -1951,6 +2646,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-store.test.js b/tests/unit/cli-token-store.test.js index 385f258..1436a8d 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,8 +9,10 @@ import { readTokenStore, tokenStoreDir, tokenStorePath, + updateTokenStore, writeTokenStore, } from "../../lib/token-store.js"; +import { ESC, assertNoRawTerminalControls } from "./helpers.js"; /** * @template T @@ -25,6 +28,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 POSIX_ONLY = { skip: process.platform === "win32" ? "POSIX-only filesystem behavior" : false }; + test("tokenStoreDir honors XDG_CONFIG_HOME, then falls back to ~/.config", () => { assert.equal( tokenStoreDir({ XDG_CONFIG_HOME: "/x/cfg" }, () => "/home/u"), @@ -68,6 +82,427 @@ 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 workerCount = 8; + const workers = Array.from({ length: workerCount }, (_, 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, workerCount); + for (let index = 0; index < workerCount; 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", POSIX_ONLY, () => { + 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", POSIX_ONLY, () => { + 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", POSIX_ONLY, () => { + 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 dangling-symlink lock at a zero stale threshold", POSIX_ONLY, () => { + 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 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`; + 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(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 does not spin on a fresh dangling-symlink lock", POSIX_ONLY, () => { + 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", POSIX_ONLY, () => { + 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"); @@ -169,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" } } }); @@ -197,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 */ @@ -213,6 +646,19 @@ 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/); + assertNoRawTerminalControls(message, "the error"); + return true; + } + ); // The win32 branch never inspects POSIX mode bits. assert.doesNotThrow(() => assertStoreDirSecure(mkdir(0o777), "win32")); } finally { @@ -220,6 +666,46 @@ 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/); + assertNoRawTerminalControls(message, "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/); + assertNoRawTerminalControls(message, "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 dae4c67..edf6e00 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -1,14 +1,12 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; -import { mkdtempSync, rmSync } 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"; import { readTokenStore, tokenStorePath, writeTokenStore } from "../../lib/token-store.js"; -import { 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/); }); }); @@ -296,6 +294,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( @@ -329,6 +343,46 @@ 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"); + + assertNoRawTerminalControls(out, "token list output"); + 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/); + assertNoRawTerminalControls(message, "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 }); @@ -348,6 +402,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); @@ -361,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); diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js index c5e3da4..8421b5d 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -1,12 +1,31 @@ // 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"; + +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") { + 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}`); +} + // 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