diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index 4818e0c..0c01a08 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, ai, 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 / AI bindings, manage D1 / R2 / AI providers / secrets through `wdl`, or troubleshoot wdl CLI output. Works with `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` projects pinned to wrangler@^4. +description: Deploy and manage Cloudflare Workers-style projects on the WDL platform via the `wdl` CLI (init, deploy, config explain, whoami, doctor, tail, secret, token, workers, delete, d1, r2, ai, workflows). Trigger when the user asks to scaffold or deploy a Worker, inspect resolved CLI configuration, identify the active control token/principal, manage the local WDL token store, run diagnostics, tail live logs, configure KV / Queues / Durable Objects / Workflows / AI bindings, manage D1 / R2 / AI providers / secrets through `wdl`, or troubleshoot wdl CLI output. Works with `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` projects pinned to wrangler@^4. --- # WDL CLI deploy skill @@ -93,6 +93,12 @@ stubs as opaque capabilities, but the receiver cannot rewrite their host-authored caller properties. Keep delegated stubs in memory; long-term irrevocable stub storage is unsupported. +`CONTROL_URL` may include a path prefix, but embedded usernames/passwords, query +strings, and fragments are rejected. A bare `.local` host defaults to HTTPS +because mDNS is not loopback, except that the existing bare `:8080` rule still +selects HTTP on any host. Every HTTP `.local` target emits the plaintext-token +warning. + Never recommend setting `CONTROL_CONNECT_HOST` outside local development: it overrides the TCP target the admin token connects to (Host header + TLS SNI still track `CONTROL_URL`), and a stale value in a CI or production shell could @@ -138,6 +144,10 @@ to confirm the resolved namespace, inspect the target with `wdl ai providers get --ns `, and use the same explicit `--ns` for deletion. Never add `--yes` without user confirmation. +`wdl delete version` also requires confirmation and has no dry-run endpoint. +Inspect the retained version first; never add `--yes` without a separate safety +check and user confirmation. + `templates/AGENTS.md` is the generic agent entrypoint that `wdl init` copies into every new project. It points at the same `docs/` through `node_modules/@wdl-dev/cli/docs/.md` paths. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a8658d..596fa18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,9 @@ jobs: group: ci-node-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" cache: npm @@ -43,7 +43,7 @@ jobs: group: ci-hygiene-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2 @@ -65,9 +65,9 @@ jobs: exit 1 fi - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" cache: npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37f8a8e..c190b8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,9 +15,9 @@ jobs: outputs: dist-tag: ${{ steps.version.outputs.dist-tag }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" cache: npm @@ -32,7 +32,7 @@ jobs: # Pre-release versions (1.2.3-rc.1) publish under the `next` dist-tag # so `npm i -g @wdl-dev/cli` keeps resolving to the last stable release. - - name: Tag must match package.json version + - name: Tag and stable changelog must match package.json version id: version run: | version="$(node -p 'require("./package.json").version')" @@ -42,7 +42,10 @@ jobs: fi case "$version" in *-*) echo "dist-tag=next" >> "$GITHUB_OUTPUT" ;; - *) echo "dist-tag=latest" >> "$GITHUB_OUTPUT" ;; + *) + echo "dist-tag=latest" >> "$GITHUB_OUTPUT" + node scripts/changelog-section.js "$version" > /dev/null + ;; esac - run: npm pack --dry-run @@ -55,9 +58,9 @@ jobs: contents: read id-token: write # OIDC: trusted-publishing auth + provenance attestation steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" registry-url: https://registry.npmjs.org @@ -83,9 +86,9 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" registry-url: https://npm.pkg.github.com @@ -103,7 +106,11 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "22" # Final releases take their notes from the matching CHANGELOG.md # section; pre-releases (x.y.z-rc.N) have no section and fall back to @@ -113,16 +120,17 @@ jobs: GH_TOKEN: ${{ github.token }} run: | version="${GITHUB_REF_NAME#v}" - awk -v ver="$version" ' - $0 == "## " ver { found=1; next } - /^## / && found { exit } - found { print } - ' CHANGELOG.md > /tmp/notes.md args=(--verify-tag) - if [ -s /tmp/notes.md ]; then + if node scripts/changelog-section.js "$version" > /tmp/notes.md 2>/tmp/changelog-error; then args+=(--notes-file /tmp/notes.md) else - args+=(--generate-notes) + status=$? + if [[ "$version" == *-* && "$status" -eq 3 ]]; then + args+=(--generate-notes) + else + cat /tmp/changelog-error >&2 + exit "$status" + fi fi case "$version" in *-*) args+=(--prerelease) ;; diff --git a/AGENTS.md b/AGENTS.md index 9efa7aa..d724a70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,9 +129,10 @@ Credential resolution layers, highest precedence first: CLI flags, shell/CI env, the project `./.env` (sectioned by namespace, with a cross-origin guard that drops a `.env`-supplied endpoint when the effective token is not from the same `.env`), then the global token store (`~/.config/wdl/credentials`, managed by -`wdl token`). The store is trusted (home directory, same-source token + -endpoint) and not subject to the guard; a project `.env` is not. The namespace -itself follows the same shape — +`wdl token`). A store that passes the directory/file type checks and POSIX +permission checks is trusted (protected per-user config location, same-source +token + endpoint) and not subject to the guard; a project `.env` is not. The +namespace itself follows the same shape — `--ns > shell WDL_NS > project .env WDL_NS > store default (base WDL_NS)` — so the store's default namespace is the lowest selector, materialized into `env.WDL_NS` before the per-key gap-fill. Keep that ordering and the guard diff --git a/CHANGELOG.md b/CHANGELOG.md index c21acf6..ea620b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ ## Unreleased +### Changed + +- Accept only Wrangler v4. +- Validate Workflow page limits as integers in `1..1000` before contacting + Control. +- List `wdl token` in deploy-skill discovery so agent token-store tasks load the + repository guidance. +- Document that R2 `--out` accepts arbitrary filesystem destinations and uses + normal overwrite and symlink-following semantics. + +### Fixed + +- Reject Control base URLs containing embedded credentials, query strings, or + fragments instead of risking credential disclosure or appending command paths + into the wrong URL component. +- Keep `wdl config explain` usable when credential resolution needs the token + store but finds it malformed, unreadable, or unsafe by reporting + `tokenStore.error` alongside the remaining provenance; fully covered + higher-precedence values continue to leave the store unread. +- Make the opt-in `WDL_ALLOW_NPX_WRANGLER=1` fallback runnable on Windows by + using `npx.exe` or npm's `npx-cli.js` instead of the blocked `npx.cmd` shim. +- Make stable release tags fail before publishing when their changelog section + is missing. + +### Security + +- Require confirmation for `wdl delete version` and reject its unsupported + `--dry-run` flag instead of silently performing the deletion. +- Reject non-file or symlink credential paths on read, and fail closed when + POSIX token-store file ownership or directory/file permissions are unsafe. +- Tighten local HTTP classification: recognize case-insensitive `localhost` and + `.test` hosts plus the full `127.0.0.0/8` range as local targets, but treat + `.local` as a network/mDNS host. Bare `.local` hosts default to HTTPS except + for the existing `:8080` rule, and every HTTP `.local` target warns before + sending the token. +- Fail `wdl tail` when cumulative SSE event data exceeds 4 MiB. +- Pin official GitHub Actions to fixed commit SHAs in CI and release jobs. + ## 1.8.0 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c93e30..9e725c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,8 +30,11 @@ parameters and returns (no implicit `any`). | `bin/wdl.js` | Dispatcher. A `REGISTRY` of command modules derives both routing and the `wdl help` table from each command's `meta`; pre-scans argv so the `.env` namespace overlay sees the same `--ns` the command will. | | `commands/*.js` | One file per command: an option schema plus a `run` body. | | `lib/command.js` | The `defineCommand` framework: flag presets, `--help` short-circuit, dependency injection, and a `context` with `resolveControl` / `nsUrl` / `fetchJson` / `fetchStream`. | -| `lib/common.js` | Control-URL/namespace/token resolution, sectioned `.env` loading with the endpoint trust guard, `CliError`, help formatting, terminal-escape choke point. | +| `lib/common.js` | `CliError`, help formatting, shared CLI options, and compact HTTP/JSON error handling. | +| `lib/credentials.js`, `lib/dotenv.js` | Control URL / namespace / token resolution, sectioned `.env` loading, and the endpoint trust guard. | +| `lib/token-store.js` | The per-user credential store, its read/write trust checks, and serialized mutations. | | `lib/control-fetch.js` | HTTP client for the control plane: timeouts, body caps, streaming, Host/IPv6 handling. | +| `lib/output.js` | Human and JSON output primitives, diagnostic formatting, token masking, and terminal escaping. | | `lib/wrangler-pack.js`, `lib/wrangler/` | Wrangler config parsing (TOML/JSONC), deploy manifest assembly, asset collection with `.assetsignore`, local bundling via `wrangler deploy --dry-run`. | | `lib/*-format.js` | Per-command output formatters. | diff --git a/GUIDE-zh.md b/GUIDE-zh.md index b9ca04d..d1cf74b 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -85,17 +85,17 @@ ADMIN_TOKEN= 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。 +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://`;loopback 和保留的 `*.test` host 默认补 `http://`。既有的裸 `:8080` 例外对任何 host(包括 `.local`)仍默认使用 HTTP。除此之外,`.local` 是局域网 / mDNS 后缀而不是 loopback,因此裸 host 默认 HTTPS;所有 HTTP `.local` 目标都会显示明文 token 告警。需要强制使用其它协议时,请显式写 scheme。Control URL 可以包含 path prefix,但不能嵌入 username/password,也不能包含 query string 或 fragment,因为命令会在这个 base URL 后追加 endpoint path,并单独发送 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)。 +推荐的做法是把这些凭证放进托管存储,而不是 shell export 或项目 `.env`:`wdl token set --ns --control-url ` 用隐藏输入读取 token、调 `/whoami` 校验后按 namespace 存入 `~/.config/wdl/credentials`(不进 shell 历史、也不落在项目文件里)。存储是优先级最低的层——命令行标志、shell env、项目 `.env` 仍然胜出——`wdl token list` / `wdl token rm` 管理它。第一个存入的 namespace 成为默认(一行 base `WDL_NS`,和项目 `.env` 一样),命令不带 `--ns` 也能跑;`wdl token use ` 切换默认。CLI 会拒绝 symlink / 非普通文件形式的 credentials 路径;在 POSIX 上,如果 store 文件不属于当前用户、store 目录可被 group/other 写或文件能被 group/other 访问,也会拒绝读取。详见 [token-zh.md](./docs/token-zh.md)。 `wdl ai`、`wdl secret` 和 `wdl token` 都可能接收凭据,因此会脱敏无效参数的细节。如果完整子命令路径前的 string option 使用分离式值,而该值本身也是命令词,请把子命令放到前面,或改用 `--flag=value` 消歧。例如写 `wdl secret list --worker put` 或 `wdl secret --worker=put list`,不要写 `wdl secret --worker put list`。 `wdl deploy` 在上传前会以你的 OS 用户身份运行项目本地的 Wrangler dry-run 和 build 钩子,这些代码能读到磁盘上的 store(env scrub 只把 WDL 变量挡在 Wrangler 子进程的环境外,挡不住文件),所以只部署你信任的项目。`--no-token-store`(或 `WDL_TOKEN_STORE=off`)让 CLI 只从 flag / shell / `.env` 解析凭据、完全不读 store —— 这是给不太信任的项目或 CI 用的解析 opt-out,不是对文件本身的保护。 -用 `wdl 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。运维方没有配置公开 platform domain 时,namespace URL 可能显示为 `(unavailable)`;认证和其它 `/whoami` 字段仍然有效。 +用 `wdl config explain` 查看最终 namespace、control URL、脱敏 token 以及每个值的来源。如果解析需要读取 token store,而该次读取发现 store 损坏、无法读取或未通过安全检查,这个诊断命令仍会排除 store 后成功退出,展示剩余 flag / shell / `.env` 来源,并在人类可读的 `tokenStore` block 或 JSON `tokenStore.error` 中报告故障;实际操作命令需要该 store 时仍会 fail closed。如果更高优先级来源已经覆盖 namespace、control URL 和 token,CLI 不会读取或诊断 store。用 `wdl whoami` 调 control-plane `/whoami`,查看当前 authenticated principal、token id、platform version、最低支持 CLI version 和 URL hints。用 `wdl doctor` 做本地可用性检查,包括 Node.js、wdl-cli、Wrangler、配置文件是否存在、凭据是否能解析,以及 `/whoami` 是否可达;在 CI 里可加 `--strict`,命令仍会打印检查结果,但只要任一检查失败就以非零退出。当 control plane 暴露 `/whoami` 时,`doctor` 可以发现 token 是否有效、principal namespace、platform version 和 CLI compatibility;更细的 capability 检查仍需要额外的 control endpoint。运维方没有配置公开 platform domain 时,namespace URL 可能显示为 `(unavailable)`;认证和其它 `/whoami` 字段仍然有效。 ## 脚手架新 Worker @@ -207,7 +207,7 @@ wdl tail hello --max-reconnects 0 # 不限制自动重连次数 `wdl tail` 会显示 fetch 请求 start/finish(包含 method、对应浏览器访问形态的 pathname(worker 内部路径加上 worker 名前缀,不含 host / query string)、status/outcome、duration),worker 在 fetch 请求路径里产生的 `console.log` / `console.info` / `console.warn` / `console.error`,以及 fetch handler 抛出的未捕获异常。它是 live-only 调试工具:首次连接不会回放历史日志;同一个 CLI 进程的单 worker 网络重连会尽量自动续读,但你按 `Ctrl+C` 退出后再重新运行是一个新进程,会从“现在以后”的日志开始,除非你显式传 `--since `。多 worker 会话在重连期间可能漏事件;如果需要对某个 worker 尽量不丢日志,单独开一个 `wdl tail ` 终端。 -`wdl tail` 是 best-effort 实时调试工具,不是审计历史。高流量 worker 或终端连接消费太慢时,可能跳过中间事件。过大的 console 或 exception 事件会整条丢弃,并以较小的 warning 事件报告,而不是截断后输出。事故复盘和完整 payload 请使用管理方提供的常规日志平台。 +`wdl tail` 是 best-effort 实时调试工具,不是审计历史。高流量 worker 或终端连接消费太慢时,可能跳过中间事件。control 侧过大的 console 或 exception 事件会整条丢弃,并以较小的 warning 事件报告,而不是截断后输出;作为独立的客户端防线,如果超大 SSE event 拼接后的 data 超过 4 MiB,CLI 会终止当前 tail 会话。事故复盘和完整 payload 请使用管理方提供的常规日志平台。 control 可能主动回收长时间运行的 tail 会话:客户端约 15s 不读会收到 `session_idle`,会话达到运维方配置的最大时长(默认 15 分钟)会收到 `session_expired`。CLI 会打印 warning 并自动重连;如果反复出现,通常说明终端或外层 wrapper 没有及时消费输出。 @@ -362,6 +362,8 @@ wdl r2 objects get uploads images/logo.png --out logo.png wdl r2 objects delete uploads images/logo.png --yes ``` +`--out` 接受项目目录外的显式文件系统路径。当前沿用普通覆盖语义:已有文件会被替换,symlink 会跟随到目标。下载前请核对目标路径。 + `examples/inspection-demo` 展示了 R2 + D1 + KV + Assets 组合使用。 ### AI @@ -548,6 +550,8 @@ wdl workflows restart api orders order-123 --yes wdl workflows terminate api orders order-123 --yes ``` +`--limit` 和 `--step-limit` 接受 1..1000 的整数,超出范围时会在请求 Control 前本地拒绝。`--step-limit` 只能和 `--include-steps` 一起使用。 + `wdl workflows list` 会把 active Worker version 不再导出的定义标为 `retired=yes`。既有实例仍可查看和 terminate,但 restart 会返回 `workflow_not_exported`;需要先部署一个重新导出该 workflow name 的 active version。 这是 WDL Workflows 支持,不是完整 Cloudflare Workflows parity。 `script_name`、跨 worker workflow、跨 worker callback、service-binding callback 和 Cloudflare source-AST visualizer 不支持。same-worker DO progress callback 和 runtime-observed parallel/DAG `step.do` execution 可用。 @@ -868,6 +872,8 @@ wdl workers wdl delete version hello v1 ``` +删除版本会要求确认,而且控制面没有对应的 dry-run endpoint。请先检查保留版本;自动化只能在另有独立安全检查后传 `--yes`。传入 `--dry-run` 会被明确拒绝,而不会被静默忽略。 + 删除整个 Worker 前先预览: ```bash @@ -880,7 +886,7 @@ wdl delete worker hello --dry-run wdl delete worker hello ``` -`wdl delete worker` 默认会要求确认。建议先用 `--dry-run` 预览受影响的线上版本、保留版本、路由、worker secrets、workflow definitions、queue consumers 和资产清理。即使没有 deployed version,`wdl workers` 也会用 `workflow-defs=yes` 显示仍有 workflow definitions 的 entry;旧 control 未上报该字段时,CLI 显示 `workflow-defs=unknown`,这不表示没有 workflow definitions。自动化脚本里只有在已有独立安全检查后,才建议传 `--yes`。 +`wdl delete worker` 同样默认要求确认。建议先用 `--dry-run` 预览受影响的线上版本、保留版本、路由、worker secrets、workflow definitions、queue consumers 和资产清理。即使没有 deployed version,`wdl workers` 也会用 `workflow-defs=yes` 显示仍有 workflow definitions 的 entry;旧 control 未上报该字段时,CLI 显示 `workflow-defs=unknown`,这不表示没有 workflow definitions。自动化脚本里只有在已有独立安全检查后,才建议传 `--yes`。 确认后删除 D1 数据库: diff --git a/GUIDE.md b/GUIDE.md index e139d1a..24169ba 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -110,13 +110,18 @@ namespaces, such as `[acme]`, or opaque operator-reserved sections shaped like `[__name__]`. Tenant Wrangler config still uses normal tenant namespace grammar unless your operator explicitly gave you such a namespace token. Do not put `__name__`-shaped names in `[[services]].ns`, `allowed_callers`, or command -examples without that operator instruction. Bare production control hosts such -as `api.wdl.dev` default to `https://`; bare local-dev hosts such as -`localhost:8080` or `*.test:8080` default to `http://`. Any bare `:8080` control -URL is treated as local HTTP. Include an explicit scheme when you need to force -a different protocol. If no namespace resolves, section values are skipped and -the command will fail normally if it needs a namespace or token. Pass `--ns` -when you want to override the default for one command. +examples without that operator instruction. A bare production host such as +`api.wdl.dev` defaults to `https://`; loopback and reserved `*.test` hosts +default to `http://`. The existing bare `:8080` exception also defaults to HTTP +on any host, including `.local`. Outside that exception, `.local` is a +network/mDNS suffix rather than loopback, so its bare hosts default to HTTPS. +Every HTTP `.local` target emits the plaintext-token warning. Include an +explicit scheme when you need to force a different protocol. A control URL may +include a path prefix, but embedded usernames/passwords, query strings, and +fragments are rejected because commands append endpoint paths to this base and +send the admin token separately. If no namespace resolves, section values are +skipped and the command will fail normally if it needs a namespace or token. +Pass `--ns` when you want to override the default for one command. `CONTROL_CONNECT_HOST` is a local-dev / debug override: it changes the TCP target the request connects to while the HTTP Host header and TLS SNI keep @@ -135,7 +140,10 @@ history or a project file). The store is the lowest-precedence layer — flags, shell env, and a project `.env` still win — and `wdl token list` / `wdl token rm` manage it. The first stored namespace becomes the default (a base `WDL_NS`, like a project `.env`'s), so commands run without `--ns`; -`wdl token use ` switches it. See [token.md](./docs/token.md). +`wdl token use ` switches it. The CLI rejects a symlink/non-file credentials +path. On POSIX, it also rejects a store in a group/world-writable directory or a +file that is not owned by the current user or is accessible to group/other +users. See [token.md](./docs/token.md). Because `wdl ai`, `wdl secret`, and `wdl token` can receive credentials, they redact invalid argument details. If a string option appears before the complete @@ -153,10 +161,16 @@ never reads the store — a resolution opt-out for less-trusted projects or CI, not protection for the file itself. Use `wdl config explain` to inspect the final namespace, control URL, masked -token, and where each value came from. 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 +token, and where each value came from. If resolution needs the token store and +that read finds it malformed, unreadable, or unsafe, this diagnostic still exits +successfully with the remaining flag/shell/`.env` provenance and reports the +failure in a human `tokenStore` block or JSON `tokenStore.error`; operating +commands still fail closed when they need that store. When higher-precedence +sources already cover the namespace, control URL, and token, the store remains +unread and is not diagnosed. 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. 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 @@ -311,9 +325,11 @@ and trigger the request after the tail is connected. The tail stream is best-effort live debugging, not audit history. Under high traffic or a slow terminal connection, some middle events can be skipped. -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-side oversized console or exception events are dropped whole and +reported as small warning events instead of being truncated. Independently, the +CLI terminates the tail session if an oversized SSE event's assembled data +exceeds 4 MiB. Use the normal log platform your operator provides for incident +reconstruction and full payloads. Control may close long-running tail sessions when the client stops reading (`session_idle`, about 15s) or when the session reaches its maximum lifetime @@ -558,6 +574,10 @@ wdl r2 objects get uploads images/logo.png --out logo.png wdl r2 objects delete uploads images/logo.png --yes ``` +`--out` accepts an explicit filesystem path outside the project. It currently +uses normal overwrite semantics: an existing file is replaced, and a symlink +target is followed. Verify the destination before downloading. + See `examples/inspection-demo` for a combined R2 + D1 + KV + Assets example. ### AI @@ -836,6 +856,10 @@ wdl workflows restart api orders order-123 --yes wdl workflows terminate api orders order-123 --yes ``` +`--limit` and `--step-limit` accept integers from 1 through 1000 and are +rejected locally outside that range. `--step-limit` may be used only with +`--include-steps`. + `wdl workflows list` marks definitions absent from the active Worker version as `retired=yes`. Existing instances remain inspectable and may be terminated, but restart returns `workflow_not_exported` until an active version exports that @@ -1264,6 +1288,10 @@ Delete a non-live version: wdl delete version hello v1 ``` +Version deletion asks for confirmation and has no dry-run endpoint. Inspect the +retained version first; automation may pass `--yes` only after an independent +safety check. Passing `--dry-run` is rejected rather than ignored. + Preview deleting a whole Worker: ```bash @@ -1276,11 +1304,11 @@ Delete after confirming: wdl delete worker hello ``` -`wdl delete worker` asks for confirmation by default. Use `--dry-run` first to -preview the affected active version, retained versions, routes, worker secrets, -workflow definitions, queue consumers, and asset cleanup. `wdl workers` reports -`workflow-defs=yes` even for entries that have no deployed version. When an -older control does not report this field, the CLI displays +`wdl delete worker` also asks for confirmation by default. Use `--dry-run` first +to preview the affected active version, retained versions, routes, worker +secrets, workflow definitions, queue consumers, and asset cleanup. `wdl workers` +reports `workflow-defs=yes` even for entries that have no deployed version. When +an older control does not report this field, the CLI displays `workflow-defs=unknown`; that does not mean no definitions exist. In automation, pass `--yes` only after a separate safety check. diff --git a/bin/wdl.js b/bin/wdl.js index f71f7e8..e6c43a0 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -116,6 +116,9 @@ export async function main(argv = process.argv.slice(2), deps = {}) { try { loadCliControlEnv(env, { nsFromFlag: scanned.ns, + // Every dispatcher-autoloaded command currently requires a namespace; + // namespace-optional diagnostics opt out of dispatcher autoloading. + requireNamespace: true, tokenFromFlag: scanned.tokenFromFlag, controlUrlFromFlag: scanned.controlUrlFromFlag, loadEnv: loadEnvOverride, diff --git a/commands/config.js b/commands/config.js index 8a3735c..f981294 100644 --- a/commands/config.js +++ b/commands/config.js @@ -1,7 +1,7 @@ import { defineCommand } from "../lib/command.js"; import { CliError, formatHelp, isMain, optionHelp } from "../lib/common.js"; import { writeResult } from "../lib/output.js"; -import { resolveCliConfigState } from "../lib/config-state.js"; +import { resolveDiagnosticConfigState } from "../lib/config-state.js"; const CONFIG_OPTIONS = ["ns", "control", "json", "help"]; @@ -23,7 +23,7 @@ async function runConfig({ values, positionals, context }) { const [subcommand, extra] = positionals; if (subcommand !== "explain" || extra) throw new CliError(usageText()); - const state = resolveCliConfigState({ + const { state, tokenStoreError } = resolveDiagnosticConfigState({ values, env: context.env, cwd: context.cwd, @@ -33,6 +33,7 @@ async function runConfig({ values, positionals, context }) { namespace: publicEntry(state.namespace), controlUrl: publicEntry(state.controlUrl), token: publicEntry(state.token), + ...(tokenStoreError ? { tokenStore: { error: tokenStoreError } } : {}), }; writeResult(values.json === true, body, () => formatConfigExplain(body), context.stdout); } @@ -59,16 +60,18 @@ function publicEntry(entry) { } /** - * @param {{ namespace: PublicConfigEntry, controlUrl: PublicConfigEntry, token: PublicConfigEntry }} body + * @param {{ namespace: PublicConfigEntry, controlUrl: PublicConfigEntry, token: PublicConfigEntry, tokenStore?: { error: string } }} body */ function formatConfigExplain(body) { - return [ + const lines = [ ...formatBlock("namespace", body.namespace), "", ...formatBlock("controlUrl", body.controlUrl), "", ...formatBlock("token", body.token), ]; + if (body.tokenStore) lines.push("", "tokenStore:", ` error: ${body.tokenStore.error}`); + return lines; } /** diff --git a/commands/delete.js b/commands/delete.js index 6895a86..cb9e505 100644 --- a/commands/delete.js +++ b/commands/delete.js @@ -16,7 +16,7 @@ const DELETE_OPTIONS = [ defineHiddenCliOption("worker", { type: "string" }), defineHiddenCliOption("version", { type: "string" }), defineCliOption("dry-run", { type: "boolean" }, "--dry-run", "Preview worker delete without changing state."), - defineCliOption("yes", { type: "boolean" }, "--yes", "Skip worker delete confirmation."), + defineCliOption("yes", { type: "boolean" }, "--yes", "Skip delete confirmation."), "ns", "control", "json", @@ -58,7 +58,19 @@ async function runDelete({ values, positionals, context }) { throw new CliError("version delete requires or --worker/--version"); } if (extraArg) throw unexpectedArgument("delete version", extraArg); + if (values["dry-run"] === true) { + throw new CliError( + "delete version does not support --dry-run; inspect the retained version first, then rerun without --dry-run" + ); + } const { headers } = context.resolveControl(); + await confirmAction({ + yes: values.yes === true, + stdin, + stderr, + prompt: `Are you sure you want to delete version "${ns}/${worker}@${version}"? [y/N] `, + action: `delete version "${ns}/${worker}@${version}"`, + }); const body = await context.fetchJson( context.nsUrl("worker", worker, "versions", version), { method: "DELETE", headers }, diff --git a/commands/deploy.js b/commands/deploy.js index 63bdce6..9af5f83 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -200,6 +200,14 @@ function promotedWorkerUrlHints(raw, includePlatform) { return { platform, routes }; } +/** @param {string} hostname */ +function usesLocalWorkerUrlOrigin(hostname) { + // `.local` is not trusted as loopback for credential warnings, but a WDL + // control plane reached on the local network still shares its public + // scheme/port with the Worker gateway in development environments. + return isLocalDevHost(hostname) || hostname.endsWith(".local"); +} + /** * @param {string} rawUrl * @param {URL} controlUrl @@ -407,7 +415,7 @@ async function runDeploy({ values, positionals, context: baseContext }) { }); const parsedControlUrl = new URL(controlUrl); - const isLocal = isLocalDevHost(parsedControlUrl.hostname); + const isLocal = usesLocalWorkerUrlOrigin(parsedControlUrl.hostname); const reportedUrlHints = promotedWorkerUrlHints(urls, workersDev !== false); const invalidUrlHints = []; const displayedPlatformUrl = diff --git a/commands/doctor.js b/commands/doctor.js index 17bf304..5bf8753 100644 --- a/commands/doctor.js +++ b/commands/doctor.js @@ -6,7 +6,7 @@ import { CliError, defineCliOption, formatHelp, isMain, isNonEmptyString, option import { warnIfInsecureControlUrl } from "../lib/credentials.js"; import { writeResult } from "../lib/output.js"; import { readTokenStore, tokenStorePath } from "../lib/token-store.js"; -import { TokenStoreConfigError, resolveCliConfigState } from "../lib/config-state.js"; +import { resolveDiagnosticConfigState } from "../lib/config-state.js"; import { CLI_ROOT, currentCliVersion, readCliPackageJson } from "../lib/package-info.js"; import { ensureControlContextFromConfigState, @@ -14,7 +14,7 @@ import { namespaceFromPrincipal, summarizeWhoami, } from "../lib/whoami.js"; -import { MIN_WRANGLER_MAJOR, probeWranglerVersion, resolveWranglerCommand } from "../lib/wrangler/command.js"; +import { checkWranglerVersion, resolveWranglerCommand } from "../lib/wrangler/command.js"; import { selectWranglerConfigFiles } from "../lib/wrangler/config.js"; const DOCTOR_OPTIONS = [ @@ -50,26 +50,12 @@ async function runDoctor({ values, positionals, context: baseContext }) { if (positionals.length > 0) throw new CliError(usageText()); const context = /** @type {DoctorContext} */ (baseContext); - 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 { state, tokenStoreError } = resolveDiagnosticConfigState({ + values, + env: context.env, + cwd: context.cwd, + warn: context.warn, + }); const checks = [ checkNode(), checkCliVersion(), @@ -96,7 +82,7 @@ async function runDoctor({ values, positionals, context: baseContext }) { /** * The resolved CLI config state doctor inspects. - * @typedef {ReturnType} ConfigState + * @typedef {ReturnType["state"]} ConfigState */ /** @@ -138,28 +124,24 @@ function checkWrangler({ cwd, env, execFile }) { }); } try { - const { version, major } = probeWranglerVersion({ + const { version } = checkWranglerVersion({ execFile, cwd, env, wrangler, fallbackVersion: () => readInstalledWranglerVersion(cwd), }); - // Mirror the gate `wdl deploy` enforces so doctor can't green-light a - // Wrangler major that deploy will reject. - const meetsMinimum = major >= MIN_WRANGLER_MAJOR; return check({ - ok: meetsMinimum, - label: `Wrangler ${version || "(unknown)"}`, - detail: meetsMinimum - ? `source: ${wrangler.source}` - : `wdl deploy requires Wrangler v${MIN_WRANGLER_MAJOR} (wrangler@^${MIN_WRANGLER_MAJOR}); found ${version || "(unknown)"} via ${wrangler.source}`, + ok: true, + label: `Wrangler ${version}`, + detail: `source: ${wrangler.source}`, }); } catch (err) { + const message = err instanceof Error && err.message ? err.message : String(err); return check({ ok: false, label: "Wrangler", - detail: err instanceof Error && err.message ? err.message : String(err), + detail: `${message}\nsource: ${wrangler.source}`, }); } } @@ -337,7 +319,12 @@ function check({ ok, label, detail = "" }) { function formatDoctor(checks) { return checks.map((item) => { const line = `${item.ok ? "✓" : "✗"} ${item.label}`; - return item.detail ? `${line}\n ${item.detail}` : line; + if (!item.detail) return line; + const detail = item.detail + .split("\n") + .map((detailLine) => ` ${detailLine}`) + .join("\n"); + return `${line}\n${detail}`; }); } diff --git a/commands/r2.js b/commands/r2.js index 9e4adaf..2a53d46 100644 --- a/commands/r2.js +++ b/commands/r2.js @@ -4,7 +4,15 @@ import { Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; import { LONG_CONTROL_TIMEOUT_MS, UNLIMITED_CONTROL_BODY_BYTES } from "../lib/control-fetch.js"; import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, isMain, optionHelp, unexpectedArgument } from "../lib/common.js"; +import { + CliError, + defineCliOption, + formatHelp, + isMain, + normalizePageLimit, + optionHelp, + unexpectedArgument, +} from "../lib/common.js"; import { confirmAction } from "../lib/stdin.js"; import { escapeTerminalText, writeResult, writeStatusLine } from "../lib/output.js"; import { formatBucketList, formatObjectHead, formatObjectList } from "../lib/r2-format.js"; @@ -66,7 +74,7 @@ async function runR2({ values, positionals, context: baseContext }) { const { headers } = context.resolveControl(); const url = withQuery(context.nsUrl("r2", "buckets"), { cursor: values.cursor, - limit: normalizeListLimit(values.limit), + limit: normalizePageLimit(values.limit, "r2 --limit"), }); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(url, { headers }, "list R2 buckets") @@ -83,7 +91,7 @@ async function runR2({ values, positionals, context: baseContext }) { prefix: values.prefix, delimiter: values.delimiter, cursor: values.cursor, - limit: normalizeListLimit(values.limit), + limit: normalizePageLimit(values.limit, "r2 --limit"), }); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(url, { headers }, "list R2 objects") @@ -199,19 +207,6 @@ 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("/"); diff --git a/commands/tail.js b/commands/tail.js index a65f8fe..f4efe82 100644 --- a/commands/tail.js +++ b/commands/tail.js @@ -19,6 +19,7 @@ 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; +export const SSE_MAX_EVENT_BYTES = 4 * 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(["ECONNRESET", "ECONNABORTED", "EPIPE", "ABORT_ERR"]); @@ -447,6 +448,8 @@ export class SseParser { this.onEvent = onEvent; this.buffer = ""; this.maxLineChars = SSE_MAX_LINE_CHARS; + this.maxEventBytes = SSE_MAX_EVENT_BYTES; + this.eventBytes = 0; this.event = "message"; /** @type {string | null} */ this.id = null; @@ -493,7 +496,14 @@ export class SseParser { } if (field === "event") this.event = value; else if (field === "id") this.id = value; - else if (field === "data") this.data.push(value); + else if (field === "data") { + const nextBytes = this.eventBytes + Buffer.byteLength(value, "utf8") + (this.data.length > 0 ? 1 : 0); + if (nextBytes > this.maxEventBytes) { + throw new CliError(`tail SSE event exceeded ${this.maxEventBytes} bytes`); + } + this.eventBytes = nextBytes; + this.data.push(value); + } // unknown fields ignored per spec } dispatch() { @@ -501,6 +511,7 @@ export class SseParser { // Reset event name even when there's no data so a subsequent // event without an explicit `event:` line falls back to "message". this.event = "message"; + this.eventBytes = 0; return; } this.onEvent({ event: this.event, id: this.id, data: this.data.join("\n") }); @@ -508,6 +519,7 @@ export class SseParser { // SSE spec: `id` persists until a new id (or `id:` with empty value) // overwrites it. We don't reset it. this.data = []; + this.eventBytes = 0; } /** @param {string} line */ assertLineLength(line) { diff --git a/commands/workflows.js b/commands/workflows.js index 98fb025..a290eda 100644 --- a/commands/workflows.js +++ b/commands/workflows.js @@ -1,5 +1,13 @@ import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, isMain, optionHelp, unexpectedArgument } from "../lib/common.js"; +import { + CliError, + defineCliOption, + formatHelp, + isMain, + normalizePageLimit, + optionHelp, + unexpectedArgument, +} from "../lib/common.js"; import { confirmAction } from "../lib/stdin.js"; import { escapeTerminalText, writeJsonOr, writeResult, writeStatusLine } from "../lib/output.js"; import { formatInstanceList, formatInstanceStatus, formatWorkflowList } from "../lib/workflows-format.js"; @@ -59,9 +67,10 @@ async function runWorkflows({ values, positionals, context }) { if (subcommand === "instances") { const { worker, workflow } = requireWorkflowRef(positionals, "workflows instances"); + const limit = normalizePageLimit(values.limit, "workflows --limit"); const { headers } = context.resolveControl(); const url = new URL(context.nsUrl("workflows", worker, workflow, "instances")); - if (values.limit) url.searchParams.set("limit", values.limit); + if (limit) url.searchParams.set("limit", limit); if (values.cursor) url.searchParams.set("cursor", values.cursor); const body = /** @type {{ instances?: import("../lib/workflows-format.js").WorkflowInstance[], cursor?: string }} */ ( @@ -76,10 +85,11 @@ async function runWorkflows({ values, positionals, context }) { if (values["step-limit"] && !values["include-steps"]) { throw new CliError("workflows status --step-limit requires --include-steps"); } + const stepLimit = normalizePageLimit(values["step-limit"], "workflows --step-limit"); 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"]); + if (stepLimit) url.searchParams.set("stepLimit", stepLimit); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(url.href, { headers }, "get workflow instance status") ); diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index 5870543..6761f1c 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -4,7 +4,7 @@ `wdl deploy ` 用 `wrangler deploy --dry-run` 打包一个 Cloudflare Workers 风格的项目,然后把产物推送到 WDL 控制平面。**它不等同于 `wrangler deploy`**,后者直接对接 Cloudflare。在这个平台上**不要**用 `wrangler deploy` —— 只用 `wdl deploy`。 -wrangler 解析顺序是 `WDL_WRANGLER_BIN`、Worker 项目本地 wrangler、CLI 包本地 wrangler、最后是 `PATH`。默认不会临时 `npx --yes wrangler` 拉包;只有设置 `WDL_ALLOW_NPX_WRANGLER=1` 时才允许这个 fallback。 +wrangler 解析顺序是 `WDL_WRANGLER_BIN`、Worker 项目本地 wrangler、CLI 包本地 wrangler、最后是 `PATH`。默认不会临时 `npx --yes wrangler@^4` 拉包;只有设置 `WDL_ALLOW_NPX_WRANGLER=1` 时才允许这个 fallback。 WDL 会隐藏这个 dry-run 子进程的 Wrangler banner(因此跳过常规 banner 更新检查)并关闭匿名遥测。Wrangler 在报告未知配置字段时仍可能访问已配置的 npm registry;项目 build hook 仍保留正常的网络访问能力。 @@ -34,7 +34,7 @@ CLI 需要三个值: **CI / 自动化:** 把 `ADMIN_TOKEN`、`CONTROL_URL`、`WDL_NS` 作为环境变量从 CI secret store 注入 —— 不用交互式的 token store,也绝不提交 `.env`。 -裸 control host 会自动补 scheme;生产 host 默认 `https://`,本地 `.test` / `.local` 或 `:8080` 默认 `http://`。如果要强制协议,直接显式写 `https://...` 或 `http://...`。 +裸 control host 会自动补 scheme;生产 host 默认 `https://`,loopback 和 `.test` host 默认 `http://`。既有的裸 `:8080` 例外对任何 host(包括 `.local`)仍默认使用 HTTP。除此之外,`.local` 是局域网 / mDNS 后缀而不是 loopback,因此默认 HTTPS;所有 HTTP `.local` 目标都会显示明文 token 告警。如果要强制协议,直接显式写 `https://...` 或 `http://...`。Control URL 可以包含 path prefix,但嵌入的 username/password、query string 和 fragment 会被拒绝。 优先级:`CLI 标志 > shell env > .env 中 [] 段 > .env 基础段 > wdl token store`。都没有提供时命令直接报错——没有内置默认值。 @@ -141,6 +141,8 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 `wdl delete worker`、`wdl delete version`、`wdl d1 delete`、`wdl secret delete` 和 `wdl ai providers delete` 默认会提示确认。如果有 `--dry-run`,先跑一遍;否则先做只读检查。删除 AI provider 前,先运行 `wdl config explain` 确认最终解析出的 namespace,再用 `wdl ai providers get --ns ` 查看目标,并在删除时传入同一个显式 `--ns`;删除 provider 会同时删除其 metadata 和 credential。只有与用户确认后才能加 `--yes`;**不要**主动加。 +`wdl delete version` 没有 dry-run endpoint:请先检查保留版本。CLI 会拒绝 `--dry-run`,不会静默执行删除。 + `wdl workers` 会显示 `workflow-defs=yes` 或 `workflow-defs=no`;`unknown` 表示旧 control 没有返回该字段,不表示没有 workflow definitions。即使 blocker 使 `wouldDelete=no`,worker delete dry-run 仍会报告 secret 和 workflow-definition 是否存在。 删除 worker **不会**删除 R2 数据 —— 见 [r2-zh.md](./r2-zh.md)。 @@ -168,6 +170,7 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 | `control promoted the worker without confirming its restart session policy` | version 已经生效,但会话可能没有重启。让必须运行新版本的 client 重连,或在 control 能确认该策略后重新部署。 | | Worker URL 返回 404 | URL 缺了 `/` 这一段。 | | `wdl tail` 没有历史日志 | tail 是 live-only;先打开 `wdl tail ` 再触发请求。 | +| `tail SSE event exceeded 4194304 bytes` | 单个 SSE event 拼接后的 UTF-8 data 超过 CLI 的 4 MiB 上限,因此当前 tail 会话已终止。修复或缩小上游 event 后再重新连接。 | | `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 c654882..4250ec3 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -9,7 +9,7 @@ Do **not** use `wrangler deploy` on this platform — only `wdl deploy`. Wrangler resolution order is `WDL_WRANGLER_BIN`, the Worker project's local wrangler, the CLI package's local wrangler, then `PATH`. By default there is no -transient `npx --yes wrangler` fetch; that fallback is allowed only when +transient `npx --yes wrangler@^4` fetch; that fallback is allowed only when `WDL_ALLOW_NPX_WRANGLER=1` is set. WDL hides Wrangler's banner (which skips the normal banner update check) and @@ -59,8 +59,13 @@ environment variables from your CI secret store — not the interactive token store, and never a committed `.env`. Bare control hosts get a scheme automatically; production hosts default to -`https://`, local `.test` / `.local` or `:8080` hosts default to `http://`. To -force a protocol, write `https://...` or `http://...` explicitly. +`https://`, while loopback and `.test` hosts default to `http://`. The existing +bare `:8080` exception also defaults to HTTP on any host, including `.local`. +Outside that exception, `.local` defaults to HTTPS because mDNS is not loopback; +every HTTP `.local` target emits the plaintext-token warning. To force a +protocol, write `https://...` or `http://...` explicitly. A control URL may +include a path prefix, but embedded usernames/passwords, query strings, and +fragments are rejected. Precedence: `CLI flag > shell env > .env [] section > .env base section > wdl token store`. @@ -294,6 +299,9 @@ same explicit `--ns` for deletion. Provider deletion removes both its metadata and credential. Add `--yes` only after confirming with the user; do **not** add it on your own. +`wdl delete version` has no dry-run endpoint: inspect the retained version +first. The CLI rejects `--dry-run` rather than silently performing the delete. + `wdl workers` reports `workflow-defs=yes` or `workflow-defs=no`; `unknown` means an older control omitted the field, not that no definitions exist. Worker delete dry-runs report secret and workflow-definition presence even when a blocker @@ -324,6 +332,7 @@ Deleting a worker does **not** delete R2 data — see [r2.md](./r2.md). | `control promoted the worker without confirming its restart session policy` | The version is live but its sessions may not have restarted. Reconnect clients that must run it, or deploy again once control confirms the policy. | | Worker URL returns 404 | The URL is missing the `/` segment. | | `wdl tail` has no history | Tail is live-only; open `wdl tail ` before triggering the request. | +| `tail SSE event exceeded 4194304 bytes` | One assembled SSE event exceeded the CLI's 4 MiB UTF-8 data cap, so the current tail session terminated. Reduce/fix the upstream event before reconnecting. | | `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream; the CLI reconnects automatically unless the reconnect cap is reached. | | Namespace secret did not take effect | NS-level secrets do not force-bump workers; redeploy once or use a worker-level secret. | | Service binding still hits the old target | Bindings are pinned at caller deploy time; redeploy the caller. | diff --git a/docs/r2-zh.md b/docs/r2-zh.md index f897d7b..716c926 100644 --- a/docs/r2-zh.md +++ b/docs/r2-zh.md @@ -106,6 +106,8 @@ wdl r2 objects delete --yes # 破坏性 —— 先确认 `wdl r2 objects get` 会写出原始 object bytes。需要 stream bytes 时请 pipe 或重定向 stdout;在交互终端中请使用 `--out `。 +`--out` 接受项目目录外的显式路径,当前沿用普通覆盖语义:已有文件会被替换,symlink 会跟随到目标。下载前请核对目标路径。 + 列表被截断时输出会带 `Next cursor: `;把它传给下一次 `--cursor` 继续翻页(`wdl r2 buckets list` 同样支持 `--cursor` / `--limit`,其中 `--limit` 必须是 1..1000): ```bash diff --git a/docs/r2.md b/docs/r2.md index 89ebd14..2482ddb 100644 --- a/docs/r2.md +++ b/docs/r2.md @@ -125,7 +125,10 @@ 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 `. +intend to stream bytes; on an interactive terminal, pass `--out `. `--out` +accepts an explicit path outside the project and currently uses normal overwrite +semantics: an existing file is replaced, and a symlink target is followed. +Verify the destination before downloading. 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 diff --git a/docs/token-zh.md b/docs/token-zh.md index 45de0b1..c6b1e4e 100644 --- a/docs/token-zh.md +++ b/docs/token-zh.md @@ -19,7 +19,7 @@ ADMIN_TOKEN="" LABEL="production" ``` -它由命令独占:`wdl token` 会 canonical 重写整个文件(默认在前,然后排序、加引号的各段),所以项目专属的值请手编项目 `.env`。文件以 `0600` 权限写入。 +它由命令独占:`wdl token` 会 canonical 重写整个文件(默认在前,然后排序、加引号的各段),所以项目专属的值请手编项目 `.env`。文件以 `0600` 权限写入。读取时会拒绝非普通文件或 symlink 的 credentials 路径;在 POSIX 上还会 fail closed:文件必须属于当前用户,所在目录不能 group/world-writable,且 group/other 用户不可访问该文件。如果可信 store 的 owner 错误,请由管理员执行 `chown `,或删除后用 `wdl token set` 重建各条目;然后用 `chmod 700 ` 和 `chmod 600 ` 修复权限。不要为了共享 store 放宽这些检查。 ## 命令 @@ -63,9 +63,11 @@ CLI 标志 > shell/CI env > 项目 ./.env > 全局 token 存储 > 未配置( 所以设了存储默认后,`wdl deploy`、`wdl doctor` 等不带 `--ns` 也能跑;要换别的就传 `--ns`(或 `wdl token use `)。当 namespace 来自存储默认时,`wdl config explain` 把来源显示为 `token store default`。 +如果解析需要读取 store,而该次读取发现它损坏、无法读取或未通过安全检查,`wdl config explain` 会排除它,以成功状态展示剩余 flag / shell / `.env` 来源,并在人类可读的 `tokenStore` block 或 JSON `tokenStore.error` 中报告故障。这个诊断 fallback 不会放宽实际操作命令:它们需要 store 时仍会 fail closed。如果更高优先级来源已经覆盖 namespace、control URL 和 token,CLI 不会读取或诊断 store。 + `wdl token` 子命令是这条链的例外:`set`、`use`、`rm` 会改动存储,所以它们只从显式 `--ns`(或 `use` 的位置参数)取 namespace —— 绝不取 ambient `WDL_NS` —— 以免一个游离的 shell 值写错、切错或删错条目。 -存储是**可信**的(它在你的 home 目录、由你经 `wdl token` 写入,token 和端点同源)。项目 `.env` **不可信**:若一个 `.env` 提供了 control 端点却没同时提供 token,该端点仍会被丢弃——这样不可信的项目目录永远无法把你存的 token 重定向到它指定的主机。 +通过上方路径和权限检查的存储才被视为**可信**:token 和端点同源,存放在受保护的用户级配置目录中。项目 `.env` **不可信**:若一个 `.env` 提供了 control 端点却没同时提供 token,该端点仍会被丢弃——这样不可信的项目目录永远无法把你存的 token 重定向到它指定的主机。 ## 安全:deploy 会以你的身份运行项目代码 diff --git a/docs/token.md b/docs/token.md index 017451e..f9bc7c9 100644 --- a/docs/token.md +++ b/docs/token.md @@ -30,7 +30,14 @@ LABEL="production" It is command-owned: `wdl token` rewrites it canonically (default first, then sorted, quoted sections), so hand-edit a project `.env` for project-specific -values instead. The file is written with `0600` permissions. +values instead. The file is written with `0600` permissions. Reads reject a +credentials path that is not a regular, non-symlink file. On POSIX, they also +fail closed unless the file is owned by the current user, the containing +directory is not group/world-writable, and the file is inaccessible to group and +other users. If a trusted store has the wrong owner, use `chown ` +from an administrative account, or delete it and recreate the entries with +`wdl token set`. Then apply `chmod 700 ` and `chmod 600 `; do not +relax these checks for a shared store. ## Commands @@ -86,16 +93,24 @@ So with a stored default you can run `wdl deploy`, `wdl doctor`, etc. without namespace comes from the store default, `wdl config explain` shows the source as `token store default`. +If resolution needs the store and that read finds it malformed, unreadable, or +unsafe, `wdl config explain` excludes it, exits successfully with the remaining +flag/shell/`.env` provenance, and reports the failure in a human `tokenStore` +block or JSON `tokenStore.error`. This diagnostic fallback does not weaken +operating commands: they still fail closed when they need the store. When +higher-precedence sources already cover the namespace, control URL, and token, +the store remains unread and is not diagnosed. + The `wdl token` subcommands are the exception to that chain: `set`, `use`, and `rm` mutate the store, so they take the namespace from an explicit `--ns` (or `use`'s positional) only — never the ambient `WDL_NS` — so a stray shell value can't write, switch, or delete the wrong entry. -The store is trusted (it lives in your home directory and you wrote it via -`wdl token`, so its token and endpoint are same-source). A project `.env` is -not: a `.env` that supplies a control endpoint without also supplying the token -is still dropped, so an untrusted project directory can never redirect your -stored token to a host it chose. +A store that passes the path and permission checks above is trusted: its token +and endpoint are same-source in a protected per-user config location. A project +`.env` is not: a `.env` that supplies a control endpoint without also supplying +the token is still dropped, so an untrusted project directory can never redirect +your stored token to a host it chose. ## Security: deploy runs project code as you diff --git a/docs/workflows-zh.md b/docs/workflows-zh.md index d5691c6..49cc7f8 100644 --- a/docs/workflows-zh.md +++ b/docs/workflows-zh.md @@ -42,6 +42,8 @@ wdl workflows restart --yes wdl workflows terminate --yes ``` +`--limit` 和 `--step-limit` 接受 1..1000 的整数;超出范围时 CLI 会在请求 Control 前拒绝。`--step-limit` 只能和 `--include-steps` 一起使用。 + `restart` 和 `terminate` 是破坏性实例生命周期操作;只有在已经独立确认 namespace、worker、workflow 和 instance id 后才传 `--yes`。 `wdl workflows list` 会把 active Worker version 不再导出的定义标为 `retired=yes`。既有实例仍可查看和 terminate,但 restart 会返回 `workflow_not_exported`;需要先部署一个重新导出该 workflow name 的 active version。 diff --git a/docs/workflows.md b/docs/workflows.md index 01c2f9b..097b197 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -70,6 +70,10 @@ wdl workflows restart --yes wdl workflows terminate --yes ``` +`--limit` and `--step-limit` accept integers from 1 through 1000. The CLI +rejects values outside that range before contacting Control; `--step-limit` +applies only with `--include-steps`. + `restart` and `terminate` are destructive instance lifecycle operations; pass `--yes` only after independently confirming the namespace, worker, workflow, and instance id. diff --git a/eslint.config.js b/eslint.config.js index 3ae3abb..1082e8c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -35,7 +35,7 @@ export default [ ignores: ["**/node_modules/**", "**/.deploy-dist/**", "**/.wrangler/**"], }, { - files: ["bin/**/*.js", "commands/**/*.js", "lib/**/*.js", "tests/**/*.js", "eslint.config.js"], + files: ["bin/**/*.js", "commands/**/*.js", "lib/**/*.js", "scripts/**/*.js", "tests/**/*.js", "eslint.config.js"], languageOptions: { ecmaVersion: 2024, sourceType: "module", diff --git a/lib/common.js b/lib/common.js index 28bf1d2..3ee52cd 100644 --- a/lib/common.js +++ b/lib/common.js @@ -59,6 +59,21 @@ export function isNonEmptyString(value) { return typeof value === "string" && value.length > 0; } +/** + * Normalize the page-size contract shared by paginated commands. + * @param {string | undefined} value + * @param {string} label + * @returns {string | undefined} + */ +export function normalizePageLimit(value, label) { + if (value == null || value === "") return undefined; + const limit = Number(value); + if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { + throw new CliError(`${escapeTerminalText(label)} must be an integer in [1, 1000]`); + } + return String(limit); +} + /** * @param {{ usage: string[], description?: string, commands?: string[], options?: string[] }} spec */ diff --git a/lib/config-state.js b/lib/config-state.js index 28c2966..39c30b1 100644 --- a/lib/config-state.js +++ b/lib/config-state.js @@ -25,20 +25,26 @@ export class TokenStoreConfigError extends CliError { */ /** - * @param {{ + * @typedef {{ * values?: Record, * env?: NodeJS.ProcessEnv, * cwd?: string, * dotenvPath?: string, + * requireNamespace?: boolean, * readStore?: (env: NodeJS.ProcessEnv) => import("./token-store.js").TokenStore, * warn?: (line: string) => void, - * }} [options] + * }} ConfigStateOptions + */ + +/** + * @param {ConfigStateOptions} [options] */ export function resolveCliConfigState({ values = {}, env = process.env, cwd = process.cwd(), dotenvPath = ".env", + requireNamespace = false, readStore, warn = () => {}, } = {}) { @@ -75,6 +81,7 @@ export function resolveCliConfigState({ loadCliControlEnv(workingEnv, { dotenvPath: resolvedDotenvPath, nsFromFlag: /** @type {string | undefined} */ (values.ns), + requireNamespace, tokenFromFlag: flagSet(values, "token"), controlUrlFromFlag: flagSet(values, "control-url"), protectedKeys, @@ -101,6 +108,39 @@ export function resolveCliConfigState({ }; } +/** + * Resolve as much diagnostic state as possible when the token store is + * unreadable or malformed. Operating commands still fail closed in the normal + * resolver; only diagnostics use this fallback. + * @param {ConfigStateOptions} [options] + */ +export function resolveDiagnosticConfigState(options = {}) { + const shownWarnings = new Set(); + const warn = options.warn; + /** @param {string} line */ + const warnOnce = (line) => { + if (shownWarnings.has(line)) return; + shownWarnings.add(line); + warn?.(line); + }; + const diagnosticOptions = warn + ? { + ...options, + warn: warnOnce, + } + : options; + const requiredDiagnosticOptions = { ...diagnosticOptions, requireNamespace: true }; + try { + return { state: resolveCliConfigState(requiredDiagnosticOptions), tokenStoreError: null }; + } catch (err) { + if (!(err instanceof TokenStoreConfigError)) throw err; + return { + state: resolveCliConfigState({ ...requiredDiagnosticOptions, readStore: () => ({}) }), + tokenStoreError: err.message, + }; + } +} + /** * @param {(env: NodeJS.ProcessEnv) => import("./token-store.js").TokenStore} readStore */ diff --git a/lib/credentials.js b/lib/credentials.js index 9356944..27e8d5e 100644 --- a/lib/credentials.js +++ b/lib/credentials.js @@ -31,10 +31,19 @@ export function resolveControlUrl(values, env = process.env) { try { parsed = new URL(normalized); } catch { - throw new CliError(`Invalid control URL ${formatDiagnosticValue(raw)}.`); + throw new CliError("Invalid control URL."); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new CliError(`Invalid control URL ${formatDiagnosticValue(raw)}: expected http:// or https://.`); + throw new CliError("Invalid control URL: expected http:// or https://."); + } + if (parsed.username || parsed.password) { + // Do not echo the raw URL: it may contain a password. + throw new CliError("Invalid control URL: embedded usernames and passwords are not supported."); + } + // URL.search/hash are empty for a bare trailing `?` / `#`, even though + // appending endpoint text would still put it in that URL component. + if (/[?#]/.test(normalized)) { + throw new CliError("Invalid control URL: query strings and fragments are not supported."); } return normalized; } @@ -125,18 +134,29 @@ function insecureControlUrlReason(controlUrl, env) { return null; } -// Loopback / dev-TLD hosts, shared by the bare-URL scheme default and the -// plaintext-token warning so the two policies cannot drift. Accepts both the -// bare IPv6 form and the bracketed form URL.hostname produces. +// Loopback / reserved test hosts trusted by the plaintext-token warning. Bare +// URL scheme selection also has a legacy :8080 compatibility exception, but a +// port alone never makes a host safe for plaintext credentials. Accepts both +// the bare IPv6 form and the bracketed form URL.hostname produces. /** @param {string} host */ export function isLocalDevHost(host) { + const normalized = host.toLowerCase(); + return ( + normalized === "localhost" || + isIpv4Loopback(normalized) || + normalized === "::1" || + normalized === "[::1]" || + normalized.endsWith(".test") + ); +} + +/** @param {string} host */ +function isIpv4Loopback(host) { + const parts = host.split("."); return ( - host === "localhost" || - host === "127.0.0.1" || - host === "::1" || - host === "[::1]" || - host.endsWith(".test") || - host.endsWith(".local") + parts.length === 4 && + parts[0] === "127" && + parts.every((part) => /^(?:0|[1-9][0-9]{0,2})$/.test(part) && Number(part) <= 255) ); } @@ -269,6 +289,7 @@ function firstDotEnvToken(body) { * @param {{ * dotenvPath?: string, * nsFromFlag?: string, + * requireNamespace?: boolean, * tokenFromFlag?: boolean, * controlUrlFromFlag?: boolean, * protectedKeys?: Set, @@ -284,6 +305,7 @@ export function loadCliControlEnv( { dotenvPath, nsFromFlag, + requireNamespace = false, tokenFromFlag = false, controlUrlFromFlag = false, protectedKeys = protectedEnvKeys(env), @@ -311,7 +333,13 @@ export function loadCliControlEnv( // way to work around it. Memoize so the at-most-one read is shared. /** @type {import("./token-store.js").TokenStore | undefined} */ let storeData; - const getStore = () => (storeData ??= readStore(env) || {}); + const getStore = () => { + if (storeData !== undefined) return storeData; + storeData = readStore(env) || {}; + return storeData; + }; + /** @type {unknown} */ + let storeReadError; let ns = firstNonEmptyString(nsFromFlag, env.WDL_NS); // The store's base WDL_NS names a default namespace — the lowest-precedence @@ -330,7 +358,8 @@ export function loadCliControlEnv( let s; try { s = getStore(); - } catch { + } catch (err) { + storeReadError = err; // corrupt/unreadable store → no usable default; do not block the command } const namespaces = (s && s.namespaces) || {}; @@ -356,10 +385,16 @@ export function loadCliControlEnv( // the gaps left by flags / shell / project .env / the guard — but only for a // slot still empty AND not supplied by a flag (resolved later). That keeps // the store unread when the credentials are already covered. + const covered = { CONTROL_URL: controlUrlFromFlag, ADMIN_TOKEN: tokenFromFlag }; + const needsFill = STORE_ENV_KEYS.some((k) => !covered[k] && (env[k] == null || env[k] === "")); if (ns) { - const covered = { CONTROL_URL: controlUrlFromFlag, ADMIN_TOKEN: tokenFromFlag }; - const needsFill = STORE_ENV_KEYS.some((k) => !covered[k] && (env[k] == null || env[k] === "")); if (needsFill) fillFromTokenStore(env, ns, getStore().namespaces || {}, onLoad, covered); + } else if ((requireNamespace || needsFill) && storeReadError !== undefined) { + // A failed default-namespace read is optional only when explicit + // credentials cover a command that needs no namespace (for example, + // `whoami`). Otherwise preserve its actionable permission/parse error + // instead of replacing it with a missing namespace/URL error. + throw storeReadError; } } diff --git a/lib/token-store.js b/lib/token-store.js index 651f38d..9acae34 100644 --- a/lib/token-store.js +++ b/lib/token-store.js @@ -1,8 +1,12 @@ import { randomUUID } from "node:crypto"; import { chmodSync, + closeSync, + constants, + fstatSync, lstatSync, mkdirSync, + openSync, readFileSync, renameSync, rmSync, @@ -83,14 +87,50 @@ export function readTokenStore(storePath) { /** @type {string} */ let text; try { - text = readFileSync(storePath, "utf8"); + // POSIX O_NOFOLLOW closes the lstat/open race on the final path component. + // Windows has no equivalent fs flag, so retain the static lstat rejection + // there; fstat still validates the descriptor actually read on all hosts. + const expectedStoreStat = process.platform === "win32" ? lstatSync(storePath) : null; + if (expectedStoreStat) assertStoreFileSecure(storePath, expectedStoreStat); + const flags = constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK); + const fd = openSync(storePath, flags); + try { + const openedStoreStat = fstatSync(fd); + assertStoreFileSecure(storePath, openedStoreStat); + if ( + expectedStoreStat && + (expectedStoreStat.dev !== openedStoreStat.dev || expectedStoreStat.ino !== openedStoreStat.ino) + ) { + throw new CliError(`refusing to read credentials: ${escapeTerminalText(storePath)} changed while opening`); + } + assertStoreDirSecure(path.dirname(storePath), process.platform, "read"); + text = readFileSync(fd, "utf8"); + } finally { + closeSync(fd); + } } catch (err) { if (hasErrorCode(err) && err.code === "ENOENT") return { defaultNs: null, namespaces: {} }; + if (hasErrorCode(err) && err.code === "ELOOP") { + throw new CliError(`refusing to read credentials: ${escapeTerminalText(storePath)} is not a regular file`); + } + throw wrapTokenStoreFsError(err, `failed to read credential store ${escapeTerminalText(storePath)}`); + } + + try { + return parseTokenStoreText(text); + } catch (err) { + if (!(err instanceof CliError)) throw err; throw new CliError( - `failed to read credential store ${escapeTerminalText(storePath)}: ${formatTokenStoreError(err)}` + `failed to parse credential store ${escapeTerminalText(storePath)}: ${formatTokenStoreError(err)}` ); } +} +/** + * @param {string} text + * @returns {{ defaultNs: string | null, namespaces: Record> }} + */ +function parseTokenStoreText(text) { /** @type {Record>} */ const namespaces = {}; /** @type {string | null} */ @@ -165,14 +205,52 @@ function wrapTokenStoreFsError(err, prefix) { /** * @param {string} storeDir * @param {NodeJS.Platform} [platform] + * @param {"read" | "write"} [operation] */ -export function assertStoreDirSecure(storeDir, platform = process.platform) { +export function assertStoreDirSecure(storeDir, platform = process.platform, operation = "write") { + const storeStat = statStoreDir(storeDir, operation); if (platform === "win32") return; - if ((statSync(storeDir).mode & 0o022) !== 0) { - const escapedDir = escapeTerminalText(storeDir); + if ((storeStat.mode & 0o022) !== 0) { const quotedDir = shellArgForDisplay(storeDir); throw new CliError( - `refusing to write credentials: ${escapedDir} is group/world-writable; restrict it with \`chmod 700 ${quotedDir}\`` + `refusing to ${operation} credentials: ${escapeTerminalText(storeDir)} is group/world-writable; ` + + `restrict it with \`chmod 700 ${quotedDir}\`` + ); + } +} + +/** + * @param {string} storeDir + * @param {"read" | "write"} operation + */ +function statStoreDir(storeDir, operation) { + // Follow a user-managed config-directory symlink (or Windows junction), but + // validate the resolved target before using it. The credentials file itself + // is independently opened and descriptor-checked, and may not be a symlink. + const storeStat = statSync(storeDir); + if (!storeStat.isDirectory()) { + throw new CliError(`refusing to ${operation} credentials: ${escapeTerminalText(storeDir)} is not a directory`); + } + return storeStat; +} + +/** + * @param {string} storePath + * @param {import("node:fs").Stats} storeStat + * @param {NodeJS.Platform} [platform] + */ +function assertStoreFileSecure(storePath, storeStat, platform = process.platform) { + const escapedPath = escapeTerminalText(storePath); + if (!storeStat.isFile()) { + throw new CliError(`refusing to read credentials: ${escapedPath} is not a regular file`); + } + if (platform !== "win32" && typeof process.geteuid === "function" && storeStat.uid !== process.geteuid()) { + throw new CliError(`refusing to read credentials: ${escapedPath} is not owned by the current user`); + } + if (platform !== "win32" && (storeStat.mode & 0o077) !== 0) { + const quotedPath = shellArgForDisplay(storePath); + throw new CliError( + `refusing to read credentials: ${escapedPath} is accessible by group/other users; restrict it with \`chmod 600 ${quotedPath}\`` ); } } @@ -217,16 +295,7 @@ function writeTokenStoreFile(storePath, store, { beforeCommit = () => {}, tempDi lines.push(""); } const storeDir = path.dirname(storePath); - mkdirSync(storeDir, { recursive: true, mode: 0o700 }); - // Best-effort tighten a pre-existing dir (mkdirSync's mode only applies on - // creation). Tolerate a chmod failure (root-owned dir / no-chmod mount) — the - // assertion below enforces the property that actually matters. - try { - chmodSync(storeDir, 0o700); - } catch { - // best-effort - } - assertStoreDirSecure(storeDir); + prepareTokenStoreDir(storeDir); const tmpPath = path.join(tempDir, `${path.basename(storePath)}.${process.pid}.${randomUUID()}.tmp`); try { writeFileSync(tmpPath, lines.join("\n"), { mode: 0o600, flag: "wx" }); @@ -315,6 +384,11 @@ function withTokenStoreLock(storePath, lockTimeoutMs, staleLockMs, fn) { /** @param {string} storeDir */ function prepareTokenStoreDir(storeDir) { mkdirSync(storeDir, { recursive: true, mode: 0o700 }); + // Confirm the resolved path is a directory before chmod can affect it. + statStoreDir(storeDir, "write"); + // Best-effort tighten a pre-existing dir (mkdirSync's mode only applies on + // creation). Tolerate a chmod failure (root-owned dir / no-chmod mount) — the + // assertion below enforces the property that actually matters. try { chmodSync(storeDir, 0o700); } catch { diff --git a/lib/wrangler/command.js b/lib/wrangler/command.js index 6caaaec..df46a0c 100644 --- a/lib/wrangler/command.js +++ b/lib/wrangler/command.js @@ -9,7 +9,7 @@ import { escapeTerminalLines, escapeTerminalText, formatDiagnosticValue } from " const CLI_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const resolveFromHere = createRequire(import.meta.url); -export const MIN_WRANGLER_MAJOR = 4; +export const SUPPORTED_WRANGLER_MAJOR = 4; /** * The subset of an `execFileSync` failure / spawn error the formatters read. @@ -44,7 +44,7 @@ export function resolveWranglerCommand({ packageDirs = [CLI_DIR], platform = process.platform, } = {}) { - // Keep deploy offline by default. `npx --yes wrangler` may hit the + // Keep deploy offline by default. `npx --yes wrangler@^4` may hit the // registry, so only use it when explicitly requested. if (env.WDL_WRANGLER_BIN) { return { command: env.WDL_WRANGLER_BIN, args: [], source: "WDL_WRANGLER_BIN" }; @@ -68,7 +68,7 @@ export function resolveWranglerCommand({ if (fromPath) return { ...fromPath, source: "path" }; if (env.WDL_ALLOW_NPX_WRANGLER === "1") { - return { command: "npx", args: ["--yes", "wrangler"], source: "npx" }; + return npxWrangler(env, platform); } if (platform === "win32") { @@ -90,15 +90,16 @@ export function resolveWranglerCommand({ * cwd: string, * env: NodeJS.ProcessEnv, * wrangler: { command: string, args: string[] }, + * fallbackVersion?: () => string, * }} options * @returns {{ version: string, major: number }} */ -export function checkWranglerVersion({ execFile = execFileSync, cwd, env, wrangler }) { - const result = probeWranglerVersion({ execFile, cwd, env, wrangler }); - if (result.major < MIN_WRANGLER_MAJOR) { +export function checkWranglerVersion({ execFile = execFileSync, cwd, env, wrangler, fallbackVersion }) { + const result = probeWranglerVersion({ execFile, cwd, env, wrangler, fallbackVersion }); + if (result.major !== SUPPORTED_WRANGLER_MAJOR) { throw new CliError( - `wdl deploy requires Wrangler v${MIN_WRANGLER_MAJOR} (wrangler@^${MIN_WRANGLER_MAJOR}); ` + - `found v${result.major}. Upgrade the Worker project's wrangler dependency before deploying.` + `wdl deploy requires Wrangler v${SUPPORTED_WRANGLER_MAJOR} (wrangler@^${SUPPORTED_WRANGLER_MAJOR}); ` + + `found v${result.version}. Install a supported v${SUPPORTED_WRANGLER_MAJOR} release in the Worker project before deploying.` ); } return result; @@ -269,6 +270,37 @@ function pathWrangler(env, platform) { return null; } +/** + * @param {NodeJS.ProcessEnv} env + * @param {NodeJS.Platform} platform + */ +function npxWrangler(env, platform) { + const args = ["--yes", "wrangler@^4"]; + if (platform !== "win32") return { command: "npx", args, source: "npx" }; + + // Node >= 20.12 refuses to execFile the npx.cmd shim. A real executable shim + // (for example Volta's npx.exe) remains safe to execute directly; otherwise + // locate npm's JS entry and run it with Node. + for (const dir of (env.PATH || "").split(path.delimiter)) { + if (!dir) continue; + const executable = path.join(dir, "npx.exe"); + if (existsSync(executable)) return { command: executable, args, source: "npx" }; + } + + const candidates = []; + if (env.npm_execpath) candidates.push(path.join(path.dirname(env.npm_execpath), "npx-cli.js")); + for (const dir of (env.PATH || "").split(path.delimiter)) { + if (dir) candidates.push(path.join(dir, "node_modules", "npm", "bin", "npx-cli.js")); + } + const script = candidates.find((candidate) => existsSync(candidate)); + if (script) return { command: process.execPath, args: [script, ...args], source: "npx" }; + + throw new CliError( + "WDL_ALLOW_NPX_WRANGLER=1 is set, but no runnable npx installation was found. " + + "Install wrangler@^4 in the Worker project or set WDL_WRANGLER_BIN to a runnable Wrangler entry." + ); +} + /** * @param {unknown} rawErr * @returns {string} diff --git a/scripts/changelog-section.js b/scripts/changelog-section.js new file mode 100644 index 0000000..e032d57 --- /dev/null +++ b/scripts/changelog-section.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; + +const version = process.argv[2]; +if (!version) { + console.error("usage: node scripts/changelog-section.js "); + process.exit(2); +} + +const lines = readFileSync("CHANGELOG.md", "utf8").split(/\r?\n/); +const start = lines.indexOf(`## ${version}`); +const end = start < 0 ? -1 : lines.findIndex((line, index) => index > start && line.startsWith("## ")); +const notes = + start < 0 + ? "" + : lines + .slice(start + 1, end < 0 ? undefined : end) + .join("\n") + .trim(); + +if (!notes) { + console.error(`stable release v${version} requires a non-empty CHANGELOG.md section`); + process.exit(3); +} + +process.stdout.write(`${notes}\n`); diff --git a/tests/integration/cli-live.test.js b/tests/integration/cli-live.test.js index b77f189..43ef021 100644 --- a/tests/integration/cli-live.test.js +++ b/tests/integration/cli-live.test.js @@ -538,7 +538,7 @@ test( assert.ok(app.versions.includes(app.activeVersion)); const oldVersion = app.versions.find((version) => version && version !== app.activeVersion); assert.ok(oldVersion, `second deploy did not leave an old version: ${app.versions.join(", ")}`); - runJson(["delete", "version", appWorker, oldVersion, "--json"], { env: storeEnv }); + runJson(["delete", "version", appWorker, oldVersion, "--yes", "--json"], { env: storeEnv }); runJson(["delete", "worker", appWorker, "--dry-run", "--json"], { env: storeEnv }); }); diff --git a/tests/unit/cli-common.test.js b/tests/unit/cli-common.test.js index 3240237..5f2b652 100644 --- a/tests/unit/cli-common.test.js +++ b/tests/unit/cli-common.test.js @@ -328,7 +328,7 @@ test("tenant lifecycle commands default namespace from WDL_NS", async () => { /** @type {ControlCall[]} */ const deleteCalls = []; - await runDeleteCommand(["version", "api", "v1", "--control-url", "http://ctl.test"], { + await runDeleteCommand(["version", "api", "v1", "--yes", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, controlFetch: async ( diff --git a/tests/unit/cli-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index 9699d52..4fd7395 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -189,6 +189,31 @@ test("config explain prints final values and sources", async () => { }); }); +test("config explain reports a malformed token store without hiding resolved provenance", async () => { + await withTempDir(async (cwd) => { + const xdg = path.join(cwd, "xdg"); + const storePath = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + mkdirSync(path.dirname(storePath), { recursive: true, mode: 0o700 }); + writeFileSync(storePath, '[demo]\nADMIN_TOKEN="unterminated\n', { mode: 0o600 }); + /** @type {string[]} */ + const lines = []; + + await runConfigCommand(["explain"], { + cwd, + env: { XDG_CONFIG_HOME: xdg, CONTROL_URL: "https://ctl.example", ADMIN_TOKEN: "shell-token" }, + /** @param {string} line */ + stdout: (line) => lines.push(line), + }); + + const out = lines.join("\n"); + assert.match(out, /namespace:\n {2}value: \(unset\)\n {2}source: \(unset\)/); + assert.match(out, /controlUrl:\n {2}value: https:\/\/ctl\.example\n {2}source: CONTROL_URL env/); + assert.match(out, /token:\n {2}value: \*+oken\n {2}source: ADMIN_TOKEN env/); + assert.match(out, /tokenStore:\n {2}error: failed to parse credential store/); + assert.match(out, /xdg[/\\]wdl[/\\]credentials/); + }); +}); + test("bin does not preload .env for local diagnostic commands", async () => { /** @type {string[]} */ const calls = []; @@ -318,7 +343,7 @@ test("doctor reports local checks plus remote whoami", async () => { let childEnv; /** @type {ControlCall[]} */ const calls = []; - const mockWranglerVersion = "9.8.7"; + const mockWranglerVersion = "4.98.7"; /** * @param {string} _cmd * @param {readonly string[]} _args @@ -396,8 +421,11 @@ test("doctor --strict exits non-zero when any check fails", async () => { ); const out = lines.join("\n"); - assert.match(out, /✗ Wrangler 3\.99\.0/); + assert.match(out, /✗ Wrangler\n/); assert.match(out, /wdl deploy requires Wrangler v4/); + assert.match(out, /found v3\.99\.0/); + assert.match(out, /found v3\.99\.0[^\n]*\n {2}source: /); + assert.doesNotMatch(out, /\nsource: /); }); }); @@ -457,8 +485,8 @@ test("doctor reports a corrupt token store as a failed check", async () => { 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'); + mkdirSync(path.dirname(storePath), { recursive: true, mode: 0o700 }); + writeFileSync(storePath, '[demo]\nADMIN_TOKEN="unterminated\n', { mode: 0o600 }); /** @type {string[]} */ const lines = []; @@ -492,8 +520,8 @@ test("doctor reports a corrupt token store even when it blocks credential resolu 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'); + mkdirSync(path.dirname(storePath), { recursive: true, mode: 0o700 }); + writeFileSync(storePath, '[demo]\nADMIN_TOKEN="unterminated\n', { mode: 0o600 }); /** @type {string[]} */ const lines = []; @@ -598,7 +626,7 @@ test("doctor reports namespace mismatch from whoami", async () => { }); }); -test("doctor flags a Wrangler major below the deploy minimum", async () => { +test("doctor flags a Wrangler major below the supported v4 contract", async () => { await withTempDir(async (cwd) => { /** @type {string[]} */ const lines = []; @@ -617,8 +645,34 @@ test("doctor flags a Wrangler major below the deploy minimum", async () => { }); const out = lines.join("\n"); - assert.match(out, /✗ Wrangler 3\.99\.0/); + assert.match(out, /✗ Wrangler\n/); + assert.match(out, /wdl deploy requires Wrangler v4/); + assert.match(out, /found v3\.99\.0/); + }); +}); + +test("doctor flags a Wrangler major above the supported v4 contract", async () => { + await withTempDir(async (cwd) => { + /** @type {string[]} */ + const lines = []; + await runDoctorCommand(["--ns", "acme", "--token", "secret-token"], { + cwd, + env: { CONTROL_URL: "https://api.wdl.dev" }, + execFile: () => "5.0.0\n", + /** @param {string} line */ + stdout: (line) => lines.push(line), + controlFetch: async () => + response({ + ok: true, + principal: { kind: "ns", ns: "acme" }, + minCliVersion: "0.7.1", + }), + }); + + const out = lines.join("\n"); + assert.match(out, /✗ Wrangler\n/); assert.match(out, /wdl deploy requires Wrangler v4/); + assert.match(out, /found v5\.0\.0/); }); }); diff --git a/tests/unit/cli-credentials.test.js b/tests/unit/cli-credentials.test.js index 94729f7..9c583ce 100644 --- a/tests/unit/cli-credentials.test.js +++ b/tests/unit/cli-credentials.test.js @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -13,7 +13,8 @@ import { resolveNamespace, warnIfInsecureControlUrl, } from "../../lib/credentials.js"; -import { ESC, assertNoRawTerminalControls } from "./helpers.js"; +import { readTokenStore } from "../../lib/token-store.js"; +import { ESC, POSIX_ONLY, assertNoRawTerminalControls } from "./helpers.js"; test("isTokenStoreDisabled honors the flag and WDL_TOKEN_STORE=off", () => { assert.equal(isTokenStoreDisabled({}, false), false); @@ -37,12 +38,13 @@ test("resolveControlUrl requires a configured endpoint", () => { assert.throws(() => resolveControlUrl({}, {}), /No control URL configured/); }); -test("resolveControlUrl escapes invalid endpoint diagnostics", () => { +test("resolveControlUrl does not echo invalid endpoint input", () => { assert.throws( () => resolveControlUrl({ "control-url": `ftp://ctl.test/${ESC}[2J\u009b` }, {}), (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /Invalid control URL/); + assert.doesNotMatch(message, /ctl\.test/); assertNoRawTerminalControls(message, "control URL errors"); return true; } @@ -52,11 +54,67 @@ test("resolveControlUrl escapes invalid endpoint diagnostics", () => { test("resolveControlUrl accepts bare control hosts as https URLs", () => { assert.equal(resolveControlUrl({ "control-url": "ctl.example" }, {}), "https://ctl.example"); assert.equal(resolveControlUrl({}, { CONTROL_URL: "ctl.uat.example/" }), "https://ctl.uat.example"); + assert.equal(resolveControlUrl({}, { CONTROL_URL: "dev.local" }), "https://dev.local"); + assert.equal(resolveControlUrl({}, { CONTROL_URL: "https://ctl.example/control" }), "https://ctl.example/control"); +}); + +test("resolveControlUrl rejects query strings and fragments", () => { + for (const controlUrl of [ + "https://ctl.example/control?tenant=demo", + "https://ctl.example/control#admin", + "https://ctl.example/control?", + "https://ctl.example/control#", + ]) { + assert.throws( + () => resolveControlUrl({ "control-url": controlUrl }, {}), + /query strings and fragments are not supported/ + ); + } +}); + +test("resolveControlUrl errors never echo embedded credentials", () => { + /** @type {{ values: Record, env: NodeJS.ProcessEnv, expected: RegExp }[]} */ + const cases = [ + { + values: { "control-url": "https://api.wdl.dev@evil.example" }, + env: {}, + expected: /embedded usernames and passwords/, + }, + { + values: {}, + env: { CONTROL_URL: "https://operator:SENTINEL_PASSWORD@ctl.example" }, + expected: /embedded usernames and passwords/, + }, + { + values: { "control-url": "ftp://operator:SENTINEL_PASSWORD@ctl.example" }, + env: {}, + expected: /expected http:\/\/ or https:\/\//, + }, + { + values: { "control-url": "https://operator:SENTINEL_PASSWORD@[invalid" }, + env: {}, + expected: /^Invalid control URL\.$/, + }, + ]; + for (const { values, env, expected } of cases) { + assert.throws( + () => resolveControlUrl(values, env), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, expected); + assert.doesNotMatch(message, /api\.wdl\.dev|operator|SENTINEL_PASSWORD|evil\.example|ctl\.example/); + return true; + } + ); + } }); test("resolveControlUrl keeps bare local dev control URLs on http", () => { assert.equal(resolveControlUrl({}, { CONTROL_URL: "ctl.test:8080" }), "http://ctl.test:8080"); + assert.equal(resolveControlUrl({}, { CONTROL_URL: "dev.local:8080" }), "http://dev.local:8080"); assert.equal(resolveControlUrl({ "control-url": "localhost:8080/" }, {}), "http://localhost:8080"); + assert.equal(resolveControlUrl({ "control-url": "LOCALHOST:9000" }, {}), "http://LOCALHOST:9000"); + assert.equal(resolveControlUrl({ "control-url": "127.0.0.2:9000" }, {}), "http://127.0.0.2:9000"); assert.equal(resolveControlUrl({ "control-url": "[::1]" }, {}), "http://[::1]"); assert.equal(resolveControlUrl({ "control-url": "[::1]:8080/" }, {}), "http://[::1]:8080"); assert.equal(resolveControlUrl({ "control-url": "ctl.test" }, {}), "http://ctl.test"); @@ -85,8 +143,13 @@ test("warnIfInsecureControlUrl escapes control endpoint text before warning", () 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"]) { +test("warnIfInsecureControlUrl treats loopback CONTROL_CONNECT_HOST overrides as local", () => { + /** @type {string[]} */ + const controlWarnings = []; + warnIfInsecureControlUrl("http://127.0.0.2:9000", (line) => controlWarnings.push(line), {}); + assert.deepEqual(controlWarnings, []); + + for (const connectHost of ["localhost:18080", "127.0.0.53:18080", "[::1]:18080", "http://localhost:18080"]) { /** @type {string[]} */ const warnings = []; warnIfInsecureControlUrl("http://admin.test:8080", (line) => warnings.push(line), { @@ -96,6 +159,20 @@ test("warnIfInsecureControlUrl treats local CONTROL_CONNECT_HOST host:port overr } }); +test("warnIfInsecureControlUrl does not treat mDNS .local hosts as loopback", () => { + /** @type {string[]} */ + const controlWarnings = []; + warnIfInsecureControlUrl("http://dev.local", (line) => controlWarnings.push(line), {}); + assert.match(controlWarnings[0], /plain http on a non-local host/); + + /** @type {string[]} */ + const connectWarnings = []; + warnIfInsecureControlUrl("http://admin.test:8080", (line) => connectWarnings.push(line), { + CONTROL_CONNECT_HOST: "dev.local:18080", + }); + assert.match(connectWarnings[0], /CONTROL_CONNECT_HOST=dev\.local:18080 is non-local/); +}); + test("resolveNamespace prefers explicit namespace before WDL_NS", () => { assert.equal(resolveNamespace({ ns: "flag" }, { WDL_NS: "env" }), "flag"); assert.equal(resolveNamespace({}, { WDL_NS: "env" }), "env"); @@ -699,6 +776,65 @@ test("loadCliControlEnv lets shell env win over the store (gap-fill only)", () = assert.equal(env.CONTROL_URL, "https://store.example", "the empty control URL slot is filled"); }); +test("loadCliControlEnv rejects an unsafe store before mixing its URL with a shell token", POSIX_ONLY, () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-store-mixed-source-")); + try { + const storeDir = path.join(dir, "wdl"); + const storePath = path.join(storeDir, "credentials"); + mkdirSync(storeDir, { mode: 0o700 }); + writeFileSync(storePath, 'WDL_NS="acme"\n[acme]\nCONTROL_URL="http://attacker.example"\n', { mode: 0o600 }); + chmodSync(storeDir, 0o777); + const env = /** @type {NodeJS.ProcessEnv} */ ({ WDL_NS: "acme", ADMIN_TOKEN: "shell-token" }); + + assert.throws( + () => + loadCliControlEnv(env, { + loadEnv: () => [], + readStore: () => readTokenStore(storePath), + }), + /refusing to read credentials: .*group\/world-writable/ + ); + assert.equal(env.ADMIN_TOKEN, "shell-token"); + assert.equal(env.CONTROL_URL, undefined); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test( + "loadCliControlEnv surfaces an unsafe store when its default namespace is the only credential source", + POSIX_ONLY, + () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-store-default-error-")); + try { + const storeDir = path.join(dir, "wdl"); + const storePath = path.join(storeDir, "credentials"); + mkdirSync(storeDir, { mode: 0o700 }); + writeFileSync( + storePath, + 'WDL_NS="acme"\n[acme]\nCONTROL_URL="https://control.example"\nADMIN_TOKEN="store-token"\n', + { mode: 0o600 } + ); + chmodSync(storePath, 0o644); + const env = /** @type {NodeJS.ProcessEnv} */ ({}); + + assert.throws( + () => + loadCliControlEnv(env, { + loadEnv: () => [], + readStore: () => readTokenStore(storePath), + }), + /accessible by group\/other users; restrict it with `chmod 600/ + ); + assert.equal(env.WDL_NS, undefined); + assert.equal(env.ADMIN_TOKEN, undefined); + assert.equal(env.CONTROL_URL, undefined); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +); + test("loadCliControlEnv does not fill a flag-covered slot from the store", () => { /** @type {NodeJS.ProcessEnv} */ const env = { WDL_NS: "acme" }; @@ -762,11 +898,13 @@ test("loadCliControlEnv surfaces a corrupt store when it is the credential sourc }); test("loadCliControlEnv tolerates a corrupt store when no namespace is needed", () => { - // No --ns/WDL_NS: the optional default-namespace lookup must not let a corrupt - // store abort a command that needs none (e.g. whoami --control-url … --token …). + // No --ns/WDL_NS and both credentials are explicit: the optional + // default-namespace lookup must not let a corrupt store abort whoami. const env = /** @type {NodeJS.ProcessEnv} */ ({}); assert.doesNotThrow(() => loadCliControlEnv(env, { + tokenFromFlag: true, + controlUrlFromFlag: true, loadEnv: () => [], readStore: () => { throw new Error("Invalid credentials line 3"); @@ -776,6 +914,24 @@ test("loadCliControlEnv tolerates a corrupt store when no namespace is needed", assert.equal(env.WDL_NS, undefined); }); +test("loadCliControlEnv surfaces a corrupt default-namespace store when the command requires namespace", () => { + const env = /** @type {NodeJS.ProcessEnv} */ ({ + CONTROL_URL: "https://shell.example", + ADMIN_TOKEN: "shell-tok", + }); + assert.throws( + () => + loadCliControlEnv(env, { + requireNamespace: true, + loadEnv: () => [], + readStore: () => { + throw new Error("Invalid credentials line 3"); + }, + }), + /Invalid credentials line 3/ + ); +}); + test("an empty .env ADMIN_TOKEN does not mark a .env endpoint same-source", () => { // Malicious cwd .env: a control endpoint + an EMPTY `ADMIN_TOKEN=` placeholder. // The empty token must not make the endpoint same-source. diff --git a/tests/unit/cli-delete.test.js b/tests/unit/cli-delete.test.js index bbc177c..95a3808 100644 --- a/tests/unit/cli-delete.test.js +++ b/tests/unit/cli-delete.test.js @@ -15,7 +15,7 @@ test("delete version calls the version hard-delete endpoint", async () => { assets: { cleanupTaskId: null, skippedSharedPrefix: false, warnings: [] }, }); - await runDeleteCommand(["version", "--ns", "demo", "api", "v1", "--control-url", "http://ctl.test"], deps); + await runDeleteCommand(["version", "--ns", "demo", "api", "v1", "--yes", "--control-url", "http://ctl.test"], deps); assert.equal(calls.length, 1); assert.equal(calls[0].url, "http://ctl.test/ns/demo/worker/api/versions/v1"); @@ -23,6 +23,78 @@ test("delete version calls the version hard-delete endpoint", async () => { assert.deepEqual(lines, ["OK demo/api@v1 deleted"]); }); +test("delete version requires confirmation and rejects dry-run without a request", async () => { + /** @type {ControlCall[]} */ + const calls = []; + const deps = { + env: { ADMIN_TOKEN: "tok" }, + stdin: stdinFrom(""), + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + calls.push({ url, init }); + return response({}); + }, + }; + + await assert.rejects( + () => runDeleteCommand(["version", "--ns", "demo", "api", "v1", "--control-url", "http://ctl.test"], deps), + /Refusing to delete version "demo\/api@v1" without interactive confirmation/ + ); + await assert.rejects( + () => + runDeleteCommand( + ["version", "--ns", "demo", "api", "v1", "--dry-run", "--yes", "--control-url", "http://ctl.test"], + deps + ), + /delete version does not support --dry-run/ + ); + assert.equal(calls.length, 0); +}); + +test("delete version proceeds after interactive confirmation", async () => { + /** @type {ControlCall[]} */ + const calls = []; + /** @type {string[]} */ + const prompts = []; + const stdin = ttyStdinLine("yes\n"); + + await runDeleteCommand(["version", "--ns", "demo", "api", "v1", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdin, + stderr: (/** @type {string} */ text) => prompts.push(text), + stdout: () => {}, + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + calls.push({ url, init }); + return response({ namespace: "demo", name: "api", version: "v1", deleted: true }); + }, + }); + + assert.equal(calls.length, 1); + assert.deepEqual(prompts, ['Are you sure you want to delete version "demo/api@v1"? [y/N] ']); + assert.equal(stdin.paused, true); +}); + +test("delete version resolves credentials before prompting", async () => { + /** @type {string[]} */ + const prompts = []; + await assert.rejects( + () => + runDeleteCommand(["version", "--ns", "demo", "api", "v1", "--control-url", "http://ctl.test"], { + env: {}, + stdin: ttyStdinLine("yes\n"), + stderr: (/** @type {string} */ text) => prompts.push(text), + controlFetch: async () => response({}), + }), + /Missing admin token/ + ); + assert.deepEqual(prompts, []); +}); + test("delete output does not expose internal cleanup task ids", async () => { const { lines, deps } = mockDeps({ namespace: "demo", diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 29f5b93..d8a9a62 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -756,7 +756,7 @@ test("runDeployCommand preserves the local control scheme and port in the Worker /** @type {string[]} */ const lines = []; let fetchCount = 0; - await runDeployCommand([dir, "--ns", "demo", "--control-url", "https://localhost:8443"], { + await runDeployCommand([dir, "--ns", "demo", "--control-url", "https://box.local:8443"], { env: { ADMIN_TOKEN: "tok", CONTROL_CONNECT_HOST: "127.0.0.1:18080" }, stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), stderr: () => {}, diff --git a/tests/unit/cli-r2.test.js b/tests/unit/cli-r2.test.js index e7ddc30..790e2a3 100644 --- a/tests/unit/cli-r2.test.js +++ b/tests/unit/cli-r2.test.js @@ -7,7 +7,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { runR2Command } from "../../commands/r2.js"; import { LONG_CONTROL_TIMEOUT_MS, UNLIMITED_CONTROL_BODY_BYTES } from "../../lib/control-fetch.js"; -import { mockDeps, response, stdinFrom } from "./helpers.js"; +import { INVALID_PAGE_LIMITS, mockDeps, response, stdinFrom } from "./helpers.js"; /** @typedef {import("./helpers.js").ControlCall} ControlCall */ @@ -161,20 +161,29 @@ test("r2 list --limit is validated locally", async () => { 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); + for (const { value, suffix } of [ + { value: "", suffix: "" }, + { value: "01", suffix: "?limit=1" }, + { value: "1e3", suffix: "?limit=1000" }, + { value: "0x10", suffix: "?limit=16" }, + { value: "+8", suffix: "?limit=8" }, + { value: " 8 ", suffix: "?limit=8" }, + ]) { + await runR2Command(["buckets", "list", "--ns", "demo", "--limit", value, "--control-url", "http://ctl.test"], deps); + assert.equal(calls.at(-1)?.url, `http://ctl.test/ns/demo/r2/buckets${suffix}`); + } + + for (const value of INVALID_PAGE_LIMITS) { + await assert.rejects( + () => + runR2Command( + ["objects", "list", "--ns", "demo", "uploads", "--limit", value, "--control-url", "http://ctl.test"], + deps + ), + /--limit must be an integer/ + ); + } + assert.equal(calls.length, 7); }); test("r2 object get waits for stdout backpressure", async () => { diff --git a/tests/unit/cli-release.test.js b/tests/unit/cli-release.test.js new file mode 100644 index 0000000..fcea82c --- /dev/null +++ b/tests/unit/cli-release.test.js @@ -0,0 +1,31 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const CLI_ROOT = path.resolve(import.meta.dirname, "../.."); + +test("changelog section extraction gates stable release notes", () => { + const changelog = readFileSync(path.join(CLI_ROOT, "CHANGELOG.md"), "utf8"); + const stableVersion = /^## (\d+\.\d+\.\d+)\r?$/m.exec(changelog)?.[1]; + assert.ok(stableVersion, "CHANGELOG.md must contain a stable release section"); + const script = path.join(CLI_ROOT, "scripts", "changelog-section.js"); + const current = spawnSync(process.execPath, [script, stableVersion], { cwd: CLI_ROOT, encoding: "utf8" }); + assert.equal(current.status, 0, current.stderr); + assert.match(current.stdout, /\S/); + + const missing = spawnSync(process.execPath, [script, "0.0.0-missing"], { cwd: CLI_ROOT, encoding: "utf8" }); + assert.equal(missing.status, 3); + assert.match(missing.stderr, /requires a non-empty CHANGELOG\.md section/); + + const emptyDir = mkdtempSync(path.join(tmpdir(), "wdl-release-script-")); + try { + const unreadable = spawnSync(process.execPath, [script, stableVersion], { cwd: emptyDir, encoding: "utf8" }); + assert.equal(unreadable.status, 1); + assert.doesNotMatch(unreadable.stderr, /requires a non-empty CHANGELOG\.md section/); + } finally { + rmSync(emptyDir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/cli-tail.test.js b/tests/unit/cli-tail.test.js index 0787a9f..d6eb9f9 100644 --- a/tests/unit/cli-tail.test.js +++ b/tests/unit/cli-tail.test.js @@ -43,6 +43,26 @@ test("SseParser rejects overlong lines", () => { assert.throws(() => parser.push(`data: ${"x".repeat(SSE_MAX_LINE_CHARS)}`), /SSE line exceeded/); }); +test("SseParser bounds cumulative event data and resets after dispatch", () => { + /** @type {import("../../commands/tail.js").SseEvent[]} */ + const events = []; + const parser = new SseParser((event) => events.push(event)); + parser.maxEventBytes = 5; + + parser.push("data: abc\ndata: d\n\n"); + parser.push("data: 12345\n\n"); + assert.deepEqual( + events.map((event) => event.data), + ["abc\nd", "12345"] + ); + + assert.throws(() => parser.push("data: abc\ndata: de\n"), /SSE event exceeded 5 bytes/); + + const multibyte = new SseParser(() => {}); + multibyte.maxEventBytes = 3; + assert.throws(() => multibyte.push("data: \u00e9\u00e9\n"), /SSE event exceeded 3 bytes/); +}); + test("wdl tail rejects errors raised while flushing a trailing SSE event", async () => { const fakeTransport = { /** diff --git a/tests/unit/cli-token-store.test.js b/tests/unit/cli-token-store.test.js index add557d..aed0187 100644 --- a/tests/unit/cli-token-store.test.js +++ b/tests/unit/cli-token-store.test.js @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { chmodSync, existsSync, @@ -650,7 +650,7 @@ test("preserves a namespace named like an Object.prototype key", () => { test("handles a __proto__ section without polluting the prototype", () => { withTempDir((dir) => { const p = path.join(dir, "credentials"); - writeFileSync(p, '[__proto__]\nADMIN_TOKEN="x"\n[acme]\nADMIN_TOKEN="a"\n'); + writeFileSync(p, '[__proto__]\nADMIN_TOKEN="x"\n[acme]\nADMIN_TOKEN="a"\n', { mode: 0o600 }); const back = readTokenStore(p); assert.deepEqual(Object.keys(back.namespaces).sort(), ["__proto__", "acme"]); assert.equal(back.namespaces["__proto__"].ADMIN_TOKEN, "x"); @@ -737,6 +737,78 @@ test("assertStoreDirSecure refuses a group/world-writable store dir", POSIX_ONLY } }); +test("readTokenStore rejects insecure directories, permissions, and file types", POSIX_ONLY, () => { + withTempDir((dir) => { + const sharedDir = path.join(dir, "shared"); + const sharedStore = path.join(sharedDir, "credentials"); + mkdirSync(sharedDir, { mode: 0o700 }); + writeFileSync(sharedStore, '[acme]\nADMIN_TOKEN="tok"\n', { mode: 0o600 }); + chmodSync(sharedDir, 0o777); + assert.throws(() => readTokenStore(sharedStore), /refusing to read credentials: .*group\/world-writable/); + + const broadStore = path.join(dir, "broad-credentials"); + writeFileSync(broadStore, '[acme]\nADMIN_TOKEN="tok"\n', { mode: 0o600 }); + chmodSync(broadStore, 0o644); + assert.throws( + () => readTokenStore(broadStore), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /accessible by group\/other users/); + assert.doesNotMatch(message, /failed to read credential store/, "the security error should not be wrapped"); + return true; + } + ); + + const target = path.join(dir, "target-credentials"); + const link = path.join(dir, "linked-credentials"); + writeFileSync(target, '[acme]\nADMIN_TOKEN="tok"\n', { mode: 0o600 }); + symlinkSync(target, link); + assert.throws(() => readTokenStore(link), /is not a regular file/); + + const directoryPath = path.join(dir, "directory-credentials"); + mkdirSync(directoryPath, { mode: 0o700 }); + assert.throws(() => readTokenStore(directoryPath), /is not a regular file/); + + const fifoPath = path.join(dir, "fifo-credentials"); + const mkfifo = spawnSync("mkfifo", [fifoPath], { encoding: "utf8" }); + assert.equal(mkfifo.status, 0, mkfifo.stderr || "mkfifo failed"); + const fifoRead = spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + `import { readTokenStore } from ${JSON.stringify(new URL("../../lib/token-store.js", import.meta.url).href)}; +try { readTokenStore(${JSON.stringify(fifoPath)}); } +catch (err) { process.stderr.write(err.message); process.exit(2); }`, + ], + { encoding: "utf8", timeout: 1_000 } + ); + const fifoReadError = /** @type {NodeJS.ErrnoException | undefined} */ (fifoRead.error); + assert.notEqual(fifoReadError?.code, "ETIMEDOUT", "reading a FIFO credential path must not block"); + assert.equal(fifoRead.status, 2, fifoRead.stderr); + assert.match(fifoRead.stderr, /is not a regular file/); + }); +}); + +test("token-store reads and writes through a secure symlinked config directory", POSIX_ONLY, () => { + withTempDir((dir) => { + const targetDir = path.join(dir, "target"); + const linkedDir = path.join(dir, "linked"); + mkdirSync(targetDir, { mode: 0o755 }); + chmodSync(targetDir, 0o755); + symlinkSync(targetDir, linkedDir); + + const storePath = path.join(linkedDir, "credentials"); + writeTokenStore(storePath, { namespaces: { acme: { ADMIN_TOKEN: "tok" } } }); + + assert.deepEqual(readTokenStore(storePath), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "tok" } }, + }); + assert.equal(statSync(targetDir).mode & 0o777, 0o700); + }); +}); + test("updateTokenStore escapes write-side filesystem errors", () => { withTempDir((dir) => { const badXdg = path.join(dir, `bad${ESC}dir\nFORGED\rBAD`); @@ -781,15 +853,20 @@ test("writeTokenStore escapes write-side filesystem errors", () => { test("readTokenStore rejects a key outside any section", () => { withTempDir((dir) => { const p = path.join(dir, "credentials"); - writeFileSync(p, "ADMIN_TOKEN=loose\n"); - assert.throws(() => readTokenStore(p), /outside a \[namespace\] section/); + writeFileSync(p, "ADMIN_TOKEN=loose\n", { mode: 0o600 }); + assert.throws( + () => readTokenStore(p), + new RegExp( + `failed to parse credential store .*credentials: Invalid credentials line 1.*outside a \\[namespace\\]` + ) + ); }); }); test("readTokenStore reads a base WDL_NS as the default namespace", () => { withTempDir((dir) => { const p = path.join(dir, "credentials"); - writeFileSync(p, 'WDL_NS="acme"\n[acme]\nADMIN_TOKEN="t"\n'); + writeFileSync(p, 'WDL_NS="acme"\n[acme]\nADMIN_TOKEN="t"\n', { mode: 0o600 }); assert.deepEqual(readTokenStore(p), { defaultNs: "acme", namespaces: { acme: { ADMIN_TOKEN: "t" } }, @@ -800,7 +877,7 @@ test("readTokenStore reads a base WDL_NS as the default namespace", () => { test("readTokenStore ignores unknown keys and comments", () => { withTempDir((dir) => { const p = path.join(dir, "credentials"); - writeFileSync(p, '# note\n[acme]\nADMIN_TOKEN="t"\nUNKNOWN=x\n'); + writeFileSync(p, '# note\n[acme]\nADMIN_TOKEN="t"\nUNKNOWN=x\n', { mode: 0o600 }); assert.deepEqual(readTokenStore(p), { defaultNs: null, namespaces: { acme: { ADMIN_TOKEN: "t" } }, diff --git a/tests/unit/cli-workflows.test.js b/tests/unit/cli-workflows.test.js index 2c863ae..962f059 100644 --- a/tests/unit/cli-workflows.test.js +++ b/tests/unit/cli-workflows.test.js @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { runWorkflowsCommand } from "../../commands/workflows.js"; import { formatInstanceList, formatInstanceStatus, formatWorkflowList } from "../../lib/workflows-format.js"; -import { ESC, assertNoRawTerminalControls, mockDeps, response } from "./helpers.js"; +import { ESC, INVALID_PAGE_LIMITS, assertNoRawTerminalControls, mockDeps, response } from "./helpers.js"; /** @typedef {import("./helpers.js").ControlCall} ControlCall */ @@ -162,6 +162,36 @@ test("workflows list accepts flags before the subcommand", async () => { assert.deepEqual(lines, ["(no workflows)"]); }); +test("workflow page limits must be integers from 1 through 1000", async () => { + const calls = []; + const deps = { + env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, + stdout: () => {}, + controlFetch: async () => { + calls.push(true); + return response({}); + }, + }; + + await runWorkflowsCommand(["status", "api", "orders", "id", "--step-limit", "", "--ns", "demo"], deps); + + for (const value of INVALID_PAGE_LIMITS) { + await assert.rejects( + () => runWorkflowsCommand(["instances", "api", "orders", "--limit", value, "--ns", "demo"], deps), + /workflows --limit must be an integer in \[1, 1000\]/ + ); + await assert.rejects( + () => + runWorkflowsCommand( + ["status", "api", "orders", "id", "--include-steps", "--step-limit", value, "--ns", "demo"], + deps + ), + /workflows --step-limit must be an integer in \[1, 1000\]/ + ); + } + assert.equal(calls.length, 1); +}); + test("workflows commands reject unexpected positional arguments", async () => { /** @type {boolean[]} */ const calls = []; diff --git a/tests/unit/cli-wrangler-command.test.js b/tests/unit/cli-wrangler-command.test.js index 569cb69..74428c5 100644 --- a/tests/unit/cli-wrangler-command.test.js +++ b/tests/unit/cli-wrangler-command.test.js @@ -53,6 +53,26 @@ test("probeWranglerVersion returns one parsed version shape for deploy and docto }); }); +test("checkWranglerVersion accepts only the supported v4 major", () => { + const base = { + cwd: "/tmp/project", + env: {}, + wrangler: { command: "wrangler", args: [] }, + }; + assert.deepEqual(checkWranglerVersion({ ...base, execFile: versionExecFile("4.123.0") }), { + version: "4.123.0", + major: 4, + }); + assert.throws( + () => checkWranglerVersion({ ...base, execFile: versionExecFile("3.99.0") }), + /requires Wrangler v4 .* found v3/ + ); + assert.throws( + () => checkWranglerVersion({ ...base, execFile: versionExecFile("5.0.0") }), + /requires Wrangler v4 .* found v5/ + ); +}); + test("checkWranglerVersion escapes unparsable version diagnostics", () => { const execFile = /** @type {typeof import("node:child_process").execFileSync} */ ( /** @type {unknown} */ (() => `bad\u009b31m\nFORGED\rBAD`) @@ -215,8 +235,9 @@ test("resolveWranglerCommand only uses npx when explicitly allowed", () => { absProject: "/project", env: { WDL_ALLOW_NPX_WRANGLER: "1" }, packageDirs: [], + platform: "linux", }), - { command: "npx", args: ["--yes", "wrangler"], source: "npx" } + { command: "npx", args: ["--yes", "wrangler@^4"], source: "npx" } ); }); @@ -310,7 +331,46 @@ test("resolveWranglerCommand on win32 fails loudly when only a bare PATH .cmd sh }), /No runnable wrangler found/ ); - // The npx opt-in still provides a working escape hatch. + const npxScript = path.join(dir, "node_modules", "npm", "bin", "npx-cli.js"); + mkdirSync(path.dirname(npxScript), { recursive: true }); + writeFileSync(npxScript, ""); + // The npx opt-in runs npm's JS entry instead of its blocked .cmd shim. + assert.deepEqual( + resolveWranglerCommand({ + absProject: "/project", + env: { PATH: dir, WDL_ALLOW_NPX_WRANGLER: "1" }, + packageDirs: [], + platform: "win32", + }), + { + command: process.execPath, + args: [npxScript, "--yes", "wrangler@^4"], + source: "npx", + } + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("resolveWranglerCommand on win32 rejects an npx opt-in without a runnable JS entry", () => { + assert.throws( + () => + resolveWranglerCommand({ + absProject: "/project", + env: { PATH: "", WDL_ALLOW_NPX_WRANGLER: "1" }, + packageDirs: [], + platform: "win32", + }), + /no runnable npx installation was found/ + ); +}); + +test("resolveWranglerCommand on win32 runs an executable npx shim directly", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-wrangler-win32-npx-exe-")); + try { + const executable = path.join(dir, "npx.exe"); + writeFileSync(executable, ""); assert.deepEqual( resolveWranglerCommand({ absProject: "/project", @@ -318,7 +378,11 @@ test("resolveWranglerCommand on win32 fails loudly when only a bare PATH .cmd sh packageDirs: [], platform: "win32", }), - { command: "npx", args: ["--yes", "wrangler"], source: "npx" } + { + command: executable, + args: ["--yes", "wrangler@^4"], + source: "npx", + } ); } finally { rmSync(dir, { recursive: true, force: true }); diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js index 3c31fe7..e8be831 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -13,6 +13,8 @@ export const POSIX_ONLY = { skip: process.platform === "win32" ? "POSIX-only filesystem behavior" : false, }; +export const INVALID_PAGE_LIMITS = ["0", "1001", "1.5", "many"]; + // The uid does not decide this — the effective uid, CAP_DAC_OVERRIDE, and // mode-ignoring mounts do. Ask tmpdir() what it actually does. function modeBitsBlockReads() { diff --git a/tsconfig.json b/tsconfig.json index d984d83..8a3ca31 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,6 @@ "lib": ["ES2024", "DOM", "DOM.Iterable"], "types": ["node"] }, - "include": ["bin/**/*.js", "commands/**/*.js", "lib/**/*.js", "tests/**/*.js", "eslint.config.js"], + "include": ["bin/**/*.js", "commands/**/*.js", "lib/**/*.js", "scripts/**/*.js", "tests/**/*.js", "eslint.config.js"], "exclude": ["**/node_modules/**", "**/.deploy-dist/**", "**/.wrangler/**"] }